Compare commits

14 Commits
Author SHA1 Message Date
nvrl 52d27ec996 chore(version): bump to 1.0.1 2026-10-03 23:00:34 +02:00
nvrl 66eb1b49a1 ci(android): keep the cached libmpv and Rust target trees
Gradle deletes the declared outputs of a task it has no history for, so
on every fresh checkout buildLibmpvAndroid threw away the libmpv tree just
restored from the cache and rebuilt it from source (~14 min per job). The
task no longer declares outputs; the script's STAMP decides.

rust-cache treats target/android/<triple> as a profile directory and
deletes its release tree before saving, so both cargo-ndk ABIs were
rebuilt from scratch on every run (~6 min per job). It now caches only the
registry and helpers; the target directory goes through actions/cache,
keyed on toolchain and lockfile and restoring from the last lockfile.
2026-10-03 23:00:19 +02:00
nvrl 22425e0bad fix(android): centre the launcher glyph optically in its mask 2026-10-03 23:00:19 +02:00
nvrl 50481e6aed fix(android): lint on the predictive-back opt-in and the shared-art modifier
enableOnBackInvokedCallback only exists from API 33 and the app runs from
31, which lint reports as UnusedAttribute; tools:targetApi says that is
intended, since older releases ignore it. sharedArt becomes an extension
on Modifier, as Compose lint asks of modifier factories.
2026-10-03 14:53:09 +02:00
nvrl bedbe0ef52 fix(android): pin the version in the settings screenshot
The Settings index shows the installed version, read from the package,
so the committed image held 0.5.1 and the 1.0.0 bump failed the
screenshot check. SettingsScreen takes the version as a parameter that
defaults to the package's, and the test passes a fixed one.
2026-10-03 14:24:32 +02:00
nvrl 7914cf4dc1 chore(version): bump to 1.0.0 2026-10-03 13:35:36 +02:00
nvrl 39a7f3cc38 docs: PLAN.md status and the screenshot list after the second Android pass 2026-10-03 13:34:28 +02:00
nvrl 630f07a462 feat(android): library and title side by side on tablets, a poster that becomes the page, and predictive back
From 840 dp wide, the library and the open title share the window, so
moving between titles is one tap and the grid stays where it was; with
nothing open, the right pane says to choose one. On narrower windows, a
poster tapped in the grid grows into the title page's backdrop and
shrinks back on the way out.

The app opts into predictive back. Dragging back on a title page shrinks
the page as the gesture goes and restores it if the gesture is
abandoned.
2026-10-03 13:34:04 +02:00
nvrl 9cba54df14 feat(android): light-palette and tablet screenshots, and a title page that fits a tablet
The screenshot tests now also capture the library and a title in
Catppuccin Latte, and a title and the history page on a tablet. The
tablet capture showed the title page opening on nothing but its
backdrop: a 16:9 picture the width of a landscape tablet is taller than
the window. The backdrop is now no taller than about half the window,
and the rest of the page keeps a phone's reading width, centred.
2026-10-03 13:27:19 +02:00
nvrl 4ff8ae303c feat(android): Change match from a library tile's long press
The tile menu now offers the same provider search as the title page, so
a title wearing the wrong poster can be fixed from the grid that shows
it, without opening the title first.
2026-10-03 13:15:59 +02:00
nvrl 6c2ec83cb1 feat(android): a downloads page of rows under each show
Saved episodes are list rows with their still, the episode's name rather
than its file, and its size, under a heading per show; the page opens
with how much is on the device. Downloads still arriving come first, with
pause or resume and cancel as icons beside the progress. Deleting a saved
episode now asks first, since getting it back is a download.
2026-10-03 13:13:41 +02:00
nvrl 677ab2d928 feat(android): settings as a page per area, and a language picker
The first Settings page lists Playback, Downloads and storage,
Appearance, Metadata and About, each with a line saying where it stands,
and each opens its own page with a way back. On one long page, every
setting added pushed the rest further from reach.

Audio and subtitle language are chosen from a list of languages by name
instead of typed as "jpn or eng". The list stores the three-letter tag a
Matroska file carries, reads a stored tag in either ISO form, and still
takes any other tag typed in.
2026-10-03 13:07:11 +02:00
nvrl fb32d6c850 feat(android): per-page app bars, edge to edge, and search behind an icon
The single "proton-stream" bar over every page is gone. The library has a
large title that folds away as the grid scrolls, with search and refresh as
icons; search opens into a field in the bar that takes the keyboard at once,
and back closes and clears it. History, Shares, Downloads and Settings each
name themselves in a bar of their own, drawn in the page's background until
content scrolls under it. The title page now draws its backdrop under the
status bar, with the back button below it.

A search with no results says so instead of claiming the library is empty.
2026-10-03 12:56:03 +02:00
nvrl 110f97caf9 feat(android): generated placeholder art and a skeleton library
A title without artwork shows the accent as a gradient with its initials
instead of a black card with a play triangle, so a library of unmatched
titles is no longer a grid of identical cards. Art with the name already
over it, and episode stills, get the gradient without initials. While
artwork loads, and while the catalog first loads, the page shows pulsing
shapes where the posters will be rather than a spinner.

The screenshot tests turn artwork loading off, so whether a failed fetch
lands before the capture no longer decides what the image shows.
2026-10-03 12:45:34 +02:00
41 changed files with 1507 additions and 616 deletions

No files matched your search

