Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.miruplay.tv.model

import java.net.URLDecoder
import java.nio.charset.StandardCharsets

fun buildExternalAudioTracks(paths: List<String>): List<ExternalAudioTrack> =
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<String>,
): List<String> {
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(".", " ", "_", "-", "[")
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ data class PlaybackSource(
val mediaSourceId: String,
val startPosition: Long = 0L, // ms
val subtitleTracks: List<SubtitleTrack> = emptyList(),
val externalAudioTracks: List<ExternalAudioTrack> = emptyList(),
val episodeId: String? = null,
val progressId: String? = episodeId,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -108,6 +111,7 @@ class ExoPlaybackController @Inject constructor(
private val exoSubtitleSelections = mutableListOf<ExoTrackSelection>()
private val exoAudioSelections = mutableListOf<ExoTrackSelection>()
private val embeddedSubtitleTrackIds = mutableListOf<Int>()
private val embeddedAudioTrackIds = mutableListOf<Int>()
private var selectedSubtitleTrackIndex: Int? = null
private var selectedAudioTrackIndex: Int? = null
private var currentSource: PlaybackSource? = null
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
),
)

Expand Down Expand Up @@ -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(),
),
)
Comment on lines +289 to +297

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve sidecar language and title metadata through both playback backends.

ExternalAudioTrack.language is derived during discovery but does not reach the displayed backend track. Exo reads Format.language, and embedded MPV reads native track metadata. A file named Episode.en.ac3 without embedded tags will therefore appear as und or unnamed in the audio menu.

  • player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt#L289-L297: preserve each external track language and title in the Exo audio-track representation.
  • player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt#L1561-L1561: pass external-audio metadata, not only paths, to embedded MPV.
  • player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt#L276-L278: apply the supplied language and title when adding the native audio track, or merge the metadata when publishing TrackInfo.

Add a backend test for a sidecar with a language suffix and no embedded audio tags.

📍 Affects 2 files
  • player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt#L289-L297 (this comment)
  • player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt#L1561-L1561
  • player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt#L276-L278
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt`
around lines 289 - 297, Preserve sidecar language and title metadata across both
playback backends: in ExoPlaybackController at
player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt:289-297,
populate the external track’s Format metadata when creating its MediaSource; at
player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt:1561-1561,
pass full external-audio metadata rather than paths to embedded MPV; and in
MiruMpvSurfaceView at
player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt:276-278,
apply or merge the supplied language and title when creating or publishing the
native audio track. Add a backend test covering a language-suffixed sidecar with
no embedded audio tags.

}
}
player.setMediaSource(MergingMediaSource(*mergedSources.toTypedArray()))
}
if (source.startPosition > 0) {
player.seekTo(source.startPosition)
autoResumeSeekCalled = true
Expand Down Expand Up @@ -461,6 +497,7 @@ class ExoPlaybackController @Inject constructor(
exoSubtitleSelections.clear()
exoAudioSelections.clear()
embeddedSubtitleTrackIds.clear()
embeddedAudioTrackIds.clear()
selectedSubtitleTrackIndex = null
selectedAudioTrackIndex = null
subtitleSelectionWasManual = false
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 },
)
}
}
Expand Down Expand Up @@ -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 },
)
}

Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading