diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 979a9935..08ff6e4e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ plugins { // 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。 // 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。 -val baseAppVersionName = "2.7.0" +val baseAppVersionName = "2.8.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/ExternalAudioTracks.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/ExternalAudioTracks.kt new file mode 100644 index 00000000..99c31063 --- /dev/null +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/ExternalAudioTracks.kt @@ -0,0 +1,77 @@ +package com.miruplay.tv.model + +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +fun buildExternalAudioTracks(paths: List): List = + paths + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + .map { path -> + ExternalAudioTrack( + language = sidecarLanguageFromPath(path), + title = externalAudioFileName(path), + path = path, + ) + } + +fun matchingExternalAudioPaths( + videoPath: String, + siblingPaths: Iterable, +): List { + val videoStem = MediaPathConventions.stem(videoPath).lowercase() + if (videoStem.isBlank()) return emptyList() + return siblingPaths + .asSequence() + .filter { candidate -> candidate != videoPath && candidate.isSupportedExternalAudioPath() } + .filter { candidate -> + val audioStem = MediaPathConventions.stem(candidate).lowercase() + audioStem == videoStem || SIDECAR_SUFFIX_SEPARATORS.any { separator -> + audioStem.startsWith("$videoStem$separator") + } + } + .distinct() + .sortedBy(String::lowercase) + .toList() +} + +internal fun sidecarLanguageFromPath(path: String): String { + val stem = MediaPathConventions.stem(externalAudioFileName(path)).replace('_', '-') + val candidate = SIDECAR_LANGUAGE_SUFFIX.find(stem) + ?.groupValues + ?.get(1) + ?.lowercase() + ?: return "und" + return when (candidate) { + "chs", "sc" -> "zh-Hans" + "cht", "tc" -> "zh-Hant" + "chi", "zho" -> "zh" + "eng" -> "en" + "jpn" -> "ja" + else -> candidate.split('-', limit = 2).let { parts -> + if (parts.size == 1) parts[0] + else parts[0] + "-" + if (parts[1].length == 2) parts[1].uppercase() + else parts[1].replaceFirstChar(Char::uppercase) + } + } +} + +private fun String.isSupportedExternalAudioPath(): Boolean = + externalAudioFileName(this).substringAfterLast('.', "").lowercase() in SUPPORTED_EXTERNAL_AUDIO_EXTENSIONS + +private fun externalAudioFileName(path: String): String { + val pathWithoutUrlSuffix = if ("://" in path) path.substringBefore('?').substringBefore('#') else path + val encodedName = pathWithoutUrlSuffix.substringAfterLast('/').substringAfterLast('\\') + return runCatching { + URLDecoder.decode(encodedName.replace("+", "%2B"), StandardCharsets.UTF_8) + }.getOrDefault(encodedName) +} + +private val SUPPORTED_EXTERNAL_AUDIO_EXTENSIONS = setOf( + "aac", "ac3", "dts", "eac3", "flac", "m4a", "mka", "mp3", "ogg", "opus", "wav", +) +private val SIDECAR_LANGUAGE_SUFFIX = Regex( + "(?:^|[.\\s\\-\\[])([a-zA-Z]{2,3}(?:-[a-zA-Z]{2,4})?)\\]?$", +) +private val SIDECAR_SUFFIX_SEPARATORS = listOf(".", " ", "_", "-", "[") diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/FileModels.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/FileModels.kt index 241c72a2..e6d4ecfa 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/FileModels.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/FileModels.kt @@ -39,6 +39,13 @@ data class SubtitleTrack( val format: SubtitleFormat = SubtitleFormat.SRT, ) +@Serializable +data class ExternalAudioTrack( + val language: String = "und", + val title: String = "", + val path: String, +) + @Serializable data class AudioTrack( val index: Int, diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt index 7c43a844..253f2a1a 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackSource.kt @@ -8,6 +8,7 @@ data class PlaybackSource( val mediaSourceId: String, val startPosition: Long = 0L, // ms val subtitleTracks: List = emptyList(), + val externalAudioTracks: List = emptyList(), val episodeId: String? = null, val progressId: String? = episodeId, ) diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/ExternalAudioTracksTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/ExternalAudioTracksTest.kt new file mode 100644 index 00000000..fbf2dd58 --- /dev/null +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/ExternalAudioTracksTest.kt @@ -0,0 +1,46 @@ +package com.miruplay.tv.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExternalAudioTracksTest { + @Test + fun `matching audio accepts same stem and language suffixes`() { + val video = "/Show/Episode 01.mkv" + + assertEquals( + listOf( + "/Show/Episode 01.en.flac", + "/Show/Episode 01.ja.ac3", + "/Show/Episode 01.m4a", + "/Show/Episode 01.zh-Hans.mka", + ), + matchingExternalAudioPaths( + video, + listOf( + video, + "/Show/Episode 01.m4a", + "/Show/Episode 01.ja.ac3", + "/Show/Episode 01.en.flac", + "/Show/Episode 01.zh-Hans.mka", + "/Show/Episode 01.en.srt", + "/Show/Episode 010.flac", + "/Show/Episode 02.flac", + ), + ), + ) + } + + @Test + fun `audio model derives language without treating title words as language`() { + val tracks = buildExternalAudioTracks( + listOf( + "/Show/Episode 01.zh-Hant.flac", + "/Show/Episode 01 Commentary.opus", + ), + ) + + assertEquals(listOf("zh-Hant", "und"), tracks.map { it.language }) + assertEquals("Episode 01.zh-Hant.flac", tracks.first().title) + } +} diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt index 17ee9c3b..dd75ad2f 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt @@ -22,8 +22,10 @@ import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData +import androidx.media3.exoplayer.source.MergingMediaSource import com.miruplay.tv.core.common.Result import com.miruplay.tv.core.common.logging.MiruLog import com.miruplay.tv.model.FormatAwareToneMappingPreferences @@ -46,6 +48,7 @@ import com.miruplay.tv.player.ijk.android.MiruIjkPlaybackRequest import com.miruplay.tv.player.ijk.android.MiruIjkPlayerListener import com.miruplay.tv.player.ijk.android.MiruIjkSurfaceView import `is`.xyz.mpv.MiruMpvSurfaceView +import `is`.xyz.mpv.subtitle.NativeAssRenderer import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException @@ -108,6 +111,7 @@ class ExoPlaybackController @Inject constructor( private val exoSubtitleSelections = mutableListOf() private val exoAudioSelections = mutableListOf() private val embeddedSubtitleTrackIds = mutableListOf() + private val embeddedAudioTrackIds = mutableListOf() private var selectedSubtitleTrackIndex: Int? = null private var selectedAudioTrackIndex: Int? = null private var currentSource: PlaybackSource? = null @@ -166,8 +170,21 @@ class ExoPlaybackController @Inject constructor( subtitleSelectionWasManual = false _requestedRenderBackend.value = sessionState.effectiveRequestedBackend(playbackPreferences.defaultBackend) _sessionRuleOverrides.value = sessionState.ruleOverrides - refreshRuntimeConfig(null) val httpConfig = httpRequestResolver.configFor(source) + externalAudioUnsupportedMessage( + backend = _requestedRenderBackend.value, + hasExternalAudio = source.externalAudioTracks.isNotEmpty(), + isWebDav = httpConfig.isWebDav(source.uri), + )?.let { message -> + stop(clearSessionState = false) + withContext(Dispatchers.Main) { + _requestedRenderBackend.value = sessionState.effectiveRequestedBackend(playbackPreferences.defaultBackend) + currentSource = source + _state.value = PlaybackState.Error(source, message) + } + return + } + refreshRuntimeConfig(null) if ( _activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID && !httpConfig.isWebDav(source.uri) @@ -217,6 +234,7 @@ class ExoPlaybackController @Inject constructor( "media_source_id" to source.mediaSourceId, "start_position_ms" to source.startPosition.toString(), "subtitle_count" to source.subtitleTracks.size.toString(), + "external_audio_count" to source.externalAudioTracks.size.toString(), ), ) @@ -262,7 +280,25 @@ class ExoPlaybackController @Inject constructor( .setSubtitleConfigurations(subtitleConfigs) .build() - player.setMediaItem(mediaItem) + if (source.externalAudioTracks.isEmpty()) { + player.setMediaItem(mediaItem) + } else { + val mediaSourceFactory = standardMediaSourceFactory(player) + val mergedSources = buildList { + add(mediaSourceFactory.createMediaSource(mediaItem)) + source.externalAudioTracks.forEach { track -> + add( + mediaSourceFactory.createMediaSource( + MediaItem.Builder() + .setUri(track.path) + .setMediaMetadata(MediaMetadata.Builder().setTitle(track.title).build()) + .build(), + ), + ) + } + } + player.setMediaSource(MergingMediaSource(*mergedSources.toTypedArray())) + } if (source.startPosition > 0) { player.seekTo(source.startPosition) autoResumeSeekCalled = true @@ -461,6 +497,7 @@ class ExoPlaybackController @Inject constructor( exoSubtitleSelections.clear() exoAudioSelections.clear() embeddedSubtitleTrackIds.clear() + embeddedAudioTrackIds.clear() selectedSubtitleTrackIndex = null selectedAudioTrackIndex = null subtitleSelectionWasManual = false @@ -518,14 +555,19 @@ class ExoPlaybackController @Inject constructor( } override suspend fun setAudioTrack(trackIndex: Int) { - if (_activeRenderBackend.value == PlaybackRenderBackend.EXPERIMENTAL_IJKPLAYER) { - withContext(Dispatchers.Main) { + when (_activeRenderBackend.value) { + PlaybackRenderBackend.EXPERIMENTAL_IJKPLAYER -> withContext(Dispatchers.Main) { val rawStreamIndex = ijkAudioRawStreamIds.getOrNull(trackIndex) ?: return@withContext ijkView?.selectAudioRawStream(rawStreamIndex) selectedAudioTrackIndex = trackIndex } - } else { - selectExoTrack(C.TRACK_TYPE_AUDIO, trackIndex) + PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED -> withContext(Dispatchers.Main) { + val nativeTrackId = embeddedAudioTrackIds.getOrNull(trackIndex) ?: return@withContext + embeddedMpvView?.setAudioTrack(nativeTrackId) + selectedAudioTrackIndex = trackIndex + } + PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID -> Unit + else -> selectExoTrack(C.TRACK_TYPE_AUDIO, trackIndex) } } @@ -616,6 +658,7 @@ class ExoPlaybackController @Inject constructor( path = embeddedMpvPlaybackUri ?: source.uri, startPositionMs = embeddedMpvPositionMs, externalSubtitlePaths = source.subtitleTracks.map { it.path }, + externalAudioPaths = source.externalAudioTracks.map { it.path }, ) } } @@ -1515,6 +1558,7 @@ class ExoPlaybackController @Inject constructor( path = embeddedMpvPlaybackUri ?: source.uri, startPositionMs = embeddedMpvPositionMs, externalSubtitlePaths = source.subtitleTracks.map { it.path }, + externalAudioPaths = source.externalAudioTracks.map { it.path }, ) } @@ -1534,7 +1578,7 @@ class ExoPlaybackController @Inject constructor( return existing } val created = MiruMpvSurfaceView(container.context).apply { - onSubtitleTracksChanged = { view -> refreshEmbeddedMpvSubtitleTracks(view) } + onTracksChanged = { view -> refreshEmbeddedMpvTracks(view) } onStateChanged = { snapshot -> embeddedMpvPositionMs = snapshot.positionMs embeddedMpvDurationMs = snapshot.durationMs @@ -1605,7 +1649,7 @@ class ExoPlaybackController @Inject constructor( return created } - private fun refreshEmbeddedMpvSubtitleTracks(view: MiruMpvSurfaceView) { + private fun refreshEmbeddedMpvTracks(view: MiruMpvSurfaceView) { val tracks = view.subtitleTracks() availableSubtitles.clear() embeddedSubtitleTrackIds.clear() @@ -1637,6 +1681,23 @@ class ExoPlaybackController @Inject constructor( selectedSubtitleTrackIndex = preferredIndex } } + + availableAudioTracks.clear() + embeddedAudioTrackIds.clear() + selectedAudioTrackIndex = null + view.audioTracks().forEach { track -> + val index = availableAudioTracks.size + availableAudioTracks.add( + AudioTrack( + index = index, + language = track.language, + title = track.title, + codec = track.codec, + ), + ) + embeddedAudioTrackIds.add(track.id) + if (track.selected) selectedAudioTrackIndex = index + } } private fun recordPlaybackClockSample(snapshot: MiruMpvSurfaceView.StateSnapshot) { @@ -1707,6 +1768,17 @@ class ExoPlaybackController @Inject constructor( ) } + private fun standardMediaSourceFactory(player: ExoPlayer): DefaultMediaSourceFactory { + val libassSession = checkNotNull(LibassSubtitleRegistry.sessionFor(player)) + return DefaultMediaSourceFactory( + ZlibSubtitleProtectingDataSourceFactory(dataSourceFactory), + ZlibSubtitleExtractorsFactory( + session = libassSession, + nativeAvailable = NativeAssRenderer::isAvailable, + ), + ) + } + private fun activeExoPlayer(): ExoPlayer = standardExoPlayer() private fun activeExoPlayerOrNull(): ExoPlayer? = standardPlayerOrNull() @@ -1748,6 +1820,23 @@ class ExoPlaybackController @Inject constructor( private fun standardPlayerOrNull(): ExoPlayer? = standardExoPlayer } +internal fun externalAudioUnsupportedMessage( + backend: PlaybackRenderBackend, + hasExternalAudio: Boolean, + isWebDav: Boolean = false, +): String? { + if (!hasExternalAudio) return null + return when { + backend == PlaybackRenderBackend.EXPERIMENTAL_IJKPLAYER -> + "IJKPlayer 不支持加载外挂音轨" + backend == PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID -> + "外部 mpv-android 不支持通过 Intent 加载外挂音轨" + backend == PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED && isWebDav -> + "嵌入式 mpv 当前不支持为 WebDAV 视频加载外挂音轨" + else -> null + } +} + internal data class EmbeddedMpvStartupState( val isPlaying: Boolean, val playbackState: PlaybackState, diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/ExternalAudioBackendSupportTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/ExternalAudioBackendSupportTest.kt new file mode 100644 index 00000000..a61a9d14 --- /dev/null +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/ExternalAudioBackendSupportTest.kt @@ -0,0 +1,35 @@ +package com.miruplay.tv.player + +import com.miruplay.tv.model.PlaybackRenderBackend +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ExternalAudioBackendSupportTest { + @Test + fun `unsupported selected backends return explicit errors`() { + assertEquals( + "IJKPlayer 不支持加载外挂音轨", + externalAudioUnsupportedMessage(PlaybackRenderBackend.EXPERIMENTAL_IJKPLAYER, true), + ) + assertEquals( + "外部 mpv-android 不支持通过 Intent 加载外挂音轨", + externalAudioUnsupportedMessage(PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID, true), + ) + assertEquals( + "嵌入式 mpv 当前不支持为 WebDAV 视频加载外挂音轨", + externalAudioUnsupportedMessage( + PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED, + hasExternalAudio = true, + isWebDav = true, + ), + ) + } + + @Test + fun `supported backends and sources without external audio have no error`() { + assertNull(externalAudioUnsupportedMessage(PlaybackRenderBackend.STANDARD_EXO, true)) + assertNull(externalAudioUnsupportedMessage(PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED, true)) + assertNull(externalAudioUnsupportedMessage(PlaybackRenderBackend.EXPERIMENTAL_IJKPLAYER, false)) + } +} diff --git a/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt b/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt index 9e3fdcbd..7fa686b6 100644 --- a/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt +++ b/player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt @@ -42,7 +42,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( val eofReached: Boolean = false, ) - data class SubtitleTrackInfo( + data class TrackInfo( val id: Int, val language: String, val title: String, @@ -52,7 +52,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( ) var onStateChanged: ((StateSnapshot) -> Unit)? = null - var onSubtitleTracksChanged: ((MiruMpvSurfaceView) -> Unit)? = null + var onTracksChanged: ((MiruMpvSurfaceView) -> Unit)? = null var onFileLoaded: (() -> Unit)? = null var onPlaybackRestart: (() -> Unit)? = null @@ -61,6 +61,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( private var appliedSessionOptions: SessionOptions? = null private var pendingStartPositionMs: Long? = null private var pendingExternalSubtitlePaths: List = emptyList() + private var pendingExternalAudioPaths: List = emptyList() private var lastState = StateSnapshot() private val recentNativeLogMessages = ArrayDeque() @@ -86,6 +87,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( initialized = false appliedSessionOptions = null pendingExternalSubtitlePaths = emptyList() + pendingExternalAudioPaths = emptyList() lastState = StateSnapshot() synchronized(recentNativeLogMessages) { recentNativeLogMessages.clear() @@ -105,6 +107,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( path: String, startPositionMs: Long = 0L, externalSubtitlePaths: List = emptyList(), + externalAudioPaths: List = emptyList(), ) { ensureInitialized() if (appliedSessionOptions != sessionOptions) { @@ -112,6 +115,7 @@ class MiruMpvSurfaceView @JvmOverloads constructor( } pendingStartPositionMs = startPositionMs.coerceAtLeast(0L).takeIf(::shouldApplyPendingStartSeek) pendingExternalSubtitlePaths = externalSubtitlePaths.filter(String::isNotBlank).distinct() + pendingExternalAudioPaths = externalAudioPaths.filter(String::isNotBlank).distinct() if (shouldLoadMpvFileImmediately(isPlaybackSurfaceAttached())) { MPVLib.command(arrayOf("loadfile", path)) } else { @@ -119,13 +123,17 @@ class MiruMpvSurfaceView @JvmOverloads constructor( } } - fun subtitleTracks(): List { + fun subtitleTracks(): List = tracksOfType("sub") + + fun audioTracks(): List = tracksOfType("audio") + + private fun tracksOfType(type: String): List { if (!initialized) return emptyList() val count = MPVLib.getPropertyInt("track-list/count") ?: return emptyList() return (0 until count).mapNotNull { index -> - if (MPVLib.getPropertyString("track-list/$index/type") != "sub") return@mapNotNull null + if (MPVLib.getPropertyString("track-list/$index/type") != type) return@mapNotNull null val id = MPVLib.getPropertyInt("track-list/$index/id") ?: return@mapNotNull null - SubtitleTrackInfo( + TrackInfo( id = id, language = MPVLib.getPropertyString("track-list/$index/lang") ?: "und", title = MPVLib.getPropertyString("track-list/$index/title") ?: "", @@ -142,6 +150,12 @@ class MiruMpvSurfaceView @JvmOverloads constructor( } } + fun setAudioTrack(trackId: Int) { + if (initialized) { + MPVLib.setPropertyString("aid", trackId.toString()) + } + } + fun pausePlayback() { if (initialized) { MPVLib.setPropertyBoolean("pause", true) @@ -217,12 +231,13 @@ class MiruMpvSurfaceView @JvmOverloads constructor( MPVLib.observeProperty("eof-reached", MPVLib.MpvFormat.MPV_FORMAT_FLAG) MPVLib.observeProperty("track-list/count", MPVLib.MpvFormat.MPV_FORMAT_INT64) MPVLib.observeProperty("sid", MPVLib.MpvFormat.MPV_FORMAT_STRING) + MPVLib.observeProperty("aid", MPVLib.MpvFormat.MPV_FORMAT_STRING) } override fun eventProperty(property: String) = Unit override fun eventProperty(property: String, value: Long) { - if (property == "track-list/count") notifySubtitleTracksChanged() + if (property == "track-list/count") notifyTracksChanged() } override fun eventProperty(property: String, value: Boolean) { @@ -233,12 +248,12 @@ class MiruMpvSurfaceView @JvmOverloads constructor( } override fun eventProperty(property: String, value: String) { - if (property == "sid") notifySubtitleTracksChanged() + if (property == "sid" || property == "aid") notifyTracksChanged() } - private fun notifySubtitleTracksChanged() { + private fun notifyTracksChanged() { post { - if (initialized) onSubtitleTracksChanged?.invoke(this) + if (initialized) onTracksChanged?.invoke(this) } } @@ -258,6 +273,9 @@ class MiruMpvSurfaceView @JvmOverloads constructor( pendingExternalSubtitlePaths.forEachIndexed { index, path -> MPVLib.command(arrayOf("sub-add", path, if (index == 0) "select" else "auto")) } + pendingExternalAudioPaths.forEach { path -> + MPVLib.command(arrayOf("audio-add", path, "auto")) + } onFileLoaded?.invoke() } MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSidecarResolver.kt similarity index 65% rename from repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt rename to repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSidecarResolver.kt index 14d2dc9f..44d2dee4 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolver.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackSidecarResolver.kt @@ -3,10 +3,12 @@ package com.miruplay.tv.repository import com.miruplay.tv.core.common.Result import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.PlaybackSource +import com.miruplay.tv.model.buildExternalAudioTracks import com.miruplay.tv.model.buildExternalSubtitleTracks +import com.miruplay.tv.model.matchingExternalAudioPaths import com.miruplay.tv.model.matchingExternalSubtitlePaths -class PlaybackSubtitleResolver( +class PlaybackSidecarResolver( private val index: MediaIndexRepository, private val mediaSources: MediaSourceRepository, private val listSiblingPaths: suspend (MediaSourceInfo, String) -> List = { _, _ -> emptyList() }, @@ -25,17 +27,19 @@ class PlaybackSubtitleResolver( mediaSource.playableUriForIndexedPath(indexed.path) == source.uri } ?: entries.firstOrNull { indexed -> indexed.path == episodePath } val videoPath = entry?.path ?: episodePath - val siblingSubtitlePaths = matchingExternalSubtitlePaths( - videoPath = videoPath, - siblingPaths = listSiblingPaths(mediaSource, videoPath), + val siblingPaths = listSiblingPaths(mediaSource, videoPath) + val discoveredSubtitles = buildExternalSubtitleTracks( + (entry?.externalSubtitlePaths.orEmpty() + matchingExternalSubtitlePaths(videoPath, siblingPaths)) + .map(mediaSource::playableUriForIndexedPath), ) - val discoveredTracks = buildExternalSubtitleTracks( - (entry?.externalSubtitlePaths.orEmpty() + siblingSubtitlePaths) + val discoveredAudio = buildExternalAudioTracks( + matchingExternalAudioPaths(videoPath, siblingPaths) .map(mediaSource::playableUriForIndexedPath), ) - if (discoveredTracks.isEmpty()) return source + if (discoveredSubtitles.isEmpty() && discoveredAudio.isEmpty()) return source return source.copy( - subtitleTracks = (source.subtitleTracks + discoveredTracks).distinctBy { it.path }, + subtitleTracks = (source.subtitleTracks + discoveredSubtitles).distinctBy { it.path }, + externalAudioTracks = (source.externalAudioTracks + discoveredAudio).distinctBy { it.path }, ) } } diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSidecarResolverTest.kt similarity index 88% rename from repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt rename to repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSidecarResolverTest.kt index dd3b043f..3ca1326b 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSubtitleResolverTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/PlaybackSidecarResolverTest.kt @@ -9,14 +9,14 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test -class PlaybackSubtitleResolverTest { +class PlaybackSidecarResolverTest { @Test fun `resolver merges indexed and listed WebDAV subtitles`() = runBlocking { val mediaSource = MediaSourceInfoConventions.webDav( url = "https://dav.example/anime", name = "DAV", ).copy(id = 7L) - val resolver = PlaybackSubtitleResolver( + val resolver = PlaybackSidecarResolver( index = FakeIndexRepository( listOf( MediaIndexEntry( @@ -36,7 +36,10 @@ class PlaybackSubtitleResolverTest { listOf( "/Show/Episode 01.zh-CN.srt", "/Show/Episode 01.ja.ass", + "/Show/Episode 01.en.flac", + "/Show/Episode 01.ja.ac3", "/Show/Episode 02.srt", + "/Show/Episode 02.flac", ) }, ) @@ -57,6 +60,14 @@ class PlaybackSubtitleResolverTest { ), resolved.subtitleTracks.map { it.path }, ) + assertEquals( + listOf( + "https://dav.example/anime/Show/Episode%2001.en.flac", + "https://dav.example/anime/Show/Episode%2001.ja.ac3", + ), + resolved.externalAudioTracks.map { it.path }, + ) + assertEquals(listOf("en", "ja"), resolved.externalAudioTracks.map { it.language }) } @Test @@ -65,7 +76,7 @@ class PlaybackSubtitleResolverTest { url = "https://dav.example/anime", name = "DAV", ).copy(id = 7L) - val resolver = PlaybackSubtitleResolver( + val resolver = PlaybackSidecarResolver( index = FakeIndexRepository(emptyList()), mediaSources = FakeSourceRepository(mediaSource), listSiblingPaths = { _, _ -> @@ -95,7 +106,7 @@ class PlaybackSubtitleResolverTest { @Test fun `resolver leaves direct playback without an indexed episode unchanged`() = runBlocking { val source = PlaybackSource(uri = "https://example.test/video.mkv", mediaSourceId = "direct") - val resolver = PlaybackSubtitleResolver( + val resolver = PlaybackSidecarResolver( index = FakeIndexRepository(emptyList()), mediaSources = FakeSourceRepository(), ) diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt index 268e61f1..b30c085f 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt @@ -38,7 +38,7 @@ import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaSourceRepository import com.miruplay.tv.repository.MetadataRepository import com.miruplay.tv.repository.NextPlaybackSourceResolver -import com.miruplay.tv.repository.PlaybackSubtitleResolver +import com.miruplay.tv.repository.PlaybackSidecarResolver import com.miruplay.tv.repository.PlaybackPreferencesRepository import com.miruplay.tv.repository.PlaybackProgressRepository import com.miruplay.tv.repository.ScanPreferencesRepository @@ -192,8 +192,8 @@ class PlayerViewModel @Inject constructor( mergeSameAnimeEnabled = { scanPreferences.getPreferences().mergeSameAnimeEnabled }, ) } - private val playbackSubtitleResolver by lazy { - PlaybackSubtitleResolver( + private val playbackSidecarResolver by lazy { + PlaybackSidecarResolver( index = mediaIndexRepository.get(), mediaSources = mediaRepository.get(), listSiblingPaths = { mediaSourceInfo, videoPath -> @@ -204,7 +204,7 @@ class PlayerViewModel @Inject constructor( fun play(source: PlaybackSource, ownerToken: Any? = activeScreenOwnerToken) { viewModelScope.launch { - val resolvedSource = playbackSubtitleResolver.resolve(source) + val resolvedSource = playbackSidecarResolver.resolve(source) _errorMessage.value = null pendingSeekPositionMs = null _currentPosition.value = resolvedSource.startPosition.coerceAtLeast(0L)