Compare commits

...

8 Commits

Author SHA1 Message Date
Niels van Velzen
8350fae372 Fix crash when favoriting Live TV program while loading additional channels in the guide
(cherry picked from commit 54a70e9e69)
2025-01-06 12:36:38 +01:00
Niels van Velzen
0912212887 Prevent seeking from crashing app if video info is not initialized yet
(cherry picked from commit 38aa2f1cb3)
2025-01-06 12:36:37 +01:00
Catherine Gilbert
f7839968b2 Dismiss playback actions popups on fragment detach (#4365)
* Dismiss playback actions popups on fragment detach

* Update CONTRIBUTORS.md

(cherry picked from commit d91d873c61)
2025-01-06 12:36:37 +01:00
Niels van Velzen
3ca8667d32 Fix unable to focus toolbar in item details screen
(cherry picked from commit b6b2c0335f)
2025-01-06 12:36:36 +01:00
Niels van Velzen
af782baf86 Fix unable to focus toolbar in server selection screen
(cherry picked from commit eb8d7f8392)
2025-01-06 12:36:36 +01:00
Niels van Velzen
920ddccc80 Fix app crashing when playback reporting fails
(cherry picked from commit c86573b671)
2025-01-06 12:36:35 +01:00
Niels van Velzen
71308f6ddc Fix Live TV fail-over not working
(cherry picked from commit 3385058775)
2025-01-06 12:36:35 +01:00
Niels van Velzen
db22ecd787 Enable audio focus for music playback
(cherry picked from commit 0533d54bbd)
2025-01-06 12:36:35 +01:00
13 changed files with 159 additions and 73 deletions

View File

@@ -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

View File

@@ -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
}
)
}

View File

@@ -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();
}

View File

@@ -512,7 +512,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 +901,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()) {

View File

@@ -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);

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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"

View File

@@ -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) }
}
}

View File

@@ -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 ->