updated readme + renamed buttons for android client

This commit is contained in:
nvrl committed 2026-08-05 14:07:52 +02:00
1 parent b399f84f54
commit 359d397a69
2 files changed
+54 -22

No files matched your search

+5 -4
View File
@@ -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
@@ -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<EpisodeRecord?>(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<LegalDocument?>(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()