From 49740c47cf9478fd6d330951cc8065516b837a1f Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:49:43 -0500 Subject: [PATCH 01/11] Create media session during playback allowing for external control (#640) ## Description Creates a `MediaSession` during playback which allows for external control such as via smart speakers, Google Home app, etc Note: one limitation is that seeking previous/next items in the playlist isn't supported. This is because Wholphin manages that independent of the `Player`, so additional dev work is needed for this. ### Related issues Closes #622 Might help with #415 --- app/build.gradle.kts | 1 + .../damontecres/wholphin/ui/playback/PlaybackViewModel.kt | 8 ++++++++ gradle/libs.versions.toml | 1 + 3 files changed, 10 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 51d2590f..c83d76a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -196,6 +196,7 @@ dependencies { implementation(libs.androidx.hilt.work) implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.ui) 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 064434c4..fa71dc7f 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 @@ -21,6 +21,7 @@ import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.session.MediaSession import coil3.imageLoader import coil3.request.ImageRequest import coil3.size.Size @@ -134,6 +135,7 @@ class PlaybackViewModel val player by lazy { playerFactory.createVideoPlayer() } + private val mediaSession: MediaSession internal val mutex = Mutex() val controllerViewState = @@ -177,10 +179,15 @@ class PlaybackViewModel } jobs.forEach { it.cancel() } player.release() + mediaSession.release() } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) + mediaSession = + MediaSession + .Builder(context, player) + .build() jobs.add(subscribe()) jobs.add(listenForTranscodeReason()) } @@ -1031,6 +1038,7 @@ class PlaybackViewModel Timber.v("release") activityListener?.release() player.release() + mediaSession.release() activityListener = null } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 964fab0d..57b03009 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } +androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } From 4df17e41cdfbfc79f23379b49c46ce4d6163dd83 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:23:55 -0500 Subject: [PATCH 02/11] Fix timing issues with segments (#648) ## Description Processing segments happens in a separate coroutine which wasn't cancelled until _after_ playback of the next item begins. So in some cases the outro from a previous item might be triggered during the very beginning of the newly starting item. So this PR instead cancels segment processing immediately when starting the process for playback of an item. Additionally, the processing also now checks that the current segment is for the current playback item as an additional safeguard. ### Related issues I believe this should fix both of these issues: Fixes #369 Fixes #647 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 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 fa71dc7f..1e56ce08 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 @@ -325,12 +325,15 @@ class PlaybackViewModel withContext(Dispatchers.IO) { Timber.i("Playing ${item.id}") - // Starting playback, so want to invalidate the last played timestamp for this item - datePlayedService.invalidate(item) // New item, so we can clear the media segment tracker & subtitle cues - autoSkippedSegments.clear() + resetSegmentState() this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf()) + viewModelScope.launchIO { + // Starting playback, so want to invalidate the last played timestamp for this item + datePlayedService.invalidate(item) + } + if (item.type !in supportItemKinds) { showToast( context, @@ -457,7 +460,7 @@ class PlaybackViewModel player.prepare() player.play() } - listenForSegments() + listenForSegments(item.id) return@withContext true } @@ -809,10 +812,19 @@ class PlaybackViewModel private var segmentJob: Job? = null + /** + * Cancels listening for segments and clears current segment state + */ + private suspend fun resetSegmentState() { + segmentJob?.cancel() + autoSkippedSegments.clear() + currentSegment.setValueOnMain(null) + } + /** * This sets up a coroutine to periodically check whether the current playback progress is within a media segment (intro, outro, etc) */ - private fun listenForSegments() { + private fun listenForSegments(itemId: UUID) { segmentJob?.cancel() segmentJob = viewModelScope.launchIO { @@ -828,7 +840,10 @@ class PlaybackViewModel .firstOrNull { it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } - if (currentSegment != null && autoSkippedSegments.add(currentSegment.id)) { + if (currentSegment != null && + currentSegment.itemId == this@PlaybackViewModel.itemId && + autoSkippedSegments.add(currentSegment.id) + ) { Timber.d( "Found media segment for %s: %s, %s", currentSegment.itemId, From 6906813bbc667d4df82ff3d1e91819c9c8e8834c Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 7 Jan 2026 14:03:14 -0500 Subject: [PATCH 03/11] Fix mpv error with MediaSession --- .../java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index 1be415e9..f46b94cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -246,7 +246,8 @@ class MpvPlayer( override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE override fun getPlayerError(): PlaybackException? { - TODO("Not yet implemented") + // TODO + return null } override fun setPlayWhenReady(playWhenReady: Boolean) { From d2e0d527ddabdc4a3412235e17542d02e5bf0d19 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:27:54 -0500 Subject: [PATCH 04/11] Fix some track selection bugs (#649) ## Description Fixes track selection issues with MPV. This was especially focused on libraries that disable embedded subtitles, but also fixes issues where the audio streams are listed before video streams. Also includes test media source JSON and unit tests to verify many scenarios. ### Related issues Fixes #494 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +- .../ui/playback/TrackSelectionUtils.kt | 72 ++- app/src/test/java/android/text/TextUtils.java | 9 + .../wholphin/test/TestTrackSelection.kt | 591 ++++++++++++++++++ app/src/test/resources/embedded_subs.json | 235 +++++++ app/src/test/resources/external_subs.json | 208 ++++++ app/src/test/resources/no_embedded_subs.json | 178 ++++++ 7 files changed, 1278 insertions(+), 28 deletions(-) create mode 100644 app/src/test/java/android/text/TextUtils.java create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt create mode 100644 app/src/test/resources/embedded_subs.json create mode 100644 app/src/test/resources/external_subs.json create mode 100644 app/src/test/resources/no_embedded_subs.json 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 1e56ce08..d8190c30 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 @@ -494,8 +494,9 @@ class PlaybackViewModel if (externalSubtitle == null) { val result = withContext(Dispatchers.Main) { - TrackSelectionUtils.applyTrackSelections( - player, + TrackSelectionUtils.createTrackSelections( + onMain { player.trackSelectionParameters }, + onMain { player.currentTracks }, playerBackend, true, audioIndex, @@ -504,6 +505,7 @@ class PlaybackViewModel ) } if (result.bothSelected) { + onMain { player.trackSelectionParameters = result.trackSelectionParameters } // TODO lots of duplicate code in this block Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") val itemPlayback = @@ -696,8 +698,9 @@ class PlaybackViewModel Timber.v("onTracksChanged: $tracks") if (tracks.groups.isNotEmpty()) { val result = - TrackSelectionUtils.applyTrackSelections( - player, + TrackSelectionUtils.createTrackSelections( + player.trackSelectionParameters, + player.currentTracks, playerBackend, source.supportsDirectPlay, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, @@ -705,6 +708,8 @@ class PlaybackViewModel source, ) if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters player.removeListener(this) } viewModelScope.launchIO { loadSubtitleDelay() } 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 44a44e1e..4c952a20 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 @@ -3,8 +3,9 @@ package com.github.damontecres.wholphin.ui.playback import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.Format -import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.preferences.PlayerBackend import org.jellyfin.sdk.model.api.MediaSourceInfo @@ -12,24 +13,24 @@ import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import timber.log.Timber +import kotlin.math.max object TrackSelectionUtils { @OptIn(UnstableApi::class) - fun applyTrackSelections( - player: Player, + fun createTrackSelections( + trackSelectionParams: TrackSelectionParameters, + tracks: Tracks, playerBackend: PlayerBackend, supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val videoStreamCount = source.videoStreamCount - val audioStreamCount = source.audioStreamCount val embeddedSubtitleCount = source.embeddedSubtitleCount val externalSubtitleCount = source.externalSubtitlesCount - val paramsBuilder = player.trackSelectionParameters.buildUpon() - val tracks = player.currentTracks.groups + val paramsBuilder = trackSelectionParams.buildUpon() + val groups = tracks.groups val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { @@ -37,7 +38,7 @@ object TrackSelectionUtils { if (subtitleIsExternal || supportsDirectPlay) { val chosenTrack = if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { - tracks.firstOrNull { group -> + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_TEXT && group.isSupported && (0.. + group.type == C.TRACK_TYPE_TEXT && + (0.. + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_TEXT && group.isSupported && (0.. + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_AUDIO && group.isSupported && (0.. { - serverIndex - externalSubtitleCount - videoStreamCount + 1 + val videoStreamsBeforeAudioCount = + source.mediaStreams + .orEmpty() + .indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount + serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1 } MediaStreamType.SUBTITLE -> { if (subtitleIsExternal) { - serverIndex + embeddedSubtitleCount + 1 + // Need to account for the actual embedded count because if the library + // disables embedded subtitles, they still exist in the direct played file, + // but not included in the MediaStreams list + serverIndex + max(actualEmbeddedCount ?: 0, embeddedSubtitleCount) + 1 } else { + val videoStreamCount = source.videoStreamCount + val audioStreamCount = source.audioStreamCount serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 } } @@ -228,12 +250,14 @@ fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = me fun List.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = subtitleIndex?.let { firstOrNull { - it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && + it.type == MediaStreamType.SUBTITLE && + (it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal) && it.index == subtitleIndex } } data class TrackSelectionResult( + val trackSelectionParameters: TrackSelectionParameters, val audioSelected: Boolean, val subtitleSelected: Boolean, ) { diff --git a/app/src/test/java/android/text/TextUtils.java b/app/src/test/java/android/text/TextUtils.java new file mode 100644 index 00000000..959d7957 --- /dev/null +++ b/app/src/test/java/android/text/TextUtils.java @@ -0,0 +1,9 @@ +package android.text; + +// Mocks static class for non-instrumented unit tests + +public class TextUtils { + public static boolean isEmpty(CharSequence str) { + return str == null || str.length() == 0; + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt new file mode 100644 index 00000000..cad496ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -0,0 +1,591 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.junit.Assert +import org.junit.Test +import java.nio.file.Paths +import kotlin.io.path.readText + +class TestTrackSelection { + /** + * Builds the tracks for the `embedded_subs.json` for the given backend + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildEmbeddedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + val video = + Format + .Builder() + .setId("1") + .setSampleMimeType("video/default") + .build() + val audios = + (2..4).map { + Format + .Builder() + .setId("$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (5..7).map { + Format + .Builder() + .setId("$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + /** + * Builds the tracks for the `no_embedded_subs.json` for the given backend + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildNoEmbeddedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("7:e:4") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (2..4).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (5..7).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("1:e:0") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + /** + * Builds the tracks for the `external_subs.json` for the given backend. + * + * Must supply the desired subtitle index because ExoPlayer uses it. + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildExternalTracks( + backend: PlayerBackend, + selectedIndex: Int, + ): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("1:1") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:1") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + listOf( + Format + .Builder() + .setId("2:1") + .setSampleMimeType("text/default") + .build(), + Format + .Builder() + .setId("3:e:2") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } else { + val video = + Format + .Builder() + .setId("0:2") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:1") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + listOf( + Format + .Builder() + .setId("0:3") // Embedded + .setSampleMimeType("text/default") + .build(), + Format + .Builder() + .setId("1:e:$selectedIndex") // External + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + private fun TrackSelectionParameters.getAudioOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_AUDIO) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null + } + + private fun TrackSelectionParameters.getSubtitleOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null + } + + @Test + fun `test MPV embedded`() { + val resource = javaClass.classLoader?.getResource("embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildEmbeddedTracks(PlayerBackend.MPV) + Assert.assertEquals(7, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer embedded`() { + val resource = javaClass.classLoader?.getResource("embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildEmbeddedTracks(PlayerBackend.EXO_PLAYER) + Assert.assertEquals(7, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 6, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV no embedded`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildNoEmbeddedTracks(PlayerBackend.MPV) + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer no embedded`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildNoEmbeddedTracks(PlayerBackend.EXO_PLAYER) + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV external`() { + val resource = javaClass.classLoader?.getResource("external_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildExternalTracks(PlayerBackend.MPV, 0) + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals("0:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3:e:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + // Select embedded subtitles + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 5, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "2:1", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + + @Test + fun `test ExoPlayer external`() { + val resource = javaClass.classLoader?.getResource("external_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + + buildExternalTracks(PlayerBackend.EXO_PLAYER, 0).also { tracks -> + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "1:e:0", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + + // Select embedded subtitles + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 5, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "0:3", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + + buildExternalTracks(PlayerBackend.EXO_PLAYER, 2).also { tracks -> + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 2, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "1:e:2", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + } +} diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json new file mode 100644 index 00000000..f24aeefc --- /dev/null +++ b/app/src/test/resources/embedded_subs.json @@ -0,0 +1,235 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 651217902, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 14919360000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "hevc", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p - HEVC - SDR", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 3491934, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1448, + "AverageFrameRate": 23.809525, + "RealFrameRate": 23.809525, + "ReferenceFrameRate": 23.809525, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "4:3", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 120, + "IsAnamorphic": false + }, + { + "Codec": "opus", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "JAP Stereo (Opus 112Kbps)", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "JAP Stereo (Opus 112Kbps) - Japanese", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 101618, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "aac", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - AAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 249225, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "por", + "TimeBase": "1/1000", + "Title": "ptBR", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "ptBR - Portuguese - Default - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "por", + "TimeBase": "1/1000", + "Title": "ptBR FORCED", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "ptBR FORCED - Portuguese - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": true, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "enUS", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "enUS - English - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 6, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 4482777, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 6, + "HasSegments": true +} diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json new file mode 100644 index 00000000..9f6a4b05 --- /dev/null +++ b/app/src/test/resources/external_subs.json @@ -0,0 +1,208 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 2147179830, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 12793200000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 0, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "0", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "0 - English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 1, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 2, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "English - Dolby Digital+ - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "h264", + "ColorSpace": "bt709", + "ColorTransfer": "bt709", + "ColorPrimaries": "bt709", + "TimeBase": "1/1000", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 13427007, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1920, + "AverageFrameRate": 23.976025, + "RealFrameRate": 23.976025, + "ReferenceFrameRate": 23.976025, + "Profile": "High", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p", + "Level": 40, + "IsAnamorphic": false + }, + { + "Codec": "subrip", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Undefined - Default - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 14067007, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 3, + "DefaultSubtitleStreamIndex": 0, + "HasSegments": true +} diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json new file mode 100644 index 00000000..be4e3051 --- /dev/null +++ b/app/src/test/resources/no_embedded_subs.json @@ -0,0 +1,178 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 651217902, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 14919360000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "subrip", + "Language": "spa", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Spanish - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 0, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "hevc", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p - HEVC - SDR", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 3491934, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1448, + "AverageFrameRate": 23.809525, + "RealFrameRate": 23.809525, + "ReferenceFrameRate": 23.809525, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "4:3", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 120, + "IsAnamorphic": false + }, + { + "Codec": "opus", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "Stereo (Opus 112Kbps)", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Stereo (Opus 112Kbps) - Japanese", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 101618, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "aac", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - AAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 249225, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 4482777, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 2, + "HasSegments": true +} From 2cd34692e914b8fd4c6fd9601e6059a33d00f4c5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:01:37 -0500 Subject: [PATCH 05/11] Show indicator for multiple versions of media file (#652) ## Description Adds an indicator to cards on grid pages if there are more than a single version of the item. Also adds the count to the video stream label, much like for multiple audio/subtitle tracks. No behavior changes, just visual changes. You still need to select the version from More->Choose Version. ### Related issues Closes #612 ### Screenshots image image --- .../damontecres/wholphin/ui/UiConstants.kt | 2 + .../wholphin/ui/cards/EpisodeCard.kt | 1 + .../wholphin/ui/cards/ExtrasRow.kt | 1 + .../damontecres/wholphin/ui/cards/GridCard.kt | 1 + .../wholphin/ui/cards/ItemCardImage.kt | 51 +++++++++++++++---- .../wholphin/ui/cards/PersonCard.kt | 1 + .../wholphin/ui/cards/SeasonCard.kt | 3 ++ .../ui/components/VideoStreamDetails.kt | 18 ++++--- .../wholphin/ui/detail/PlaylistDetails.kt | 1 + .../ui/detail/episode/EpisodeDetailsHeader.kt | 1 + .../ui/detail/movie/MovieDetailsHeader.kt | 1 + .../ui/detail/series/FocusedEpisodeHeader.kt | 1 + .../wholphin/ui/theme/ThemePreview.kt | 4 +- 13 files changed, 67 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index beafccb2..6b33ad49 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -51,6 +51,7 @@ val DefaultItemFields = ItemFields.SORT_NAME, ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, + ItemFields.MEDIA_SOURCE_COUNT, ) /** @@ -63,6 +64,7 @@ val SlimItemFields = ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.SORT_NAME, + ItemFields.MEDIA_SOURCE_COUNT, ) object Cards { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index 8cc3ad12..e4ffa5ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -97,6 +97,7 @@ fun EpisodeCard( watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, watchedPercent = dto?.userData?.playedPercentage, + numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 39750da3..adfff582 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -68,6 +68,7 @@ fun ExtrasRow( isPlayed = false, unplayedItemCount = -1, playedPercentage = -1.0, + numberOfVersions = -1, aspectRatio = AspectRatios.FOUR_THREE, // TODO ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 1bc35a08..7b653865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -91,6 +91,7 @@ fun GridCard( watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, watchedPercent = dto?.userData?.playedPercentage, + numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, contentScale = imageContentScale, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 9254fbd3..1a2bbacf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -58,6 +58,7 @@ fun ItemCardImage( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, imageType: ImageType = ImageType.PRIMARY, useFallbackText: Boolean = true, @@ -86,6 +87,7 @@ fun ItemCardImage( watched = watched, unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, + numberOfVersions = numberOfVersions, modifier = modifier.onLayoutRectChanged( throttleMillis = 100, @@ -107,6 +109,7 @@ fun ItemCardImage( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, @@ -143,6 +146,7 @@ fun ItemCardImage( watched = watched, unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, + numberOfVersions = numberOfVersions, modifier = Modifier, ) } @@ -198,20 +202,45 @@ fun ItemCardImageOverlay( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { - if (favorite) { - Text( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(8.dp), - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = 20.sp, - fontFamily = FontAwesome, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp), + ) { + if (numberOfVersions > 1) { + Box( + modifier = + Modifier + .background( + AppColors.TransparentBlack50, + shape = RoundedCornerShape(25), + ), + ) { + Text( + text = numberOfVersions.toString(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, +// fontSize = 16.sp, + modifier = Modifier.padding(4.dp), + ) + } + } + if (favorite) { + Text( + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 20.sp, + fontFamily = FontAwesome, + modifier = Modifier, + ) + } } Row( horizontalArrangement = Arrangement.spacedBy(4.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 83d72010..4d0c0fe5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -77,6 +77,7 @@ fun PersonCard( favorite = item.favorite, watched = false, unwatchedCount = -1, + numberOfVersions = -1, watchedPercent = null, useFallbackText = false, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 243bb6d6..e3e6059e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -88,6 +88,7 @@ fun SeasonCard( isPlayed = item?.data?.userData?.played ?: false, unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, onClick = onClick, onLongClick = onLongClick, modifier = modifier, @@ -112,6 +113,7 @@ fun SeasonCard( isPlayed: Boolean, unplayedItemCount: Int, playedPercentage: Double, + numberOfVersions: Int, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -172,6 +174,7 @@ fun SeasonCard( watched = isPlayed, unwatchedCount = unplayedItemCount, watchedPercent = playedPercentage, + numberOfVersions = numberOfVersions, useFallbackText = false, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index a557d7f0..e9a11d54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -47,12 +47,14 @@ import org.jellyfin.sdk.model.api.VideoRangeType @NonRestartableComposable fun VideoStreamDetails( chosenStreams: ChosenStreams?, + numberOfVersions: Int, modifier: Modifier = Modifier, ) = VideoStreamDetails( chosenStreams?.source, chosenStreams?.videoStream, chosenStreams?.audioStream, chosenStreams?.subtitleStream, + numberOfVersions, modifier, ) @@ -62,6 +64,7 @@ fun VideoStreamDetails( videoStream: MediaStream?, audioStream: MediaStream?, subtitleStream: MediaStream?, + numberOfVersions: Int = 0, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -86,13 +89,16 @@ fun VideoStreamDetails( null } val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) - listOfNotNull( - resName.concatWithSpace(range), - it.codec?.uppercase(), - ) - }.orEmpty() + resName.concatWithSpace(range) + } } - video.forEach { + video?.let { + StreamLabel( + text = it, + count = numberOfVersions, + ) + } + videoStream?.codec?.uppercase()?.let { StreamLabel(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 4907d71e..5bf996a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -444,6 +444,7 @@ fun PlaylistItem( watched = item?.data?.userData?.played ?: false, unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, watchedPercent = 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, modifier = Modifier.width(160.dp), useFallbackText = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index d3c77b67..09c8ed0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -59,6 +59,7 @@ fun EpisodeDetailsHeader( VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = dto.mediaSourceCount ?: 0, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index c976e79b..df30078c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -73,6 +73,7 @@ fun MovieDetailsHeader( VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = movie.data.mediaSourceCount ?: 0, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 5b25783b..97791749 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -38,6 +38,7 @@ fun FocusedEpisodeHeader( if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = dto.mediaSourceCount ?: 0, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index dfae73c5..8d3735e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -30,10 +30,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.WatchedIcon -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavItem import com.github.damontecres.wholphin.ui.playback.PlaybackButton @@ -109,6 +107,7 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 50.0, + numberOfVersions = 2, onClick = { }, onLongClick = {}, imageHeight = 120.dp, @@ -125,6 +124,7 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 0.0, + numberOfVersions = 0, onClick = { }, onLongClick = {}, imageHeight = 120.dp, From ee73e7d7231fe78fc65f7f9bc1d3e051d6692ea9 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:59:37 -0500 Subject: [PATCH 06/11] Fix duplicate commands from key presses/MediaSession (#663) ## Description Fixes duplicate play/pause commands from a remote button press and `MediaSession`. This ended up being a bit complicated because Wholphin overrides the default play/pause behavior for the skip back setting and to show the controller on pause. This means using a forwarding player which meant implementing more functionality into `MpvPlayer`. ### Related issues Fixes #653 --- .../ui/playback/MediaSessionPlayer.kt | 27 ++++ .../ui/playback/PlaybackKeyHandler.kt | 23 +-- .../wholphin/ui/playback/PlaybackViewModel.kt | 22 ++- .../wholphin/util/mpv/MpvPlayer.kt | 138 ++++++++++++++++-- 4 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt new file mode 100644 index 00000000..94a41aa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.media3.common.ForwardingSimpleBasePlayer +import androidx.media3.common.Player +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.skipBackOnResume +import com.github.damontecres.wholphin.ui.seekBack +import com.google.common.util.concurrent.ListenableFuture +import timber.log.Timber + +class MediaSessionPlayer( + player: Player, + private val controllerViewState: ControllerViewState, + private val playbackPreferences: PlaybackPreferences, +) : ForwardingSimpleBasePlayer(player) { + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") + if (!playWhenReady && player.isPlaying) { + controllerViewState.showControls() + } else if (playWhenReady) { + playbackPreferences.skipBackOnResume?.let { + player.seekBack(it) + } + } + return super.handleSetPlayWhenReady(playWhenReady) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 303de20c..c95b0896 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -61,27 +61,8 @@ class PlaybackKeyHandler( } } else if (isMedia(it)) { when (it.key) { - Key.MediaPlay -> { - Util.handlePlayButtonAction(player) - skipBackOnResume?.let { - player.seekBack(it) - } - } - - Key.MediaPause -> { - Util.handlePauseButtonAction(player) - controllerViewState.showControls() - } - - Key.MediaPlayPause -> { - Util.handlePlayPauseButtonAction(player) - if (!player.isPlaying) { - controllerViewState.showControls() - } else { - skipBackOnResume?.let { - player.seekBack(it) - } - } + Key.MediaPlay, Key.MediaPause, Key.MediaPlayPause -> { + // no-op, MediaSession will handle } Key.MediaFastForward, Key.MediaSkipForward -> { 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 d8190c30..a9792bb8 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 @@ -135,7 +135,7 @@ class PlaybackViewModel val player by lazy { playerFactory.createVideoPlayer() } - private val mediaSession: MediaSession + private var mediaSession: MediaSession? = null internal val mutex = Mutex() val controllerViewState = @@ -179,15 +179,11 @@ class PlaybackViewModel } jobs.forEach { it.cancel() } player.release() - mediaSession.release() + mediaSession?.release() } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) - mediaSession = - MediaSession - .Builder(context, player) - .build() jobs.add(subscribe()) jobs.add(listenForTranscodeReason()) } @@ -284,6 +280,18 @@ class PlaybackViewModel } else { throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } + + val sessionPlayer = + MediaSessionPlayer( + player, + controllerViewState, + preferences.appPreferences.playbackPreferences, + ) + mediaSession = + MediaSession + .Builder(context, sessionPlayer) + .build() + val item = BaseItem.from(base, api) val played = @@ -1058,7 +1066,7 @@ class PlaybackViewModel Timber.v("release") activityListener?.release() player.release() - mediaSession.release() + mediaSession?.release() activityListener = null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index f46b94cf..58c976c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -53,6 +53,7 @@ import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.update import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -231,11 +232,6 @@ class MpvPlayer( override fun prepare() { if (DEBUG) Timber.v("prepare") - playbackState.update { - it.copy( - state = Player.STATE_READY, - ) - } } override fun getPlaybackState(): Int { @@ -395,8 +391,7 @@ class MpvPlayer( override fun getCurrentTimeline(): Timeline { if (DEBUG) Timber.v("getCurrentTimeline") - // TODO - return Timeline.EMPTY + return playbackState.load().timeline } override fun getCurrentPeriodIndex(): Int { @@ -528,7 +523,7 @@ class MpvPlayer( return playbackState.load().videoSize } - override fun getSurfaceSize(): Size = throw UnsupportedOperationException() + override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO @@ -655,12 +650,15 @@ class MpvPlayer( } MPV_EVENT_FILE_LOADED -> { - playbackState.update { - it.copy(isLoadingFile = false) - } - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } Timber.d("event: MPV_EVENT_FILE_LOADED") - internalHandler.post(updatePlaybackState) + playbackState.update { + it.copy( + isLoadingFile = false, + ) + } + updatePlaybackState.run() + notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } + playbackState.load().media?.mediaItem?.let { media -> media.localConfiguration?.subtitleConfigurations?.forEach { val url = it.uri.toString() @@ -767,10 +765,63 @@ class MpvPlayer( private fun loadFile(media: MediaAndPosition) { Timber.v("loadFile: media=$media") + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + media.mediaItem.mediaId, + media.mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + true, + media.mediaItem.liveConfiguration, + 0L, + C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + media.mediaItem.mediaId, + media.mediaItem.mediaId, + 0, + C.TIME_UNSET, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId + } playbackState.update { it.copy( isLoadingFile = true, + state = STATE_READY, media = media, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, ) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } @@ -838,7 +889,8 @@ class MpvPlayer( private val updatePlaybackState: Runnable = Runnable { - if (playbackState.load().media == null) { + val state = playbackState.load() + if (state.media == null) { return@Runnable } val positionMs = @@ -861,6 +913,53 @@ class MpvPlayer( VideoSize.UNKNOWN } + val mediaItem = state.media.mediaItem + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + mediaItem.mediaId, + mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + false, + mediaItem.liveConfiguration, + 0L, + if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + mediaItem.mediaId, + mediaItem.mediaId, + 0, + state.durationMs.milliseconds.inWholeMicroseconds, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId + } + playbackState.update { it.copy( timestamp = System.currentTimeMillis(), @@ -870,6 +969,13 @@ class MpvPlayer( speed = speed, isPaused = paused, videoSize = videoSize, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_SOURCE_UPDATE, ) } } @@ -1080,12 +1186,13 @@ private data class PlaybackState( val videoSize: VideoSize, @param:Player.State val state: Int, val tracks: Tracks, + val timeline: Timeline, ) { companion object { val EMPTY = PlaybackState( timestamp = C.TIME_UNSET, - isLoadingFile = true, + isLoadingFile = false, media = null, positionMs = C.TIME_UNSET, durationMs = C.TIME_UNSET, @@ -1096,6 +1203,7 @@ private data class PlaybackState( videoSize = VideoSize.UNKNOWN, state = Player.STATE_IDLE, subtitleDelay = 0.0, + timeline = Timeline.EMPTY, ) } } From 8bdc8a6f8fce6c68bb7070d6c3cb845490a98f04 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:46:38 -0500 Subject: [PATCH 07/11] Consistent audio/subtitle info across dialogs (#664) ## Description Makes the labels for audio & subtitle stream consistent across the app The More->Choose dialogs now highlight which track is currently choosen. Also uses more of the brand names for audio codecs (eg DD+) and allows for these to be translated into other languages. ### Related issues Closes #621 Fixes #668 Related to https://github.com/damontecres/Wholphin/issues/528#issuecomment-3678876207, I adjusted the background & selection colors of the dialogs during playback ### Screenshots #### Choose subtitles from More dialog ![subtitle_dialog1 Large](https://github.com/user-attachments/assets/edaf96a0-fea4-4110-b274-e8c57494bc59) #### Choose subtitles during playback ![subtitle_dialog2 Large](https://github.com/user-attachments/assets/c0dd76ab-bb2c-42a9-bfa7-2d3b6ffe2b35) #### Choose audio ![audio_tracks Large](https://github.com/user-attachments/assets/3214879d-9845-48ff-918c-e9cd470ed0bc) --- .../wholphin/ui/components/Dialogs.kt | 18 +- .../ui/components/SelectedLeadingContent.kt | 32 ++ .../ui/components/VideoStreamDetails.kt | 129 +------- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 13 +- .../ui/detail/episode/EpisodeDetails.kt | 7 + .../wholphin/ui/detail/movie/MovieDetails.kt | 8 + .../ui/detail/series/SeriesOverview.kt | 7 + .../wholphin/ui/playback/CurrentMediaInfo.kt | 4 +- .../wholphin/ui/playback/Models.kt | 73 ---- .../wholphin/ui/playback/PlaybackControls.kt | 53 ++- .../wholphin/ui/playback/PlaybackDialog.kt | 313 ++++++++++++++---- .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +- .../wholphin/ui/playback/SimpleMediaStream.kt | 25 ++ .../ui/playback/SubtitleSearchUtils.kt | 53 ++- .../wholphin/ui/theme/ThemePreview.kt | 21 +- .../ui/theme/colors/PurpleThemeColors.kt | 2 +- .../wholphin/ui/util/StreamFormatting.kt | 171 ++++++++++ app/src/main/res/values/strings.xml | 6 + 18 files changed, 620 insertions(+), 328 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 4b985056..85065b43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -56,6 +56,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -84,6 +85,7 @@ data class DialogItem( val leadingContent: @Composable (BoxScope.() -> Unit)? = null, val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, + val selected: Boolean = false, ) : DialogItemEntry { constructor( @StringRes text: Int, @@ -266,7 +268,7 @@ fun DialogPopupContent( is DialogItem -> { ListItem( - selected = false, + selected = it.selected, enabled = !waiting && it.enabled, onClick = { if (dismissOnClick) { @@ -502,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( context: Context, streams: List, + currentIndex: Int?, type: MediaStreamType, onClick: (Int) -> Unit, ): DialogParams = @@ -513,6 +516,10 @@ fun chooseStream( if (type == MediaStreamType.SUBTITLE) { add( DialogItem( + selected = currentIndex == null, + leadingContent = { + SelectedLeadingContent(currentIndex == null) + }, headlineContent = { Text(text = stringResource(R.string.none)) }, @@ -524,12 +531,17 @@ fun chooseStream( } addAll( streams.filter { it.type == type }.mapIndexed { index, stream -> - val title = stream.displayTitle ?: stream.title ?: "" + val simpleStream = SimpleMediaStream.from(context, stream, true) DialogItem( + selected = currentIndex == stream.index, + leadingContent = { + SelectedLeadingContent(currentIndex == stream.index) + }, headlineContent = { - Text(text = title) + Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) }, supportingContent = { + if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, onClick = { onClick.invoke(stream.index) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt new file mode 100644 index 00000000..efc2ddac --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt @@ -0,0 +1,32 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalContentColor + +@Composable +fun BoxScope.SelectedLeadingContent( + selected: Boolean, + modifier: Modifier = Modifier, +) { + if (selected) { + Box( + modifier = + modifier + .padding(horizontal = 4.dp) + .clip(CircleShape) + .align(Alignment.Center) + .background(LocalContentColor.current) + .size(8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index e9a11d54..7805a95a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -31,17 +30,18 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.audioStreamCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.StreamFormatting.concatWithSpace +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatVideoRange +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString import com.github.damontecres.wholphin.util.languageName -import com.github.damontecres.wholphin.util.profile.Codec import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.VideoRange -import org.jellyfin.sdk.model.api.VideoRangeType @Composable @NonRestartableComposable @@ -155,35 +155,6 @@ fun VideoStreamDetails( } } -fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" - -// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 -fun resolutionString( - width: Int, - height: Int, - interlaced: Boolean, -): String = - if (height > width) { - // Vertical video - resolutionString(height, width, interlaced) - } else { - when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" - else -> height.toString() + interlaced(interlaced) - } - } - @Composable fun StreamLabel( text: String, @@ -257,93 +228,3 @@ private fun StreamLabelPreview() { } } } - -fun formatVideoRange( - context: Context, - videoRange: VideoRange?, - type: VideoRangeType?, - doviTitle: String?, -): String? = - when (videoRange) { - VideoRange.UNKNOWN, - VideoRange.SDR, null, - -> { - null - } - - VideoRange.HDR -> { - if (doviTitle.isNotNullOrBlank()) { - context.getString(R.string.dolby_vision) - } else { - when (type) { - VideoRangeType.UNKNOWN, - VideoRangeType.SDR, - null, - -> null - - VideoRangeType.HDR10 -> "HDR10" - - VideoRangeType.HDR10_PLUS -> "HDR10+" - - VideoRangeType.HLG -> "HLG" - - VideoRangeType.DOVI, - VideoRangeType.DOVI_WITH_HDR10, - VideoRangeType.DOVI_WITH_HLG, - VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) - } - } - } - } - -fun formatAudioCodec( - context: Context, - codec: String?, - profile: String?, -): String? = - when { - profile?.contains("Dolby Atmos", true) == true -> { - context.getString(R.string.dolby_atmos) - } - - profile?.contains("DTS:X", true) == true -> { - "DTS:X" - } - - profile?.contains("DTS:HD", true) == true -> { - "DTS:HD" - } - - else -> { - when (codec?.lowercase()) { - Codec.Audio.TRUEHD -> "TrueHD" - - Codec.Audio.OGG, - Codec.Audio.OPUS, - Codec.Audio.VORBIS, - -> codec.replaceFirstChar { it.uppercase() } - - null -> null - - else -> codec.uppercase() - } - } - } - -fun formatSubtitleCodec(codec: String?): String? = - when (codec?.lowercase()) { - Codec.Subtitle.DVBSUB -> "DVB" - Codec.Subtitle.DVDSUB -> "DVD" - Codec.Subtitle.PGSSUB -> "PGS" - Codec.Subtitle.SUBRIP -> "SRT" - null -> null - else -> codec.uppercase() - } - -fun String?.concatWithSpace(str: String?): String? = - when { - this != null && str != null -> "$this $str" - this == null -> str - else -> this - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 8e6eb07f..fa32c595 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -21,11 +21,11 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.components.ScrollableDialog -import com.github.damontecres.wholphin.ui.components.formatAudioCodec -import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec import com.github.damontecres.wholphin.util.languageName import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -391,7 +391,7 @@ private fun buildAudioStreamInfo( val bitrateLabel = context.getString(R.string.bitrate) val sampleRateLabel = context.getString(R.string.sample_rate) val defaultLabel = context.getString(R.string.default_track) - val externalLabel = context.getString(R.string.external_track) + val profileLabel = context.getString(R.string.profile) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) val sampleRateUnit = context.getString(R.string.sample_rate_unit) @@ -399,11 +399,12 @@ private fun buildAudioStreamInfo( stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase() + val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)" add(codecLabel to formattedCodec) } stream.channelLayout?.let { add(layoutLabel to it) } stream.channels?.let { add(channelsLabel to it.toString()) } + stream.profile?.let { add(profileLabel to it) } stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } @@ -428,7 +429,7 @@ private fun buildSubtitleStreamInfo( stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() + val formattedCodec = formatSubtitleCodec(it) + " ($it)" add(codecLabel to formattedCodec) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } @@ -438,7 +439,7 @@ private fun buildSubtitleStreamInfo( add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) } if (showPath) { - stream.path?.let { pathLabel to it } + stream.path?.let { add(pathLabel to it) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 6d53b0c1..cb9e2977 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -181,6 +182,12 @@ fun EpisodeDetails( chooseStream( context = context, streams = source.mediaStreams.orEmpty(), + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, type = type, onClick = { trackIndex -> viewModel.saveTrackSelection( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index a63dd128..6055ef1f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -210,6 +211,7 @@ fun MovieDetails( moreDialog = null }, onChooseTracks = { type -> + viewModel.streamChoiceService .chooseSource( movie.data, @@ -220,6 +222,12 @@ fun MovieDetails( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( movie, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 891d3bc2..7553c9e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks @@ -242,6 +243,12 @@ fun SeriesOverview( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( ep, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 0fe5c564..309f099b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -4,8 +4,8 @@ import com.github.damontecres.wholphin.data.model.Chapter import org.jellyfin.sdk.model.api.TrickplayInfo data class CurrentMediaInfo( - val audioStreams: List, - val subtitleStreams: List, + val audioStreams: List, + val subtitleStreams: List, val chapters: List, val trickPlayInfo: TrickplayInfo?, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt deleted file mode 100644 index 728f283a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.WholphinApplication -import org.jellyfin.sdk.model.api.MediaStream - -data class SubtitleStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val external: Boolean, - val forced: Boolean, - val default: Boolean, - val displayTitle: String?, -) { - val displayName: String - get() = - displayTitle ?: listOfNotNull( - language, - title, - codec, - ).joinToString(" - ") - .ifBlank { WholphinApplication.instance.getString(R.string.unknown) } - - companion object { - fun from(it: MediaStream): SubtitleStream = - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } -} - -data class AudioStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val channels: Int?, - val channelLayout: String?, -) { - val displayName: String - get() = - listOfNotNull( - language, - title, - codec, - channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" }, - ).joinToString(" - ").ifBlank { "Unknown" } - - companion object { - fun from(it: MediaStream): AudioStream = - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 8cb05699..75ac0951 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -57,11 +56,13 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward @@ -462,12 +463,12 @@ fun PlaybackButton( } @Composable -fun BottomDialog( - choices: List, +fun BottomDialog( + choices: List>, onDismissRequest: () -> Unit, - onSelectChoice: (Int, String) -> Unit, + onSelectChoice: (Int, BottomDialogItem) -> Unit, gravity: Int, - currentChoice: Int? = null, + currentChoice: BottomDialogItem? = null, ) { // TODO enforcing a width ends up ignore the gravity Dialog( @@ -485,7 +486,10 @@ fun BottomDialog( Modifier .wrapContentSize() .padding(8.dp) - .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(8.dp), + ), ) { LazyColumn( modifier = @@ -498,38 +502,27 @@ fun BottomDialog( ) { itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - val color = - if (focused) { - MaterialTheme.colorScheme.inverseOnSurface - } else { - MaterialTheme.colorScheme.onSurface - } ListItem( - selected = index == currentChoice, + selected = choice == currentChoice, onClick = { onDismissRequest() onSelectChoice(index, choice) }, leadingContent = { - if (index == currentChoice) { - Box( - modifier = - Modifier - .padding(horizontal = 4.dp) - .clip(CircleShape) - .align(Alignment.Center) - .background(color) - .size(8.dp), - ) - } + SelectedLeadingContent(choice == currentChoice) }, headlineContent = { Text( - text = choice, - color = color, + text = choice.headline, ) }, + supportingContent = { + choice.supporting?.let { + Text( + text = it, + ) + } + }, interactionSource = interactionSource, ) } @@ -542,6 +535,12 @@ data class MoreButtonOptions( val options: Map, ) +data class BottomDialogItem( + val data: T, + val headline: String, + val supporting: String?, +) + @PreviewTvSpec @Composable private fun ButtonPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 1f13b278..f56a8e2d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -2,11 +2,20 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView @@ -15,11 +24,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -import timber.log.Timber +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import kotlin.time.Duration enum class PlaybackDialogType { @@ -35,9 +47,9 @@ enum class PlaybackDialogType { data class PlaybackSettings( val showDebugInfo: Boolean, val audioIndex: Int?, - val audioStreams: List, + val audioStreams: List, val subtitleIndex: Int?, - val subtitleStreams: List, + val subtitleStreams: List, val playbackSpeed: Float, val contentScale: ContentScale, val subtitleDelay: Duration, @@ -59,7 +71,13 @@ fun PlaybackDialog( PlaybackDialogType.MORE -> { val options = buildList { - add(stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info)) + add( + BottomDialogItem( + data = 0, + headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), + supporting = null, + ), + ) } BottomDialog( choices = options, @@ -75,76 +93,82 @@ fun PlaybackDialog( } PlaybackDialogType.CAPTIONS -> { - val subtitleStreams = settings.subtitleStreams - val options = subtitleStreams.map { it.displayName } - Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options") - val currentChoice = - subtitleStreams.indexOfFirstOrNull { it.index == settings.subtitleIndex } ?: subtitleStreams.size - BottomDialog( - choices = - options + - listOf( - stringResource(R.string.none), - stringResource(R.string.search_and_download), - ), - currentChoice = currentChoice, + SubtitleChoiceBottomDialog( + choices = settings.subtitleStreams, + currentChoice = settings.subtitleIndex, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// // TODO this is hacky, but playback changes force refocus and this is a workaround -// delay(250L) -// captionFocusRequester.tryRequestFocus() -// } }, - onSelectChoice = { index, _ -> - if (index in subtitleStreams.indices) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) - } else { - val idx = index - subtitleStreams.size - if (idx == 0) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) - } else { - onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) - } + onSelectChoice = { subtitleIndex -> + onDismissRequest.invoke() + if (subtitleIndex >= 0) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex)) + } else if (subtitleIndex == TrackIndex.DISABLED) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) } }, + onSelectSearch = { + onDismissRequest.invoke() + onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) + }, gravity = Gravity.END, ) } PlaybackDialogType.SETTINGS -> { + val currentAudio = + remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } } val options = buildList { - add(stringResource(R.string.audio)) - add(stringResource(R.string.playback_speed)) + add( + BottomDialogItem( + data = PlaybackDialogType.AUDIO, + headline = stringResource(R.string.audio), + supporting = currentAudio?.displayTitle, + ), + ) + add( + BottomDialogItem( + data = PlaybackDialogType.PLAYBACK_SPEED, + headline = stringResource(R.string.playback_speed), + supporting = settings.playbackSpeed.toString(), + ), + ) if (enableVideoScale) { - add(stringResource(R.string.video_scale)) + add( + BottomDialogItem( + data = PlaybackDialogType.VIDEO_SCALE, + headline = stringResource(R.string.video_scale), + supporting = playbackScaleOptions[settings.contentScale], + ), + ) } if (enableSubtitleDelay) { - add(stringResource(R.string.subtitle_delay)) + add( + BottomDialogItem( + data = PlaybackDialogType.SUBTITLE_DELAY, + headline = stringResource(R.string.subtitle_delay), + supporting = settings.subtitleDelay.toString(), + ), + ) } } BottomDialog( choices = options, currentChoice = null, onDismissRequest = onDismissRequest, - onSelectChoice = { index, _ -> - when (index) { - 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) - 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) - 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) - 3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY) - } + onSelectChoice = { _, choice -> + onClickPlaybackDialogType(choice.data) }, gravity = Gravity.END, ) } PlaybackDialogType.AUDIO -> { - BottomDialog( - choices = settings.audioStreams.map { it.displayName }, - currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex }, + StreamChoiceBottomDialog( + choices = settings.audioStreams, + currentChoice = settings.audioIndex, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -153,17 +177,25 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index)) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(choice.index)) }, gravity = Gravity.END, ) } PlaybackDialogType.PLAYBACK_SPEED -> { + val choices = + playbackSpeedOptions.map { + BottomDialogItem( + data = it.toFloat(), + headline = it, + supporting = null, + ) + } BottomDialog( - choices = playbackSpeedOptions, - currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.playbackSpeed }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -173,16 +205,24 @@ fun PlaybackDialog( // } }, onSelectChoice = { _, value -> - onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat())) + onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) }, gravity = Gravity.END, ) } PlaybackDialogType.VIDEO_SCALE -> { + val choices = + playbackScaleOptions.map { (scale, name) -> + BottomDialogItem( + data = scale, + headline = name, + supporting = null, + ) + } BottomDialog( - choices = playbackScaleOptions.values.toList(), - currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.contentScale }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -191,8 +231,8 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index])) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) }, gravity = Gravity.END, ) @@ -225,3 +265,164 @@ fun PlaybackDialog( } } } + +@Composable +fun SubtitleChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int) -> Unit, + onSelectSearch: () -> Unit, + gravity: Int, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + ListItem( + selected = currentChoice == TrackIndex.DISABLED, + onClick = { + onSelectChoice(TrackIndex.DISABLED) + }, + leadingContent = { + SelectedLeadingContent(currentChoice == TrackIndex.DISABLED) + }, + headlineContent = { + Text( + text = stringResource(R.string.none), + ) + }, + supportingContent = {}, + ) + } + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onSelectChoice(choice.index) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + item { + HorizontalDivider() + ListItem( + selected = false, + onClick = onSelectSearch, + leadingContent = {}, + headlineContent = { + Text( + text = stringResource(R.string.search_and_download), + ) + }, + supportingContent = {}, + ) + } + } + } + } +} + +@Composable +fun StreamChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int, SimpleMediaStream) -> Unit, + gravity: Int, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onDismissRequest() + onSelectChoice(index, choice) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + } + } + } +} 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 a9792bb8..eb7c314b 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 @@ -384,15 +384,18 @@ class PlaybackViewModel val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map(SubtitleStream::from) - .orEmpty() + ?.map { + SimpleMediaStream.from(context, it, true) + }.orEmpty() + val audioStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } - ?.map(AudioStream::from) - ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + ?.map { + SimpleMediaStream.from(context, it, true) + } +// ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() - val audioStream = streamChoiceService .chooseAudioStream( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt new file mode 100644 index 00000000..885be814 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -0,0 +1,25 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle +import org.jellyfin.sdk.model.api.MediaStream + +data class SimpleMediaStream( + val index: Int, + val streamTitle: String?, + val displayTitle: String, +) { + companion object { + fun from( + context: Context, + mediaStream: MediaStream, + includeFlags: Boolean = true, + ): SimpleMediaStream = + SimpleMediaStream( + index = mediaStream.index, + streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, + displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 8568b286..021f4290 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.extensions.subtitleApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import timber.log.Timber @@ -81,16 +82,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( itemId = it.sourceId ?: it.itemId, subtitleId = subtitleId, ) + val currentSource = + this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo val currentSubtitleStreams = - this@downloadAndSwitchSubtitles - .currentMediaInfo.value - ?.subtitleStreams + currentSource + ?.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() + val externalPaths = currentSubtitleStreams.map { it.path } + val subtitleCount = currentSubtitleStreams.size var newCount = subtitleCount var maxAttempts = 4 - var newStreams: List = listOf() + var mediaSource: MediaSourceInfo? = null // The server triggers a refresh in the background, so query periodically for the item until its updated while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- @@ -100,7 +105,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( api.userLibraryApi.getItem(itemId = it.itemId).content, api, ) - val mediaSource = streamChoiceService.chooseSource(item.data, it) + mediaSource = streamChoiceService.chooseSource(item.data, it) if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -116,26 +121,6 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() newCount = subtitleStreams.size - - if (subtitleCount != newCount) { - newStreams = - subtitleStreams.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } - updateCurrentMedia { - it.copy(subtitleStreams = newStreams) - } - } } if (maxAttempts == 0) { showToast( @@ -144,12 +129,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } else { // Find the new subtitle stream + val subtitlesStreams = + mediaSource?.mediaStreams?.filter { it.type == MediaStreamType.SUBTITLE } val newStream = - newStreams - .toMutableList() - .apply { - removeAll(currentSubtitleStreams) - }.firstOrNull { it.external } + subtitlesStreams?.firstOrNull { stream -> + stream.isExternal && stream.path !in externalPaths + } if (newStream != null) { var audioIndex = currentItemPlayback.value?.audioIndex if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { @@ -158,6 +143,14 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") audioIndex += 1 } + updateCurrentMedia { + it.copy( + subtitleStreams = + subtitlesStreams.map { + SimpleMediaStream.from(context, it, true) + }, + ) + } this@downloadAndSwitchSubtitles.changeStreams( item, currentItemPlayback.value!!, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 8d3735e9..9cae3698 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.NavigationDrawerScope @@ -41,7 +42,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=2000dp", + device = "spec:width=1200dp,height=2500dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @@ -270,6 +271,24 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + ListItem( + selected = false, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + ListItem( + selected = true, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt index 813aaf79..68583c4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt @@ -41,7 +41,7 @@ val PurpleThemeColors = val secondaryDark = Color(0xFFD2BCFF) val onSecondaryDark = Color(0xFF3B167D) val secondaryContainerDark = Color(0xFF48415B) - val onSecondaryContainerDark = Color(0xFFC2A6FF) + val onSecondaryContainerDark = Color(0xFFDFD3F8) val tertiaryDark = Color(0xFFA071F8) val onTertiaryDark = Color(0xFF5E0052) val tertiaryContainerDark = Color(0xFFB800A3) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt new file mode 100644 index 00000000..ff4e62da --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -0,0 +1,171 @@ +package com.github.damontecres.wholphin.ui.util + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.util.languageName +import com.github.damontecres.wholphin.util.profile.Codec +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.VideoRange +import org.jellyfin.sdk.model.api.VideoRangeType + +/** + * Collection of utility functions for formatting the display of media streams + */ +object StreamFormatting { + fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" + + // Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 + fun resolutionString( + width: Int, + height: Int, + interlaced: Boolean, + ): String = + if (height > width) { + // Vertical video + resolutionString(height, width, interlaced) + } else { + when { + width <= 256 && height <= 144 -> "144" + interlaced(interlaced) + width <= 426 && height <= 240 -> "240" + interlaced(interlaced) + width <= 640 && height <= 360 -> "360" + interlaced(interlaced) + width <= 682 && height <= 384 -> "384" + interlaced(interlaced) + width <= 720 && height <= 404 -> "404" + interlaced(interlaced) + width <= 854 && height <= 480 -> "480" + interlaced(interlaced) + width <= 960 && height <= 544 -> "540" + interlaced(interlaced) + width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) + width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) + width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) + width <= 4096 && height <= 3072 -> "4K" + width <= 8192 && height <= 6144 -> "8K" + else -> height.toString() + interlaced(interlaced) + } + } + + fun formatVideoRange( + context: Context, + videoRange: VideoRange?, + type: VideoRangeType?, + doviTitle: String?, + ): String? = + when (videoRange) { + VideoRange.UNKNOWN, + VideoRange.SDR, null, + -> { + null + } + + VideoRange.HDR -> { + if (doviTitle.isNotNullOrBlank()) { + context.getString(R.string.dolby_vision) + } else { + when (type) { + VideoRangeType.UNKNOWN, + VideoRangeType.SDR, + null, + -> null + + VideoRangeType.HDR10 -> "HDR10" + + VideoRangeType.HDR10_PLUS -> "HDR10+" + + VideoRangeType.HLG -> "HLG" + + VideoRangeType.DOVI, + VideoRangeType.DOVI_WITH_HDR10, + VideoRangeType.DOVI_WITH_HLG, + VideoRangeType.DOVI_WITH_SDR, + -> context.getString(R.string.dolby_vision) + } + } + } + } + + fun formatAudioCodec( + context: Context, + codec: String?, + profile: String?, + ): String? = + when { + profile?.contains("Dolby Atmos", true) == true -> { + context.getString(R.string.dolby_atmos) + } + + profile?.contains("DTS:X", true) == true -> { + context.getString(R.string.dts_x) + } + + profile?.contains("DTS:HD", true) == true -> { + context.getString(R.string.dts_hd) + } + + else -> { + when (codec?.lowercase()) { + Codec.Audio.TRUEHD -> context.getString(R.string.truehd) + + Codec.Audio.AC3 -> context.getString(R.string.dolby_digital) + + Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus) + + Codec.Audio.DCA -> context.getString(R.string.dts) + + Codec.Audio.OGG, + Codec.Audio.OPUS, + Codec.Audio.VORBIS, + -> codec.replaceFirstChar { it.uppercase() } + + null -> null + + else -> codec.uppercase() + } + } + } + + fun formatSubtitleCodec(codec: String?): String? = + when (codec?.lowercase()) { + Codec.Subtitle.DVBSUB -> "DVB" + Codec.Subtitle.DVDSUB -> "DVD" + Codec.Subtitle.PGSSUB -> "PGS" + Codec.Subtitle.SUBRIP -> "SRT" + null -> null + else -> codec.uppercase() + } + + fun String?.concatWithSpace(str: String?): String? = + when { + this != null && str != null -> "$this $str" + this == null -> str + else -> this + } + + fun mediaStreamDisplayTitle( + context: Context, + stream: MediaStream, + includeFlags: Boolean, + ): String { + val name = + buildList { + add(languageName(stream.language)) + if (stream.type == MediaStreamType.AUDIO) { + add(formatAudioCodec(context, stream.codec, stream.profile)) + add(stream.channelLayout) + } else if (stream.type == MediaStreamType.SUBTITLE) { + "SDH".takeIf { stream.isHearingImpaired }?.let(::add) + add(formatSubtitleCodec(stream.codec)) + } + }.joinToString(" ") + if (includeFlags) { + val flags = + buildList { + if (stream.isDefault) add(stream.localizedDefault) + if (stream.isForced) add(stream.localizedForced) + if (stream.isExternal) add(stream.localizedExternal) + }.joinToString(", ") + if (flags.isNotEmpty()) { + return "$name ($flags)" + } + } + return name + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d589ee14..6b6f8c2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -403,6 +403,12 @@ Play trailer No trailers Clear track choices + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS Disabled From ed67e5575fe4fe6e6498b7dd74ad277cf68cf3a2 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Sun, 11 Jan 2026 12:11:39 -0600 Subject: [PATCH 08/11] Add "Only Forced Subtitles" option to subtitle selection (#589) ## Description Adds a client-side "Only Forced Subtitles" option when selecting subtitles, per the discussion in #571. ### Changes - **TrackIndex.ONLY_FORCED** (`-3`): New constant for the special case - **StreamChoiceService**: Intercepts `ONLY_FORCED` at the top of `chooseSubtitleStream()` before server-side `SubtitlePlaybackMode` logic (which remains untouched) - **UI**: Adds "Only Forced Subtitles" option to both: - In-player subtitle menu (`PlaybackDialog.kt`) - Pre-playback stream selection dialog (`Dialogs.kt`) ### Forced track detection Uses metadata `isForced` flag as primary check, with title-based fallback ("forced", "signs", "songs" patterns) when language matches audio. ### Related issues Closes #571 ### Screenshots N/A - subtitle selection menu only ### AI/LLM usage Used Claude Code for assistance and to draft PR description. --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Damontecres --- .../wholphin/data/ItemPlaybackRepository.kt | 4 +- .../wholphin/data/model/ItemPlayback.kt | 5 + .../wholphin/services/StreamChoiceService.kt | 73 ++++++++++ .../wholphin/ui/components/Dialogs.kt | 10 ++ .../wholphin/ui/playback/PlaybackDialog.kt | 19 +++ .../wholphin/ui/playback/PlaybackViewModel.kt | 44 +++++- app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestStreamChoiceService.kt | 128 ++++++++++++++++++ 8 files changed, 279 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 460d97b2..3f8dc198 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -133,7 +133,7 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) { + if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { if (type == MediaStreamType.AUDIO) { val stream = source.mediaStreams?.first { it.index == trackIndex } if (stream?.language != null) { @@ -147,7 +147,7 @@ class ItemPlaybackRepository subtitlesDisabled = true, ) } else { - val stream = source.mediaStreams?.first { it.index == trackIndex } + val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } if (stream?.language != null) { streamChoiceService.updateSubtitles( item.data, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index 83f2d5df..94a30475 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -54,4 +54,9 @@ object TrackIndex { * The user has explicitly disabled the tracks (eg turned off subtitles) */ const val DISABLED = -2 + + /** + * The user wants only forced subtitles (for foreign language dialogue, signs, etc.) + */ + const val ONLY_FORCED = -3 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 98708ead..e38957cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -162,6 +162,40 @@ class StreamChoiceService } } + /** + * Resolves ONLY_FORCED to an actual subtitle track index. + * Returns the original index if not ONLY_FORCED or DISABLED. + */ + suspend fun resolveSubtitleIndex( + source: MediaSourceInfo, + audioStreamIndex: Int?, + seriesId: UUID?, + subtitleIndex: Int, + prefs: UserPreferences, + ): Int? = + if (subtitleIndex != TrackIndex.ONLY_FORCED) { + subtitleIndex + } else { + val audioStream = + source.mediaStreams?.firstOrNull { + it.type == MediaStreamType.AUDIO && it.index == audioStreamIndex + } + val itemPlayback = + ItemPlayback( + userId = serverRepository.currentUser.value!!.rowId, + itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution + subtitleIndex = TrackIndex.ONLY_FORCED, + ) + chooseSubtitleStream( + source = source, + audioStream = audioStream, + seriesId = seriesId, + itemPlayback = itemPlayback, + plc = null, + prefs = prefs, + )?.index + } + fun chooseSubtitleStream( audioStreamLang: String?, candidates: List, @@ -171,6 +205,14 @@ class StreamChoiceService ): MediaStream? { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { return null + } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { + // Client-side manual override: User selected "Only Forced" in player menu + val seriesLang = + playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } + val subtitleLanguage = + (seriesLang ?: userConfig?.subtitleLanguagePreference) + ?.takeIf { it.isNotNullOrBlank() } + return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) } else if (itemPlayback?.subtitleIndexEnabled == true) { return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } } else { @@ -261,6 +303,37 @@ class StreamChoiceService } } } + + /** Returns true if the track is forced (via metadata flag or title patterns). */ + private fun isForcedOrSigns(track: MediaStream): Boolean { + if (track.isForced) return true + val title = track.title ?: track.displayTitle ?: return false + return title.contains("forced", ignoreCase = true) || + title.contains("signs", ignoreCase = true) || + title.contains("songs", ignoreCase = true) + } + + /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ + private fun findForcedTrack( + candidates: List, + subtitleLanguage: String?, + audioLanguage: String?, + ): MediaStream? { + // 1. User's preferred subtitle language + if (subtitleLanguage != null) { + candidates + .firstOrNull { it.language.equals(subtitleLanguage, true) && isForcedOrSigns(it) } + ?.let { return it } + } + // 2. Audio language (for sign-subtitles if no preference match) + if (audioLanguage != null) { + candidates + .firstOrNull { it.language.equals(audioLanguage, true) && isForcedOrSigns(it) } + ?.let { return it } + } + // 3. Unknown language forced track + return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } + } } private val String?.isUnknown: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 85065b43..85ea35cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -528,6 +528,16 @@ fun chooseStream( onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) + add( + DialogItem( + headlineContent = { + Text(text = stringResource(R.string.only_forced_subtitles)) + }, + supportingContent = { + }, + onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, + ), + ) } addAll( streams.filter { it.type == type }.mapIndexed { index, stream -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index f56a8e2d..771a84e2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -106,6 +106,8 @@ fun PlaybackDialog( onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex)) } else if (subtitleIndex == TrackIndex.DISABLED) { onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) + } else if (subtitleIndex == TrackIndex.ONLY_FORCED) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.ONLY_FORCED)) } }, onSelectSearch = { @@ -322,6 +324,23 @@ fun SubtitleChoiceBottomDialog( supportingContent = {}, ) } + item { + ListItem( + selected = currentChoice == TrackIndex.ONLY_FORCED, + onClick = { + onSelectChoice(TrackIndex.ONLY_FORCED) + }, + leadingContent = { + SelectedLeadingContent(currentChoice == TrackIndex.ONLY_FORCED) + }, + headlineContent = { + Text( + text = stringResource(R.string.only_forced_subtitles), + ) + }, + supportingContent = {}, + ) + } itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } ListItem( 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 eb7c314b..880087e6 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 @@ -523,7 +523,13 @@ class PlaybackViewModel currentItemPlayback.copy( sourceId = source.id?.toUUIDOrNull(), audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, + // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index + subtitleIndex = + if (currentItemPlayback.subtitleIndex < 0) { + currentItemPlayback.subtitleIndex + } else { + subtitleIndex ?: TrackIndex.DISABLED + }, ) if (userInitiated) { viewModelScope.launchIO { @@ -744,11 +750,27 @@ class PlaybackViewModel type = MediaStreamType.AUDIO, ) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + + // Resolve ONLY_FORCED to actual track based on new audio language + val source = currentPlayback.value?.mediaSourceInfo + val resolvedSubtitleIndex = + if (source != null) { + streamChoiceService.resolveSubtitleIndex( + source = source, + audioStreamIndex = index, + seriesId = item.data.seriesId, + subtitleIndex = itemPlayback.subtitleIndex, + prefs = preferences, + ) + } else { + itemPlayback.subtitleIndex.takeIf { it >= 0 } + } + changeStreams( item, itemPlayback, index, - itemPlayback.subtitleIndex, + resolvedSubtitleIndex, onMain { player.currentPosition }, true, ) @@ -766,11 +788,27 @@ class PlaybackViewModel type = MediaStreamType.SUBTITLE, ) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + + // Resolve ONLY_FORCED to actual track index for playback + val source = currentPlayback.value?.mediaSourceInfo + val resolvedIndex = + if (source != null) { + streamChoiceService.resolveSubtitleIndex( + source = source, + audioStreamIndex = itemPlayback.audioIndex, + seriesId = item.data.seriesId, + subtitleIndex = index, + prefs = preferences, + ) + } else { + index.takeIf { it >= 0 } + } + changeStreams( item, itemPlayback, itemPlayback.audioIndex, - index, + resolvedIndex, onMain { player.currentPosition }, true, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b6f8c2d..c9fc0ed5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,6 +75,7 @@ No scheduled recordings No update available None + Only Forced Subtitles Outro Path People diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt index 0f3e5ed7..e41bf292 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -613,6 +613,132 @@ class TestStreamChoiceServiceMultipleChoices( } } +/** + * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). + * This tests the findForcedTrack function with user subtitle language preference. + */ +@RunWith(Parameterized::class) +class TestStreamChoiceServiceOnlyForcedClientOverride( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + // Test 1: Prefer user's subtitle language preference for forced tracks + TestInput( + expectedIndex = 1, // spa forced track (matches user pref) + userSubtitleMode = null, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 2: User subtitle preference matches Signs track via title (not forced flag) + TestInput( + expectedIndex = 0, // eng signs track via title detection + userSubtitleMode = null, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "jpn", // different from subtitle pref + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 3: Falls back to audio-matching forced when no preference match + TestInput( + expectedIndex = 0, // eng forced (audio match, step 2) + userSubtitleMode = null, + userSubtitleLang = "spa", // no spa forced exists + subtitles = + listOf( + subtitle(0, "eng", forced = true), // matches audio + subtitle(1, "und", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 4: Falls back to audio-matching signs when user pref has no match + TestInput( + expectedIndex = 0, // eng signs (audio match fallback) + userSubtitleMode = null, + userSubtitleLang = "fre", // user prefers French, no French forced exists + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), // matches audio + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 5: Use audio language for signs/songs when NO subtitle preference + TestInput( + expectedIndex = 0, // eng signs track matching audio + userSubtitleMode = null, + userSubtitleLang = null, // no preference + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 6: Unknown language forced track with no preference + TestInput( + expectedIndex = 0, // unknown forced track + userSubtitleMode = null, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, null, forced = true), // unknown/null language + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 7: Unknown language forced track when no audio match + TestInput( + expectedIndex = 0, // unknown forced track (step 3) + userSubtitleMode = null, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "und", forced = true), // unknown language forced + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", // no eng tracks exist + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 8: No matching forced track returns null (not irrelevant language) + TestInput( + expectedIndex = null, // no forced track matches - return null instead of wrong language + userSubtitleMode = null, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "chi", forced = true), // Chinese forced - wrong language + subtitle(1, "spa", forced = true), // Spanish forced - wrong language + ), + streamAudioLang = "eng", // audio is English, no English forced + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + ) + } +} + data class TestInput( val expectedIndex: Int?, val userSubtitleMode: SubtitlePlaybackMode?, @@ -674,6 +800,7 @@ fun subtitle( lang: String?, default: Boolean = false, forced: Boolean = false, + title: String? = null, ): MediaStream = MediaStream( type = MediaStreamType.SUBTITLE, @@ -686,6 +813,7 @@ fun subtitle( isExternal = false, isTextSubtitleStream = true, supportsExternalStream = true, + title = title, ) private fun itemPlayback( From 2377a15611d493d59621efc77e02d2fdc7f7502e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:21:02 -0500 Subject: [PATCH 09/11] Disable subtitle download button if user doesn't have permission (#671) ## Description Just a UI change to disable the Search & Download subtitles button if the user doesn't have permission to manage subtitles. This avoids the 403 error if the user attempts to use it. ### Related issues Closes #483 --- .../github/damontecres/wholphin/ui/playback/PlaybackDialog.kt | 4 ++++ .../github/damontecres/wholphin/ui/playback/PlaybackPage.kt | 3 +++ .../damontecres/wholphin/ui/playback/PlaybackViewModel.kt | 2 ++ 3 files changed, 9 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 771a84e2..5051912c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -53,6 +53,7 @@ data class PlaybackSettings( val playbackSpeed: Float, val contentScale: ContentScale, val subtitleDelay: Duration, + val hasSubtitleDownloadPermission: Boolean, ) @Composable @@ -96,6 +97,7 @@ fun PlaybackDialog( SubtitleChoiceBottomDialog( choices = settings.subtitleStreams, currentChoice = settings.subtitleIndex, + hasDownloadPermission = settings.hasSubtitleDownloadPermission, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -275,6 +277,7 @@ fun SubtitleChoiceBottomDialog( onSelectChoice: (Int) -> Unit, onSelectSearch: () -> Unit, gravity: Int, + hasDownloadPermission: Boolean, currentChoice: Int? = null, ) { // TODO enforcing a width ends up ignore the gravity @@ -366,6 +369,7 @@ fun SubtitleChoiceBottomDialog( HorizontalDivider() ListItem( selected = false, + enabled = hasDownloadPermission, onClick = onSelectSearch, leadingContent = {}, headlineContent = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 3dcf6584..122fc5d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -130,6 +130,7 @@ fun PlaybackPage( val player = viewModel.player val mediaInfo by viewModel.currentMediaInfo.observeAsState() + val userDto by viewModel.currentUserDto.observeAsState() val currentPlayback by viewModel.currentPlayback.collectAsState() val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( @@ -561,6 +562,8 @@ fun PlaybackPage( playbackSpeed = playbackSpeed, contentScale = contentScale, subtitleDelay = subtitleDelay, + hasSubtitleDownloadPermission = + remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, ), onDismissRequest = { playbackDialog = null 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 880087e6..fe72d7e4 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 @@ -168,6 +168,8 @@ class PlaybackViewModel val subtitleSearch = MutableLiveData(null) val subtitleSearchLanguage = MutableLiveData(Locale.current.language) + val currentUserDto = serverRepository.currentUserDto + init { addCloseable { player.removeListener(this@PlaybackViewModel) From 19833a1a03b3ca2c59c55ea54a42bd52deceeb59 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Sun, 11 Jan 2026 16:07:40 -0600 Subject: [PATCH 10/11] Add scrim behind season tabs for readability (#657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds a dark gradient scrim behind the season tabs on the TV Show Details screen to improve text readability against bright backdrop images. This follows the same pattern used in PlaybackOverlay.kt for the playback controls. The scrim uses two combined gradients: - Vertical: Dark at top (0.80α → 0.5α) fading to transparent at bottom - Horizontal: Fades in from transparent (left) to full opacity (right), starting at 15% screen width to avoid overlapping with the nav rail The gradients are composited using BlendMode.DstIn to create a smooth 2D fade effect. ### Related issues Fixes #547 ### Screenshots season_darkened_background ### AI/LLM usage AI used to draft PR description and documentation --- .../wholphin/ui/nav/ApplicationContent.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 14f34747..2ada8504 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -49,6 +49,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds +// Top scrim configuration for text readability (clock, season tabs) +private const val TOP_SCRIM_ALPHA = 0.55f +private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height + @HiltViewModel class ApplicationContentViewModel @Inject @@ -73,6 +77,7 @@ fun ApplicationContent( navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, + enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { val backStack: MutableList = @@ -179,6 +184,20 @@ fun ApplicationContent( .alpha(.95f) .drawWithContent { drawContent() + // Subtle top scrim for system UI readability (clock, tabs) + if (enableTopScrim) { + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } drawRect( brush = Brush.horizontalGradient( From 75a98ddaa5780e11b03922cb80cd92ab81412cfa Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:07:47 -0500 Subject: [PATCH 11/11] Minor UI changes (#673) ## Description - Always update home page backdrop - Use smaller font size for episode names & force single line - Use smaller cards for episode cards on person page --- .../damontecres/wholphin/ui/UiConstants.kt | 1 + .../ui/components/SeriesComponents.kt | 4 +++- .../wholphin/ui/detail/PersonPage.kt | 23 ++++++++++++++++++- .../wholphin/ui/detail/movie/MovieDetails.kt | 10 +++++++- .../damontecres/wholphin/ui/main/HomePage.kt | 14 +++++------ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 6b33ad49..989bb2f9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -69,6 +69,7 @@ val SlimItemFields = object Cards { val height2x3 = 172.dp + val heightEpisode = height2x3 * .75f val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index 3939df91..031d4cb9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.formatDateTime @@ -42,7 +43,8 @@ fun EpisodeName( text = episodeName ?: "", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineSmall, - maxLines = 2, + fontSize = 20.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index b715af8d..1f28720d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -43,10 +43,12 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -54,7 +56,9 @@ import com.github.damontecres.wholphin.ui.components.LoadingRow import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -334,8 +338,25 @@ fun PersonPageContent( onClickItem = onClickItem, onClickPosition = { position = it }, showIfEmpty = false, - horizontalPadding = 32.dp, + horizontalPadding = 24.dp, modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(EPISODE_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + modifier = + mod + .ifElse( + position.row == EPISODE_ROW && position.column == index, + Modifier.focusRequester(focusRequester), + ), + ) + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 6055ef1f..0a0f6afc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -506,6 +506,14 @@ fun MovieDetailsContent( } if (similar.isNotEmpty()) { item { + val imageHeight = + remember(movie.type) { + if (movie.type == BaseItemKind.MOVIE) { + Cards.height2x3 + } else { + Cards.heightEpisode + } + } ItemRow( title = stringResource(R.string.more_like_this), items = similar, @@ -524,7 +532,7 @@ fun MovieDetailsContent( onLongClick = onLongClick, modifier = mod, showImageOverlay = true, - imageHeight = Cards.height2x3, + imageHeight = imageHeight, imageWidth = Dp.Unspecified, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 811278fa..97d48772 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.EpisodeName import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -259,6 +260,9 @@ fun HomePageContent( LaunchedEffect(position) { listState.animateScrollToItem(position.row) } + LaunchedEffect(onUpdateBackdrop, focusedItem) { + focusedItem?.let { onUpdateBackdrop.invoke(it) } + } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -379,7 +383,7 @@ fun HomePageContent( .onFocusChanged { if (it.isFocused) { position = RowColumn(rowIndex, index) - item?.let(onUpdateBackdrop) +// item?.let(onUpdateBackdrop) } if (it.isFocused && onFocusPosition != null) { val nonEmptyRowBefore = @@ -466,13 +470,7 @@ fun HomePageHeader( val subtitle = if (isEpisode) dto.name else null val overview = dto.overview subtitle?.let { - Text( - text = subtitle, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + EpisodeName(it) } when (item.type) { BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)