Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d27ec996 | ||
|
|
66eb1b49a1 | ||
|
|
22425e0bad | ||
|
|
50481e6aed | ||
|
|
bedbe0ef52 | ||
|
|
7914cf4dc1 | ||
|
|
39a7f3cc38 | ||
|
|
630f07a462 | ||
|
|
9cba54df14 | ||
|
|
4ff8ae303c | ||
|
|
6c2ec83cb1 | ||
|
|
677ab2d928 | ||
|
|
fb32d6c850 | ||
|
|
110f97caf9 |
No files matched your search
@@ -107,19 +107,34 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
# One cache for both jobs: the signed build compiles the same crates for
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
# the same targets. Saved on failure too, so a run that breaks late does
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
# not throw away everything it compiled before the break.
|
# `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
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
with:
|
with:
|
||||||
shared-key: android
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
cache-on-failure: true
|
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
|
# 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
|
# 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
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
@@ -167,6 +182,12 @@ jobs:
|
|||||||
- name: Android checks and artifacts
|
- name: Android checks and artifacts
|
||||||
run: bash scripts/build-android.sh all
|
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:
|
signed-release:
|
||||||
name: Signed release
|
name: Signed release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
||||||
@@ -209,19 +230,34 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
# One cache for both jobs: the signed build compiles the same crates for
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
# the same targets. Saved on failure too, so a run that breaks late does
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
# not throw away everything it compiled before the break.
|
# `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
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
with:
|
with:
|
||||||
shared-key: android
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
cache-on-failure: true
|
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
|
# 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
|
# 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
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
@@ -266,6 +302,12 @@ jobs:
|
|||||||
- name: Build signed APK and AAB
|
- name: Build signed APK and AAB
|
||||||
run: bash scripts/build-android.sh signed
|
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
|
# 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.
|
# it. The AAB is for Play only and stays out of the GitHub release.
|
||||||
- name: Stage release files
|
- name: Stage release files
|
||||||
|
|||||||
@@ -4359,7 +4359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -4378,7 +4378,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4409,7 +4409,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -4427,7 +4427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4445,7 +4445,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4458,7 +4458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -4473,7 +4473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.1"
|
version = "1.0.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
|
|||||||
@@ -10,16 +10,21 @@ gained about twenty features on 2026-10-02 that Android does not have.
|
|||||||
|
|
||||||
## Status (2026-10-03)
|
## Status (2026-10-03)
|
||||||
|
|
||||||
Done: section 0, every parity item, the Library, Title, Shares and Settings
|
Done: section 0, every parity item, and every recommendation in section 2 —
|
||||||
redesigns, Inter, and screenshot tests. Not yet verified on a device; the
|
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
|
||||||
host gate (ktlint, lint, unit and screenshot tests) and CI's build job pass.
|
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
|
Two departures from the text below. The tablet layout is a two-pane `Row`
|
||||||
bar, skeleton loading and generated placeholder art, a search icon that
|
rather than `ListDetailPaneScaffold`, which would have added the
|
||||||
expands, a language picker, Settings sub-pages, the Downloads page, light
|
`adaptive-layout` artifact for one split. Pull to refresh shows the
|
||||||
theme and tablet screenshots, `ListDetailPaneScaffold` on tablets, shared
|
indicator while a crawl runs, but not the crawl's progress.
|
||||||
element transitions and predictive back, and a long-press Change match (the
|
|
||||||
title page has it).
|
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||||
|
tests) passes, and Android lint runs in CI.
|
||||||
|
|
||||||
## 0. CI (done)
|
## 0. CI (done)
|
||||||
|
|
||||||
|
|||||||
@@ -180,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
|
|||||||
val hostRustLibrary = repositoryRoot.resolve(
|
val hostRustLibrary = repositoryRoot.resolve(
|
||||||
"target/debug/${System.mapLibraryName("pstr_android")}",
|
"target/debug/${System.mapLibraryName("pstr_android")}",
|
||||||
)
|
)
|
||||||
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
|
|
||||||
|
|
||||||
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
||||||
group = "native"
|
group = "native"
|
||||||
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
||||||
workingDir(repositoryRoot)
|
workingDir(repositoryRoot)
|
||||||
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
||||||
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
|
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
|
||||||
outputs.files(
|
// deletes the declared outputs of a task it has no history for, which on a
|
||||||
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
|
// fresh CI checkout threw away the tree just restored from the cache and
|
||||||
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
|
// rebuilt libmpv from source on every run.
|
||||||
stagedMpvDir.map { it.file("include/mpv/client.h") },
|
|
||||||
stagedMpvDir.map { it.file("REVISION") },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val buildRustHost by tasks.registering(Exec::class) {
|
val buildRustHost by tasks.registering(Exec::class) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
android:name=".ProtonStreamApplication"
|
android:name=".ProtonStreamApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
tools:targetApi="33"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
|||||||
@@ -954,6 +954,7 @@ private fun EpisodeChooser(
|
|||||||
episode.label,
|
episode.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
@@ -1048,6 +1049,7 @@ private fun UpNextCard(
|
|||||||
upNext.label,
|
upNext.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = upNext.thumbnailSource,
|
fallback = upNext.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
|
|||||||
@@ -1,15 +1,96 @@
|
|||||||
package io.narl.protonstream.ui
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
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
|
@Composable
|
||||||
internal fun EmptyState(title: String, body: String) {
|
internal fun EmptyState(title: String, body: String) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -38,3 +119,21 @@ internal fun formatBytes(bytes: ULong): String {
|
|||||||
else -> "$bytes bytes"
|
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
|
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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material3.Card
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import io.narl.protonstream.download.DownloadCoordinator
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
import io.narl.protonstream.download.DownloadStateStore
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
import io.narl.protonstream.download.RetainedDownload
|
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.AccentProgress
|
||||||
import io.narl.protonstream.ui.theme.QuietButton
|
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
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
|
@Composable
|
||||||
internal fun DownloadsScreen(
|
internal fun DownloadsScreen(
|
||||||
state: AppUiState,
|
state: AppUiState,
|
||||||
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
|
onRemove: (OfflineRecord) -> Unit,
|
||||||
onPause: (RetainedDownload) -> Unit,
|
onPause: (RetainedDownload) -> Unit,
|
||||||
onResume: (RetainedDownload) -> Unit,
|
onResume: (RetainedDownload) -> Unit,
|
||||||
onDeletePartial: (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.
|
// but not its show, and the library is the only thing that does.
|
||||||
val groups = remember(state.offline, state.titles) {
|
val groups = remember(state.offline, state.titles) {
|
||||||
val shows = state.titles.flatMap { title ->
|
val shows = state.titles.flatMap { title ->
|
||||||
title.seasons.flatMap(SeasonRecord::episodes).map {
|
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
|
||||||
"${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name)
|
|
||||||
}
|
|
||||||
}.toMap()
|
}.toMap()
|
||||||
state.offline
|
state.offline
|
||||||
.groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" }
|
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||||
.toSortedMap()
|
.map { (title, files) ->
|
||||||
|
OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
// 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()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
|
||||||
|
}
|
||||||
|
return@TabPage
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(body),
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
if (state.offline.isNotEmpty()) {
|
||||||
Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
|
item(key = "summary") {
|
||||||
Spacer(Modifier.height(16.dp))
|
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)
|
||||||
}
|
}
|
||||||
if (retained.isEmpty() && state.offline.isEmpty()) {
|
|
||||||
item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
|
|
||||||
}
|
}
|
||||||
// Under the show they belong to, as on desktop: a saved season is
|
// Under the show they belong to, as on desktop: a saved season is
|
||||||
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
||||||
groups.forEach { (show, files) ->
|
groups.forEach { group ->
|
||||||
item(key = "group/$show") {
|
item(key = "group/${group.name}") {
|
||||||
Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp))
|
SectionHeading(
|
||||||
Text(
|
group.name,
|
||||||
"${files.size} ${if (files.size == 1) "episode" else "episodes"} · " +
|
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
|
||||||
formatBytes(files.sumOf { it.size }),
|
formatBytes(group.files.sumOf { it.size }),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
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(
|
||||||
|
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
|
||||||
|
"It still streams, and can be downloaded again.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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
|
val episode = file.episode
|
||||||
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
ListItem(
|
||||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
leadingContent = {
|
||||||
Column(Modifier.weight(1f)) {
|
RemoteArtwork(
|
||||||
Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
|
episode?.stillUrl ?: title?.backdropUrl,
|
||||||
Text(formatBytes(file.size))
|
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")
|
||||||
}
|
}
|
||||||
TonalButton(onClick = { onRemove(file) }) {
|
},
|
||||||
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
Spacer(Modifier.width(8.dp))
|
)
|
||||||
Text("Delete")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
}
|
* A download not yet finished: how far it has got, why it stopped if it did,
|
||||||
}
|
* and pause or resume beside cancel.
|
||||||
items(retained, key = { "${it.shareId}/${it.linkId}" }) { download ->
|
*/
|
||||||
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
|
@Composable
|
||||||
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
private fun PartialRow(
|
||||||
Column(Modifier.padding(16.dp)) {
|
download: RetainedDownload,
|
||||||
Text(download.label, fontWeight = FontWeight.SemiBold)
|
onPause: (RetainedDownload) -> Unit,
|
||||||
Text(download.status.replaceFirstChar { it.uppercase() })
|
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) {
|
if (download.total > 0L) {
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
progress = { progress },
|
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
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) }
|
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
Row(
|
}
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
},
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
trailingContent = {
|
||||||
) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (download.status == RetainedDownload.STATUS_RUNNING ||
|
if (active) {
|
||||||
download.status == RetainedDownload.STATUS_QUEUED
|
IconButton(onClick = { onPause(download) }) {
|
||||||
) {
|
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||||
TonalButton(onClick = { onPause(download) }) { Text("Pause") }
|
}
|
||||||
} else {
|
} else {
|
||||||
TonalButton(onClick = { onResume(download) }) { Text("Resume") }
|
IconButton(onClick = { onResume(download) }) {
|
||||||
}
|
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
|
||||||
QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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,12 +80,15 @@ internal fun HistoryScreen(
|
|||||||
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
||||||
}
|
}
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
TabPage("History", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = 24.dp)) {
|
TabPage("History", padding) { body ->
|
||||||
|
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
days.forEach { (day, played) ->
|
days.forEach { (day, played) ->
|
||||||
item(key = "day/$day") {
|
item(key = "day/$day") {
|
||||||
Text(
|
Text(
|
||||||
@@ -106,6 +109,7 @@ internal fun HistoryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The still plays the episode, the text opens its title, and the cross takes
|
* The still plays the episode, the text opens its title, and the cross takes
|
||||||
@@ -130,6 +134,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o
|
|||||||
episode.label,
|
episode.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
AccentProgress(
|
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
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
@@ -10,6 +23,7 @@ import java.time.LocalDate
|
|||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Download
|
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.Info
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -90,11 +103,14 @@ internal fun LibraryScreen(
|
|||||||
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
||||||
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
onMatchChanged: () -> Unit = {},
|
||||||
|
onError: (Throwable) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
var menuResume by remember { mutableStateOf<Resumable?>(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
|
// 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.
|
// episodes of the same series is a shelf with room for nothing else.
|
||||||
val resumable = remember(state.titles) {
|
val resumable = remember(state.titles) {
|
||||||
@@ -106,41 +122,49 @@ internal fun LibraryScreen(
|
|||||||
?.let { Resumable(title, it.value, it.index) }
|
?.let { Resumable(title, it.value, it.index) }
|
||||||
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||||
}
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
// Search is an icon until it is wanted: a permanent field above the
|
||||||
// A filled pill rather than an outlined form field: search is how the
|
// library spent a band of every visit on something used in few of them.
|
||||||
// page is navigated, not a value being entered.
|
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
|
||||||
TextField(
|
val closeSearch = {
|
||||||
value = state.query,
|
searching = false
|
||||||
onValueChange = onSearch,
|
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")
|
|
||||||
}
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
// 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 = TextFieldDefaults.colors(
|
colors = pageBarColors(),
|
||||||
focusedIndicatorColor = Color.Transparent,
|
scrollBehavior = scroll,
|
||||||
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.
|
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = state.refreshing,
|
isRefreshing = state.refreshing,
|
||||||
onRefresh = onRefresh,
|
onRefresh = onRefresh,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.weight(1f).padding(padding),
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
state.loading -> LibrarySkeleton()
|
||||||
CircularProgressIndicator()
|
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
|
||||||
}
|
"Nothing matches",
|
||||||
|
"No title in the library is called “${state.query.trim()}”.",
|
||||||
|
)
|
||||||
state.titles.isEmpty() -> EmptyState(
|
state.titles.isEmpty() -> EmptyState(
|
||||||
"Your library is empty",
|
"Your library is empty",
|
||||||
"Add a Proton Drive public link under Shares, then refresh.",
|
"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) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||||
if (allWatched) "Mark unwatched" else "Mark watched",
|
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||||
) { onSetTitleWatched(title, !allWatched) }
|
) { 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 ->
|
menuResume?.let { entry ->
|
||||||
TileMenu(
|
TileMenu(
|
||||||
entry.title.canonicalName ?: entry.title.name,
|
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
|
* A long press's menu, from the bottom of the screen where the thumb already
|
||||||
* is. Each row closes it before doing its thing.
|
* is. Each row closes it before doing its thing.
|
||||||
@@ -282,7 +364,7 @@ private fun LibraryGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
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 ->
|
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
|
* 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
|
* 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,
|
title.canonicalName ?: title.name,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = title.thumbnailSource,
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
@@ -408,7 +520,7 @@ private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: ()
|
|||||||
RemoteArtwork(
|
RemoteArtwork(
|
||||||
title.posterUrl ?: title.backdropUrl,
|
title.posterUrl ?: title.backdropUrl,
|
||||||
title.canonicalName ?: title.name,
|
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,
|
fallback = title.thumbnailSource,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package io.narl.protonstream.ui
|
|||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.calculateEndPadding
|
||||||
import androidx.compose.foundation.layout.calculateStartPadding
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
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.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material.icons.Icons
|
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.Download
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.Close
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlayerScreen
|
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. */
|
/** How much room the floating mini transport needs at the foot of a page. */
|
||||||
private val MINI_TRANSPORT_INSET = 88.dp
|
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),
|
Settings("Settings", Icons.Default.Settings),
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProtonStreamApp(
|
fun ProtonStreamApp(
|
||||||
playerHost: LibmpvHost? = null,
|
playerHost: LibmpvHost? = null,
|
||||||
@@ -162,23 +170,10 @@ fun ProtonStreamApp(
|
|||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbars) },
|
snackbarHost = { SnackbarHost(snackbars) },
|
||||||
topBar = {
|
// Each page draws its own bar under the status bar, and the title
|
||||||
TopAppBar(
|
// page draws its backdrop there, so the top inset is theirs.
|
||||||
title = { Text(selectedTitle?.name ?: "proton-stream") },
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets
|
||||||
actions = {
|
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom),
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// Back from a secondary tab returns to the library rather than
|
// 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
|
// leaving the app; the library's own back is the system's, which is
|
||||||
@@ -200,7 +195,11 @@ fun ProtonStreamApp(
|
|||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
AnimatedContent(destination, label = "primary navigation") { target ->
|
AnimatedContent(destination, label = "primary navigation") { target ->
|
||||||
when (target) {
|
when (target) {
|
||||||
Destination.Library -> if (selectedTitle == null) {
|
// 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(
|
LibraryScreen(
|
||||||
state,
|
state,
|
||||||
model::search,
|
model::search,
|
||||||
@@ -214,13 +213,16 @@ fun ProtonStreamApp(
|
|||||||
model::setTitleWatched,
|
model::setTitleWatched,
|
||||||
model::forgetPosition,
|
model::forgetPosition,
|
||||||
body,
|
body,
|
||||||
|
onMatchChanged = model::reloadAfterMetadataChange,
|
||||||
|
onError = model::reportError,
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
|
val titlePane = @Composable { selected: TitleRecord ->
|
||||||
TitleScreen(
|
TitleScreen(
|
||||||
selectedTitle,
|
selected,
|
||||||
playerHost != null,
|
playerHost != null,
|
||||||
{ _, index ->
|
{ _, index ->
|
||||||
playingTitleKey = selectedTitle.key
|
playingTitleKey = selected.key
|
||||||
playingIndex = index
|
playingIndex = index
|
||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
},
|
},
|
||||||
@@ -231,6 +233,29 @@ fun ProtonStreamApp(
|
|||||||
body,
|
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(
|
Destination.Shares -> SharesScreen(
|
||||||
state.shares,
|
state.shares,
|
||||||
model::addShare,
|
model::addShare,
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import android.graphics.BitmapFactory
|
|||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,12 +21,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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 io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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
|
* [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
|
* 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
|
@Composable
|
||||||
internal fun RemoteArtwork(
|
internal fun RemoteArtwork(
|
||||||
@@ -72,17 +86,96 @@ internal fun RemoteArtwork(
|
|||||||
description: String,
|
description: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fallback: ThumbnailSource? = null,
|
fallback: ThumbnailSource? = null,
|
||||||
|
labelled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
val enabled = LocalArtworkLoading.current
|
||||||
LaunchedEffect(url, fallback) {
|
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
LaunchedEffect(url, fallback, enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
image = ArtworkLoad.Missing
|
||||||
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
||||||
image?.let {
|
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||||
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
?: ArtworkLoad.Missing
|
||||||
} ?: 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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.clickable
|
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.border
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -77,6 +90,10 @@ internal fun SettingsScreen(
|
|||||||
onClearCache: () -> Unit,
|
onClearCache: () -> Unit,
|
||||||
onRemoveAllOffline: () -> Unit,
|
onRemoveAllOffline: () -> Unit,
|
||||||
padding: PaddingValues,
|
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 context = LocalContext.current
|
||||||
val settings = remember { SettingsStore(context) }
|
val settings = remember { SettingsStore(context) }
|
||||||
@@ -103,11 +120,46 @@ internal fun SettingsScreen(
|
|||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
var showMetadata by remember { mutableStateOf(false) }
|
var showMetadata by remember { mutableStateOf(false) }
|
||||||
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
||||||
// Rows with one line of explanation each, in groups, rather than a toggle
|
// One row per area on the first page, each opening its own page: grouped
|
||||||
// followed by a paragraph: the page read as a document to get through
|
// on one page, the storage controls sat a long scroll below playback, and
|
||||||
// before reaching the switch that was wanted.
|
// every setting added made the rest harder to find.
|
||||||
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) {
|
var page by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
SettingsGroup("Playback")
|
BackHandler(enabled = page != null) { page = null }
|
||||||
|
val back: @Composable () -> Unit = {
|
||||||
|
IconButton(onClick = { page = null }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(entry.icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { page = entry },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
|
||||||
prefs?.let { current ->
|
prefs?.let { current ->
|
||||||
SettingSwitch(
|
SettingSwitch(
|
||||||
"Autoplay next episode",
|
"Autoplay next episode",
|
||||||
@@ -124,27 +176,16 @@ internal fun SettingsScreen(
|
|||||||
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
|
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
|
||||||
update { it.copy(subtitles = on) }
|
update { it.copy(subtitles = on) }
|
||||||
}
|
}
|
||||||
// Language tags, not a picker: which languages exist is a property
|
// Preferences, not filters: a file with nothing in the language
|
||||||
// of each file, and a list built from one episode is wrong for the
|
// plays its own default track. A show that has been given its
|
||||||
// next. A show that has been given its own choice keeps it.
|
// own choice keeps it.
|
||||||
Row(
|
LanguageSetting("Audio language", current.audioLanguage) { tag ->
|
||||||
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) }
|
update { it.copy(audioLanguage = tag) }
|
||||||
}
|
}
|
||||||
LanguageField("Subtitle language", current.subtitleLanguage, Modifier.weight(1f)) { tag ->
|
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
|
||||||
update { it.copy(subtitleLanguage = 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SettingSwitch(
|
SettingSwitch(
|
||||||
"Hardware decoding",
|
"Hardware decoding",
|
||||||
"Turn off if video shows green or torn frames",
|
"Turn off if video shows green or torn frames",
|
||||||
@@ -161,8 +202,8 @@ internal fun SettingsScreen(
|
|||||||
backgroundAudio = it
|
backgroundAudio = it
|
||||||
settings.backgroundAudio = it
|
settings.backgroundAudio = it
|
||||||
}
|
}
|
||||||
|
}
|
||||||
SettingsGroup("Downloads and storage")
|
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||||
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||||
wifiOnly = it
|
wifiOnly = it
|
||||||
settings.wifiOnly = it
|
settings.wifiOnly = it
|
||||||
@@ -221,11 +262,11 @@ internal fun SettingsScreen(
|
|||||||
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
|
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
|
||||||
onClick = { confirmDelete = true },
|
onClick = { confirmDelete = true },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
SettingsGroup("Appearance")
|
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
|
||||||
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
|
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
|
||||||
|
}
|
||||||
SettingsGroup("Metadata")
|
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
|
||||||
SettingAction(
|
SettingAction(
|
||||||
"Metadata enrichment",
|
"Metadata enrichment",
|
||||||
if (state.metadataSettings.enabled) {
|
if (state.metadataSettings.enabled) {
|
||||||
@@ -244,15 +285,20 @@ internal fun SettingsScreen(
|
|||||||
enabled = state.metadataSettings.enabled && !state.refreshing,
|
enabled = state.metadataSettings.enabled && !state.refreshing,
|
||||||
onClick = onMatchAgain,
|
onClick = onMatchAgain,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
SettingsGroup("About")
|
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.") {
|
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
||||||
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
||||||
}
|
}
|
||||||
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
|
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
|
||||||
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
}
|
||||||
}
|
}
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
AlertDialog(
|
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
|
@Composable
|
||||||
private fun SettingsGroup(label: String) {
|
private fun SettingsSubPage(
|
||||||
Text(
|
page: SettingsPage,
|
||||||
label,
|
padding: PaddingValues,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
back: @Composable () -> Unit,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
rows: @Composable ColumnScope.() -> Unit,
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 4.dp),
|
) {
|
||||||
)
|
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. */
|
/** 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.MOCHA -> "Catppuccin Mocha"
|
||||||
FlavorChoice.PERSONA5 -> "Persona 5"
|
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,18 +60,12 @@ internal fun SharesScreen(
|
|||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
TabPage("Shares", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
if (shares.isEmpty()) {
|
if (shares.isEmpty()) {
|
||||||
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
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 ->
|
items(shares, key = { it.id }) { share ->
|
||||||
ShareRow(
|
ShareRow(
|
||||||
share = share,
|
share = share,
|
||||||
@@ -93,6 +87,7 @@ internal fun SharesScreen(
|
|||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (showAdd || incomingLink != null) {
|
if (showAdd || incomingLink != null) {
|
||||||
AddShareDialog(
|
AddShareDialog(
|
||||||
initialUrl = incomingLink.orEmpty(),
|
initialUrl = incomingLink.orEmpty(),
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
import android.content.Intent
|
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.clickable
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
@@ -93,7 +100,19 @@ internal fun TitleScreen(
|
|||||||
var showMatch by remember(title.key) { mutableStateOf(false) }
|
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||||
var overviewOpen 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
|
// Display order across seasons: what previous/next and autoplay walk, and
|
||||||
// the same order the desktop client uses.
|
// the same order the desktop client uses.
|
||||||
@@ -116,8 +135,26 @@ internal fun TitleScreen(
|
|||||||
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||||
val downloads = remember(work) { DownloadStateStore(context).records() }
|
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
) {
|
||||||
|
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||||
|
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -125,12 +162,13 @@ internal fun TitleScreen(
|
|||||||
// Edge to edge, fading into the page: the art is the first thing
|
// 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
|
// the page says, and a framed thumbnail with a margin round it read
|
||||||
// as one card among the many below it.
|
// as one card among the many below it.
|
||||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f)) {
|
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
||||||
RemoteArtwork(
|
RemoteArtwork(
|
||||||
title.backdropUrl ?: title.posterUrl,
|
title.backdropUrl ?: title.posterUrl,
|
||||||
title.canonicalName ?: title.name,
|
title.canonicalName ?: title.name,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = title.thumbnailSource,
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
@@ -143,6 +181,7 @@ internal fun TitleScreen(
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color.Black.copy(alpha = 0.4f)),
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
@@ -152,7 +191,7 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(key = "heading") {
|
item(key = "heading") {
|
||||||
Column(Modifier.padding(horizontal = 16.dp)) {
|
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||||
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
|
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
|
||||||
title.originalName?.takeIf { it != title.canonicalName }?.let {
|
title.originalName?.takeIf { it != title.canonicalName }?.let {
|
||||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -246,7 +285,7 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
item(key = "seasons") {
|
item(key = "seasons") {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 12.dp),
|
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -267,6 +306,7 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
season?.let { shown ->
|
season?.let { shown ->
|
||||||
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||||
|
Box(Modifier.padding(horizontal = side)) {
|
||||||
EpisodeRow(
|
EpisodeRow(
|
||||||
episode = episode,
|
episode = episode,
|
||||||
numbering = episode.numbering(shown.number, position),
|
numbering = episode.numbering(shown.number, position),
|
||||||
@@ -284,6 +324,8 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (showMatch) {
|
if (showMatch) {
|
||||||
ChangeMatchDialog(
|
ChangeMatchDialog(
|
||||||
title = title,
|
title = title,
|
||||||
@@ -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. */
|
/** One of the title's secondary actions: an icon with its name under it. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun TitleAction(
|
private fun TitleAction(
|
||||||
@@ -386,6 +431,7 @@ private fun EpisodeRow(
|
|||||||
heading,
|
heading,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
AccentProgress(
|
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
|
@Composable
|
||||||
private fun ChangeMatchDialog(
|
internal fun ChangeMatchDialog(
|
||||||
title: TitleRecord,
|
title: TitleRecord,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onChanged: () -> Unit,
|
onChanged: () -> Unit,
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
|
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
|
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.
|
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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp" android:height="108dp"
|
android:width="108dp" android:height="108dp"
|
||||||
android:viewportWidth="108" android:viewportHeight="108">
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
<group
|
<group
|
||||||
android:pivotX="64" android:pivotY="62"
|
android:pivotX="70" android:pivotY="62"
|
||||||
android:scaleX="0.6923" android:scaleY="0.6923"
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
android:translateX="-10" android:translateY="-8">
|
android:translateX="-16" android:translateY="-8">
|
||||||
<group android:translateY="3">
|
<group android:translateY="3">
|
||||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
<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" />
|
<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:width="108dp" android:height="108dp"
|
||||||
android:viewportWidth="108" android:viewportHeight="108">
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
<group
|
<group
|
||||||
android:pivotX="64" android:pivotY="62"
|
android:pivotX="70" android:pivotY="62"
|
||||||
android:scaleX="0.6923" android:scaleY="0.6923"
|
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" />
|
<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" />
|
<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>
|
</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 androidx.work.testing.WorkManagerTestInitHelper
|
||||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
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 io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -24,6 +26,8 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import uniffi.pstr_android.StorageUsageRecord
|
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 —
|
* 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
|
* 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
|
* 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,
|
* bridge is not available on the host, so every tile shows its generated
|
||||||
* which is also what a title without metadata looks like on a phone.
|
* placeholder, which is also what a title without metadata looks like on a
|
||||||
|
* phone.
|
||||||
*/
|
*/
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@@ -48,14 +53,24 @@ class ScreenshotTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
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) {
|
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
|
CompositionLocalProvider(LocalArtworkLoading provides false) {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +85,28 @@ class ScreenshotTest {
|
|||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
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
|
@Test
|
||||||
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
||||||
// Robolectric has no font-scale qualifier; the density is what Compose
|
// Robolectric has no font-scale qualifier; the density is what Compose
|
||||||
@@ -85,6 +122,11 @@ class ScreenshotTest {
|
|||||||
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library while the catalog loads`() = capture("library-loading") {
|
||||||
|
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a title with seasons`() = capture("title-phone") {
|
fun `a title with seasons`() = capture("title-phone") {
|
||||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
@@ -97,7 +139,17 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the settings page`() = capture("settings-phone") {
|
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
|
@Test
|
||||||
@@ -107,12 +159,42 @@ class ScreenshotTest {
|
|||||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
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
|
@Test
|
||||||
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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 {
|
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
|
||||||
// `detail` is the filename unless the filename named the episode,
|
// `detail` is the filename unless the filename named the episode,
|
||||||
// which is what the bridge sends.
|
// which is what the bridge sends.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 821 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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.
|
across fails here rather than being noticed in a screenshot.
|
||||||
|
|
||||||
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
|
**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,
|
records — Library on a phone, a tablet, at font scale 1.5, while loading and
|
||||||
a title, History, Shares, Settings and Downloads — and `check` compares them with the
|
empty; a title on a phone and a tablet; History on a phone and a tablet; Shares;
|
||||||
images in `app/src/test/screenshots`. A layout regression compiles and passes
|
the Settings index and its Playback and Storage pages; Downloads with a season
|
||||||
everything else; here it fails, and the review shows the changed image. After a
|
saved and empty; and the library and a title again in Catppuccin Latte — and
|
||||||
deliberate change, `gradlew recordRoborazziDebug` rewrites the images and they
|
`check` compares them with the images in `app/src/test/screenshots`. A layout
|
||||||
are committed with it. No artwork loads on the host, so tiles show their
|
regression compiles and passes everything else; here it fails, and the review
|
||||||
placeholder, and the palette is the shipped default.
|
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
|
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
||||||
equivalent:
|
equivalent:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
# builds from the registry alone.
|
# builds from the registry alone.
|
||||||
|
|
||||||
pkgname=proton-stream
|
pkgname=proton-stream
|
||||||
pkgver=0.5.1
|
pkgver=1.0.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||