diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt new file mode 100644 index 00000000..0305978e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt @@ -0,0 +1,91 @@ +package com.nuvio.app.features.player + +import android.content.res.Resources +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTrackNameProvider + +@UnstableApi +class CustomDefaultTrackNameProvider(resources: Resources) : DefaultTrackNameProvider(resources) { + + override fun getTrackName(format: Format): String { + var trackName = super.getTrackName(format) + + if (format.sampleMimeType != null) { + var sampleFormat = formatNameFromMime(format.sampleMimeType) + if (sampleFormat == null) { + sampleFormat = formatNameFromMime(format.codecs) + } + if (sampleFormat == null) { + sampleFormat = format.sampleMimeType + } + if (sampleFormat != null) { + trackName += " ($sampleFormat)" + } + } + + if (format.label != null) { + if (!trackName.startsWith(format.label!!)) { + trackName += " - ${format.label}" + } + } + + return trackName + } + + companion object { + fun formatNameFromMime(mimeType: String?): String? { + if (mimeType == null) return null + + return when (mimeType) { + MimeTypes.AUDIO_DTS -> "DTS" + MimeTypes.AUDIO_DTS_HD -> "DTS-HD" + MimeTypes.AUDIO_DTS_EXPRESS -> "DTS Express" + MimeTypes.AUDIO_TRUEHD -> "TrueHD" + MimeTypes.AUDIO_AC3 -> "AC-3" + MimeTypes.AUDIO_E_AC3 -> "E-AC-3" + MimeTypes.AUDIO_E_AC3_JOC -> "E-AC-3-JOC" + MimeTypes.AUDIO_AC4 -> "AC-4" + MimeTypes.AUDIO_AAC -> "AAC" + MimeTypes.AUDIO_MPEG -> "MP3" + MimeTypes.AUDIO_MPEG_L2 -> "MP2" + MimeTypes.AUDIO_VORBIS -> "Vorbis" + MimeTypes.AUDIO_OPUS -> "Opus" + MimeTypes.AUDIO_FLAC -> "FLAC" + MimeTypes.AUDIO_ALAC -> "ALAC" + MimeTypes.AUDIO_WAV -> "WAV" + MimeTypes.AUDIO_AMR -> "AMR" + MimeTypes.AUDIO_AMR_NB -> "AMR-NB" + MimeTypes.AUDIO_AMR_WB -> "AMR-WB" + MimeTypes.AUDIO_IAMF -> "IAMF" + MimeTypes.AUDIO_MPEGH_MHA1 -> "MPEG-H" + MimeTypes.AUDIO_MPEGH_MHM1 -> "MPEG-H" + MimeTypes.VIDEO_H264 -> "AVC" + MimeTypes.VIDEO_H265 -> "HEVC" + MimeTypes.VIDEO_AV1 -> "AV1" + MimeTypes.VIDEO_VP8 -> "VP8" + MimeTypes.VIDEO_VP9 -> "VP9" + MimeTypes.VIDEO_DOLBY_VISION -> "Dolby Vision" + "application/pgs" -> "PGS" + MimeTypes.APPLICATION_SUBRIP -> "SRT" + MimeTypes.TEXT_SSA -> "SSA" + MimeTypes.TEXT_VTT -> "VTT" + MimeTypes.APPLICATION_TTML -> "TTML" + MimeTypes.APPLICATION_TX3G -> "TX3G" + MimeTypes.APPLICATION_DVBSUBS -> "DVB" + else -> null + } + } + + fun getChannelLayoutName(channelCount: Int): String? { + return when (channelCount) { + 1 -> "Mono" + 2 -> "Stereo" + 6 -> "5.1" + 8 -> "7.1" + else -> if (channelCount > 0) "${channelCount}ch" else null + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index b6f7be0c..0d9568b0 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -58,7 +58,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.HttpURLConnection import java.net.URL -import java.util.Locale private const val TAG = "NuvioPlayer" @@ -298,10 +297,10 @@ actual fun PlatformPlayerSurface( } override fun getAudioTracks(): List = - exoPlayer.extractAudioTracks() + exoPlayer.extractAudioTracks(context) override fun getSubtitleTracks(): List { - val tracks = exoPlayer.extractSubtitleTracks() + val tracks = exoPlayer.extractSubtitleTracks(context) Log.d(TAG, "getSubtitleTracks: found ${tracks.size} tracks") tracks.forEach { t -> Log.d(TAG, " track idx=${t.index} id=${t.id} label='${t.label}' lang=${t.language} selected=${t.isSelected}") @@ -559,47 +558,20 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { } } -private fun ExoPlayer.extractAudioTracks(): List { +private fun ExoPlayer.extractAudioTracks(context: Context): List { val tracks = mutableListOf() + val trackNameProvider = CustomDefaultTrackNameProvider(context.resources) var idx = 0 for (group in currentTracks.groups) { if (group.type != C.TRACK_TYPE_AUDIO) continue val format = group.mediaTrackGroup.getFormat(0) - val channelLabel = when { - format.channelCount == 1 -> "Mono" - format.channelCount == 2 -> "Stereo" - format.channelCount == 6 -> "5.1" - format.channelCount == 8 -> "7.1" - format.channelCount > 0 -> "${format.channelCount}ch" - else -> null - } - val mime = format.sampleMimeType?.lowercase() - val codecLabel = when { - mime == null -> null - mime.contains("eac3-joc") -> "Dolby Atmos" - mime.contains("truehd") && format.channelCount >= 8 -> "Dolby Atmos" - mime.contains("truehd") -> "Dolby TrueHD" - mime.contains("eac3") -> "Dolby Digital Plus" - mime.contains("ac3") -> "Dolby Digital" - mime.contains("opus") -> "Opus" - mime.contains("aac") -> "AAC" - mime.contains("dts-hd") -> "DTS-HD" - mime.contains("dts") -> "DTS" - else -> null - } - val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } } - val baseName = format.label?.takeIf { it.isNotBlank() } - ?: resolvedLanguage - ?: format.language + val label = trackNameProvider.getTrackName(format).takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) } - val suffix = listOfNotNull(channelLabel, codecLabel) - .joinToString(" ") - .let { if (it.isNotBlank()) " ($it)" else "" } tracks.add( AudioTrack( index = idx, id = format.id ?: idx.toString(), - label = "$baseName$suffix", + label = label, language = format.language, isSelected = group.isSelected, ) @@ -609,8 +581,9 @@ private fun ExoPlayer.extractAudioTracks(): List { return tracks } -private fun ExoPlayer.extractSubtitleTracks(): List { +private fun ExoPlayer.extractSubtitleTracks(context: Context): List { val tracks = mutableListOf() + val trackNameProvider = CustomDefaultTrackNameProvider(context.resources) var idx = 0 for (group in currentTracks.groups) { if (group.type != C.TRACK_TYPE_TEXT) continue @@ -620,7 +593,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List { SubtitleTrack( index = idx, id = format.id ?: idx.toString(), - label = format.label ?: "", + label = trackNameProvider.getTrackName(format), language = format.language, isSelected = group.isSelected, isForced = inferForcedSubtitleTrack( diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 9839d1f0..06779ac2 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -579,15 +579,29 @@ final class MPVPlayerViewController: UIViewController { for i in 0.. String { + (getString("track-list/\(index)/\(field)") ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func formatTrackTitle( + type: String, + index: Int, + title: String, + lang: String, + codec: String, + decoderDescription: String, + channels: String, + channelCount: Int + ) -> String { + let base = ifNotBlank(title) + ?? localizedLanguageName(lang) + ?? (type == "sub" ? "Subtitle \(index + 1)" : "Track \(index + 1)") + let codecName = codecDisplayName(codec) ?? codecDisplayName(decoderDescription) + let channelName = type == "audio" ? channelLayoutName(channels: channels, channelCount: channelCount) : nil + let details = [channelName, codecName] + .compactMap { $0 } + .filter { detail in !base.localizedCaseInsensitiveContains(detail) } + return details.isEmpty ? base : "\(base) (\(details.joined(separator: ", ")))" + } + + private func ifNotBlank(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func localizedLanguageName(_ languageCode: String) -> String? { + guard let code = ifNotBlank(languageCode) else { return nil } + return Locale.current.localizedString(forLanguageCode: code) ?? code + } + + private func channelLayoutName(channels: String, channelCount: Int) -> String? { + if let normalized = ifNotBlank(channels), normalized != "unknown" { + let lower = normalized.lowercased() + if lower == "mono" { return "Mono" } + if lower == "stereo" { return "Stereo" } + return normalized + } + switch channelCount { + case 1: + return "Mono" + case 2: + return "Stereo" + case 6: + return "5.1" + case 8: + return "7.1" + case let count where count > 0: + return "\(count)ch" + default: + return nil + } + } + + private func codecDisplayName(_ value: String) -> String? { + guard let raw = ifNotBlank(value) else { return nil } + let codec = raw.lowercased() + if codec.contains("eac3") || codec.contains("e-ac-3") || codec.contains("e ac-3") { + return codec.contains("joc") || codec.contains("atmos") ? "E-AC-3-JOC" : "E-AC-3" + } + if codec.contains("truehd") || codec.contains("true hd") { return "TrueHD" } + if codec.contains("ac3") || codec.contains("ac-3") { return "AC-3" } + if codec.contains("dts-hd") || codec.contains("dtshd") || codec.contains("dts hd") { return "DTS-HD" } + if codec.contains("dts") || codec == "dca" { return "DTS" } + if codec.contains("aac") { return "AAC" } + if codec.contains("mp3") || codec.contains("mpeg audio") { return "MP3" } + if codec.contains("mp2") { return "MP2" } + if codec.contains("opus") { return "Opus" } + if codec.contains("vorbis") { return "Vorbis" } + if codec.contains("flac") { return "FLAC" } + if codec.contains("alac") { return "ALAC" } + if codec.contains("pcm") || codec.contains("wav") { return "WAV" } + if codec.contains("amr_wb") || codec.contains("amr-wb") { return "AMR-WB" } + if codec.contains("amr_nb") || codec.contains("amr-nb") { return "AMR-NB" } + if codec.contains("amr") { return "AMR" } + if codec.contains("iamf") { return "IAMF" } + if codec.contains("mpegh") || codec.contains("mpeg-h") { return "MPEG-H" } + if codec.contains("pgs") || codec.contains("hdmv") { return "PGS" } + if codec.contains("subrip") || codec == "srt" { return "SRT" } + if codec.contains("ass") || codec.contains("ssa") { return "SSA" } + if codec.contains("webvtt") || codec == "vtt" { return "VTT" } + if codec.contains("ttml") { return "TTML" } + if codec.contains("mov_text") || codec.contains("tx3g") { return "TX3G" } + if codec.contains("dvb") { return "DVB" } + return raw + } + private func clearPlaybackError() { errorStateLock.lock() recentPlaybackLogs.removeAll(keepingCapacity: true)