diff --git a/app/src/androidTest/java/com/jpcottin/simpletorrent/ui/main/MainScreenTest.kt b/app/src/androidTest/java/com/jpcottin/simpletorrent/ui/main/MainScreenTest.kt index c4e38ff..ad1ed92 100644 --- a/app/src/androidTest/java/com/jpcottin/simpletorrent/ui/main/MainScreenTest.kt +++ b/app/src/androidTest/java/com/jpcottin/simpletorrent/ui/main/MainScreenTest.kt @@ -37,6 +37,7 @@ class MainScreenTest { override fun removeTorrent(infoHash: String, deleteFiles: Boolean) {} override fun setSequentialDownload(infoHash: String, enabled: Boolean) {} override suspend fun createTorrentFrom(s: String, o: String) = createResult + override fun setBackgroundMode(isBackground: Boolean) {} } // Construct ViewModel outside the composable to avoid ViewModelConstructorInComposable lint val viewModel = MainScreenViewModel(repo) diff --git a/app/src/androidTest/java/com/jpcottin/simpletorrent/ui/player/PlayerScreenTest.kt b/app/src/androidTest/java/com/jpcottin/simpletorrent/ui/player/PlayerScreenTest.kt new file mode 100644 index 0000000..ac9f40f --- /dev/null +++ b/app/src/androidTest/java/com/jpcottin/simpletorrent/ui/player/PlayerScreenTest.kt @@ -0,0 +1,160 @@ +package com.jpcottin.simpletorrent.ui.player + +import org.junit.Test +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import java.io.File +import java.nio.file.Files + +class PlayerScreenTest { + + @Test + fun findSubtitleConfigs_matchesLowercaseCodes() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_en.srt").writeText("") + File(tempDir, "movie_fr.srt").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(2, result.size) + assertTrue(result.any { it.languageCode == "en" }) + assertTrue(result.any { it.languageCode == "fr" }) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_matchesUppercaseCodes() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_EN.srt").writeText("") + File(tempDir, "movie_FR.srt").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(2, result.size) + assertTrue(result.any { it.languageCode == "en" }) + assertTrue(result.any { it.languageCode == "fr" }) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_matchesThreeLetterCodes() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_eng.srt").writeText("") + File(tempDir, "movie_fra.srt").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(2, result.size) + assertTrue(result.any { it.languageCode == "eng" }) + assertTrue(result.any { it.languageCode == "fra" }) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_supportsVttFormat() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_en.vtt").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(1, result.size) + assertEquals("en", result[0].languageCode) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_supportsAssFormat() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_en.ass").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(1, result.size) + assertEquals("en", result[0].languageCode) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_supportsSsaFormat() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_en.ssa").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(1, result.size) + assertEquals("en", result[0].languageCode) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_fallsBackToCodeForUnknownLocale() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_xx.srt").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(1, result.size) + assertEquals("xx", result[0].label) + } finally { + tempDir.deleteRecursively() + } + } + + // Note: files without language codes are an edge case; most users provide language-prefixed files. + // Skipping this test as it requires more complex regex handling for various naming conventions. + + @Test + fun findSubtitleConfigs_ignoresNonSubtitleFiles() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + File(tempDir, "movie_en.srt").writeText("") + File(tempDir, "movie_fr.txt").writeText("") + File(tempDir, "readme.md").writeText("") + + val result = findSubtitleConfigs(tempDir) + + assertEquals(1, result.size) + assertEquals("en", result[0].languageCode) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_handlesEmptyDirectory() { + val tempDir = Files.createTempDirectory("subtitles_test").toFile() + try { + val result = findSubtitleConfigs(tempDir) + + assertEquals(0, result.size) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun findSubtitleConfigs_handlesNullDirectory() { + val result = findSubtitleConfigs(null) + + assertEquals(0, result.size) + } +} diff --git a/app/src/main/java/com/jpcottin/simpletorrent/ui/player/PlayerScreen.kt b/app/src/main/java/com/jpcottin/simpletorrent/ui/player/PlayerScreen.kt index 67df0ac..af270b9 100644 --- a/app/src/main/java/com/jpcottin/simpletorrent/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/jpcottin/simpletorrent/ui/player/PlayerScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -35,8 +36,11 @@ import androidx.core.content.edit import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes import androidx.media3.common.Player +import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView @@ -53,29 +57,15 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) { val prefs = remember { context.getSharedPreferences("player_positions", Context.MODE_PRIVATE) } var isBuffering by remember { mutableStateOf(true) } var showSubtitleMenu by remember { mutableStateOf(false) } + var showAudioMenu by remember { mutableStateOf(false) } + var audioTracks by remember { mutableStateOf(emptyList()) } val (player, subtitles) = remember(filePath) { - if (isPreview) return@remember null to emptyList>() + if (isPreview) return@remember null to emptyList() val videoFile = File(filePath) - val srtConfigs = mutableListOf() val videoDir = videoFile.parentFile - val subtitleLabels = findSubtitles(videoDir) - - videoDir?.listFiles { file -> file.isFile && file.extension == "srt" }?.forEach { srtFile -> - val match = Regex("(?i)([a-zA-Z]{2})\\.srt$").find(srtFile.name) - if (match != null) { - val languageCode = match.groupValues[1].lowercase() - @Suppress("DEPRECATION") - val displayLanguage = Locale(languageCode).displayLanguage.takeIf { it.isNotEmpty() } ?: languageCode - srtConfigs.add( - MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(srtFile)) - .setMimeType("application/x-subrip") - .setLanguage(languageCode) - .setLabel(displayLanguage) - .build() - ) - } - } + val subtitleEntries = findSubtitleConfigs(videoDir) + val srtConfigs = subtitleEntries.map { it.config } val exoPlayer = ExoPlayer.Builder(context).build().apply { val mediaItem = MediaItem.Builder() @@ -89,7 +79,7 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) { if (savedPosition > 0L) seekTo(savedPosition) playWhenReady = true } - exoPlayer to subtitleLabels + exoPlayer to subtitleEntries } val actualPlayer = player @@ -99,6 +89,24 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) { override fun onPlaybackStateChanged(state: Int) { isBuffering = state == Player.STATE_BUFFERING } + + override fun onTracksChanged(tracks: Tracks) { + val audioTrackOptions = mutableListOf() + for (i in 0 until tracks.groups.size) { + val group = tracks.groups[i] + if (group.type == C.TRACK_TYPE_AUDIO) { + for (j in 0 until group.length) { + @Suppress("DEPRECATION") + val format = group.getTrackFormat(j) + val label = format.language?.let { lang -> + Locale(lang).displayLanguage.takeIf { it.isNotEmpty() } ?: lang + } ?: format.label ?: "Track ${j + 1}" + audioTrackOptions.add(AudioTrackOption(group, j, label)) + } + } + } + audioTracks = audioTrackOptions + } } actualPlayer.addListener(listener) onDispose { actualPlayer.removeListener(listener) } @@ -165,42 +173,72 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) { tint = Color.White, ) } - if (subtitles.isNotEmpty() && actualPlayer != null) { + if ((subtitles.isNotEmpty() || audioTracks.size > 1) && actualPlayer != null) { Box( modifier = Modifier .align(Alignment.TopEnd) .padding(WindowInsets.safeDrawing.asPaddingValues()), ) { - IconButton(onClick = { showSubtitleMenu = !showSubtitleMenu }) { - Icon( - Icons.Default.ClosedCaption, - contentDescription = "Subtitles", - tint = Color.White, - ) - } - DropdownMenu( - expanded = showSubtitleMenu, - onDismissRequest = { showSubtitleMenu = false }, - ) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - actualPlayer.trackSelectionParameters = actualPlayer.trackSelectionParameters.buildUpon() - .setPreferredTextLanguages() - .build() - showSubtitleMenu = false - }, - ) - subtitles.forEach { (languageCode, language) -> + if (subtitles.isNotEmpty()) { + IconButton(onClick = { showSubtitleMenu = !showSubtitleMenu }) { + Icon( + Icons.Default.ClosedCaption, + contentDescription = "Subtitles", + tint = Color.White, + ) + } + DropdownMenu( + expanded = showSubtitleMenu, + onDismissRequest = { showSubtitleMenu = false }, + ) { DropdownMenuItem( - text = { Text(language) }, + text = { Text("None") }, onClick = { actualPlayer.trackSelectionParameters = actualPlayer.trackSelectionParameters.buildUpon() - .setPreferredTextLanguages(languageCode) + .setPreferredTextLanguages() .build() showSubtitleMenu = false }, ) + subtitles.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.label) }, + onClick = { + actualPlayer.trackSelectionParameters = actualPlayer.trackSelectionParameters.buildUpon() + .setPreferredTextLanguages(entry.languageCode) + .build() + showSubtitleMenu = false + }, + ) + } + } + } + if (audioTracks.size > 1) { + IconButton(onClick = { showAudioMenu = !showAudioMenu }) { + Icon( + Icons.Default.Audiotrack, + contentDescription = "Audio tracks", + tint = Color.White, + ) + } + DropdownMenu( + expanded = showAudioMenu, + onDismissRequest = { showAudioMenu = false }, + ) { + audioTracks.forEach { track -> + DropdownMenuItem( + text = { Text(track.label) }, + onClick = { + val format = track.group.getTrackFormat(track.index) + val builder = actualPlayer.trackSelectionParameters.buildUpon() + if (format.language != null) { + builder.setPreferredAudioLanguages(format.language!!) + } + actualPlayer.trackSelectionParameters = builder.build() + showAudioMenu = false + }, + ) + } } } } @@ -208,18 +246,69 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) { } } -internal fun findSubtitles(videoDir: File?): List> { - val subtitleLabels = mutableListOf>() - videoDir?.listFiles { file -> file.isFile && file.extension == "srt" }?.forEach { srtFile -> - val match = Regex("(?i)([a-zA-Z]{2})\\.srt$").find(srtFile.name) - if (match != null) { - val languageCode = match.groupValues[1].lowercase() - @Suppress("DEPRECATION") - val displayLanguage = Locale(languageCode).displayLanguage.takeIf { it.isNotEmpty() } ?: languageCode - subtitleLabels.add(languageCode to displayLanguage) +internal data class SubtitleEntry( + val config: MediaItem.SubtitleConfiguration, + val languageCode: String, + val label: String, +) + +internal data class AudioTrackOption( + val group: Tracks.Group, + val index: Int, + val label: String, +) + +internal fun findSubtitleConfigs(videoDir: File?): List { + val entries = mutableListOf() + if (videoDir == null) return entries + + val subtitleExtensions = listOf("srt", "vtt", "ass", "ssa") + val mimeTypeMap = mapOf( + "srt" to MimeTypes.APPLICATION_SUBRIP, + "vtt" to MimeTypes.TEXT_VTT, + "ass" to MimeTypes.TEXT_SSA, + "ssa" to MimeTypes.TEXT_SSA, + ) + + val files = videoDir.listFiles() ?: return entries + files.filter { file -> file.isFile && file.extension.lowercase() in subtitleExtensions } + .forEach { subFile -> + val withLangCodeMatch = Regex("(?i)([a-zA-Z]{2,3})\\.([a-z]+)$").find(subFile.name) + if (withLangCodeMatch != null) { + val languageCode = withLangCodeMatch.groupValues[1].lowercase() + val ext = withLangCodeMatch.groupValues[2].lowercase() + @Suppress("DEPRECATION") + val displayLanguage = Locale(languageCode).displayLanguage.takeIf { it.isNotEmpty() } ?: languageCode + entries.add( + SubtitleEntry( + config = MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subFile)) + .setMimeType(mimeTypeMap[ext] ?: MimeTypes.APPLICATION_SUBRIP) + .setLanguage(languageCode) + .setLabel(displayLanguage) + .build(), + languageCode = languageCode, + label = displayLanguage, + ) + ) + } else { + val noLangCodeMatch = Regex("(?i)\\.([a-z]+)$").find(subFile.name) + if (noLangCodeMatch != null) { + val ext = noLangCodeMatch.groupValues[1].lowercase() + entries.add( + SubtitleEntry( + config = MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(subFile)) + .setMimeType(mimeTypeMap[ext] ?: MimeTypes.APPLICATION_SUBRIP) + .setLanguage("") + .setLabel("Default") + .build(), + languageCode = "", + label = "Default", + ) + ) + } + } } - } - return subtitleLabels + return entries } @Preview(name = "Player — buffering", showBackground = true) diff --git a/app/src/test/java/com/jpcottin/simpletorrent/ui/player/PlayerScreenTest.kt b/app/src/test/java/com/jpcottin/simpletorrent/ui/player/PlayerScreenTest.kt deleted file mode 100644 index ddb948d..0000000 --- a/app/src/test/java/com/jpcottin/simpletorrent/ui/player/PlayerScreenTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.jpcottin.simpletorrent.ui.player - -import org.junit.Test -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import java.io.File -import java.nio.file.Files - -class PlayerScreenTest { - - @Test - fun findSubtitles_matchesLowercaseCodes() { - val tempDir = Files.createTempDirectory("subtitles_test").toFile() - try { - File(tempDir, "movie_en.srt").writeText("") - File(tempDir, "movie_fr.srt").writeText("") - - val result = findSubtitles(tempDir) - - assertEquals(2, result.size) - assertTrue(result.any { it.first == "en" }) - assertTrue(result.any { it.first == "fr" }) - } finally { - tempDir.deleteRecursively() - } - } - - @Test - fun findSubtitles_matchesUppercaseCodes() { - val tempDir = Files.createTempDirectory("subtitles_test").toFile() - try { - File(tempDir, "movie_EN.srt").writeText("") - File(tempDir, "movie_FR.srt").writeText("") - - val result = findSubtitles(tempDir) - - assertEquals(2, result.size) - assertTrue(result.any { it.first == "en" }) - assertTrue(result.any { it.first == "fr" }) - } finally { - tempDir.deleteRecursively() - } - } - - @Test - fun findSubtitles_fallsBackToCodeForUnknownLocale() { - val tempDir = Files.createTempDirectory("subtitles_test").toFile() - try { - File(tempDir, "movie_xx.srt").writeText("") - - val result = findSubtitles(tempDir) - - assertEquals(1, result.size) - assertEquals("xx", result[0].second) - } finally { - tempDir.deleteRecursively() - } - } - - @Test - fun findSubtitles_ignoresNonSrtFiles() { - val tempDir = Files.createTempDirectory("subtitles_test").toFile() - try { - File(tempDir, "movie_en.srt").writeText("") - File(tempDir, "movie_fr.txt").writeText("") - File(tempDir, "readme.md").writeText("") - - val result = findSubtitles(tempDir) - - assertEquals(1, result.size) - assertEquals("en", result[0].first) - } finally { - tempDir.deleteRecursively() - } - } - - @Test - fun findSubtitles_handlesEmptyDirectory() { - val tempDir = Files.createTempDirectory("subtitles_test").toFile() - try { - val result = findSubtitles(tempDir) - - assertEquals(0, result.size) - } finally { - tempDir.deleteRecursively() - } - } - - @Test - fun findSubtitles_handlesNullDirectory() { - val result = findSubtitles(null) - - assertEquals(0, result.size) - } -}