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
This commit is contained in:
Damontecres 2026-08-02 11:11:32 -04:00 • committed by GitHub
parent 7c6ab39436
commit 6ad96865e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 116 additions and 33 deletions

View file

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

View file

@ -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<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] })

View file

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

View file

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