From 6ad96865e7d7f3111d2eaf6b9bf2dcb2ee854f19 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 2 Aug 2026 11:11:32 -0400 Subject: [PATCH] Fix live tv & recorded tv playback (#1770) ## Description #1654 broke playback for both live TV and recorded programs in `.ts` format. This PR fixes both issues! Jellyfin returns `NoCompatibleStream` for live tv if the source ID is passed in, so that is now skipped for live tv. `.ts` files may have IDs formatted with a slash instead of colon, so the logic is updated to handle that plus fallbacks for potentially unknown future formats. ### Related issues Fixes #1767 ### Testing Emulator & nvidia shield, plus unit tests ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackViewModel.kt | 78 ++++++++++++------- .../ui/playback/TrackSelectionUtils.kt | 12 ++- .../test/TestSelectionTrackExamples.kt | 57 ++++++++++++++ .../damontecres/wholphin/test/TestTracks.kt | 2 + 4 files changed, 116 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index da55b98b..a3847f63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -466,10 +466,16 @@ class PlaybackViewModel } } } - val mediaSource = streamChoiceService.chooseSource(base, itemPlayback) + val mediaSource = + if (!isLiveTv) { + streamChoiceService.chooseSource(base, itemPlayback) + } else { + null + } + val plc = streamChoiceService.getPlaybackLanguageChoice(base) - if (mediaSource == null) { + if (mediaSource == null && !isLiveTv) { showToast( context, "Item has no media sources, skipping...", @@ -479,7 +485,8 @@ class PlaybackViewModel } val videoStream = - mediaSource.mediaStreams + mediaSource + ?.mediaStreams ?.firstOrNull { it.type == MediaStreamType.VIDEO } ?.let { val isHdr = @@ -493,37 +500,48 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - val subtitleStreams = getSubtitleStreams(mediaSource) - val audioStreams = getAudioStreams(mediaSource) + val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() + val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() val audioStream = - streamChoiceService - .chooseAudioStream( - source = mediaSource, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - ) + mediaSource?.let { + streamChoiceService + .chooseAudioStream( + source = mediaSource, + seriesId = base.seriesId, + itemPlayback = itemPlayback, + plc = plc, + prefs = preferences, + ) + } val audioIndex = audioStream?.index val subtitleIndex = - streamChoiceService - .chooseSubtitleStream( - source = mediaSource, - audioStream = audioStream, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - )?.index - - Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + mediaSource?.let { + streamChoiceService + .chooseSubtitleStream( + source = mediaSource, + audioStream = audioStream, + seriesId = base.seriesId, + itemPlayback = itemPlayback, + plc = plc, + prefs = preferences, + )?.index + } + Timber.d( + "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", + mediaSource?.id, + audioIndex, + subtitleIndex, + ) val trickPlayInfo = - item.data.trickplay - ?.get(mediaSource.id) - ?.values - ?.firstOrNull() + mediaSource?.id?.let { sourceId -> + item.data.trickplay + ?.get(sourceId) + ?.values + ?.firstOrNull() + } + trickPlayInfo?.let { trickplayInfo -> mediaSource.runTimeTicks?.ticks?.let { duration -> viewModelScope.launchIO { @@ -545,7 +563,7 @@ class PlaybackViewModel } updateCurrentMedia { CurrentMediaInfo( - sourceId = mediaSource.id, + sourceId = mediaSource?.id, videoStream = videoStream, audioStreams = audioStreams, subtitleStreams = subtitleStreams, @@ -559,7 +577,7 @@ class PlaybackViewModel audioIndex = audioIndex, subtitleIndex = subtitleIndex, positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET, - sourceId = mediaSource.id, + sourceId = mediaSource?.id, enableDirectPlay = !forceTranscoding, enableDirectStream = !forceTranscoding, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index c9a16e79..7b377399 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -158,10 +158,16 @@ object TrackSelectionUtils { } else { track.trackFormats[0] .id - ?.split(":") - ?.map { it.toInt() } + ?.split(":", "/") + ?.map { it.toIntOrNull() ?: Int.MAX_VALUE } ?.let { - track to it + if (it.size >= 2) { + track to it + } else if (it.isEmpty()) { + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) + } else { + track to listOf(it[0], Int.MAX_VALUE) + } } } }.sortedWith(compareBy>> { it.second[0] }.thenBy { it.second[1] }) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt index 564ab7a9..a98440f4 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -296,4 +296,61 @@ class TestSelectionTrackExamples { Assert.assertFalse(result.bothSelected) } } + + @Test + fun `test exo track IDs with slashes`() { + val testTracks = + TestTracks( + listOf( + // .ts files (eg recorded TV) report IDs like this in ExoPlayer + TestTrack("3/49", 0, MediaStreamType.VIDEO, false), + TestTrack("3/52", 1, MediaStreamType.AUDIO, false), + TestTrack("", 2, MediaStreamType.DATA, false), + TestTrack("3/53", 3, MediaStreamType.AUDIO, false), + ), + ) + val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.DATA, false, C.FORMAT_HANDLED) + .addAudio() + .buildMediaSourceInfo() + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 3, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt index f9b1eab2..7370f29e 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.test import androidx.media3.common.C import androidx.media3.common.Format +import androidx.media3.common.MimeTypes import androidx.media3.common.TrackGroup import androidx.media3.common.Tracks import org.jellyfin.sdk.model.api.MediaProtocol @@ -35,6 +36,7 @@ class TestTracks( MediaStreamType.VIDEO -> "video/sample" MediaStreamType.AUDIO -> "audio/default" MediaStreamType.SUBTITLE -> "text/sample" + MediaStreamType.DATA -> MimeTypes.APPLICATION_ID3 else -> throw UnsupportedOperationException("${it.type}") } val format =