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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ Android NDK and the libtorrent-rasterbar C++ library.
- Adaptive multi-column grid — 1 column on phones, 2 on foldables, 3 on tablets
- Full edge-to-edge UI with proper IME insets, Material You theming

## Recent Improvements

- **Subtitle support robustness**: Case-insensitive language code matching, proper fallback for unknown locales, fixed Compose state mutation
- **User feedback**: Sample torrents now show confirmation snackbar when added
- **Code quality**: Extracted testable subtitle detection logic, added unit tests for edge cases

## Demo

https://github.com/user-attachments/assets/6182efdb-9862-47ee-9fa7-011a64ee87eb
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jpcottin.simpletorrent.data

import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
Expand All @@ -19,7 +20,7 @@ class DefaultDataRepository : DataRepository {
override val torrents: Flow<List<TorrentInfo>> = flow {
while (true) {
emit(TorrentManager.getTorrents())
delay(3_000L)
delay(3.seconds)
}
}

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/jpcottin/simpletorrent/theme/Theme.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jpcottin.simpletorrent.theme

import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
Expand Down Expand Up @@ -29,6 +30,7 @@ private val LightColorScheme =
*/
)

@SuppressLint("NewApi")
@Composable
fun SimpleTorrentTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
Expand Down
48 changes: 30 additions & 18 deletions app/src/main/java/com/jpcottin/simpletorrent/ui/main/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,46 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material3.Scaffold
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
Expand All @@ -74,9 +77,11 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.core.content.edit
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavKey
import com.jpcottin.simpletorrent.Player
import com.jpcottin.simpletorrent.data.DefaultDataRepository
import com.jpcottin.simpletorrent.data.FileInfo
import com.jpcottin.simpletorrent.data.PeerInfo
Expand All @@ -99,9 +104,11 @@ fun MainScreen(
val createState by viewModel.createState.collectAsStateWithLifecycle()
var magnetInput by remember { mutableStateOf("") }
var showSampleSheet by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val scope = rememberCoroutineScope()

fun resolveTreeToPath(uri: android.net.Uri): String? {
fun resolveTreeToPath(uri: Uri): String? {
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(uri)
val colon = treeDocId.indexOf(':')
if (colon < 0) return null
Expand Down Expand Up @@ -200,6 +207,7 @@ fun MainScreen(
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.safeDrawing,
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier = Modifier
Expand Down Expand Up @@ -247,17 +255,17 @@ fun MainScreen(
onPause = { viewModel.pause(torrent.infoHash) },
onResume = { viewModel.resume(torrent.infoHash) },
onRemove = {
val savePath = com.jpcottin.simpletorrent.data.TorrentManager.currentSavePath
val prefs = context.getSharedPreferences("player_positions", android.content.Context.MODE_PRIVATE)
prefs.edit().apply {
val savePath = TorrentManager.currentSavePath
val prefs = context.getSharedPreferences("player_positions", Context.MODE_PRIVATE)
prefs.edit {
torrent.fileList.forEach { remove("$savePath/${it.name}") }
}.apply()
}
viewModel.remove(torrent.infoHash, deleteFiles = true)
},
onPlay = { relPath, title ->
viewModel.setSequentialDownload(torrent.infoHash, true)
val savePath = com.jpcottin.simpletorrent.data.TorrentManager.currentSavePath
onItemClick(com.jpcottin.simpletorrent.Player("$savePath/$relPath", title))
val savePath = TorrentManager.currentSavePath
onItemClick(Player("$savePath/$relPath", title))
},
)
}
Expand All @@ -274,6 +282,9 @@ fun MainScreen(
onAdd = { magnet ->
showSampleSheet = false
viewModel.addMagnet(magnet)
scope.launch {
snackbarHostState.showSnackbar("Adding torrent to downloads")
}
},
)
}
Expand Down Expand Up @@ -305,8 +316,9 @@ internal fun MagnetInputBar(
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onAdd() }),
)
androidx.compose.material3.Button(onClick = onAdd) { Text("Add") }
Button(onClick = onAdd) { Text("Add") }
IconButton(onClick = onSampleTorrents) {
@Suppress("DEPRECATION")
Icon(Icons.Filled.PlaylistAdd, contentDescription = "Sample torrents")
}
Box {
Expand Down Expand Up @@ -495,7 +507,7 @@ internal fun TorrentCard(
}
// Play / buffering indicator for single-file playable torrents
if (torrent.fileList.size == 1 && torrent.fileList[0].name.isPlayable()) {
if (torrent.progress >= 0.01f && torrent.progress < 0.05f) {
if (torrent.progress in 0.01f..<0.05f) {
CircularProgressIndicator(modifier = Modifier.size(24.dp).padding(2.dp))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,26 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.media3.common.Player
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.edit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.material3.Text
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
Expand All @@ -52,33 +53,31 @@ 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 subtitles by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }

val player = remember {
if (isPreview) return@remember null
val (player, subtitles) = remember(filePath) {
if (isPreview) return@remember null to emptyList<Pair<String, String>>()
val videoFile = File(filePath)
val srtConfigs = mutableListOf<MediaItem.SubtitleConfiguration>()
val videoDir = videoFile.parentFile
val baseNameNoExt = videoFile.nameWithoutExtension
val subtitleLabels = findSubtitles(videoDir)

// Find all .srt files in the same directory
val srtConfigs = videoDir?.listFiles { file ->
file.isFile && file.extension == "srt"
}?.mapNotNull { srtFile ->
// Extract language code from filename pattern: *_xx.srt or subtitle_xx.srt
val match = Regex("([a-z]{2})\\.srt$").find(srtFile.name)
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]
val displayLanguage = Locale(languageCode).displayLanguage
subtitles = subtitles + (languageCode to displayLanguage)
MediaItem.SubtitleConfiguration.Builder(Uri.fromFile(srtFile))
.setMimeType("application/x-subrip")
.setLanguage(languageCode)
.setLabel(displayLanguage)
.build()
} else null
} ?: emptyList()
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()
)
}
}

