From 359d397a69e4b12d656c27b2a07e42df1fefe0a3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 5 Aug 2026 14:07:52 +0200 Subject: [PATCH] updated readme + renamed buttons for android client --- README.md | 9 +-- .../narl/protonstream/ui/ProtonStreamApp.kt | 67 ++++++++++++++----- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1fad2f8..a352f7f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # proton-stream -A Netflix-style desktop client for **Proton Drive public links**. +A Netflix-style cross-platform client for **Proton Drive public links**. Paste a share URL and its password; get a browsable, streamable library. No Proton account, no server to host, no download step — episodes start playing in @@ -29,8 +29,9 @@ countdown, optional metadata, themes and share management. What is left is mostl macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`. A native Android 12+ client is under `android/`, sharing the Rust catalog and -streaming implementation through UniFFI. Its toolchain, emulator, signing, and -release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md). +streaming implementation through UniFFI. It features a modern Material 3 interface, +an immersive fullscreen player with auto-hiding controls, and offline downloads. +Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md). ```bash cargo run --release -p pstr-app # the app @@ -68,7 +69,7 @@ against the SDK's own range download. | UI | egui/eframe on glow; mpv renders into the same GL context. | | Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares page, applied without a restart. | | Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. | -| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. The Android 12+ phone/tablet client is under active development. | +| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. | ## Building diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 9d7fa2a..a5dcaa3 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -53,6 +55,9 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable @@ -261,6 +266,10 @@ private fun TitleScreen( var subtitlesEnabled by remember(title.key) { mutableStateOf(false) } var playing by remember(title.key) { mutableStateOf(null) } var showMatch by remember(title.key) { mutableStateOf(false) } + var expandedSeasons by remember(title.key) { mutableStateOf(setOf(title.seasons.firstOrNull()?.label)) } + + BackHandler(enabled = playing == null, onBack = onBack) + LaunchedEffect(title.key) { runCatching { withContext(Dispatchers.IO) { @@ -422,32 +431,50 @@ private fun TitleScreen( }) { Text("Save track preferences") } } title.seasons.forEach { season -> + val isExpanded = expandedSeasons.contains(season.label) item { Row( - Modifier.fillMaxWidth().padding(top = 12.dp), + Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clickable { + expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label + } + .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(season.label, style = MaterialTheme.typography.titleLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + ) + Spacer(Modifier.width(8.dp)) + Text(season.label, style = MaterialTheme.typography.titleLarge) + } FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) Text("Download season") } } } - items(season.episodes, key = { it.linkId }) { episode -> - Card(Modifier.fillMaxWidth()) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Button(onClick = { playing = episode }, enabled = playerHost != null) { - Text(if (playerHost == null) "Player loading…" else "Play") + if (isExpanded) { + items(season.episodes, key = { it.linkId }) { episode -> + Card(Modifier.fillMaxWidth()) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = { playing = episode }, enabled = playerHost != null) { + Text(if (playerHost == null) "Player loading…" else "Play") + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text(episode.label, fontWeight = FontWeight.SemiBold) + Text(episode.detail, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + FilledTonalButton( + onClick = { DownloadCoordinator.enqueue(context, episode) }, + enabled = !episode.offline, + ) { Text(if (episode.offline) "Offline" else "Download") } } - Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { - Text(episode.label, fontWeight = FontWeight.SemiBold) - Text(episode.detail, style = MaterialTheme.typography.bodySmall, maxLines = 1) - } - FilledTonalButton( - onClick = { DownloadCoordinator.enqueue(context, episode) }, - enabled = !episode.offline, - ) { Text(if (episode.offline) "Offline" else "Download") } } } } @@ -611,7 +638,7 @@ private fun DownloadsScreen( // Reading retained metadata on every WorkInfo transition also hydrates // paused/failed/cancelled entries after WorkManager history is pruned. val retained = remember(downloads) { DownloadStateStore(context).records() } - Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) { Text("Offline downloads", style = MaterialTheme.typography.titleLarge) Spacer(Modifier.height(16.dp)) if (retained.isEmpty() && state.offline.isEmpty()) { @@ -625,7 +652,11 @@ private fun DownloadsScreen( Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold) Text(formatBytes(file.size)) } - FilledTonalButton(onClick = { onRemove(file) }) { Text("Make online only") } + FilledTonalButton(onClick = { onRemove(file) }) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Delete") + } } } } @@ -674,7 +705,7 @@ private fun SettingsScreen( var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) } var showMetadata by remember { mutableStateOf(false) } var legalDocument by remember { mutableStateOf(null) } - Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) { Text("Settings", style = MaterialTheme.typography.titleLarge) SettingToggle("Download on Wi-Fi only", wifiOnly) { wifiOnly = it; settings.wifiOnly = it } HorizontalDivider()