+52 -10
View File
@@ -107,19 +107,34 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android
# One cache for both jobs: the signed build compiles the same crates for
# the same targets. Saved on failure too, so a run that breaks late does
# not throw away everything it compiled before the break.
# The registry and the installed helpers only. rust-cache reads
# `target/android/<triple>` as a profile directory and deletes the
# `release` tree under it before saving, so with its target caching on
# every run rebuilt both ABIs from scratch.
- uses: Swatinem/rust-cache@v2.9.1
with:
shared-key: android
shared-key: android-registry
cache-targets: false
cache-on-failure: true
# The target directory as is: the host build the UniFFI bindings come
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
# build compiles the same crates for the same targets. A new lockfile
# starts from the last one's tree, so a version bump rebuilds only the
# workspace crates.
- id: target
uses: actions/cache/restore@v6
with:
path: target
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
# libmpv and its dependency tree are the slowest part of the build, and
# depend only on the pinned revision and the build script, which stamps
# its output with both and skips the build when the stamp matches. Built
@@ -167,6 +182,12 @@ jobs:
- name: Android checks and artifacts
run: bash scripts/build-android.sh all
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
signed-release:
name: Signed release
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
@@ -209,19 +230,34 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android
# One cache for both jobs: the signed build compiles the same crates for
# the same targets. Saved on failure too, so a run that breaks late does
# not throw away everything it compiled before the break.
# The registry and the installed helpers only. rust-cache reads
# `target/android/<triple>` as a profile directory and deletes the
# `release` tree under it before saving, so with its target caching on
# every run rebuilt both ABIs from scratch.
- uses: Swatinem/rust-cache@v2.9.1
with:
shared-key: android
shared-key: android-registry
cache-targets: false
cache-on-failure: true
# The target directory as is: the host build the UniFFI bindings come
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
# build compiles the same crates for the same targets. A new lockfile
# starts from the last one's tree, so a version bump rebuilds only the
# workspace crates.
- id: target
uses: actions/cache/restore@v6
with:
path: target
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
# libmpv and its dependency tree are the slowest part of the build, and
# depend only on the pinned revision and the build script, which stamps
# its output with both and skips the build when the stamp matches. Built
@@ -266,6 +302,12 @@ jobs:
- name: Build signed APK and AAB
run: bash scripts/build-android.sh signed
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
# The APK bundles a GPL libmpv, so its corresponding source ships next to
# it. The AAB is for Play only and stays out of the GitHub release.
- name: Stage release files
Generated
+7 -7
View File
@@ -4359,7 +4359,7 @@ dependencies = [
[[package]]
name = "pstr-android"
version = "0.5.1"
version = "1.0.1"
dependencies = [
"android_logger",
"jni 0.21.1",
@@ -4378,7 +4378,7 @@ dependencies = [
[[package]]
name = "pstr-app"
version = "0.5.1"
version = "1.0.1"
dependencies = [
"anyhow",
"chrono",
@@ -4409,7 +4409,7 @@ dependencies = [
[[package]]
name = "pstr-cli"
version = "0.5.1"
version = "1.0.1"
dependencies = [
"anyhow",
"clap",
@@ -4427,7 +4427,7 @@ dependencies = [
[[package]]
name = "pstr-core"
version = "0.5.1"
version = "1.0.1"
dependencies = [
"directories",
"futures",
@@ -4445,7 +4445,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
version = "0.5.1"
version = "1.0.1"
dependencies = [
"pstr-core",
"reqwest",
@@ -4458,7 +4458,7 @@ dependencies = [
[[package]]
name = "pstr-player"
version = "0.5.1"
version = "1.0.1"
dependencies = [
"async-trait",
"libloading",
@@ -4473,7 +4473,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
version = "0.5.1"
version = "1.0.1"
dependencies = [
"async-trait",
"futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "0.5.1"
version = "1.0.1"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
+14 -9
View File
@@ -10,16 +10,21 @@ gained about twenty features on 2026-10-02 that Android does not have.
## Status (2026-10-03)
Done: section 0, every parity item, the Library, Title, Shares and Settings
redesigns, Inter, and screenshot tests. Not yet verified on a device; the
host gate (ktlint, lint, unit and screenshot tests) and CI's build job pass.
Done: section 0, every parity item, and every recommendation in section 2 —
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
bars drawn edge to edge, search behind an icon, skeleton loading and
generated placeholder art, Settings sub-pages and a language picker, a
long-press Change match, Inter, light and tablet screenshots, the library
and a title side by side from 840 dp, a poster-to-backdrop shared element
transition, and predictive back on the title page.
Open, from section 2: per-screen app bars and edge-to-edge under the status
bar, skeleton loading and generated placeholder art, a search icon that
expands, a language picker, Settings sub-pages, the Downloads page, light
theme and tablet screenshots, `ListDetailPaneScaffold` on tablets, shared
element transitions and predictive back, and a long-press Change match (the
title page has it).
Two departures from the text below. The tablet layout is a two-pane `Row`
rather than `ListDetailPaneScaffold`, which would have added the
`adaptive-layout` artifact for one split. Pull to refresh shows the
indicator while a crawl runs, but not the crawl's progress.
Not yet verified on a device; the host gate (ktlint, unit and screenshot
tests) passes, and Android lint runs in CI.
## 0. CI (done)
+4 -8
View File
@@ -180,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
val hostRustLibrary = repositoryRoot.resolve(
"target/debug/${System.mapLibraryName("pstr_android")}",
)
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
val buildLibmpvAndroid by tasks.registering(Exec::class) {
group = "native"
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
workingDir(repositoryRoot)
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
outputs.files(
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
stagedMpvDir.map { it.file("include/mpv/client.h") },
stagedMpvDir.map { it.file("REVISION") },
)
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
// deletes the declared outputs of a task it has no history for, which on a
// fresh CI checkout threw away the tree just restored from the cache and
// rebuilt libmpv from source on every run.
}
val buildRustHost by tasks.registering(Exec::class) {
+2
View File
@@ -11,6 +11,8 @@
android:name=".ProtonStreamApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
tools:targetApi="33"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
@@ -954,6 +954,7 @@ private fun EpisodeChooser(
episode.label,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
AccentProgress(
@@ -1048,6 +1049,7 @@ private fun UpNextCard(
upNext.label,
Modifier.fillMaxSize(),
fallback = upNext.thumbnailSource,
labelled = false,
)
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
CircularProgressIndicator(
@@ -1,15 +1,96 @@
package io.narl.protonstream.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import uniffi.pstr_android.EpisodeRecord
/**
* A tab's page: its own name in an app bar, over whatever the page holds.
*
* One bar for the whole app said "proton-stream" on every page, which is a band
* of height spent naming the app the viewer is already in. The bar draws under
* the status bar itself, which is why the shell hands pages no top padding;
* [content] gets the rest of [padding] (the navigation bar and the mini
* transport) to apply as it scrolls.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun TabPage(
title: String,
padding: PaddingValues,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
// Pinned: the bar stays, and takes the container tint once the page has
// scrolled under it, so the edge between the two is still visible.
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
colors = pageBarColors(),
scrollBehavior = scroll,
)
Box(Modifier.weight(1f)) { content(padding) }
}
}
/**
* The page's own background until something scrolls under the bar, then the
* container tint. A bar in `surface` from the start read as a band across the
* top of a page drawn in `background`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
)
/**
* The shared-element scopes the library-to-title switch runs in, or null where
* there is none — the two-pane layout, a screenshot, a preview. Locals rather
* than parameters, because the poster that animates is three calls deep in the
* grid and nothing between needs to know.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
internal val LocalSharedTransition = staticCompositionLocalOf<SharedTransitionScope?> { null }
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { null }
/**
* A title's art as one element across the switch: the poster tapped in the
* grid grows into the backdrop of the page it opens, and shrinks back on the
* way out, so the page is visibly *that* title's rather than a new screen.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.sharedArt(key: String): Modifier {
val transition = LocalSharedTransition.current ?: return this
val visibility = LocalPaneVisibility.current ?: return this
return with(transition) {
this@sharedArt.sharedBounds(rememberSharedContentState("art/$key"), visibility)
}
}
@Composable
internal fun EmptyState(title: String, body: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -38,3 +119,21 @@ internal fun formatBytes(bytes: ULong): String {
else -> "$bytes bytes"
}
}
/**
* What an episode row is headed with: "1. Mother and Children".
*
* The provider's name for the episode, else the name the filename gave it,
* else "Episode 1". The bare filename only when none of those exist — a column
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/
internal fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name }
val number = number?.toInt()
return when {
named != null && number != null -> "$number. $named"
named != null -> named
number != null -> "Episode $number"
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
}
}
@@ -1,47 +1,59 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Arrangement
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
/** One show's saved episodes, and the show itself where the library has it. */
private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List<OfflineRecord>)
@Composable
internal fun DownloadsScreen(
state: AppUiState,
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
onRemove: (OfflineRecord) -> Unit,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
@@ -57,83 +69,175 @@ internal fun DownloadsScreen(
// but not its show, and the library is the only thing that does.
val groups = remember(state.offline, state.titles) {
val shows = state.titles.flatMap { title ->
title.seasons.flatMap(SeasonRecord::episodes).map {
"${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name)
}
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
}.toMap()
state.offline
.groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" }
.toSortedMap()
.groupBy { shows["${it.shareId}/${it.linkId}"] }
.map { (title, files) ->
OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files)
}
.sortedBy { it.name }
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
item {
Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
}
// Deleting a saved episode asks first: it is the viewer's own choice to
// keep it, and getting it back is a download, not a tap.
var deleting by remember { mutableStateOf<OfflineRecord?>(null) }
TabPage("Downloads", padding) { body ->
if (retained.isEmpty() && state.offline.isEmpty()) {
item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
Box(Modifier.fillMaxSize().padding(body)) {
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
}
return@TabPage
}
// Under the show they belong to, as on desktop: a saved season is
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
groups.forEach { (show, files) ->
item(key = "group/$show") {
Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp))
LazyColumn(
modifier = Modifier.fillMaxSize().padding(body),
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (state.offline.isNotEmpty()) {
item(key = "summary") {
Text(
"${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " +
"${formatBytes(state.offline.sumOf { it.size })} on this device",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
// What is still arriving comes first: it is the part of the page
// that changes, and the part with something to do.
if (retained.isNotEmpty()) {
item(key = "in-progress") { SectionHeading("In progress") }
items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download ->
PartialRow(download, onPause, onResume, onDeletePartial)
}
}
// Under the show they belong to, as on desktop: a saved season is
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
groups.forEach { group ->
item(key = "group/${group.name}") {
SectionHeading(
group.name,
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
formatBytes(group.files.sumOf { it.size }),
)
}
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
SavedRow(file, group.title, onDelete = { deleting = file })
}
}
}
}
deleting?.let { file ->
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete this download?") },
text = {
Text(
"${files.size} ${if (files.size == 1) "episode" else "episodes"} · " +
formatBytes(files.sumOf { it.size }),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
"It still streams, and can be downloaded again.",
)
}
items(files, key = { "${it.shareId}/${it.linkId}" }) { file ->
val episode = file.episode
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
Text(formatBytes(file.size))
}
TonalButton(onClick = { onRemove(file) }) {
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Delete")
}
}
}
}
}
items(retained, key = { "${it.shareId}/${it.linkId}" }) { download ->
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Column(Modifier.padding(16.dp)) {
Text(download.label, fontWeight = FontWeight.SemiBold)
Text(download.status.replaceFirstChar { it.uppercase() })
if (download.total > 0L) {
AccentProgress(
progress = { progress },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall)
}
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 8.dp),
) {
if (download.status == RetainedDownload.STATUS_RUNNING ||
download.status == RetainedDownload.STATUS_QUEUED
) {
TonalButton(onClick = { onPause(download) }) { Text("Pause") }
} else {
TonalButton(onClick = { onResume(download) }) { Text("Resume") }
}
QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
}
}
}
},
confirmButton = {
AccentButton(onClick = {
deleting = null
onRemove(file)
}) { Text("Delete") }
},
dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } },
)
}
}
@Composable
private fun SectionHeading(title: String, caption: String? = null) {
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
caption?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
/** A saved episode: its still, what it is, its size, and a way to remove it. */
@Composable
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) {
val episode = file.episode
ListItem(
leadingContent = {
RemoteArtwork(
episode?.stillUrl ?: title?.backdropUrl,
title?.name ?: file.linkId,
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
fallback = episode?.thumbnailSource,
labelled = episode == null,
)
},
headlineContent = {
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
},
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
/**
* A download not yet finished: how far it has got, why it stopped if it did,
* and pause or resume beside cancel.
*/
@Composable
private fun PartialRow(
download: RetainedDownload,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
) {
val active = download.status == RetainedDownload.STATUS_RUNNING ||
download.status == RetainedDownload.STATUS_QUEUED
ListItem(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Column {
Text(
listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
).joinToString(" · "),
)
if (download.total > 0L) {
AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (active) {
IconButton(onClick = { onPause(download) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
} else {
IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
}
}
// What has arrived so far is discarded; nothing finished is.
IconButton(onClick = { onDeletePartial(download) }) {
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -80,28 +80,32 @@ internal fun HistoryScreen(
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
}
if (days.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding)) {
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
TabPage("History", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) {
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
}
}
return
}
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = 24.dp)) {
days.forEach { (day, played) ->
item(key = "day/$day") {
Text(
dayHeading(day, today),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
)
}
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
HistoryRow(
entry,
onPlay = { onPlay(entry.title, entry.index) },
onTitle = { onTitle(entry.title) },
onRemove = { onRemove(entry.title, entry.episode) },
)
TabPage("History", padding) { body ->
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
days.forEach { (day, played) ->
item(key = "day/$day") {
Text(
dayHeading(day, today),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
)
}
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
HistoryRow(
entry,
onPlay = { onPlay(entry.title, entry.index) },
onTitle = { onTitle(entry.title) },
onRemove = { onRemove(entry.title, entry.episode) },
)
}
}
}
}
@@ -130,6 +134,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o
episode.label,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
@@ -0,0 +1,183 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
/**
* The languages offered by name, as the three-letter tag a Matroska file
* carries.
*
* The same short list the desktop names tracks from (`pstr-player`'s
* `language_name`): what the shares this app is pointed at actually contain.
* mpv treats the two- and three-letter forms of a language as one, so the tag
* chosen here also matches a file that says "ja". Anything else is still one
* typed tag away.
*/
internal val LANGUAGES: List<Pair<String, String>> = listOf(
"jpn" to "Japanese",
"eng" to "English",
"ger" to "German",
"fre" to "French",
"spa" to "Spanish",
"ita" to "Italian",
"por" to "Portuguese",
"dut" to "Dutch",
"rus" to "Russian",
"pol" to "Polish",
"swe" to "Swedish",
"dan" to "Danish",
"nor" to "Norwegian",
"fin" to "Finnish",
"cze" to "Czech",
"hun" to "Hungarian",
"tur" to "Turkish",
"ara" to "Arabic",
"heb" to "Hebrew",
"hin" to "Hindi",
"kor" to "Korean",
"chi" to "Chinese",
"tha" to "Thai",
"vie" to "Vietnamese",
"ukr" to "Ukrainian",
"rum" to "Romanian",
"gre" to "Greek",
"ind" to "Indonesian",
"may" to "Malay",
)
/**
* "Japanese", for a tag in any of the forms a file or the desktop may have
* stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
* Null is no preference.
*/
internal fun languageLabel(tag: String?): String {
val value = tag?.trim()?.takeIf(String::isNotEmpty) ?: return "No preference"
return languageOf(value)?.second ?: value
}
/** The listed language a tag names, matching either ISO 639 form. */
private fun languageOf(tag: String): Pair<String, String>? {
val base = tag.split('-', '_').first().lowercase()
return LANGUAGES.firstOrNull { (code, _) -> code == base || TWO_LETTER[code] == base || terminology(code) == base }
}
/** ISO 639-2/T, for the handful whose bibliographic code differs. */
private fun terminology(code: String): String? = when (code) {
"ger" -> "deu"
"fre" -> "fra"
"dut" -> "nld"
"cze" -> "ces"
"chi" -> "zho"
"rum" -> "ron"
"gre" -> "ell"
"may" -> "msa"
else -> null
}
/** ISO 639-1, the form a file or the desktop may also have stored. */
private val TWO_LETTER = mapOf(
"jpn" to "ja", "eng" to "en", "ger" to "de", "fre" to "fr", "spa" to "es", "ita" to "it",
"por" to "pt", "dut" to "nl", "rus" to "ru", "pol" to "pl", "swe" to "sv", "dan" to "da",
"nor" to "no", "fin" to "fi", "cze" to "cs", "hun" to "hu", "tur" to "tr", "ara" to "ar",
"heb" to "he", "hin" to "hi", "kor" to "ko", "chi" to "zh", "tha" to "th", "vie" to "vi",
"ukr" to "uk", "rum" to "ro", "gre" to "el", "ind" to "id", "may" to "ms",
)
/**
* A language preference as a row, opening a list to choose from.
*
* It was a free-text field asking for "jpn or eng", which is a question only
* someone who has read a Matroska header can answer.
*/
@Composable
internal fun LanguageSetting(headline: String, value: String?, onChange: (String?) -> Unit) {
var choosing by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(headline) },
supportingContent = { Text(languageLabel(value)) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(role = Role.Button) { choosing = true },
)
if (choosing) {
LanguageDialog(
headline,
value,
onDismiss = { choosing = false },
onChoose = {
choosing = false
onChange(it)
},
)
}
}
@Composable
private fun LanguageDialog(headline: String, value: String?, onDismiss: () -> Unit, onChoose: (String?) -> Unit) {
val current = value?.let(::languageOf)?.first ?: value
// A tag that is not on the list starts in the field, so it is shown and
// can be edited rather than silently replaced.
var typed by remember { mutableStateOf(current?.takeIf { tag -> LANGUAGES.none { it.first == tag } }.orEmpty()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(headline) },
text = {
Column {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) {
item(key = "none") { LanguageOption("No preference", current == null) { onChoose(null) } }
items(LANGUAGES, key = { it.first }) { (code, name) ->
LanguageOption(name, current == code) { onChoose(code) }
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
label = { Text("Another tag") },
supportingText = { Text("As it appears in the file, such as \"tgl\"") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
AccentButton(enabled = typed.isNotBlank(), onClick = { onChoose(typed.trim().lowercase()) }) {
Text("Use tag")
}
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun LanguageOption(name: String, selected: Boolean, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(name, style = MaterialTheme.typography.bodyLarge) },
leadingContent = { RadioButton(selected = selected, onClick = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(role = Role.RadioButton, onClick = onClick),
)
}
@@ -1,6 +1,19 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.background
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Brush
@@ -10,6 +23,7 @@ import java.time.LocalDate
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RemoveCircleOutline
@@ -44,7 +58,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -90,11 +103,14 @@ internal fun LibraryScreen(
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
padding: PaddingValues,
onMatchChanged: () -> Unit = {},
onError: (Throwable) -> Unit = {},
) {
val context = LocalContext.current
// What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) }
var matching by remember { mutableStateOf<TitleRecord?>(null) }
// Most recently played first, one episode per show: a shelf that lists four
// episodes of the same series is a shelf with room for nothing else.
val resumable = remember(state.titles) {
@@ -106,41 +122,49 @@ internal fun LibraryScreen(
?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}
Column(Modifier.fillMaxSize().padding(padding)) {
// A filled pill rather than an outlined form field: search is how the
// page is navigated, not a value being entered.
TextField(
value = state.query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
shape = CircleShape,
placeholder = { Text("Search library") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (state.query.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
// Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them.
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
val closeSearch = {
searching = false
onSearch("")
}
BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back
// only at the top: the page says what it is on arrival, then gets out of
// the way.
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else {
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
}
},
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
// Pull to refresh, the gesture every list on a phone answers to; the
// icon in the bar stays for a viewer who does not know it.
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive public link under Shares, then refresh.",
@@ -169,8 +193,22 @@ internal fun LibraryScreen(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
if (allWatched) "Mark unwatched" else "Mark watched",
) { onSetTitleWatched(title, !allWatched) }
// The same search the title page offers, for the tile that is
// plainly wrong — the wrong poster is what the grid shows.
MenuRow(Icons.Default.Edit, "Change match") { matching = title }
}
}
matching?.let { title ->
ChangeMatchDialog(
title = title,
onDismiss = { matching = null },
onChanged = {
matching = null
onMatchChanged()
},
onError = onError,
)
}
menuResume?.let { entry ->
TileMenu(
entry.title.canonicalName ?: entry.title.name,
@@ -186,6 +224,50 @@ internal fun LibraryScreen(
}
}
/**
* The bar while searching: back closes the search and clears it, and the field
* takes the keyboard as soon as it opens, since opening it is asking to type.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchBar(query: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
TopAppBar(
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
}
},
title = {
// A filled pill rather than an outlined form field: search is how
// the page is navigated, not a value being entered.
TextField(
value = query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth().padding(end = 8.dp).focusRequester(focus),
singleLine = true,
shape = CircleShape,
placeholder = { Text("Search library") },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
}
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
},
colors = pageBarColors(),
)
}
/**
* A long press's menu, from the bottom of the screen where the thumb already
* is. Each row closes it before doing its thing.
@@ -282,7 +364,7 @@ private fun LibraryGrid(
}
}
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Text("Library", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
}
}
items(state.titles, key = { it.key }) { title ->
@@ -291,6 +373,35 @@ private fun LibraryGrid(
}
}
/**
* The grid's shape before the catalog has answered: posters and their two
* lines of text as blocks, so the page does not jump when the titles land.
*/
@Composable
private fun LibrarySkeleton() {
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
userScrollEnabled = false,
) {
items(SKELETON_TILES) {
Column {
Skeleton(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium))
Spacer(Modifier.height(8.dp))
Skeleton(Modifier.fillMaxWidth(0.8f).height(12.dp).clip(MaterialTheme.shapes.small))
Spacer(Modifier.height(6.dp))
Skeleton(Modifier.fillMaxWidth(0.5f).height(10.dp).clip(MaterialTheme.shapes.small))
}
}
}
}
/** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18
/**
* The title the banner shows: the one watched last, or with nothing part
* watched, one of the titles the provider has a backdrop for, turning over
@@ -313,6 +424,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
title.canonicalName ?: title.name,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
Box(
Modifier.fillMaxSize().background(
@@ -408,7 +520,7 @@ private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: ()
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium),
Modifier.sharedArt(title.key).fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium),
fallback = title.thumbnailSource,
)
Spacer(Modifier.height(6.dp))
@@ -2,12 +2,16 @@ package io.narl.protonstream.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.VerticalDivider
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -17,16 +21,18 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.plus
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
@@ -35,7 +41,6 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
@@ -68,6 +73,9 @@ import io.narl.protonstream.playback.LibmpvHost
import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlayerScreen
/** From this width the library and an open title share the window. */
private val TWO_PANE_WIDTH = 840.dp
/** How much room the floating mini transport needs at the foot of a page. */
private val MINI_TRANSPORT_INSET = 88.dp
@@ -86,7 +94,7 @@ private enum class Destination(val label: String, val icon: ImageVector) {
Settings("Settings", Icons.Default.Settings),
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun ProtonStreamApp(
playerHost: LibmpvHost? = null,
@@ -162,23 +170,10 @@ fun ProtonStreamApp(
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbars) },
topBar = {
TopAppBar(
title = { Text(selectedTitle?.name ?: "proton-stream") },
actions = {
if (destination == Destination.Library) {
if (state.refreshing) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(Modifier.width(16.dp))
} else {
IconButton(onClick = model::refresh) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
}
},
)
},
// Each page draws its own bar under the status bar, and the title
// page draws its backdrop there, so the top inset is theirs.
contentWindowInsets = ScaffoldDefaults.contentWindowInsets
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom),
) { padding ->
// Back from a secondary tab returns to the library rather than
// leaving the app; the library's own back is the system's, which is
@@ -200,36 +195,66 @@ fun ProtonStreamApp(
Box(Modifier.fillMaxSize()) {
AnimatedContent(destination, label = "primary navigation") { target ->
when (target) {
Destination.Library -> if (selectedTitle == null) {
LibraryScreen(
state,
model::search,
{ title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = it.key },
model::refresh,
model::setTitleWatched,
model::forgetPosition,
body,
)
} else {
TitleScreen(
selectedTitle,
playerHost != null,
{ _, index ->
playingTitleKey = selectedTitle.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = null },
model::reportError,
model::reloadAfterMetadataChange,
model::setWatched,
body,
)
// Side by side where there is room for both: on a tablet the
// library stays in view while a title is open, so moving
// between titles is one tap rather than back and in again.
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
val libraryPane = @Composable {
LibraryScreen(
state,
model::search,
{ title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = it.key },
model::refresh,
model::setTitleWatched,
model::forgetPosition,
body,
onMatchChanged = model::reloadAfterMetadataChange,
onError = model::reportError,
)
}
val titlePane = @Composable { selected: TitleRecord ->
TitleScreen(
selected,
playerHost != null,
{ _, index ->
playingTitleKey = selected.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = null },
model::reportError,
model::reloadAfterMetadataChange,
model::setWatched,
body,
)
}
if (maxWidth >= TWO_PANE_WIDTH) {
Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(0.45f)) { libraryPane() }
VerticalDivider()
Box(Modifier.weight(0.55f)) {
selectedTitle?.let { titlePane(it) } ?: Box(Modifier.padding(body)) {
EmptyState("Choose a title", "Its seasons and episodes open here.")
}
}
}
} else {
SharedTransitionLayout {
AnimatedContent(selectedTitle, contentKey = { it?.key }, label = "title") { open ->
CompositionLocalProvider(
LocalSharedTransition provides this@SharedTransitionLayout,
LocalPaneVisibility provides this,
) {
open?.let { titlePane(it) } ?: libraryPane()
}
}
}
}
}
Destination.Shares -> SharesScreen(
state.shares,
@@ -6,8 +6,13 @@ import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,12 +21,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -64,7 +76,9 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one.
* one. Until either answers, the box pulses as a skeleton; when neither has
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it.
*/
@Composable
internal fun RemoteArtwork(
@@ -72,19 +86,98 @@ internal fun RemoteArtwork(
description: String,
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
labelled: Boolean = true,
) {
val context = LocalContext.current
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, fallback) {
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
val enabled = LocalArtworkLoading.current
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
LaunchedEffect(url, fallback, enabled) {
if (!enabled) {
image = ArtworkLoad.Missing
return@LaunchedEffect
}
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
}
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let {
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
when (val loaded = image) {
ArtworkLoad.Pending -> Skeleton(modifier)
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
}
}
private sealed interface ArtworkLoad {
data object Pending : ArtworkLoad
data object Missing : ArtworkLoad
data class Loaded(val image: ImageBitmap) : ArtworkLoad
}
/**
* Whether [RemoteArtwork] fetches anything at all.
*
* Off in the screenshot tests: there is no bridge on the host, and whether a
* failed fetch lands before or after the capture would decide between a
* skeleton and a placeholder in the committed image.
*/
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
/**
* Something being loaded: the surface tint, breathing.
*
* A spinner per tile is a grid of spinners; a shape where the poster will be
* says what is coming and where, and settles the layout before it arrives.
*/
@Composable
internal fun Skeleton(modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.55f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "skeleton alpha",
)
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
}
/**
* Art for a title that has none: the accent as a gradient, and its initials.
*
* A black card with a play triangle on it said nothing about which title it
* was, so a grid of unmatched titles was a grid of identical cards. Each name
* sets where on the accent ramp its gradient starts, so neighbours differ
* while every one stays in the theme's colours.
*/
@Composable
internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) {
val scheme = MaterialTheme.colorScheme
val brush = remember(name, scheme.primary, scheme.secondary) {
val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f)
Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f)))
}
BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) {
if (!labelled) return@BoxWithConstraints
val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() }
Text(
initials(name),
color = Color.White.copy(alpha = 0.9f),
fontSize = size,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
/**
* "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
* capitalised words, so particles and articles in a romanised name do not
* take a slot. A name with no capitals uses its first two words.
*/
internal fun initials(name: String): String {
val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true }
val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words }
return picked.take(2).joinToString("") { it.first().uppercase() }
}
/**
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
*
@@ -1,6 +1,19 @@
package io.narl.protonstream.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ManageSearch
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.border
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -77,6 +90,10 @@ internal fun SettingsScreen(
onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit,
padding: PaddingValues,
initialPage: SettingsPage? = null,
// A parameter so the screenshot tests can pin it: read from the package,
// every version bump changed the committed image of this page.
version: String = appVersion(),
) {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
@@ -103,156 +120,185 @@ internal fun SettingsScreen(
var confirmDelete by remember { mutableStateOf(false) }
var showMetadata by remember { mutableStateOf(false) }
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
// Rows with one line of explanation each, in groups, rather than a toggle
// followed by a paragraph: the page read as a document to get through
// before reaching the switch that was wanted.
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) {
SettingsGroup("Playback")
prefs?.let { current ->
SettingSwitch(
"Autoplay next episode",
"Starts the next episode when the credits begin",
current.autoplayNext,
) { on -> update { it.copy(autoplayNext = on) } }
// Off is the safer default: a mis-named chapter then costs a tap on
// Skip rather than a scene.
SettingSwitch(
"Skip openings and endings",
"Uses the release's chapters; off shows a Skip button instead",
current.autoSkip,
) { on -> update { it.copy(autoSkip = on) } }
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
update { it.copy(subtitles = on) }
}
// Language tags, not a picker: which languages exist is a property
// of each file, and a list built from one episode is wrong for the
// next. A show that has been given its own choice keeps it.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LanguageField("Audio language", current.audioLanguage, Modifier.weight(1f)) { tag ->
update { it.copy(audioLanguage = tag) }
}
LanguageField("Subtitle language", current.subtitleLanguage, Modifier.weight(1f)) { tag ->
update { it.copy(subtitleLanguage = tag) }
}
}
Text(
"Tags as they appear in the file, such as jpn or eng",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
// One row per area on the first page, each opening its own page: grouped
// on one page, the storage controls sat a long scroll below playback, and
// every setting added made the rest harder to find.
var page by rememberSaveable { mutableStateOf(initialPage) }
BackHandler(enabled = page != null) { page = null }
val back: @Composable () -> Unit = {
IconButton(onClick = { page = null }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
}
SettingSwitch(
"Hardware decoding",
"Turn off if video shows green or torn frames",
hardwareDecoding,
) {
hardwareDecoding = it
settings.hardwareDecoding = it
}
SettingSwitch(
"Background audio",
"Keeps playing with the app closed, from the notification",
backgroundAudio,
) {
backgroundAudio = it
settings.backgroundAudio = it
}
SettingsGroup("Downloads and storage")
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
wifiOnly = it
settings.wifiOnly = it
// Constraints are baked in at enqueue time, so a queue that already
// exists keeps the policy it was queued under until it is re-issued.
DownloadCoordinator.applyNetworkPolicy(context)
}
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("Streaming cache")
Text(
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AccentProgress(
progress = {
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
.toFloat().coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
)
// What has been watched is kept up to this much, so going back over
// a scene costs no download. A lower choice frees the difference at
// once. Wrapping, so five chips survive a narrow phone at a large
// font scale.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
FilterChip(
selected = gib == cacheBudgetGib,
onClick = {
cacheBudgetGib = gib
settings.cacheBudgetGib = gib
scope.launch(Dispatchers.IO) {
runCatching {
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
}
}
}
when (page) {
null -> TabPage("Settings", padding) { body ->
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
SettingsPage.entries.forEach { entry ->
ListItem(
headlineContent = { Text(entry.title) },
supportingContent = {
Text(
when (entry) {
SettingsPage.Playback -> "Autoplay, languages and decoding"
SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " +
"${state.storage.offlineCount} offline"
SettingsPage.Appearance -> "Palette, accent and gradient"
SettingsPage.Metadata -> if (state.metadataSettings.enabled) {
"On · ${state.metadataSettings.provider.displayName()}"
} else {
"Off"
}
SettingsPage.About -> "Version $version · licences"
},
)
},
label = { Text("$gib GiB") },
leadingContent = { Icon(entry.icon, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { page = entry },
)
}
}
}
// The cache is rebuildable, so it goes without asking. Offline episodes
// are a choice the viewer made, so that one asks.
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
SettingAction(
"Delete all offline episodes",
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
" · ${formatBytes(state.storage.offlineBytes)}" +
(
state.storage.partialBytes.takeIf { it > 0uL }
?.let { " · ${formatBytes(it)} unfinished" }
.orEmpty()
),
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
onClick = { confirmDelete = true },
)
SettingsGroup("Appearance")
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
SettingsGroup("Metadata")
SettingAction(
"Metadata enrichment",
if (state.metadataSettings.enabled) {
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
} else {
"Off, the privacy default"
},
onClick = { showMetadata = true },
)
// Every title looked up again, matched ones included: the way out of a
// library the provider answered wrong, which otherwise stays wrong for
// as long as the match is remembered.
SettingAction(
"Match everything again",
"Looks every title up again, hand-picked matches excepted",
enabled = state.metadataSettings.enabled && !state.refreshing,
onClick = onMatchAgain,
)
SettingsGroup("About")
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
prefs?.let { current ->
SettingSwitch(
"Autoplay next episode",
"Starts the next episode when the credits begin",
current.autoplayNext,
) { on -> update { it.copy(autoplayNext = on) } }
// Off is the safer default: a mis-named chapter then costs a tap on
// Skip rather than a scene.
SettingSwitch(
"Skip openings and endings",
"Uses the release's chapters; off shows a Skip button instead",
current.autoSkip,
) { on -> update { it.copy(autoSkip = on) } }
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
update { it.copy(subtitles = on) }
}
// Preferences, not filters: a file with nothing in the language
// plays its own default track. A show that has been given its
// own choice keeps it.
LanguageSetting("Audio language", current.audioLanguage) { tag ->
update { it.copy(audioLanguage = tag) }
}
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
update { it.copy(subtitleLanguage = tag) }
}
}
SettingSwitch(
"Hardware decoding",
"Turn off if video shows green or torn frames",
hardwareDecoding,
) {
hardwareDecoding = it
settings.hardwareDecoding = it
}
SettingSwitch(
"Background audio",
"Keeps playing with the app closed, from the notification",
backgroundAudio,
) {
backgroundAudio = it
settings.backgroundAudio = it
}
}
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
wifiOnly = it
settings.wifiOnly = it
// Constraints are baked in at enqueue time, so a queue that already
// exists keeps the policy it was queued under until it is re-issued.
DownloadCoordinator.applyNetworkPolicy(context)
}
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("Streaming cache")
Text(
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AccentProgress(
progress = {
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
.toFloat().coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
)
// What has been watched is kept up to this much, so going back over
// a scene costs no download. A lower choice frees the difference at
// once. Wrapping, so five chips survive a narrow phone at a large
// font scale.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
FilterChip(
selected = gib == cacheBudgetGib,
onClick = {
cacheBudgetGib = gib
settings.cacheBudgetGib = gib
scope.launch(Dispatchers.IO) {
runCatching {
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
}
}
},
label = { Text("$gib GiB") },
)
}
}
}
// The cache is rebuildable, so it goes without asking. Offline episodes
// are a choice the viewer made, so that one asks.
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
SettingAction(
"Delete all offline episodes",
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
" · ${formatBytes(state.storage.offlineBytes)}" +
(
state.storage.partialBytes.takeIf { it > 0uL }
?.let { " · ${formatBytes(it)} unfinished" }
.orEmpty()
),
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
onClick = { confirmDelete = true },
)
}
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
}
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
SettingAction(
"Metadata enrichment",
if (state.metadataSettings.enabled) {
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
} else {
"Off, the privacy default"
},
onClick = { showMetadata = true },
)
// Every title looked up again, matched ones included: the way out of a
// library the provider answered wrong, which otherwise stays wrong for
// as long as the match is remembered.
SettingAction(
"Match everything again",
"Looks every title up again, hand-picked matches excepted",
enabled = state.metadataSettings.enabled && !state.refreshing,
onClick = onMatchAgain,
)
}
SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) {
ListItem(
headlineContent = { Text("Proton Stream") },
supportingContent = { Text("Version $version") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
}
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
}
}
Spacer(Modifier.height(24.dp))
}
if (confirmDelete) {
AlertDialog(
@@ -286,15 +332,39 @@ internal fun SettingsScreen(
}
}
/** A group's heading, in the accent, the way Android's own settings mark them. */
/** The areas the first page lists, each a page of its own. */
internal enum class SettingsPage(val title: String, val icon: ImageVector) {
Playback("Playback", Icons.Default.PlayCircle),
Storage("Downloads and storage", Icons.Default.Storage),
Appearance("Appearance", Icons.Default.Palette),
Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch),
About("About", Icons.Default.Info),
}
/** One area's page: its name, a way back, and its rows. */
@Composable
private fun SettingsGroup(label: String) {
Text(
label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 4.dp),
)
private fun SettingsSubPage(
page: SettingsPage,
padding: PaddingValues,
back: @Composable () -> Unit,
rows: @Composable ColumnScope.() -> Unit,
) {
TabPage(page.title, padding, navigationIcon = back) { body ->
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
rows()
Spacer(Modifier.height(24.dp))
}
}
}
/** The installed version, as the launcher and the store show it. */
@Composable
private fun appVersion(): String {
val context = LocalContext.current
return remember {
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
.getOrNull() ?: "unknown"
}
}
/** A switch with its one line of explanation; the whole row toggles it. */
@@ -521,20 +591,3 @@ private fun FlavorChoice.label() = when (this) {
FlavorChoice.MOCHA -> "Catppuccin Mocha"
FlavorChoice.PERSONA5 -> "Persona 5"
}
/**
* One language tag, committed as it is typed.
*
* Blank is a real answer and means "no preference" — the bridge stores it as
* absent, which is what leaves the choice to the container's own default track.
*/
@Composable
private fun LanguageField(label: String, value: String?, modifier: Modifier = Modifier, onChange: (String?) -> Unit) {
OutlinedTextField(
value = value.orEmpty(),
onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) },
label = { Text(label) },
singleLine = true,
modifier = modifier,
)
}
@@ -60,38 +60,33 @@ internal fun SharesScreen(
var showAdd by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
var removing by remember { mutableStateOf<ShareRecord?>(null) }
Box(Modifier.fillMaxSize().padding(padding)) {
if (shares.isEmpty()) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
item(key = "heading") {
Text(
"Proton Drive public links",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(shares, key = { it.id }) { share ->
ShareRow(
share = share,
onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share },
onRemove = { removing = share },
)
TabPage("Shares", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) {
if (shares.isEmpty()) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
items(shares, key = { it.id }) { share ->
ShareRow(
share = share,
onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share },
onRemove = { removing = share },
)
}
}
}
// Where a thumb is, and the one thing this page is for. The share
// sheet and a tapped link land in the same form.
ExtendedFloatingActionButton(
onClick = { showAdd = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add share") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
// Where a thumb is, and the one thing this page is for. The share
// sheet and a tapped link land in the same form.
ExtendedFloatingActionButton(
onClick = { showAdd = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add share") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
if (showAdd || incomingLink != null) {
AddShareDialog(
@@ -1,7 +1,11 @@
package io.narl.protonstream.ui
import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -14,6 +18,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
@@ -93,7 +100,19 @@ internal fun TitleScreen(
var showMatch by remember(title.key) { mutableStateOf(false) }
var overviewOpen by remember(title.key) { mutableStateOf(false) }
BackHandler(onBack = onBack)
// Predictive back: the page shrinks towards the library as the gesture
// is dragged, and springs back if it is let go of, so the viewer sees
// where back goes before committing to it.
var backProgress by remember { mutableFloatStateOf(0f) }
PredictiveBackHandler { gesture ->
try {
gesture.collect { backProgress = it.progress }
onBack()
} catch (cancelled: CancellationException) {
backProgress = 0f
throw cancelled
}
}
// Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses.
@@ -116,171 +135,194 @@ internal fun TitleScreen(
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
val downloads = remember(work) { DownloadStateStore(context).records() }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
// On a tablet the page keeps a phone's reading width, centred: a Play
// button and synopsis lines two thousand pixels wide are read by turning
// the head. The backdrop still spans the window, but no taller than about
// half of it, or a landscape tablet opened on a picture and nothing else.
BoxWithConstraints(
Modifier
.fillMaxSize()
.graphicsLayer {
val scale = 1f - 0.1f * backProgress
scaleX = scale
scaleY = scale
shape = RoundedCornerShape((32 * backProgress).dp)
clip = backProgress > 0f
}
.padding(padding),
) {
item(key = "backdrop") {
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
1f to MaterialTheme.colorScheme.background,
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(key = "backdrop") {
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
1f to MaterialTheme.colorScheme.background,
),
),
),
)
IconButton(
onClick = onBack,
modifier = Modifier
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
}
item(key = "heading") {
Column(Modifier.padding(horizontal = 16.dp)) {
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
title.originalName?.takeIf { it != title.canonicalName }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
listOfNotNull(
(title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
// The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which
// episode of forty it meant.
AccentButton(
onClick = { onPlay(playlist, nextUp) },
enabled = playerReady && playlist.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
when {
!playerReady -> "Player loading…"
upNext == null -> "Play"
upNext.resumeAt != null -> "Resume ${upNext.label}"
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
playlist.size == 1 -> "Play"
else -> "Play ${upNext.label}"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Everything else as labelled icons in one row: four actions of
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
onPlay(playlist, 0)
}
}
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
DownloadCoordinator.enqueue(context, playlist)
}
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes
// to check that the thing the app matched is what they have.
title.externalUrl?.let { url ->
TitleAction(
Icons.AutoMirrored.Filled.OpenInNew,
title.metadataProvider?.displayName() ?: "Provider",
Modifier.weight(1f),
) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}
}
}
title.overview?.let { overview ->
// Three lines, then a tap for the rest: a synopsis with its
// source notes ran to a screen and a half on a phone.
Text(
overview.trim(),
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
IconButton(
onClick = onBack,
modifier = Modifier
.padding(top = 12.dp)
.clickable { overviewOpen = !overviewOpen },
)
}
// Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
.statusBarsPadding()
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
}
}
item(key = "seasons") {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
title.originalName?.takeIf { it != title.canonicalName }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
listOfNotNull(
(title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
// The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which
// episode of forty it meant.
AccentButton(
onClick = { onPlay(playlist, nextUp) },
enabled = playerReady && playlist.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
when {
!playerReady -> "Player loading…"
upNext == null -> "Play"
upNext.resumeAt != null -> "Resume ${upNext.label}"
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
playlist.size == 1 -> "Play"
else -> "Play ${upNext.label}"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Everything else as labelled icons in one row: four actions of
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
onPlay(playlist, 0)
}
}
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
DownloadCoordinator.enqueue(context, playlist)
}
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes
// to check that the thing the app matched is what they have.
title.externalUrl?.let { url ->
TitleAction(
Icons.AutoMirrored.Filled.OpenInNew,
title.metadataProvider?.displayName() ?: "Provider",
Modifier.weight(1f),
) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}
}
}
title.overview?.let { overview ->
// Three lines, then a tap for the rest: a synopsis with its
// source notes ran to a screen and a half on a phone.
Text(
overview.trim(),
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 12.dp)
.clickable { overviewOpen = !overviewOpen },
)
}
// Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
season?.let {
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
}
item(key = "seasons") {
Row(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
}
}
season?.let {
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
}
}
}
}
}
season?.let { shown ->
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
EpisodeRow(
episode = episode,
numbering = episode.numbering(shown.number, position),
position = position,
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
season?.let { shown ->
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
Box(Modifier.padding(horizontal = side)) {
EpisodeRow(
episode = episode,
numbering = episode.numbering(shown.number, position),
position = position,
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
}
}
}
}
}
@@ -294,6 +336,9 @@ internal fun TitleScreen(
}
}
/** How wide the page's text and controls run on a window wider than a phone. */
private val READABLE_WIDTH = 720.dp
/** One of the title's secondary actions: an icon with its name under it. */
@Composable
private fun TitleAction(
@@ -386,6 +431,7 @@ private fun EpisodeRow(
heading,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
@@ -483,26 +529,8 @@ private fun EpisodeRow(
}
}
/**
* What an episode row is headed with: "1. Mother and Children".
*
* The provider's name for the episode, else the name the filename gave it,
* else "Episode 1". The bare filename only when none of those exist — a column
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/
private fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name }
val number = number?.toInt()
return when {
named != null && number != null -> "$number. $named"
named != null -> named
number != null -> "Episode $number"
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
}
}
@Composable
private fun ChangeMatchDialog(
internal fun ChangeMatchDialog(
title: TitleRecord,
onDismiss: () -> Unit,
onChanged: () -> Unit,
@@ -2,15 +2,17 @@
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
revision is fetched in. The paths are in the SVG's 128-unit space; the group
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
It centres x=70, between the glyph's centroid (66) and its box (73): the
SVG's own centre, 64, leaves it sitting visibly right in a round mask.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<group
android:pivotX="64" android:pivotY="62"
android:pivotX="70" android:pivotY="62"
android:scaleX="0.6923" android:scaleY="0.6923"
android:translateX="-10" android:translateY="-8">
android:translateX="-16" android:translateY="-8">
<group android:translateY="3">
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path android:fillColor="#4D2E1596" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
@@ -3,9 +3,9 @@
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<group
android:pivotX="64" android:pivotY="62"
android:pivotX="70" android:pivotY="62"
android:scaleX="0.6923" android:scaleY="0.6923"
android:translateX="-10" android:translateY="-8">
android:translateX="-16" android:translateY="-8">
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
</group>
@@ -0,0 +1,28 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class ArtworkTest {
@Test
fun `initials skip the lower-case particles of a romanised name`() {
assertEquals("OK", initials("Oshi no Ko"))
assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."))
}
@Test
fun `a one-word name has one initial`() {
assertEquals("A", initials("Akira"))
}
@Test
fun `a name with no capitals falls back to its first two words`() {
assertEquals("BW", initials("blue world order"))
}
@Test
fun `punctuation and separators are not words`() {
assertEquals("S2", initials("Steins;Gate - 2"))
assertEquals("", initials(" - "))
}
}
@@ -0,0 +1,31 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LanguagePickerTest {
@Test
fun `a stored tag reads as its language in either ISO form`() {
assertEquals("Japanese", languageLabel("jpn"))
assertEquals("Japanese", languageLabel("ja"))
assertEquals("German", languageLabel("deu"))
assertEquals("German", languageLabel("ger"))
}
@Test
fun `a region suffix and capitals do not hide the language`() {
assertEquals("Portuguese", languageLabel("PT-br"))
}
@Test
fun `a tag not on the list is shown as typed, and blank is no preference`() {
assertEquals("tgl", languageLabel("tgl"))
assertEquals("No preference", languageLabel(null))
assertEquals("No preference", languageLabel(" "))
}
@Test
fun `every listed language has a two-letter form`() {
LANGUAGES.forEach { (code, name) -> assertEquals(code, name, languageLabel(code)) }
}
}
@@ -13,6 +13,8 @@ import androidx.test.core.app.ApplicationProvider
import androidx.work.testing.WorkManagerTestInitHelper
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonFallback
import io.narl.protonstream.ui.theme.ProtonStreamTheme
import java.time.LocalDate
import java.time.ZoneOffset
@@ -24,6 +26,8 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.StorageUsageRecord
@@ -36,8 +40,9 @@ import uniffi.pstr_android.TitleType
* The screens are about to be redesigned one by one, and a layout regression —
* a row that wraps, a button pushed off the edge — compiles and passes every
* other test. Here it is a changed PNG in review. No artwork is loaded: the
* bridge is not available on the host, so every tile shows its placeholder,
* which is also what a title without metadata looks like on a phone.
* bridge is not available on the host, so every tile shows its generated
* placeholder, which is also what a title without metadata looks like on a
* phone.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -48,12 +53,22 @@ class ScreenshotTest {
@Before
fun setUp() {
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
// The palette is process-wide and the sandbox outlives a test, so a
// light capture would otherwise repaint every one after it.
AppearanceState.apply(ProtonFallback)
}
private fun captureLight(name: String, content: @Composable () -> Unit) {
AppearanceState.apply(LATTE)
capture(name, content)
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
ProtonStreamTheme {
Surface(color = MaterialTheme.colorScheme.background) { content() }
CompositionLocalProvider(LocalArtworkLoading provides false) {
Surface(color = MaterialTheme.colorScheme.background) { content() }
}
}
}
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
@@ -70,6 +85,28 @@ class ScreenshotTest {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `a title in the light palette`() = captureLight("title-phone-light") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `a title on a tablet`() = capture("title-tablet") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the history page on a tablet`() = capture("history-tablet") {
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
}
@Test
fun `the library at a large font scale`() = capture("library-phone-large-font") {
// Robolectric has no font-scale qualifier; the density is what Compose
@@ -85,6 +122,11 @@ class ScreenshotTest {
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library while the catalog loads`() = capture("library-loading") {
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `a title with seasons`() = capture("title-phone") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
@@ -97,7 +139,17 @@ class ScreenshotTest {
@Test
fun `the settings page`() = capture("settings-phone") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues())
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
}
@Test
fun `the playback settings`() = capture("settings-playback") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
}
@Test
fun `the storage settings`() = capture("settings-storage") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
}
@Test
@@ -107,12 +159,42 @@ class ScreenshotTest {
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
}
@Test
fun `the downloads page with a season saved`() = capture("downloads-phone") {
val saved = SERIES.seasons.first().episodes.take(2).map {
OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it)
}
// One title: the fixtures reuse link ids across titles, so with the
// whole library the episodes would be filed under whichever came last.
DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues())
}
@Test
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
}
private companion object {
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
val LATTE = PaletteRecord(
background = 0xFFEFF1F5u,
surface = 0xFFE6E9EFu,
sunken = 0xFFDCE0E8u,
card = 0xFFCCD0DAu,
cardHover = 0xFFBCC0CCu,
border = 0xFFBCC0CCu,
text = 0xFF4C4F69u,
muted = 0xFF6C6F85u,
accent = 0xFF732FCCu,
accentAlt = 0xFF1856D1u,
accentDim = 0xFFC4BBE4u,
onAccent = 0xFFEFF1F5u,
danger = 0xFFD20F39u,
elevated = 0xFFAAAEBDu,
dangerDim = 0xFFE7D1D5u,
light = true,
)
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
// `detail` is the filename unless the filename named the episode,
// which is what the bridge sends.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+11 -7
View File
@@ -212,13 +212,17 @@ client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
across fails here rather than being noticed in a screenshot.
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
records — Library on a phone, a tablet and at font scale 1.5, an empty library,
a title, History, Shares, Settings and Downloads — and `check` compares them with the
images in `app/src/test/screenshots`. A layout regression compiles and passes
everything else; here it fails, and the review shows the changed image. After a
deliberate change, `gradlew recordRoborazziDebug` rewrites the images and they
are committed with it. No artwork loads on the host, so tiles show their
placeholder, and the palette is the shipped default.
records — Library on a phone, a tablet, at font scale 1.5, while loading and
empty; a title on a phone and a tablet; History on a phone and a tablet; Shares;
the Settings index and its Playback and Storage pages; Downloads with a season
saved and empty; and the library and a title again in Catppuccin Latte — and
`check` compares them with the images in `app/src/test/screenshots`. A layout
regression compiles and passes everything else; here it fails, and the review
shows the changed image. After a deliberate change, `gradlew
recordRoborazziDebug` rewrites the images and they are committed with it.
Artwork loading is switched off (`LocalArtworkLoading`), so every tile shows
its generated placeholder whatever the timing, and the palette is the shipped
default except in the Latte captures.
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
equivalent:
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone.
pkgname=proton-stream
pkgver=0.5.1
pkgver=1.0.1
pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64')