Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958a388048 | ||
|
|
4eca5b7bd8 | ||
|
|
71bd5cc74d | ||
|
|
70c5d161f9 | ||
|
|
21346ab47b | ||
|
|
3d305bdcb2 | ||
|
|
61d9539dda | ||
|
|
379d3433c4 | ||
|
|
91aae29e39 | ||
|
|
80b2a39e24 | ||
|
|
832ca67a02 | ||
|
|
52885af7b9 | ||
|
|
8350fae372 | ||
|
|
0912212887 | ||
|
|
f7839968b2 | ||
|
|
3ca8667d32 | ||
|
|
af782baf86 | ||
|
|
920ddccc80 | ||
|
|
71308f6ddc | ||
|
|
db22ecd787 |
@@ -14,6 +14,7 @@
|
||||
- [mohd-akram](https://github.com/mohd-akram)
|
||||
- [3l0w](https://github.com/3l0w)
|
||||
- [MajMongoose](https://github.com/majmongoose)
|
||||
- [Olaren15](https://github.com/Olaren15)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -21,4 +21,8 @@
|
||||
<issue id="Typos" severity="ignore" />
|
||||
<issue id="UnknownNullness" severity="informational" />
|
||||
<issue id="UnusedIds" severity="ignore" />
|
||||
<issue id="UnusedAttribute">
|
||||
<ignore regexp="usesCleartextTraffic" />
|
||||
<ignore regexp="networkSecurityConfig" />
|
||||
</issue>
|
||||
</lint>
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Jellyfin"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<!-- Legacy service required for AccountManagerMigration -->
|
||||
<service
|
||||
|
||||
@@ -3,11 +3,13 @@ package org.jellyfin.androidtv.di
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil3.ImageLoader
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.serviceLoaderEnabled
|
||||
import coil3.svg.SvgDecoder
|
||||
import coil3.util.Logger
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
@@ -45,6 +47,8 @@ import org.jellyfin.androidtv.util.KeyProcessor
|
||||
import org.jellyfin.androidtv.util.MarkdownRenderer
|
||||
import org.jellyfin.androidtv.util.PlaybackHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
|
||||
import org.jellyfin.androidtv.util.coil.CoilTimberLogger
|
||||
import org.jellyfin.androidtv.util.coil.createCoilConnectivityChecker
|
||||
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
|
||||
import org.jellyfin.androidtv.util.sdk.legacy
|
||||
import org.jellyfin.apiclient.AppInfo
|
||||
@@ -106,8 +110,12 @@ val appModule = module {
|
||||
single {
|
||||
ImageLoader.Builder(androidContext()).apply {
|
||||
serviceLoaderEnabled(false)
|
||||
logger(CoilTimberLogger(if (BuildConfig.DEBUG) Logger.Level.Warn else Logger.Level.Error))
|
||||
|
||||
components {
|
||||
add(OkHttpNetworkFetcherFactory())
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
add(OkHttpNetworkFetcherFactory(connectivityChecker = ::createCoilConnectivityChecker))
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
|
||||
else add(GifDecoder.Factory())
|
||||
add(SvgDecoder.Factory())
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -50,7 +51,9 @@ import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun NowPlayingComposable() {
|
||||
fun NowPlayingComposable(
|
||||
onFocusableChange: (focusable: Boolean) -> Unit,
|
||||
) {
|
||||
val playbackManager = koinInject<PlaybackManager>()
|
||||
val navigationRepository = koinInject<NavigationRepository>()
|
||||
val imageHelper = koinInject<ImageHelper>()
|
||||
@@ -59,6 +62,8 @@ fun NowPlayingComposable() {
|
||||
val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value
|
||||
val progress = rememberPlayerProgress(playbackManager)
|
||||
|
||||
LaunchedEffect(item == null) { onFocusableChange(item != null) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = item != null,
|
||||
enter = fadeIn(),
|
||||
@@ -153,5 +158,11 @@ class NowPlayingView @JvmOverloads constructor(
|
||||
defStyle: Int = 0
|
||||
) : AbstractComposeView(context, attrs, defStyle) {
|
||||
@Composable
|
||||
override fun Content() = NowPlayingComposable()
|
||||
override fun Content() = NowPlayingComposable(
|
||||
// Workaround for older Android versions unable to find focus in our toolbar view when the NowPlayingView is added but inactive
|
||||
onFocusableChange = { focusable ->
|
||||
isFocusable = focusable
|
||||
descendantFocusability = if (focusable) FOCUS_AFTER_DESCENDANTS else FOCUS_BLOCK_DESCENDANTS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -415,12 +415,6 @@ object BrowsingUtils {
|
||||
recursive = true,
|
||||
)
|
||||
|
||||
CollectionType.BOXSETS -> baseRequest.copy(
|
||||
includeItemTypes = setOf(BaseItemKind.BOX_SET),
|
||||
parentId = null,
|
||||
recursive = true,
|
||||
)
|
||||
|
||||
CollectionType.MUSIC -> baseRequest.copy(
|
||||
includeItemTypes = setOf(BaseItemKind.MUSIC_ALBUM),
|
||||
recursive = true,
|
||||
|
||||
@@ -42,14 +42,14 @@ public class ChannelCardView extends FrameLayout {
|
||||
.append(DateTimeExtensionsKt.getTimeFormatter(getContext()).format(program.getEndDate()))
|
||||
);
|
||||
|
||||
if (program.getStartDate().isAfter(LocalDateTime.now()) && program.getEndDate().isBefore(LocalDateTime.now())) {
|
||||
if (program.getStartDate().isBefore(LocalDateTime.now()) && program.getEndDate().isAfter(LocalDateTime.now())) {
|
||||
Duration duration = Duration.between(program.getStartDate(), program.getEndDate());
|
||||
Duration progress = Duration.between(program.getStartDate(), LocalDateTime.now());
|
||||
|
||||
binding.progress.setProgress((int) (progress.getSeconds() / duration.getSeconds() * 100));
|
||||
|
||||
binding.progress.setProgress((int) ((progress.getSeconds() / (double) duration.getSeconds()) * 100));
|
||||
} else {
|
||||
binding.progress.setProgress(0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.time.setText("");
|
||||
binding.progress.setProgress(0);
|
||||
|
||||
@@ -235,7 +235,9 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
|
||||
public void refreshFavorite(UUID channelId){
|
||||
for (int i = 0; i < mChannels.getChildCount(); i++) {
|
||||
GuideChannelHeader gch = (GuideChannelHeader)mChannels.getChildAt(i);
|
||||
View child = mChannels.getChildAt(i);
|
||||
if (!(child instanceof GuideChannelHeader)) continue;
|
||||
GuideChannelHeader gch = (GuideChannelHeader) child;
|
||||
if (gch.getChannel().getId().equals(channelId.toString()))
|
||||
gch.refreshFavorite();
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ import java.util.List;
|
||||
import kotlin.Lazy;
|
||||
import timber.log.Timber;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
// Frequency to report playback progress
|
||||
private final static long PROGRESS_REPORTING_INTERVAL = TimeUtils.secondsToMillis(3);
|
||||
@@ -512,7 +514,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
internalOptions.setMediaSourceId(currentMediaSource.getId());
|
||||
}
|
||||
DeviceProfile internalProfile = new ExoPlayerProfile(
|
||||
isLiveTv && !userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled()),
|
||||
!internalOptions.getEnableDirectStream(),
|
||||
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()),
|
||||
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO
|
||||
);
|
||||
@@ -901,6 +903,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
// set seekPosition so real position isn't used until playback starts again
|
||||
mSeekPosition = pos;
|
||||
|
||||
if (mCurrentStreamInfo == null) return;
|
||||
|
||||
// rebuild the stream
|
||||
// if an older device uses exoplayer to play a transcoded stream but falls back to the generic http stream instead of hls, rebuild the stream
|
||||
if (!mVideoManager.isSeekable()) {
|
||||
@@ -978,7 +982,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
BaseItemDto program = updatedChannel.getCurrentProgram();
|
||||
if (program != null) {
|
||||
mCurrentProgramEnd = program.getEndDate();
|
||||
mCurrentProgramStart = program.getPremiereDate();
|
||||
mCurrentProgramStart = program.getStartDate();
|
||||
if (mFragment != null) mFragment.updateDisplay();
|
||||
}
|
||||
return null;
|
||||
@@ -987,11 +991,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
private long getRealTimeProgress() {
|
||||
long progress = Instant.now().toEpochMilli();
|
||||
if (mCurrentProgramStart != null) {
|
||||
progress -= mCurrentProgramStart.toInstant(ZoneOffset.UTC).toEpochMilli();
|
||||
return Duration.between(mCurrentProgramStart, LocalDateTime.now()).toMillis();
|
||||
}
|
||||
return progress;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private long getTimeShiftedProgress() {
|
||||
|
||||
@@ -75,7 +75,7 @@ fun PlaybackController.setSubtitleIndex(index: Int, force: Boolean = false) {
|
||||
play(mCurrentPosition, index)
|
||||
} else {
|
||||
val mediaSource = currentMediaSource
|
||||
val stream = mediaSource.mediaStreams?.first { it.type == MediaStreamType.SUBTITLE && it.index == index }
|
||||
val stream = mediaSource.mediaStreams?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == index }
|
||||
if (stream == null) {
|
||||
Timber.w("Failed to find correct media stream")
|
||||
return setSubtitleIndex(-1)
|
||||
@@ -108,7 +108,7 @@ fun PlaybackController.setSubtitleIndex(index: Int, force: Boolean = false) {
|
||||
.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS }
|
||||
.indexOf(stream)
|
||||
.takeIf { index -> index != -1 }
|
||||
.takeIf { it != -1 }
|
||||
|
||||
if (localIndex == null) {
|
||||
Timber.w("Failed to find local subtitle index")
|
||||
|
||||
@@ -109,6 +109,13 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
|
||||
protected void onDetachedFromHost() {
|
||||
mHandler.removeCallbacks(mRefreshEndTime);
|
||||
mHandler.removeCallbacks(mRefreshViewVisibility);
|
||||
|
||||
closedCaptionsAction.removePopup();
|
||||
playbackSpeedAction.dismissPopup();
|
||||
selectAudioAction.dismissPopup();
|
||||
selectQualityAction.dismissPopup();
|
||||
zoomAction.dismissPopup();
|
||||
|
||||
super.onDetachedFromHost();
|
||||
}
|
||||
|
||||
@@ -169,6 +176,7 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
|
||||
super.onBindRowViewHolder(vh, item);
|
||||
vh.setOnKeyListener(CustomPlaybackTransportControlGlue.this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onUnbindRowViewHolder(RowPresenter.ViewHolder vh) {
|
||||
super.onUnbindRowViewHolder(vh);
|
||||
|
||||
@@ -17,6 +17,8 @@ class ClosedCaptionsAction(
|
||||
context: Context,
|
||||
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
|
||||
) : CustomAction(context, customPlaybackTransportControlGlue) {
|
||||
private var popup: PopupMenu? = null
|
||||
|
||||
init {
|
||||
initializeWithIcon(R.drawable.ic_select_subtitle)
|
||||
}
|
||||
@@ -34,7 +36,8 @@ class ClosedCaptionsAction(
|
||||
}
|
||||
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
|
||||
PopupMenu(context, view, Gravity.END).apply {
|
||||
removePopup()
|
||||
popup = PopupMenu(context, view, Gravity.END).apply {
|
||||
with(menu) {
|
||||
var order = 0
|
||||
add(0, -1, order++, context.getString(R.string.lbl_none)).apply {
|
||||
@@ -51,11 +54,19 @@ class ClosedCaptionsAction(
|
||||
|
||||
setGroupCheckable(0, true, false)
|
||||
}
|
||||
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
setOnDismissListener {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
|
||||
popup = null
|
||||
}
|
||||
setOnMenuItemClickListener { item ->
|
||||
playbackController.setSubtitleIndex(item.itemId)
|
||||
true
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
popup?.show()
|
||||
}
|
||||
|
||||
fun removePopup() {
|
||||
popup?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class PlaybackSpeedAction(
|
||||
) : CustomAction(context, customPlaybackTransportControlGlue) {
|
||||
private val speedController = VideoSpeedController(playbackController)
|
||||
private val speeds = VideoSpeedController.SpeedSteps.entries.toTypedArray()
|
||||
private var popup: PopupMenu? = null
|
||||
|
||||
init {
|
||||
initializeWithIcon(R.drawable.ic_playback_speed)
|
||||
@@ -30,17 +31,20 @@ class PlaybackSpeedAction(
|
||||
view: View,
|
||||
) {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
|
||||
val speedMenu = populateMenu(context, view, speedController)
|
||||
dismissPopup()
|
||||
popup = populateMenu(context, view, speedController)
|
||||
|
||||
speedMenu.setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
popup?.setOnDismissListener {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
|
||||
popup = null
|
||||
}
|
||||
|
||||
speedMenu.setOnMenuItemClickListener { menuItem ->
|
||||
popup?.setOnMenuItemClickListener { menuItem ->
|
||||
speedController.currentSpeed = speeds[menuItem.itemId]
|
||||
speedMenu.dismiss()
|
||||
true
|
||||
}
|
||||
|
||||
speedMenu.show()
|
||||
popup?.show()
|
||||
}
|
||||
|
||||
private fun populateMenu(
|
||||
@@ -57,4 +61,7 @@ class PlaybackSpeedAction(
|
||||
menu.getItem(speeds.indexOf(speedController.currentSpeed)).isChecked = true
|
||||
}
|
||||
|
||||
fun dismissPopup() {
|
||||
popup?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackManager
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
|
||||
|
||||
class SelectAudioAction(
|
||||
@@ -16,6 +15,8 @@ class SelectAudioAction(
|
||||
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
|
||||
private val playbackManager: PlaybackManager,
|
||||
) : CustomAction(context, customPlaybackTransportControlGlue) {
|
||||
private var popup: PopupMenu? = null
|
||||
|
||||
init {
|
||||
initializeWithIcon(R.drawable.ic_select_audio)
|
||||
}
|
||||
@@ -31,7 +32,8 @@ class SelectAudioAction(
|
||||
?: return
|
||||
val currentAudioIndex = playbackController.audioStreamIndex
|
||||
|
||||
PopupMenu(context, view, Gravity.END).apply {
|
||||
dismissPopup()
|
||||
popup = PopupMenu(context, view, Gravity.END).apply {
|
||||
with(menu) {
|
||||
for (track in audioTracks) {
|
||||
add(0, track.index, track.index, track.displayTitle).apply {
|
||||
@@ -41,11 +43,19 @@ class SelectAudioAction(
|
||||
setGroupCheckable(0, true, false)
|
||||
}
|
||||
|
||||
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
setOnDismissListener {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
|
||||
popup = null
|
||||
}
|
||||
setOnMenuItemClickListener { item ->
|
||||
playbackController.switchAudioStream(item.itemId)
|
||||
true
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
popup?.show()
|
||||
}
|
||||
|
||||
fun dismissPopup() {
|
||||
popup?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class SelectQualityAction(
|
||||
private val previousQualitySelection = userPreferences[UserPreferences.maxBitrate]
|
||||
private val qualityController = VideoQualityController(previousQualitySelection, userPreferences)
|
||||
private val qualityProfiles = getQualityProfiles(context)
|
||||
private var popup: PopupMenu? = null
|
||||
|
||||
init {
|
||||
initializeWithIcon(R.drawable.ic_select_quality)
|
||||
@@ -32,7 +33,8 @@ class SelectQualityAction(
|
||||
view: View,
|
||||
) {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
|
||||
PopupMenu(context, view, Gravity.END).apply {
|
||||
dismissPopup()
|
||||
popup = PopupMenu(context, view, Gravity.END).apply {
|
||||
qualityProfiles.values.forEachIndexed { i, selected ->
|
||||
menu.add(0, i, i, selected)
|
||||
}
|
||||
@@ -45,13 +47,21 @@ class SelectQualityAction(
|
||||
}
|
||||
}
|
||||
|
||||
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
setOnDismissListener {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
|
||||
popup = null
|
||||
}
|
||||
setOnMenuItemClickListener { menuItem ->
|
||||
qualityController.currentQuality = qualityProfiles.keys.elementAt(menuItem.itemId)
|
||||
playbackController.refreshStream()
|
||||
dismiss()
|
||||
true
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
popup?.show()
|
||||
}
|
||||
|
||||
fun dismissPopup() {
|
||||
popup?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.playback.overlay.action
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.constant.ZoomMode
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
@@ -14,6 +15,8 @@ class ZoomAction(
|
||||
context: Context,
|
||||
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
|
||||
) : CustomAction(context, customPlaybackTransportControlGlue) {
|
||||
private var popup: PopupMenu? = null
|
||||
|
||||
init {
|
||||
initializeWithIcon(R.drawable.ic_aspect_ratio)
|
||||
}
|
||||
@@ -25,7 +28,8 @@ class ZoomAction(
|
||||
view: View,
|
||||
) {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
|
||||
val popup = popupMenu(context, view, Gravity.END) {
|
||||
dismissPopup()
|
||||
popup = popupMenu(context, view, Gravity.END) {
|
||||
item(context.getString(R.string.lbl_fit)) {
|
||||
playbackController.setZoom(ZoomMode.FIT)
|
||||
}.apply {
|
||||
@@ -44,8 +48,15 @@ class ZoomAction(
|
||||
isChecked = playbackController.zoomMode == ZoomMode.STRETCH
|
||||
}
|
||||
}
|
||||
popup.menu.setGroupCheckable(0, true, true)
|
||||
popup.setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
popup.show()
|
||||
popup?.menu?.setGroupCheckable(0, true, true)
|
||||
popup?.setOnDismissListener {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
|
||||
popup = null
|
||||
}
|
||||
popup?.show()
|
||||
}
|
||||
|
||||
fun dismissPopup() {
|
||||
popup?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.jellyfin.androidtv.util.coil
|
||||
|
||||
import android.util.Log
|
||||
import coil3.util.Logger
|
||||
import timber.log.Timber
|
||||
|
||||
class CoilTimberLogger(
|
||||
override var minLevel: Logger.Level = Logger.Level.Debug,
|
||||
) : Logger {
|
||||
override fun log(tag: String, level: Logger.Level, message: String?, throwable: Throwable?) {
|
||||
val priority = when (level) {
|
||||
Logger.Level.Verbose -> Log.VERBOSE
|
||||
Logger.Level.Debug -> Log.DEBUG
|
||||
Logger.Level.Info -> Log.INFO
|
||||
Logger.Level.Warn -> Log.WARN
|
||||
Logger.Level.Error -> Log.ERROR
|
||||
}
|
||||
|
||||
Timber.tag("CoilTimberLogger.$tag").log(priority, throwable, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.jellyfin.androidtv.util.coil
|
||||
|
||||
import android.content.Context
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.network.ConnectivityChecker
|
||||
|
||||
/**
|
||||
* Coil uses a "connectivity checker" to skip the network if it thinks there is no available connection. In Android this is sometimes
|
||||
* detected by pinging a known host (e.g. google.com) which might fail for users that blackhole these hosts or only use the app on their
|
||||
* local network. This implementation of the connectivity checker will always return `true` to force a network request to be made.
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@ExperimentalCoilApi
|
||||
fun createCoilConnectivityChecker(context: Context) = ConnectivityChecker { true }
|
||||
@@ -70,7 +70,9 @@
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/notifications"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
android:layout_height="wrap_content"
|
||||
android:descendantFocusability="blocksDescendants"
|
||||
android:focusable="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
9
app/src/main/res/xml/network_security_config.xml
Normal file
9
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools" >
|
||||
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" >
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" tools:ignore="AcceptsUserCertificates" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -24,12 +24,12 @@ androidx-tv = "1.0.0"
|
||||
androidx-tvprovider = "1.1.0-alpha01"
|
||||
androidx-window = "1.3.0"
|
||||
androidx-work = "2.10.0"
|
||||
coil = "3.0.4"
|
||||
coil = "3.1.0"
|
||||
detekt = "1.23.7"
|
||||
java-jdk = "21"
|
||||
jellyfin-androidx-media = "1.5.0+1"
|
||||
jellyfin-apiclient = "v0.7.10"
|
||||
jellyfin-sdk = "1.6.3"
|
||||
jellyfin-sdk = "1.6.6"
|
||||
koin = "4.0.1"
|
||||
koin-compose = "4.0.1"
|
||||
kotest = "5.9.1"
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.QueueItem
|
||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||
import timber.log.Timber
|
||||
import kotlin.math.roundToInt
|
||||
import org.jellyfin.sdk.model.api.RepeatMode as SdkRepeatMode
|
||||
|
||||
@@ -70,27 +71,29 @@ class PlaySessionService(
|
||||
val stream = entry.mediaStream ?: return
|
||||
val item = entry.baseItem ?: return
|
||||
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
itemId = item.id,
|
||||
playSessionId = stream.identifier,
|
||||
playlistItemId = item.playlistItemId,
|
||||
canSeek = true,
|
||||
isMuted = state.volume.muted,
|
||||
volumeLevel = (state.volume.volume * 100).roundToInt(),
|
||||
isPaused = state.playState.value != PlayState.PLAYING,
|
||||
aspectRatio = state.videoSize.value.aspectRatio.toString(),
|
||||
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
|
||||
playMethod = stream.conversionMethod.playMethod,
|
||||
repeatMode = state.repeatMode.value.remoteRepeatMode,
|
||||
nowPlayingQueue = getQueue(),
|
||||
playbackOrder = when (state.playbackOrder.value) {
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
|
||||
}
|
||||
runCatching {
|
||||
api.playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
itemId = item.id,
|
||||
playSessionId = stream.identifier,
|
||||
playlistItemId = item.playlistItemId,
|
||||
canSeek = true,
|
||||
isMuted = state.volume.muted,
|
||||
volumeLevel = (state.volume.volume * 100).roundToInt(),
|
||||
isPaused = state.playState.value != PlayState.PLAYING,
|
||||
aspectRatio = state.videoSize.value.aspectRatio.toString(),
|
||||
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
|
||||
playMethod = stream.conversionMethod.playMethod,
|
||||
repeatMode = state.repeatMode.value.remoteRepeatMode,
|
||||
nowPlayingQueue = getQueue(),
|
||||
playbackOrder = when (state.playbackOrder.value) {
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}.onFailure { error -> Timber.w("Failed to send playback start event", error) }
|
||||
}
|
||||
|
||||
private suspend fun sendStreamUpdate() {
|
||||
@@ -98,27 +101,29 @@ class PlaySessionService(
|
||||
val stream = entry.mediaStream ?: return
|
||||
val item = entry.baseItem ?: return
|
||||
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = item.id,
|
||||
playSessionId = stream.identifier,
|
||||
playlistItemId = item.playlistItemId,
|
||||
canSeek = true,
|
||||
isMuted = state.volume.muted,
|
||||
volumeLevel = (state.volume.volume * 100).roundToInt(),
|
||||
isPaused = state.playState.value != PlayState.PLAYING,
|
||||
aspectRatio = state.videoSize.value.aspectRatio.toString(),
|
||||
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
|
||||
playMethod = stream.conversionMethod.playMethod,
|
||||
repeatMode = state.repeatMode.value.remoteRepeatMode,
|
||||
nowPlayingQueue = getQueue(),
|
||||
playbackOrder = when (state.playbackOrder.value) {
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
|
||||
}
|
||||
runCatching {
|
||||
api.playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = item.id,
|
||||
playSessionId = stream.identifier,
|
||||
playlistItemId = item.playlistItemId,
|
||||
canSeek = true,
|
||||
isMuted = state.volume.muted,
|
||||
volumeLevel = (state.volume.volume * 100).roundToInt(),
|
||||
isPaused = state.playState.value != PlayState.PLAYING,
|
||||
aspectRatio = state.videoSize.value.aspectRatio.toString(),
|
||||
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
|
||||
playMethod = stream.conversionMethod.playMethod,
|
||||
repeatMode = state.repeatMode.value.remoteRepeatMode,
|
||||
nowPlayingQueue = getQueue(),
|
||||
playbackOrder = when (state.playbackOrder.value) {
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
|
||||
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}.onFailure { error -> Timber.w("Failed to send playback update event", error) }
|
||||
}
|
||||
|
||||
private suspend fun sendStreamStop() {
|
||||
@@ -126,15 +131,17 @@ class PlaySessionService(
|
||||
val stream = entry.mediaStream ?: return
|
||||
val item = entry.baseItem ?: return
|
||||
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = item.id,
|
||||
playSessionId = stream.identifier,
|
||||
playlistItemId = item.playlistItemId,
|
||||
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
|
||||
failed = false,
|
||||
nowPlayingQueue = getQueue(),
|
||||
runCatching {
|
||||
api.playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = item.id,
|
||||
playSessionId = stream.identifier,
|
||||
playlistItemId = item.playlistItemId,
|
||||
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
|
||||
failed = false,
|
||||
nowPlayingQueue = getQueue(),
|
||||
)
|
||||
)
|
||||
)
|
||||
}.onFailure { error -> Timber.w("Failed to send playback stop event", error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
@@ -101,6 +102,9 @@ class ExoPlayerBackend(
|
||||
setConstantBitrateSeekingAlwaysEnabled(true)
|
||||
}
|
||||
))
|
||||
.setAudioAttributes(AudioAttributes.Builder().apply {
|
||||
setUsage(C.USAGE_MEDIA)
|
||||
}.build(), true)
|
||||
.setPauseAtEndOfMediaItems(true)
|
||||
.build()
|
||||
.also { player ->
|
||||
|
||||
Reference in New Issue
Block a user