ExoPlayer.Builder(context).build().apply {
val exoPlayer = ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.Builder()
.setUri(Uri.fromFile(videoFile))
.setSubtitleConfigurations(srtConfigs)
Expand All @@ -90,36 +89,38 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) {
if (savedPosition > 0L) seekTo(savedPosition)
playWhenReady = true
}
exoPlayer to subtitleLabels
}
val actualPlayer = player

DisposableEffect(player) {
if (player == null) return@DisposableEffect onDispose {}
DisposableEffect(actualPlayer) {
if (actualPlayer == null) return@DisposableEffect onDispose {}
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
isBuffering = state == Player.STATE_BUFFERING
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
actualPlayer.addListener(listener)
onDispose { actualPlayer.removeListener(listener) }
}

DisposableEffect(lifecycleOwner, player) {
if (player == null) return@DisposableEffect onDispose {}
DisposableEffect(lifecycleOwner, actualPlayer) {
if (actualPlayer == null) return@DisposableEffect onDispose {}
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
prefs.edit().putLong(filePath, player.currentPosition).apply()
player.pause()
prefs.edit { putLong(filePath, actualPlayer.currentPosition) }
actualPlayer.pause()
}
Lifecycle.Event.ON_RESUME -> player.play()
Lifecycle.Event.ON_RESUME -> actualPlayer.play()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
prefs.edit().putLong(filePath, player.currentPosition).apply()
prefs.edit { putLong(filePath, actualPlayer.currentPosition) }
lifecycleOwner.lifecycle.removeObserver(observer)
player.release()
actualPlayer.release()
}
}

Expand All @@ -128,11 +129,11 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) {
.fillMaxSize()
.background(Color.Black),
) {
if (player != null) {
if (actualPlayer != null) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
this.player = actualPlayer
setShowNextButton(false)
setShowPreviousButton(false)
}
Expand Down Expand Up @@ -164,7 +165,7 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) {
tint = Color.White,
)
}
if (subtitles.isNotEmpty() && player != null) {
if (subtitles.isNotEmpty() && actualPlayer != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
Expand All @@ -184,7 +185,7 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) {
DropdownMenuItem(
text = { Text("None") },
onClick = {
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
actualPlayer.trackSelectionParameters = actualPlayer.trackSelectionParameters.buildUpon()
.setPreferredTextLanguages()
.build()
showSubtitleMenu = false
Expand All @@ -194,7 +195,7 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) {
DropdownMenuItem(
text = { Text(language) },
onClick = {
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
actualPlayer.trackSelectionParameters = actualPlayer.trackSelectionParameters.buildUpon()
.setPreferredTextLanguages(languageCode)
.build()
showSubtitleMenu = false
Expand All @@ -207,6 +208,20 @@ fun PlayerScreen(filePath: String, title: String, onBack: () -> Unit) {
}
}

internal fun findSubtitles(videoDir: File?): List<Pair<String, String>> {
val subtitleLabels = mutableListOf<Pair<String, String>>()
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)
}
}
return subtitleLabels
}

@Preview(name = "Player — buffering", showBackground = true)
@Composable
private fun PlayerScreenBufferingPreview() {
Expand Down
Loading