Compare commits

77 Commits
Author SHA1 Message Date
nvrl e3dcbec1f2 chore(version): bump to 1.1.1
Patch, not minor. Nothing a viewer uses changes: the Android build moves to
NDK r30 and mpv-android 687c7fd, which brings newer harfbuzz, fribidi,
unibreak, fontconfig, libxml2 and curl into the bundled libmpv. The catalog
schema and on-disk data are unchanged from 1.1.0.
2026-10-04 02:54:21 +02:00
nvrl 99abc8f205 build(android): move to NDK r30 and mpv-android 687c7fd
Upstream mpv-android moved its build to NDK r30 (30.0.16248370), and each
mpv-android revision pins its own NDK, so the two move together. The new
revision also updates harfbuzz to 14.5.0, unibreak to 8.0, fribidi to
1.0.17, libxml2 to 2.15.4, fontconfig to 2.18.3 and curl to 8.22.0, and
builds libass with assembly on x86_64 (nasm, already installed in CI).

r30 brings an LLVM update with codegen and LTO fixes and sysroots up to
API 37. It is also the NDK current SDK managers install, so a fresh setup
no longer needs r29 installed beside it.

The script change invalidates the cached libmpv tree, so CI rebuilds it
once. The staged libraries are the same set as before, all 16 KiB aligned.
2026-10-04 02:42:53 +02:00
nvrl 3638e0e5df feat(core,app,android): sort, fold and shelve the Android library
Bring the phase 2 library to Android. Search, sorting and franchise folding
move from the desktop app into pstr_core::browse, so both clients find,
fold and order the same titles the same way.

The bridge gains AndroidEngine::arrangement, which returns the grid's tiles
and up to three shelves as keys into the library's titles. Each TitleRecord
now carries its display name, wide art, format, season, studios, directors,
tags, airing state and franchise. The library bar has a sort menu with the
franchise toggle, folded tiles show a +N badge, and shelves sit above the
grid. The title page shows the same facts, tags and franchise row as the
desktop. Settings -> Appearance gains Title names.

The default title name is now English, which is what Android always showed.
A folded franchise now sorts by its lead's displayed name rather than by its
alphabetically first member.
2026-10-04 02:04:15 +02:00
nvrl aef182e7e8 chore(version): bump to 1.1.0
Minor, not patch. This release adds features and changes on-disk data:

- ani.zip episode names and fanart.
- Franchise grouping, shelves, poster grid and new sorts.
- Two catalog migrations (v9, v10). Catalogs are forward-only, so 1.0.x
  refuses to open a catalog 1.1.0 has upgraded.

The first match run after upgrading fetches every title's details and
episode list again. It refetches by id and does not search, so
hand-picked matches are kept.
2026-10-04 01:27:40 +02:00
nvrl 6853725ca0 feat(core,meta,app,android,cli): sort, group and shelve the library
The AniList query now keeps the start date, season, banner, popularity,
status, next airing episode, tags, main studios, director and story
relations, as TitleDetails in one JSON column (schema v10). Matches
stored without them are fetched again by id, never searched, so
hand-picked matches stay; refreshing the same entry keeps its episodes
and fanart. Enrichment records the entries it walked. node_first_seen
remembers when each file appeared, across recrawls.

pstr_core::franchise groups titles that share a provider id. The grid
folds each franchise into its lead with a +N badge, and the title page
lists the whole franchise in release order. pstr_core::shelves picks
rows by airing status, director, studio and genre. A row needs three
franchises, so one franchise's titles never make a row on their own.

Desktop:
- Covers are the default grid (Settings > Appearance > Library tiles).
- The hero and title band use fanart or banner decoded at 1920 px.
- New sorts: recently added, newest, highest rated, most popular.
- Titles can show the share's, English or romaji name; search matches
  all of them.

Android carries TitleDetails through a hand-picked match; its screens
are unchanged.
2026-10-04 01:26:21 +02:00
nvrl 4c80076762 fix(core,meta,app,android,cli): number episodes the way releases do
AniList's per-season lookup searched for "<title> 2nd Season" and the
scorer picked the base entry again, so Attack on Titan seasons 2-4 and
Jigokuraku season 2 were captioned with season one's names. Episode
names came from streamingEpisodes, which is partial and numbered however
a streaming site counted. Bleach S17 and Mushishi S02/S03 had none.

AniList now walks SEQUEL relations from the match and takes each
entry's episodes from ani.zip, which carries TVDB's season, number and
absolute number. An episode row keeps all three plus its place in the
AniList entry. EpisodeGuide::get matches whichever numbering a file
uses and never answers from another season.

Episodes and fanart are stored as one enrichment. The episode list is
replaced even when empty, and the enrichment is recorded as asked, so a
film is not re-asked every run. Storing a different match drops the old
entry's episode rows. AniList's 4.75:1 bannerImage is no longer stored
as the 16:9 backdrop; ani.zip's 1920x1080 fanart is used instead.

Schema v9 adds the columns and deletes the AniList episode rows and
banners. Matches are kept. Desktop, Android and the CLI now share
service::plan and MetadataService::work. The settings text names
ani.zip. Audit and roadmap in docs/METADATA.md; bug entry B61.
2026-10-04 00:47:41 +02:00
nvrl 52d27ec996 chore(version): bump to 1.0.1 2026-10-03 23:00:34 +02:00
nvrl 66eb1b49a1 ci(android): keep the cached libmpv and Rust target trees
Gradle deletes the declared outputs of a task it has no history for, so
on every fresh checkout buildLibmpvAndroid threw away the libmpv tree just
restored from the cache and rebuilt it from source (~14 min per job). The
task no longer declares outputs; the script's STAMP decides.

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

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

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

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

The screenshot tests turn artwork loading off, so whether a failed fetch
lands before the capture no longer decides what the image shows.
2026-10-03 12:45:34 +02:00
nvrl 0563c91b9e fix(PKGBUILD) 2026-10-03 12:39:17 +02:00
nvrl 3b9f4b549c chore(version) bumped to 0.5.1 2026-10-03 12:37:59 +02:00
nvrl d5c819ee94 docs: PLAN.md status after the Android parity and redesign pass 2026-10-03 05:25:45 +02:00
nvrl 153859a735 feat(android): a featured title at the top of the library
The desktop's rule: the title watched last, or with nothing part watched,
one of the titles the provider has a backdrop for, turning over once a
day. Large art fading to black behind the name, rating, genres and three
lines of synopsis, with Resume or Play and More info.
2026-10-03 05:25:33 +02:00
nvrl 1c61aadb22 feat(android): a history page
History sits second in the navigation, as on the desktop: every played
episode, newest first, under Today, Yesterday, a weekday or a date. The
still plays the episode, the text opens its title, and the cross takes
it off the page — forgetting its position and watched mark — with Undo.

The list is the desktop's Library::history rule over the records the
library already holds, so no bridge call is added; HistoryTest pins the
rule and the day headings.
2026-10-03 05:23:26 +02:00
nvrl 8d40b211ed feat(android,player): the title's episodes from inside the player
An Episodes button in the controls opens a sheet of the title's episodes,
scrolled to the one playing, each with its still, its name, and whether
it is playing, watched or part way. A tap switches to it the way
previous and next do, saving the outgoing position first.
2026-10-03 05:20:14 +02:00
nvrl 6229a59561 feat(android,player): an up-next card with the next still and a countdown ring
The card shows the next episode's still, or Proton's thumbnail of it, with
a ring over it that runs down to the switch and plays it at once on a tap.
The provider's episode name heads it where there is one. "Watch till the
end" stays under it.
2026-10-03 05:18:30 +02:00
nvrl cfd654b267 feat(android,player): the read-ahead on the seek bar
pstr_mpv observes demuxer-cache-time and hands it over with the rest of
the state, and the seek bar shades from the start to where the demuxer
has read, lighter than the played part. A jump that lands in the shaded
run costs no fetch, as on the desktop.
2026-10-03 05:17:03 +02:00
nvrl 0bc7cba428 feat(android): long-press menus on library tiles, and Undo
- a long press on a title opens a sheet: Play or Resume, Open, Download
  all, and Mark watched or unwatched for the whole title
- a long press on a Continue watching card offers Resume, Open, and
  Remove from Continue watching, which forgets the position as the
  desktop does
- marking a title and leaving Continue watching both say what they did
  in a snackbar with Undo, which puts every changed watch state back
- the "next up" episode is one shared function, used by the title page
  and the menu alike
2026-10-03 05:14:34 +02:00
nvrl 65ecc099b6 feat(android): settings as grouped rows
Playback, Downloads and storage, Appearance, Metadata and About, each a
group of rows with one line of explanation, and the whole row as the
target. The paragraphs under the switches are gone; what they said is
either the row's supporting line or a code comment.

Storage shows the cache's use against its budget as a bar over the
budget chips. Clearing the cache and deleting offline episodes are rows
that say how much they free, and the delete still asks first. Licence
and notices are rows under About.
2026-10-03 05:10:12 +02:00
nvrl 65a6ae9373 feat(android): shares as list rows with a menu, and Add as a button at the thumb
- each share is a row with its name and how it is unlocked; Refresh,
  Re-enter link and Remove sit behind a menu instead of three differently
  styled controls that squeezed the name into a column a word wide
- Remove asks first, saying that offline episodes and watch progress go
  with it
- Add share is an extended floating button, where the share sheet and a
  tapped link land too
- an empty page says what to do instead of showing a bare heading
2026-10-03 05:03:22 +02:00
nvrl 2fbbd0e090 feat(android): a poster library with pull to refresh
- titles are 2:3 posters three across on a phone, named under the poster
  instead of in a card body; cropped backdrops at full width fitted two
  titles to a screen
- a title's caption says "Film" or "1 episode" rather than "1 episodes",
  and wraps at large font scales instead of being cut to "2023 · 7"
- Continue watching cards carry the resume bar on the still and say how
  much is left: "S03E11 · 12 min left"
- search is a filled pill with a clear button
- pull to refresh crawls the shares, as the bar's refresh icon does
2026-10-03 04:58:35 +02:00
nvrl eee45bc8f8 feat(android): a title page built around one Play button
- the backdrop runs edge to edge and fades into the page, with the back
  button over it
- one full-width Play that names the episode it plays ("Resume S02E01")
- Start over, Download, Match and the provider link as one row of
  labelled icons instead of four buttons in three styles over three lines
- the synopsis is clamped to three lines and opens on a tap; the watched
  count joins the year, rating and genres
- seasons are chips showing one season at a time, opened on the season
  Play lands in, instead of stacked accordions
- episode rows head with "1. Mother and Children", or "Episode 1", rather
  than the filename; numbering, size and air date sit under it, and the
  resume bar lies on the still's lower edge
- the per-title track form is gone: the player's track chooser already
  stores each title's choice, and Settings holds the global default
2026-10-03 04:53:59 +02:00
nvrl 1c8df80c27 feat(android): Inter, the desktop's typeface
The type ramp now draws in Inter Regular and SemiBold, the same two files
the desktop bundles, so the clients read as one app. Inter is declared
above the ramp: top-level properties initialise in file order, and the
ramp built first would have captured it as null and silently used the
system face. Its OFL text ships in the licences beside the notices.
Screenshots re-recorded.
2026-10-03 04:48:21 +02:00
nvrl 5673a44310 fix(android): mark the share-link filter autoVerify="false"
Lint's AppLinkWarning failed the gate: a BROWSABLE https filter reads as an
App Link missing autoVerify. It can never be one, since drive.proton.me is
Proton's domain, so the attribute now says so.
2026-10-03 04:48:21 +02:00
nvrl c63f08ac91 test(android): screenshot tests for every screen
Roborazzi draws Library (phone, tablet, font scale 1.5, empty), a title,
Shares, Settings and Downloads from fixed records under Robolectric, and
build-android.sh check compares them with the committed images in
app/src/test/screenshots. The redesign changes every screen; a layout
regression now fails the gate and shows up in review as a changed image.
2026-10-03 04:34:46 +02:00
nvrl f2e69dc852 feat(android): streaming cache budget and a hardware decoding switch
- Settings > Storage chooses the streaming cache's budget, 1 to 16 GiB,
  and shows what it holds against it. The bridge gains
  set_stream_cache_budget, which the next connection is built with and
  an open one evicts down to at once. Android keeps the choice in its own
  settings, like the desktop keeps it in desktop.json, and tells the
  engine as it is created. Unset stays at the shared 4 GiB default.
- Settings > Playback can turn hardware decoding off for a device whose
  decoder shows green or torn frames. pstr_mpv sets hwdec before each
  loadfile, so it applies from the next episode.
2026-10-03 04:12:20 +02:00
nvrl 8a1807c262 feat(android): open share links from a browser or the share sheet
A drive.proton.me/urls/ link opened from a browser, or text containing
one shared to the app, switches to Shares and opens the Add form with
the link filled in, fragment included. A playing episode moves to the
mini transport instead of covering the form. A recreated activity does
not offer the same link again.

The view filter is not autoVerify, since the domain is Proton's, so
Android only routes browser links here once "Open supported links" is
allowed in the app's settings; sharing works without that.
2026-10-03 03:59:41 +02:00
nvrl 47b33cdb91 ci(android): keep the built libmpv when Gradle fails, OldTargetApi informational
- libmpv is built in its own step and saved with actions/cache/save right
  after, so a failure later in the job no longer discards a 12 minute
  build the way a post-job save did
- lint's OldTargetApi fails the gate whenever a new platform ships, like
  the dependency freshness checks already marked informational; raising
  targetSdk is a deliberate change with its own device pass
2026-10-03 03:55:07 +02:00
nvrl 2d7d895dda feat(app,android): a new icon, and a launcher label that fits
The icon follows proton-drive-linux's logo: a violet gradient tile with
a darker lower edge, and a white play mark with a soft shadow, cut into
the blocks a revision is fetched in. The SVG, the window PNG and the
Windows .ico are rendered from one source.

On Android it is an adaptive icon with a monochrome layer for themed
icons, and the notification glyph is the same mark. The app is named
"Proton Stream"; the launcher shows "Stream", since "proton-stream" was
cut to "proton-st…".
2026-10-03 03:45:50 +02:00
nvrl 0d33799199 refactor(android): one file per screen
ProtonStreamApp.kt had grown to 1665 lines holding every screen, and
every planned UI change touches it. The shell, Library, Title, Shares,
Downloads and Settings screens now each have their own file, plus
Common.kt for the empty state and byte formatting they share. Code is
moved unchanged; declarations used across files went from private to
internal.
2026-10-03 03:45:50 +02:00
nvrlandClaude Opus 5.5 2ac906cf37 ci(android): build libmpv with meson 1.12.1 from PyPI
The fontconfig that the pinned mpv-android revision builds requires
meson >= 1.11, and ubuntu-24.04's apt meson is 1.3.2, so buildLibmpvAndroid
failed configuring fontconfig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:27:21 +02:00
nvrlandClaude Opus 5.5 cf065fc5fe ci(android): install libdbus, cache cargo and the staged libmpv
Every Android run since August failed in :app:buildRustHost: the host
build of pstr-android reaches libdbus-sys through keyring's Secret
Service store, and the runner had no libdbus-1-dev. With no successful
run, nothing was ever cached, so each run rebuilt from nothing.

- libdbus-1-dev in both jobs
- one shared, save-on-failure Rust cache for both jobs
- the staged libmpv tree cached on the build script's hash; the script
  stamps its output with the revision and its own hash and skips the
  libmpv build when the stamp matches

PLAN.md records the Android parity and UI work that follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:06:53 +02:00
nvrl 55ee412a1b ci(android): stop setup-android requesting the removed tools package 2026-10-03 02:53:16 +02:00
nvrl 2275df40a8 ci(android,release): publish the signed APK with each release 2026-10-03 02:45:13 +02:00
nvrlandClaude Opus 5.5 78ffafe21a ci(android): build the signed release on tags and on request
The signing secrets are now configured, so the job no longer needs to be
switched off. It runs for v* tags and for a manual dispatch with
signed_release set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:06:59 +02:00
nvrl 6e09129d0c fixed font loading 2026-10-03 02:03:24 +02:00
nvrl 476fec8c04 chore(VER): bumped 0.5.0 2026-10-02 23:42:13 +02:00
nvrl 7af0969e38 fix(stream): fetch a block again after a retriable failure
The SDK retries a request that fails before its response starts, but not a
block body cut off part way through. That error stopped playback or failed
a download, and the SDK's new keep-alive pings make it the way a dead
connection now ends: seconds in, rather than at the storage timeout.

A block is now fetched up to three times, 250 ms and then 500 ms apart,
while ProtonError::is_retriable says a replay could succeed. A permanent
refusal still fails on the first attempt.
2026-10-02 23:42:12 +02:00
nvrl 3df0644bd8 chore(deps): proton-sdk and proton-drive-rs 0.7.2, lru 0.18
The SDK now pings idle HTTP/2 connections and bounds the connect phase, so
a connection dropped by a NAT or a network switch is replaced within seconds
instead of stalling every request on it until the timeout. It also clamps a
server's Retry-After to two minutes, re-mints expired block URLs on the
whole-file download path as well as the reader, and exposes
ProtonError::is_retriable.

lru follows the SDK to 0.18, so one copy is built instead of two.
2026-10-02 23:42:12 +02:00
nvrl 5d079b05c9 feat(ios): a sideloadable iOS client over the shared Rust engine
A SwiftUI app that drives the same UniFFI engine as Android: Rust owns the
Proton sessions, SQLite and every decrypted byte, and Swift asks it questions
off the main thread. Playback goes through MPVKit's libmpv, with the pstr://
stream protocol ported from the Android JNI half, and secrets live in the
Keychain.

scripts/build-ios.sh builds the static library for aarch64-apple-ios, writes
the Swift bindings (module PstrBridge), generates the Xcode project with
XcodeGen and packages an unsigned .ipa for SideStore or AltStore, which
re-sign it with the viewer's own Apple ID. The generated project, Info.plist
and build output are ignored.
2026-10-02 23:34:17 +02:00
nvrl 4d44db0949 feat(app): desktop notifications on Windows
Toasts through WinRT, under the app id io.narl.proton-stream. The id is
registered for the current user under HKCU\Software\Classes\AppUserModelId
with a display name and icon on the first notification of a session, which
is what lets an app with no package or installer-made shortcut show one. If
that write fails, the toast goes out under PowerShell's id rather than not
at all.
2026-10-02 23:27:25 +02:00
nvrl 4a54af3245 feat(app): one window at a time, share link as an argument
A second launch hands its request to the open window and exits: over a
Unix socket in the user's runtime directory, or a named pipe on Windows
whose name carries a random key from the user's config directory, so
another account cannot take the name first. The open window comes forward,
and a share link given as the first such argument opens the Add form with
it filled in. Setup failures are logged and the app runs without the lock.
2026-10-02 23:24:28 +02:00
nvrl 7e734dbf19 feat(app): an episode list beside the picture
A button in the player's title strip, or E, opens the title's episodes
down the right of the picture, a season at a time, starting on the
playing season and scrolled to the playing episode. Each row has its still
(which plays it), its name, and whether it is playing, watched or part
way. The controls stay up while the list is out, and Esc closes it before
it leaves fullscreen or the player.
2026-10-02 23:20:28 +02:00
nvrl e7eeaa518f feat(core,app): a history page
Every played episode, newest first, under Today, Yesterday, a weekday or a
date. A row shows the still (which plays it), the title (which opens it),
the episode, and where it stopped or that it was finished. Removing a row
resets that file to never played, with an Undo. History sits second in the
top bar, so Ctrl+1 to Ctrl+5 now run Library, History, Shares, Downloads,
Settings.
2026-10-02 23:17:36 +02:00
nvrl 39866bee8d perf(app): read the metadata API key and the share list off the UI thread
A provider with an API key read it from the OS credential store inside
Engine::new, which is a D-Bus round trip to a keyring that may have to
unlock first. It is now built on the blocking pool. Until it lands, poster
fetches, match runs and match searches wait for it rather than concluding
there is no provider and marking posters missing. A settings change made
in the meantime wins over the launch-time build. The share list is read
on the blocking pool too.
2026-10-02 23:12:25 +02:00
nvrl 85092fa893 refactor(app): spacing, radius and motion tokens
theme::space, theme::radius and theme::motion name the steps the UI is
drawn with. Literal gaps, corner radii and animation durations across ui/
now come from them, which folds a few near-misses onto the scale (10 and 14
point gaps onto 12, 10 point radii onto 12, 0.10 s hovers onto 0.12 s).
Page fades and toasts share one ease_out curve.
2026-10-02 23:10:39 +02:00
nvrl 2128a2d705 fix(app): the highest episode row on screen no longer stretches to the window bottom
The watched and download buttons were laid out with with_layout(right_to_left,
Center), which takes every point of height the scroll area has left and
centres the buttons in it. Give the cluster the still's height instead.
2026-10-02 23:08:30 +02:00
nvrl 558446b9e4 feat(app): bundled Inter and Phosphor icons, window icon
Headings are set in Inter SemiBold, everything else in Inter Regular, so a
title sets the same on every desktop. Media and navigation glyphs (⏭, 🔊, ⛶,
★) come from the Phosphor icon font instead of whichever fallback font had
them. The CJK fallback still loads on its own thread and now joins the
bundled fonts instead of replacing them. The window carries the app icon
on X11 and Windows.
2026-10-02 23:07:05 +02:00
nvrl 6518aef5ee docs: new config files, scaled art caches, media widget privacy 2026-10-02 20:16:27 +02:00
nvrl 9037e6c2d5 feat(app,player): page fade, Ctrl+S screenshots
- a page fades in over 140 ms when the page changes, rather than cutting
- Ctrl+S in the player saves the frame, without subtitles, to
  Pictures/proton-stream as "Title  S01E04  12-34.png"
2026-10-02 20:16:27 +02:00
nvrl d3325843a9 feat(app): keyboard navigation across the library and episode list
Tiles and episode stills light up under focus as they do under the
pointer and scroll into view when the arrow keys or Tab reach them;
Enter opens or plays. Global keys now check for a focused text field
rather than any focused widget, so / and Esc still work over a tile.
2026-10-02 20:16:27 +02:00
nvrl 79c3f6885d feat(app): undo on toasts for marking a title and leaving Continue watching
A toast can carry a button. Marking a whole title watched or unwatched
from its tile menu, and taking it off Continue watching, both lose
state the viewer may have wanted; each now says what it did with an
Undo that puts every changed watch state back as it was.
2026-10-02 20:16:27 +02:00
nvrl a432a93cf9 feat(app,stream): storage and hardware-decoding settings
- the streaming cache's budget (1-16 GiB) is chosen in Settings, shows
  what it holds, and can be emptied; a lower budget evicts at once
- hardware decoding can be turned off for a driver that shows green
  frames, from the next file
- both live in desktop.json rather than the playback prefs shared with
  Android, whose bridge record would reset them on every save. A file
  that will not parse is never overwritten
2026-10-02 20:16:27 +02:00
nvrl 935a985589 feat(app): live picture in the mini-player
The film keeps playing small at the left of the transport bar while
browsing, and is the button back to the player page. It sits beside
both rows of controls, so the bar is no taller for it, and renders at
thumbnail size four times a second rather than at the film's rate.
2026-10-02 20:16:27 +02:00
nvrl 7d03736954 feat(app): a featured-title banner at the top of the library
The title watched last, or with nothing part-watched one of the titles a
provider has a backdrop for, turning over once a day. Large art behind
the name, rating, genres, three lines of overview, and Resume / More
info.
2026-10-02 20:16:27 +02:00
nvrl 53a1d0afda feat(app): desktop notifications, paste a share link anywhere
- a finished batch of downloads, and a crawl long enough to walk away
  from, notify through org.freedesktop.Notifications when the window is
  not focused; once per batch, not once per file
- Ctrl+V with a share link outside a text field opens the add form
  with the link in it
2026-10-02 20:16:27 +02:00
nvrl 959547cdc8 feat(app): media keys and desktop media widget, keep the screen on during a film
- MPRIS on Linux and SMTC on Windows through souvlaki: play/pause, next,
  previous, stop and seeking from media keys and the panel widget, with
  the title, episode, duration and the cached cover as a file:// URL
- the screensaver is held off while a film plays on the player page,
  through the desktop portal with org.freedesktop.ScreenSaver as the
  fallback, and SetThreadExecutionState on Windows. mpv's own
  stop-screensaver has no window to act for under the render API
2026-10-02 20:16:27 +02:00
nvrl c60ffc7ab1 feat(app): up-next card with the next still and a countdown ring, remember the window size
The up-next card shows the next episode's still with the seconds left
running round it, and the still itself plays it. The window opens at
the size and maximized state it closed at; position is left to the
window manager.
2026-10-02 20:16:27 +02:00
nvrl c5808a5e50 feat(app,player): tile context menu, buffered range on the seek bar, stall spinner
- right-click a library tile to play or resume it, mark it watched or
  unwatched, download it, change its match, or take it off Continue
  watching
- mpv's demuxer-cache-time is shaded on the seek bar, so it shows
  whether a jump lands in what is already read ahead
- paused-for-cache shows a spinner over the picture and in the controls
2026-10-02 20:16:27 +02:00
nvrl 41d1fbf073 feat(app): crawl shares in parallel, with a stop button
Each share is its own session against its own tree, so one large share
no longer holds the rest back. Stop ends every listing still running;
a share already past its listing finishes storing what it found, since
stopping halfway through replacing its rows would leave them disagreeing
with its downloads.
2026-10-02 19:36:41 +02:00
nvrl ff362d0d08 perf(app,core): bounded image cache, shrunk poster cache, in-place watch updates
- ImageCache drops the picture asked for longest ago past 256 MiB and
  uploads at most six textures a frame
- posters and thumbnails larger than a card are stored scaled down, so
  a cached grid no longer decodes and resizes full backdrops every launch
- a saved watch state patches the library in place instead of rebuilding
  it from the catalog; the offline index reuses the library just built
- download byte progress sends one row, not a sorted copy of every row
- the CJK fallback font is found and read off the UI thread
- the watch-state upsert uses a cached statement
2026-10-02 19:34:45 +02:00
nvrl 4027ab5fb3 feat(app): polish pass - toasts, shortcuts, redesigned pages, cheaper frames
- toasts replace the status line; keyboard shortcuts with a ? overlay
- library: filters, sort, row virtualisation, cached search, skeleton
  tiles until the catalog loads, gradient placeholder art
- title page: season pills, episode rows with stills and one download
  glyph, portrait poster, clamped overview, resume time in the button
- player: fading chrome, smooth scrims, subtitles lifted over the
  controls, drag-following seek knob, OSD, speed menu, auto-skip
- settings as grouped cards with toggles and palette previews
- shares: confirm before removing, inline link validation, form kept
  until the add succeeds; downloads scroll and show rate and ETA
- downloads pipeline four blocks; the next episode is warmed near the end
- a film behind another page repaints at 4 Hz, not its frame rate

Fixes B55-B60.
2026-10-02 19:27:28 +02:00
nvrl c5adcddb1e feat(player): subtitle position and playback speed 2026-10-02 19:27:27 +02:00
nvrl 8f78f5c1b1 perf(stream,core): take the disk cache off the read path, warm file edges, report crawl progress
A block the player waits for no longer waits on its disk-cache write and
two fsyncs; the put runs in a spawned task and only the block itself is
synced before the rename. Opening a stream can fetch the first and last
block together, where MKV cues and the MP4 moov live. Downloads open a
copying stream that reads the disk cache but never fills it.

crawl_reporting reports the running file count after each listing.
2026-10-02 19:27:27 +02:00
nvrl 6f28a9c377 chore(deps) bump proton-sdk/proton-drive-rs to 0.6.3, version 0.4.2
Adds the new Node fields (direct_role, share_id) to the catalog test fixture and applies rustfmt to the track-prefs test.
2026-09-15 14:10:00 +02:00
nvrl d3c5f071fb feat(player,core): remember the audio/subtitle track by name across episodes
A track picked on one episode now carries over to the next by its title as
well as its language, so e.g. "Signs & Songs" vs "Full" subtitles in the same
language stick. Catalog migration V8 adds the title columns; the Android
bridge keeps a stored title only while its language is unchanged.
2026-09-15 13:54:07 +02:00
145 changed files with 18554 additions and 3558 deletions

No files matched your search

+148 -14
View File
@@ -3,7 +3,15 @@ name: Android
on:
push:
branches: [main]
tags: ['v*']
# A `v*` tag reaches this workflow through release.yml, which calls it and
# publishes the signed APK. Triggering on the tag here as well would build
# the release twice.
workflow_call:
inputs:
signed_release:
required: false
default: false
type: boolean
pull_request:
paths:
- 'android/**'
@@ -31,8 +39,8 @@ env:
CARGO_TERM_COLOR: always
ANDROID_API: '36'
ANDROID_BUILD_TOOLS: '37.0.0'
ANDROID_NDK: '29.0.14206865'
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865
ANDROID_NDK: '30.0.16248370'
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
jobs:
build:
@@ -74,14 +82,23 @@ jobs:
distribution: temurin
java-version: '17'
# The action's default `packages` includes `tools`, which cmdline-tools
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
# build needs are installed explicitly below.
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
log-accepted-android-sdk-licenses: false
- name: Install native build prerequisites
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends \
autoconf automake build-essential cmake curl git gperf libtool \
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
# mpv-android revision builds requires >= 1.11.
pipx install meson==1.12.1
- name: Install Android SDK and NDK
run: >-
@@ -90,12 +107,53 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android
# The registry and the installed helpers only. rust-cache reads
# `target/android/<triple>` as a profile directory and deletes the
# `release` tree under it before saving, so with its target caching on
# every run rebuilt both ABIs from scratch.
- uses: Swatinem/rust-cache@v2.9.1
with:
shared-key: android-registry
cache-targets: false
cache-on-failure: true
# The target directory as is: the host build the UniFFI bindings come
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
# build compiles the same crates for the same targets. A new lockfile
# starts from the last one's tree, so a version bump rebuilds only the
# workspace crates.
- id: target
uses: actions/cache/restore@v6
with:
path: target
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
# libmpv and its dependency tree are the slowest part of the build, and
# depend only on the pinned revision and the build script, which stamps
# its output with both and skips the build when the stamp matches. Built
# and saved in steps of its own, so a later failure in Gradle does not
# discard it the way a post-job cache save would.
- id: libmpv
uses: actions/cache/restore@v6
with:
path: android/app/build/generated/mpv
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
- name: Build libmpv
run: bash scripts/build-libmpv-android.sh
- if: steps.libmpv.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: android/app/build/generated/mpv
key: ${{ steps.libmpv.outputs.cache-primary-key }}
- uses: gradle/actions/setup-gradle@v4
with:
@@ -124,9 +182,15 @@ jobs:
- name: Android checks and artifacts
run: bash scripts/build-android.sh all
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
signed-release:
name: Signed release
if: false
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
needs: build
runs-on: ubuntu-24.04
env:
@@ -141,14 +205,23 @@ jobs:
distribution: temurin
java-version: '17'
# The action's default `packages` includes `tools`, which cmdline-tools
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
# build needs are installed explicitly below.
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
log-accepted-android-sdk-licenses: false
- name: Install native build prerequisites
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends \
autoconf automake build-essential cmake curl git gperf libtool \
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
# mpv-android revision builds requires >= 1.11.
pipx install meson==1.12.1
- name: Install Android SDK and NDK
run: >-
@@ -157,12 +230,53 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android
# The registry and the installed helpers only. rust-cache reads
# `target/android/<triple>` as a profile directory and deletes the
# `release` tree under it before saving, so with its target caching on
# every run rebuilt both ABIs from scratch.
- uses: Swatinem/rust-cache@v2.9.1
with:
shared-key: android-registry
cache-targets: false
cache-on-failure: true
# The target directory as is: the host build the UniFFI bindings come
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
# build compiles the same crates for the same targets. A new lockfile
# starts from the last one's tree, so a version bump rebuilds only the
# workspace crates.
- id: target
uses: actions/cache/restore@v6
with:
path: target
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
# libmpv and its dependency tree are the slowest part of the build, and
# depend only on the pinned revision and the build script, which stamps
# its output with both and skips the build when the stamp matches. Built
# and saved in steps of its own, so a later failure in Gradle does not
# discard it the way a post-job cache save would.
- id: libmpv
uses: actions/cache/restore@v6
with:
path: android/app/build/generated/mpv
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
- name: Build libmpv
run: bash scripts/build-libmpv-android.sh
- if: steps.libmpv.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: android/app/build/generated/mpv
key: ${{ steps.libmpv.outputs.cache-primary-key }}
- uses: gradle/actions/setup-gradle@v4
with:
@@ -188,10 +302,30 @@ jobs:
- name: Build signed APK and AAB
run: bash scripts/build-android.sh signed
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
# The APK bundles a GPL libmpv, so its corresponding source ships next to
# it. The AAB is for Play only and stays out of the GitHub release.
- name: Stage release files
run: |
version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
mkdir -p dist/android
cp android/app/build/outputs/apk/release/app-release.apk \
"dist/android/proton-stream-$version-android.apk"
cp android/app/build/generated/mpv/mpv-android-source-*.tar.gz dist/android/
- uses: actions/upload-artifact@v7
with:
name: android-signed
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
name: android
path: dist/android/*
if-no-files-found: error
- uses: actions/upload-artifact@v7
with:
name: android-bundle
path: android/app/build/outputs/bundle/release/*.aab
if-no-files-found: error
+23 -18
View File
@@ -20,9 +20,6 @@ permissions:
env:
CARGO_TERM_COLOR: always
# Android remains a workflow artifact only until the native libmpv adapter,
# corresponding-source bundle, and on-device verification are complete.
ANDROID_PUBLIC_RELEASE_ENABLED: 'false'
jobs:
verify:
@@ -60,11 +57,6 @@ jobs:
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
- name: Android publication remains blocked
run: |
test "$ANDROID_PUBLIC_RELEASE_ENABLED" = false
echo "Android APK/AAB publication is blocked until native playback is release-ready."
linux:
name: Linux artifacts
needs: verify
@@ -148,25 +140,38 @@ jobs:
dist/*.msi
if-no-files-found: error
# The signed APK plus the corresponding source of the libmpv it bundles. The
# keystore and its passwords come from the ANDROID_RELEASE_* secrets.
android:
name: Android
needs: verify
uses: ./.github/workflows/android.yml
with:
signed_release: true
secrets: inherit
publish:
name: Publish desktop artifacts
name: Publish
if: startsWith(github.ref, 'refs/tags/')
needs: [linux, windows]
needs: [linux, windows, android]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
# By name: the Android workflow also uploads the AAB, which is for Play
# and does not belong on the release.
- uses: actions/download-artifact@v8
with:
name: linux
path: artifacts
- uses: actions/download-artifact@v8
with:
name: windows
path: artifacts
- uses: actions/download-artifact@v8
with:
name: android
path: artifacts
merge-multiple: true
- name: Refuse mobile artifacts while Android publishing is blocked
run: |
if find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q .; then
echo "::error::Android publishing is disabled while native playback remains incomplete"
exit 1
fi
- name: Checksums
run: |
+6
View File
@@ -20,3 +20,9 @@
/packaging/pkg/
/packaging/src/
/packaging/*.pkg.tar.zst
# iOS generated state: XcodeGen owns the project and the Info.plist, and
# scripts/build-ios.sh writes the bindings and DerivedData under ios/build.
/ios/build/
/ios/*.xcodeproj/
/ios/ProtonStream/Info.plist
Generated
+251 -45
View File
@@ -119,12 +119,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-activity"
version = "0.6.1"
@@ -609,6 +603,12 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1029,6 +1029,36 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types",
"libc",
"objc",
]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -1136,6 +1166,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types 0.3.2",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.23.2"
@@ -1145,7 +1188,7 @@ dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1377,6 +1420,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-crossroads"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8"
dependencies = [
"dbus",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
@@ -1709,6 +1761,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "egui-phosphor"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4"
dependencies = [
"egui",
]
[[package]]
name = "egui-wgpu"
version = "0.35.0"
@@ -2003,12 +2064,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -2024,6 +2079,15 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -2031,7 +2095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -2045,6 +2109,12 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2279,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
"foldhash",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -2403,6 +2473,25 @@ dependencies = [
"euclid",
]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -2436,24 +2525,13 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
"foldhash",
]
[[package]]
@@ -2462,7 +2540,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"foldhash",
]
[[package]]
@@ -2592,6 +2670,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -3150,11 +3229,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
version = "0.12.5"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [
"hashbrown 0.15.5",
"hashbrown 0.17.1",
]
[[package]]
@@ -3163,6 +3242,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3483,6 +3571,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -4202,12 +4299,12 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proton-drive-rs"
version = "0.5.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d"
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
dependencies = [
"async-trait",
"base64 0.22.1",
"base64 0.23.0",
"bytes",
"futures",
"getrandom 0.4.3",
@@ -4229,13 +4326,13 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.5.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3"
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
dependencies = [
"aes-gcm 0.11.0",
"async-trait",
"base64 0.22.1",
"base64 0.23.0",
"bcrypt",
"bytes",
"chrono",
@@ -4262,7 +4359,7 @@ dependencies = [
[[package]]
name = "pstr-android"
version = "0.4.1"
version = "1.1.1"
dependencies = [
"android_logger",
"jni 0.21.1",
@@ -4281,11 +4378,15 @@ dependencies = [
[[package]]
name = "pstr-app"
version = "0.4.1"
version = "1.1.1"
dependencies = [
"anyhow",
"chrono",
"dbus",
"directories",
"eframe",
"egui",
"egui-phosphor",
"futures",
"image",
"parking_lot",
@@ -4293,17 +4394,22 @@ dependencies = [
"pstr-meta",
"pstr-player",
"pstr-stream",
"raw-window-handle",
"serde",
"serde_json",
"sha2 0.10.9",
"skrifa",
"souvlaki",
"tauri-winrt-notification",
"tokio",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
name = "pstr-cli"
version = "0.4.1"
version = "1.1.1"
dependencies = [
"anyhow",
"clap",
@@ -4321,7 +4427,7 @@ dependencies = [
[[package]]
name = "pstr-core"
version = "0.4.1"
version = "1.1.1"
dependencies = [
"directories",
"futures",
@@ -4339,7 +4445,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
version = "0.4.1"
version = "1.1.1"
dependencies = [
"pstr-core",
"reqwest",
@@ -4352,7 +4458,7 @@ dependencies = [
[[package]]
name = "pstr-player"
version = "0.4.1"
version = "1.1.1"
dependencies = [
"async-trait",
"libloading",
@@ -4367,7 +4473,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
version = "0.4.1"
version = "1.1.1"
dependencies = [
"async-trait",
"futures",
@@ -4647,6 +4753,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
@@ -5401,6 +5508,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "souvlaki"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8"
dependencies = [
"base64 0.22.1",
"block",
"cocoa",
"core-graphics 0.22.3",
"dbus",
"dbus-crossroads",
"dispatch",
"objc",
"thiserror 1.0.69",
"windows 0.44.0",
]
[[package]]
name = "spin"
version = "0.9.9"
@@ -5489,6 +5614,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tauri-winrt-notification"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
dependencies = [
"thiserror 2.0.19",
"windows 0.62.2",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5644,6 +5780,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
@@ -6539,6 +6676,36 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -6552,6 +6719,17 @@ dependencies = [
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -6580,6 +6758,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -6715,6 +6903,24 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -6911,7 +7117,7 @@ dependencies = [
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
"core-graphics",
"core-graphics 0.23.2",
"cursor-icon",
"dpi",
"js-sys",
+23 -4
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "0.4.1"
version = "1.1.1"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2
[workspace.dependencies]
proton-sdk = { version = "0.5.1" }
proton-drive-rs = { version = "0.5.1" }
proton-sdk = { version = "0.7.2" }
proton-drive-rs = { version = "0.7.2" }
# Internal crates.
pstr-core = { path = "crates/pstr-core" }
@@ -51,6 +51,8 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
# Local dates for the history page. Already in the tree through the SDK.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
sha2 = "0.10"
# `bundled` so a Windows build needs no system SQLite.
rusqlite = { version = "0.32", features = ["bundled"] }
@@ -77,6 +79,12 @@ eframe = { version = "0.35", default-features = false, features = [
"x11",
] }
egui = "0.35"
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
# font has them, at its own size and baseline; an icon font is one set.
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
# Tests only: reads the bundled fonts' character maps. The version epaint
# already builds, so it costs no extra compile.
skrifa = { version = "0.42", default-features = false, features = ["std"] }
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
# pulls in every exotic format is weight this app has no use for.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
@@ -84,9 +92,20 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
# behind a poisoned lock for the rest of the session. Same reason
# `proton-drive-linux` uses it.
parking_lot = "0.12"
# Media keys and the desktop's media widget: MPRIS on Linux, SMTC on Windows.
# `dbus` rather than `zbus` because the keyring's Secret Service store already
# links libdbus, so this adds no second D-Bus stack.
souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] }
# Holding the screensaver off while a film plays. mpv does that for a window of
# its own, but the picture here is drawn into ours, and mpv has no window to
# hold it for.
dbus = "0.9"
windows-sys = "0.61"
# Toasts on Windows. Linux notifications go over the `dbus` above.
tauri-winrt-notification = "0.8"
rpassword = "7"
libloading = "0.8"
lru = "0.12"
lru = "0.18"
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
# be tested against a fake without a network or an account.
+170
View File
@@ -0,0 +1,170 @@
# Android plan
Where the Android client stands against the desktop app, and what to change to
make it feel like a modern streaming app rather than a settings form with a
player attached. Written 2026-10-03 from a source review and four screenshots
(Library, Title, Shares, Settings) taken on a phone running v0.5.0.
Android last caught up with the desktop on 2026-08-10 (`4ef6225`); the desktop
gained about twenty features on 2026-10-02 that Android does not have.
## Status (2026-10-03)
Done: section 0, every parity item, and every recommendation in section 2 —
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
bars drawn edge to edge, search behind an icon, skeleton loading and
generated placeholder art, Settings sub-pages and a language picker, a
long-press Change match, Inter, light and tablet screenshots, the library
and a title side by side from 840 dp, a poster-to-backdrop shared element
transition, and predictive back on the title page.
Two departures from the text below. The tablet layout is a two-pane `Row`
rather than `ListDetailPaneScaffold`, which would have added the
`adaptive-layout` artifact for one split. Pull to refresh shows the
indicator while a crawl runs, but not the crawl's progress.
Not yet verified on a device; the host gate (ktlint, unit and screenshot
tests) passes, and Android lint runs in CI.
## 0. CI (done)
Every `Android` workflow run since August failed in `:app:buildRustHost`:
`pstr-core` pulls `keyring` → `dbus-secret-service` → `libdbus-sys`, which needs
`libdbus-1-dev` on the host. Because no run ever succeeded, no cache was ever
saved, so each run started from nothing.
- `libdbus-1-dev` added to both jobs' apt install.
- `Swatinem/rust-cache` shares one key between the two jobs and saves on failure.
- `android/app/build/generated/mpv` is cached on the hash of
`scripts/build-libmpv-android.sh`; the script writes a `STAMP` (revision plus
its own hash) last and skips the whole libmpv build when the stamp matches.
Later runs also needed meson 1.12.1 from PyPI (noble's 1.3.2 is too old for
the pinned fontconfig), libmpv built and cached in its own steps so a later
failure keeps it, and lint's `OldTargetApi` made informational. A cached run
restores libmpv in under a minute.
## 1. Desktop parity
Ordered by value on a phone.
1. **Open share links from outside the app.** The manifest has only the
launcher intent. Add an `ACTION_VIEW` filter for `drive.proton.me/urls/*` and
an `ACTION_SEND` (`text/plain`) filter; both open the add-share dialog with
the link filled in. Desktop: `4a54af3`, `53a1d0a`.
2. **Streaming cache budget.** Android runs on the fixed 4 GiB default
(`DiskCacheConfig::DEFAULT_BUDGET_BYTES`), large for a phone. Expose
`DiskCache::set_budget` through `pstr-android`, add a 1–16 GiB choice with a
usage readout under Storage. Desktop: `a432a93`.
3. **Hardware-decoding switch.** `pstr_mpv.cpp` hard-codes
`hwdec=auto-copy-safe`; a device with green frames has no way out. A
Settings toggle, applied from the next file.
4. **Episode list in the player.** A bottom sheet (portrait) or side panel
(landscape) with the title's episodes, opened on the playing season and
scrolled to the playing episode. Desktop: `7e734db`.
5. **Long-press menu on tiles.** Play/resume, mark watched or unwatched,
download, change match, remove from Continue watching, in a
`ModalBottomSheet`. Desktop: `c5808a5`.
6. **Undo on snackbars** for marking a title and leaving Continue watching.
`AppUiState.message` is a bare string today; it needs an action. Desktop:
`79c3f68`.
7. **History tab.** Needs a bridge call over the `pstr-core::library` query
the desktop page uses. Desktop: `e7eeaa5`.
8. **Buffered range on the seek bar** from mpv's `demuxer-cache-time`.
9. **Up-next card** with the next episode's still and a countdown ring instead
of "Up next in 7s" text.
10. **Featured banner** at the top of the library. Desktop: `7d03736`.
11. **Bundled Inter** in `ui/theme/Type.kt`, so both clients read the same.
Desktop: `558446b`.
## 2. UI recommendations
### Everywhere
- **Colours stay the theme's.** The red is the Persona theme's accent and the
card tint comes from the shared palette, so the redesign changes layout and
button hierarchy, not colours.
- **One button hierarchy.** At most one filled accent button per screen;
secondary actions tonal or outlined; destructive actions behind a menu and a
confirmation, never a filled red button in a list row.
- **Edge-to-edge, per-screen titles.** The "proton-stream" app bar on every
page wastes a band of height. Library uses a collapsing large title; Title
pages draw the backdrop under the status bar; other tabs use their name.
- **Skeletons instead of spinners** for library and title loading, and
generated placeholder art (gradient from the accent plus the title's
initials) instead of a black card with a play triangle (Akira).
- **Pull-to-refresh** (`PullToRefreshBox`) replaces the top-bar refresh icon,
with crawl progress shown in the indicator area.
- **Split `ui/ProtonStreamApp.kt`** (1665 lines) into one file per screen
before the work below; every item touches it.
### Library
- Poster grid (2:3, `posterUrl`) at three columns on a phone, title under the
poster with no card body. The current 210 dp cropped backdrops fit two titles
per screen.
- Continue watching: 16:9 still with the progress bar laid over the bottom
edge of the image and "S03E11 · 12 min left" under it.
- Search becomes an icon in the app bar that expands into an M3 `SearchBar`,
instead of a permanent field above everything.
- Fix "1 episodes" (line ~507; the season header already pluralises).
### Title page
- Full-bleed backdrop with a gradient scrim, poster, title and meta chips
(year, score, genres) over it, collapsing as the page scrolls.
- Synopsis clamped to three lines with "More".
- One full-width primary button, "Resume S03E11" or "Play S01E01", then a row
of icon actions: download, mark watched, and a ⋮ menu holding Start over,
Change match and More on AniList. Today five buttons of three styles wrap
over three lines.
- Drop the "Preferred tracks" form and its Save button from this page. The
player already remembers tracks by name per title (`d3c5f07`); expose any
override from the player's track chooser.
- Season picker as a dropdown chip or tabs, not stacked accordions; a 40
episode title is one long scroll today.
- Episode rows: 16:9 still with progress overlay, "1 · Mother and Children"
(provider name, else "Episode 1") rather than the raw file name
`[Judas] Oshi no Ko - S01E01.mkv`, runtime or air date as supporting text,
watched and download state as small trailing icons. The red `S01E01` label
above each row goes.
### Shares
- `ListItem` rows: name, "12 titles · crawled 2 h ago" or the failure, and a ⋮
menu with Refresh, Re-enter link and Remove (confirmed). The current row
wraps "Custom password stored securely" over three lines beside three
differently styled actions.
- "Add share" as an extended FAB, which is also where the share-link intent
from parity item 1 lands.
### Settings
- Grouped `ListItem` rows (headline, one line of supporting text, trailing
switch), with the long explanations moved into the supporting line or an
info dialog. Today each toggle carries a paragraph.
- Split into sub-pages once storage and decoding settings land: Playback,
Downloads & storage, Appearance, Metadata, About.
- Language fields become a picker dialog over a language list, not free text.
- Storage shows a usage bar (cache, offline episodes) above the budget choice.
### Robustness
- Screenshot tests (Roborazzi, JVM, no device) for each screen in light and
dark, phone and tablet, at font scale 1.0 and 1.5 — the redesign changes
every screen, and `docs/TESTING.md` has no visual layer today.
- 48 dp minimum touch targets and content descriptions on the new icon-only
actions.
- Tablet: `ListDetailPaneScaffold` for library + title, since
`NavigationSuiteScaffold` already switches to a rail there.
- Shared-element transition from a poster to its title page, and predictive
back.
## Suggested order
1. CI green (section 0).
2. Split `ProtonStreamApp.kt`; add screenshot tests for the current screens.
3. Parity 1–3 (small, phone-specific fixes).
4. Button hierarchy and Inter — every later screen inherits them.
5. Title page, then Library, then Shares and Settings.
6. Parity 4–10.
+17 -3
View File
@@ -35,8 +35,12 @@ Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID
```bash
cargo run --release -p pstr-app # the app
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
```
One window at a time: a second launch brings the open window forward and
hands it any share link it was given, then exits.
The `pstr` CLI does the same work headlessly, and is what tells you which layer
is broken:
@@ -68,7 +72,7 @@ against the SDK's own range download.
| Transcoding | None. mpv demuxes and decodes natively. |
| UI | egui/eframe on glow; mpv renders into the same GL context. |
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
## Building
@@ -103,11 +107,21 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
a local HTTP server: there is no port for another process on the machine to ask
for the plaintext, and mpv is handed an opaque token rather than a URL that
would put a share secret in its log and window title.
- The block cache holds decrypted content. It lives in your cache directory and
can be deleted at any time the app is not running.
- The block cache holds decrypted content. It lives in your cache directory,
can be deleted at any time the app is not running, and can be sized or
emptied from Settings → Storage.
- A share link given as a command-line argument is visible to other users of
the machine in the process list while that launch runs, and may land in
shell history. Pasting it into the window (Ctrl+V) does neither.
- The desktop's media widget is handed the title, the episode and a `file://`
path to artwork already on disk — never a provider URL, so the shell never
contacts a metadata provider on its own.
## License
The shared Rust crates and desktop application are MIT licensed. The Android
application and its combined native distribution are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
The desktop application embeds the Inter typeface, under the SIL Open Font
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
and the Phosphor icon font, under MIT.
+2 -1
View File
@@ -7,11 +7,12 @@ distributions.
| Component | License |
|---|---|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
| LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
| AndroidX and Jetpack Compose | Apache-2.0 |
| Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later |
| UniFFI | MPL-2.0 |
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
| proton-stream shared Rust crates | MIT |
This summary is not a replacement for dependency license files. Before a
+21 -9
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ktlint)
alias(libs.plugins.roborazzi)
}
val repositoryRoot = rootProject.projectDir.parentFile
@@ -46,7 +47,7 @@ android {
namespace = "io.narl.protonstream"
compileSdk = 36
buildToolsVersion = "37.0.0"
ndkVersion = "29.0.14206865"
ndkVersion = "30.0.16248370"
defaultConfig {
applicationId = "io.narl.protonstream"
@@ -142,6 +143,9 @@ android {
"GradleDependency",
"NewerVersionAvailable",
"AndroidGradlePluginVersion",
// The same for the platform: raising targetSdk opts into new
// behaviour and needs its own pass on a device.
"OldTargetApi",
)
sarifReport = true
}
@@ -176,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
val hostRustLibrary = repositoryRoot.resolve(
"target/debug/${System.mapLibraryName("pstr_android")}",
)
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
val buildLibmpvAndroid by tasks.registering(Exec::class) {
group = "native"
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
workingDir(repositoryRoot)
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
outputs.files(
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
stagedMpvDir.map { it.file("include/mpv/client.h") },
stagedMpvDir.map { it.file("REVISION") },
)
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
// deletes the declared outputs of a task it has no history for, which on a
// fresh CI checkout threw away the tree just restored from the cache and
// rebuilt libmpv from source on every run.
}
val buildRustHost by tasks.registering(Exec::class) {
@@ -269,6 +269,11 @@ dependencies {
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.work.testing)
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.junit.rule)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.core)
@@ -292,6 +297,13 @@ ktlint {
}
}
// Screenshot baselines live beside the tests that draw them, so a change to how
// a screen looks shows up in review as a changed image. `recordRoborazziDebug`
// rewrites them; `verifyRoborazziDebug` fails on any difference.
roborazzi {
outputDir.set(file("src/test/screenshots"))
}
// The generated UniFFI directory is a main source dir, so ktlint reads it even
// though the filter above drops every file in it. Without this, Gradle refuses
// the build for an undeclared dependency between the two tasks.
+22 -1
View File
@@ -11,7 +11,9 @@
android:name=".ProtonStreamApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/ic_launcher_foreground"
android:enableOnBackInvokedCallback="true"
tools:targetApi="33"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ProtonStream">
@@ -29,6 +31,7 @@
<activity
android:name=".MainActivity"
android:label="@string/launcher_name"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
android:exported="true"
android:launchMode="singleTask"
@@ -38,6 +41,24 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Proton Drive public links. autoVerify="false", said explicitly:
the domain is Proton's, so this can never be a verified App Link.
Android only opens these here once "Open supported links" is
allowed in the app's settings; sharing a link to the app works
without that.
-->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="drive.proton.me" android:pathPrefix="/urls/" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -6,11 +6,12 @@ complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
| Component | License |
|---|---|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
| LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
| AndroidX and Jetpack Compose | Apache-2.0 |
| Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later |
| UniFFI | MPL-2.0 |
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
| proton-stream reusable Rust crates | MIT |
Before release, replace this dependency summary with an inventory generated
+19 -6
View File
@@ -31,6 +31,8 @@ extern "C" {
namespace {
constexpr const char *kProtocol = "pstr";
/// mpv's `hwdec` when hardware decoding is on. Why this value: `Player()`.
constexpr const char *kHardwareDecoding = "auto-copy-safe";
struct StreamCookie {
uint64_t handle;
@@ -191,6 +193,9 @@ struct PlaybackState {
/// Between a seek being issued and playback actually resuming. The seek bar
/// has moved but the picture has not.
bool seeking = false;
/// How far into the file the demuxer has read ahead, in seconds; what the
/// seek bar shades, so a jump into it is known to cost no fetch.
double cached_until = 0.0;
/// True between `loadfile` being issued and mpv acknowledging it with
/// `START_FILE`. Everything mpv reports in that window still describes the
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
@@ -216,6 +221,7 @@ class Player {
option("vo", "libmpv");
option("force-window", "no");
option("video-timing-offset", "0");
// The default; `load` sets it per file from the viewer's setting.
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
// an `ANativeWindow` the decoder is handed at init, and this player has
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
@@ -224,7 +230,7 @@ class Player {
// `hevc_mediacodec: Both surface and native_window are NULL` before
// falling back. The copy variants decode in hardware and hand back
// ordinary frames, which is what this render path can actually use.
option("hwdec", "auto-copy-safe");
option("hwdec", kHardwareDecoding);
option("cache", "yes");
option("cache-secs", "30");
option("demuxer-readahead-secs", "30");
@@ -245,6 +251,7 @@ class Player {
observe("video-params/dh", 7, MPV_FORMAT_INT64);
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
observe("demuxer-cache-time", 10, MPV_FORMAT_DOUBLE);
// Errors mpv reports about the file itself, rather than about the
// request that opened it. Without these a failed demux is silent.
mpv_request_log_messages(mpv_, "error");
@@ -294,8 +301,12 @@ class Player {
}
bool load(uint64_t handle, double start, const std::string &audio,
const std::string &subtitle, bool subtitles) {
const std::string &subtitle, bool subtitles,
bool hardware_decoding) {
if (!mpv_) return false;
// Per file, before `loadfile`: a device whose decoder shows green
// frames is fixed from the next episode without restarting the core.
set_string("hwdec", hardware_decoding ? kHardwareDecoding : "no");
// No wait for a render context. It is created with the render thread and
// needs no window, and a load that cannot get one plays audio anyway —
// waiting for a surface here is what made background playback fail after
@@ -591,6 +602,7 @@ class Player {
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
else if (!std::strcmp(property.name, "demuxer-cache-time")) state_.cached_until = *static_cast<double *>(property.data);
}
static void render_update(void *opaque) noexcept {
@@ -822,7 +834,7 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
}
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles) noexcept {
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles, jboolean hardware_decoding) noexcept {
return c_boundary<jboolean>(JNI_FALSE, [=] {
std::string audio_text;
std::string subtitle_text;
@@ -832,7 +844,8 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
auto *p = from(handle);
return static_cast<jboolean>(
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
subtitle_text, subtitles == JNI_TRUE)
subtitle_text, subtitles == JNI_TRUE,
hardware_decoding == JNI_TRUE)
? JNI_TRUE
: JNI_FALSE);
});
@@ -868,8 +881,8 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
state.video_width, state.video_height,
static_cast<jdouble>(state.end_reason),
state.buffering ? 1.0 : 0.0, state.cache_percent,
state.seeking ? 1.0 : 0.0};
constexpr jsize kFields = 13;
state.seeking ? 1.0 : 0.0, state.cached_until};
constexpr jsize kFields = 14;
jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast<jdoubleArray>(nullptr);
env->SetDoubleArrayRegion(result, 0, kFields, values);
@@ -0,0 +1,22 @@
package io.narl.protonstream
import android.content.Intent
/**
* A Proton Drive public link, wherever in a piece of text it sits.
*
* The fragment is kept: it is the share's secret, and a link without it cannot
* be opened. Matching stops at whitespace and at the quotes and brackets a
* message app wraps a link in, which never occur in a link itself.
*/
private val SHARE_LINK = Regex("""https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+""")
/** The share link an `ACTION_VIEW` URL or an `ACTION_SEND` text carries, if any. */
internal fun shareLinkFrom(action: String?, url: String?, text: CharSequence?): String? = when (action) {
Intent.ACTION_VIEW -> url?.let { SHARE_LINK.matchEntire(it)?.value }
Intent.ACTION_SEND -> text?.let { SHARE_LINK.find(it)?.value }
else -> null
}
internal fun Intent.shareLink(): String? =
shareLinkFrom(action, dataString, getCharSequenceExtra(Intent.EXTRA_TEXT))
@@ -48,6 +48,9 @@ import uniffi.pstr_android.PaletteRecord
class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
private val inPictureInPicture = mutableStateOf(false)
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
private val incomingShareLink = mutableStateOf<String?>(null)
/**
* Whether a bind is outstanding, which is what has to be unbound.
*
@@ -97,6 +100,9 @@ class MainActivity : ComponentActivity() {
// Infallible by construction — an unreadable theme file resolves to the
// shipped default rather than throwing.
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
// Only on a fresh start: a recreated activity is handed the intent it
// was launched with again, and would offer to add the same share twice.
if (savedInstanceState == null) incomingShareLink.value = intent.shareLink()
enableEdgeToEdge()
// Re-applied on every change, not just at startup: picking Latte from
// the settings page has to turn the status bar's icons dark in the same
@@ -134,7 +140,12 @@ class MainActivity : ComponentActivity() {
}
setContent {
ProtonStreamTheme {
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
ProtonStreamApp(
playerHost.value,
inPictureInPicture.value,
shareLink = incomingShareLink.value,
onShareLinkTaken = { incomingShareLink.value = null },
)
}
}
}
@@ -159,6 +170,13 @@ class MainActivity : ComponentActivity() {
}
}
// singleTask: a link opened while the app is running arrives here, not in
// a second activity.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.shareLink()?.let { incomingShareLink.value = it }
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// The backstop for launchers that still send this instead of arming
@@ -1,6 +1,7 @@
package io.narl.protonstream.native
import android.content.Context
import io.narl.protonstream.settings.SettingsStore
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -56,5 +57,9 @@ object NativeRuntime {
}
private fun createEngine(): AndroidEngine =
AndroidEngine(paths(), KeystoreSecretStore(appContext))
AndroidEngine(paths(), KeystoreSecretStore(appContext)).apply {
// The budget is Android's setting, so the engine starts on the
// default and is told the chosen one before anything streams.
setStreamCacheBudget(SettingsStore.gibToBytes(SettingsStore(appContext).cacheBudgetGib))
}
}
@@ -75,6 +75,8 @@ data class MpvPlaybackState(
val cachePercent: Double = 0.0,
/** A seek has been issued but playback has not resumed at the new position. */
val seeking: Boolean = false,
/** How far the demuxer has read ahead, in seconds into the file. */
val cachedUntil: Double = 0.0,
) {
/** Whether the picture is stopped for a reason the viewer did not choose. */
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
@@ -184,12 +186,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
audioLanguage: String?,
subtitleLanguage: String?,
subtitles: Boolean,
hardwareDecoding: Boolean,
) {
check(size > 0uL) { "Cannot play an empty stream" }
try {
withContext(Dispatchers.IO) {
val loaded = withOpenHandle {
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
nativeLoad(
it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles,
hardwareDecoding,
).also { loaded ->
if (loaded) {
// The outgoing stream is released by mpv closing it,
// not from here: `loadfile` is asynchronous and its
@@ -309,6 +315,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
buffering = values[BUFFERING] != 0.0,
cachePercent = values[CACHE_PERCENT],
seeking = values[SEEKING] != 0.0,
cachedUntil = values[CACHED_UNTIL],
)
mutableState.value = next
// The listeners drive the media session and the player UI,
@@ -398,7 +405,15 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private external fun nativeDestroy(handle: Long)
private external fun nativeAttachSurface(handle: Long, surface: Surface)
private external fun nativeDetachSurface(handle: Long)
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
private external fun nativeLoad(
handle: Long,
stream: Long,
start: Double,
audio: String?,
subtitle: String?,
subtitles: Boolean,
hardwareDecoding: Boolean,
): Boolean
private external fun nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double)
@@ -419,7 +434,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
private const val FIELDS = 13
private const val FIELDS = 14
private const val GENERATION = 6
private const val WIDTH = 7
private const val HEIGHT = 8
@@ -427,6 +442,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private const val BUFFERING = 10
private const val CACHE_PERCENT = 11
private const val SEEKING = 12
private const val CACHED_UNTIL = 13
init {
System.loadLibrary("pstr_android")
@@ -76,6 +76,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentTrack
@@ -83,6 +84,19 @@ import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.thumbnailSource
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.foundation.layout.PaddingValues
import io.narl.protonstream.ui.theme.AccentProgress
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import io.narl.protonstream.ui.RemoteArtwork
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.draw.clip
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -207,6 +221,7 @@ fun PlayerScreen(
nativeHost.play(
key, startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
SettingsStore(context).hardwareDecoding,
)
// After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume)
@@ -272,6 +287,12 @@ fun PlayerScreen(
nativeHost.beginTransition()
onIndexChange(index - 1)
}
fun jumpTo(target: Int) {
if (target == index || target !in episodes.indices) return
save()
nativeHost.beginTransition()
onIndexChange(target)
}
// What the media notification, the lock screen and Picture-in-Picture show.
// None of them can reach this composition, and all three have to name the
@@ -281,7 +302,7 @@ fun PlayerScreen(
NowPlaying(
media = key,
title = episode.label,
show = title.canonicalName ?: title.name,
show = title.displayName,
detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource,
@@ -404,7 +425,7 @@ fun PlayerScreen(
style = MaterialTheme.typography.titleMedium,
)
Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "),
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
@@ -444,6 +465,9 @@ fun PlayerScreen(
hasNext = next != null,
onPrevious = { retreat() },
onNext = { advance() },
episodes = episodes,
playing = index,
onEpisode = { jumpTo(it) },
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
landscapeLocked = landscapeLocked,
)
@@ -591,11 +615,15 @@ private fun PlayerControls(
hasNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
episodes: List<EpisodeRecord>,
playing: Int,
onEpisode: (Int) -> Unit,
onRotate: () -> Unit,
landscapeLocked: Boolean,
) {
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) }
var showEpisodes by remember { mutableStateOf(false) }
var showChapters by remember { mutableStateOf(false) }
var showSpeeds by remember { mutableStateOf(false) }
// What the store says, so the dialog opens on the rate that is playing.
@@ -638,7 +666,12 @@ private fun PlayerControls(
scrubbing = null
},
valueRange = 0f..duration,
track = { AccentTrack(it) },
track = {
AccentTrack(
it,
buffered = if (state.duration > 0) (state.cachedUntil / state.duration).toFloat() else 0f,
)
},
)
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
}
@@ -664,6 +697,9 @@ private fun PlayerControls(
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (episodes.size > 1) {
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
}
if (chapters.size > 1) {
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
}
@@ -716,6 +752,14 @@ private fun PlayerControls(
},
)
}
if (showEpisodes) {
EpisodeChooser(
episodes = episodes,
playing = playing,
onDismiss = { showEpisodes = false },
onSelect = { showEpisodes = false; onEpisode(it) },
)
}
if (showChapters) {
ChapterChooser(
chapters = chapters,
@@ -870,6 +914,83 @@ private fun ChapterChooser(
)
}
/**
* The title's episodes, from the player: a sheet rather than a dialog, opened
* scrolled to the one playing so the next few are in view without a search.
* Each row has its still, its name, and whether it is playing, watched or
* part way.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EpisodeChooser(
episodes: List<EpisodeRecord>,
playing: Int,
onDismiss: () -> Unit,
onSelect: (Int) -> Unit,
) {
val list = rememberLazyListState(initialFirstVisibleItemIndex = (playing - 1).coerceAtLeast(0))
ModalBottomSheet(onDismissRequest = onDismiss) {
Text(
"Episodes",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
LazyColumn(state = list, contentPadding = PaddingValues(bottom = 24.dp)) {
itemsIndexed(episodes, key = { _, it -> it.linkId }) { position, episode ->
val current = position == playing
Row(
Modifier
.fillMaxWidth()
.clickable { onSelect(position) }
.background(
if (current) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.width(112.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small)) {
RemoteArtwork(
episode.stillUrl,
episode.label,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(
episode.providerName ?: episode.label,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
when {
current -> "Playing"
episode.watched -> "${episode.label} · Watched"
episode.resumeAt != null -> "${episode.label} · Part way"
else -> episode.label
},
style = MaterialTheme.typography.bodySmall,
color = if (current) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}
}
/**
* The countdown into the next episode, shown once the credits run starts.
*
@@ -898,26 +1019,65 @@ private fun UpNextCard(
}
// `showing` already implies a non-null episode, and the compiler knows it.
if (!showing) return
// Eased between ticks, so the ring runs down rather than stepping once a
// second.
val ring by animateFloatAsState(
targetValue = remaining.toFloat() / UP_NEXT_SECONDS,
animationSpec = tween(durationMillis = 1_000, easing = LinearEasing),
label = "up-next countdown",
)
Column(
modifier
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.width(280.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
.padding(12.dp),
) {
Text(
"Up next in ${remaining}s",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
Text(
upNext.label,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
AccentButton(onClick = onPlayNow) { Text("Play now") }
QuietButton(onClick = { held = true }) { Text("Watch till the end") }
Row(verticalAlignment = Alignment.CenterVertically) {
// The next episode's still, so the card says which episode
// without being read; the ring over it is both the countdown and
// the button that skips it.
Box(
Modifier
.width(112.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onPlayNow),
contentAlignment = Alignment.Center,
) {
RemoteArtwork(
upNext.stillUrl,
upNext.label,
Modifier.fillMaxSize(),
fallback = upNext.thumbnailSource,
labelled = false,
)
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
CircularProgressIndicator(
progress = { ring },
modifier = Modifier.size(40.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.25f),
strokeWidth = 3.dp,
)
Icon(Icons.Default.PlayArrow, contentDescription = "Play now", tint = Color.White)
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(
"Up next in ${remaining}s",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
Text(
upNext.providerName ?: upNext.label,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
}
}
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
Text("Watch till the end")
}
}
}
@@ -92,6 +92,7 @@ interface LibmpvHost {
audioLanguage: String? = null,
subtitleLanguage: String? = null,
subtitles: Boolean = true,
hardwareDecoding: Boolean = true,
)
/** Calls pstr_android_stream_release exactly once for a published handle. */
@@ -20,9 +20,34 @@ class SettingsStore(context: Context) {
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
private companion object {
const val NAME = "proton_stream_settings"
const val KEY_WIFI_ONLY = "wifi_only"
const val KEY_BACKGROUND_AUDIO = "background_audio"
/**
* Whether mpv may decode in hardware. Off is the way out for a device whose
* MediaCodec decoder shows green or torn frames; it costs battery, so it is
* on unless the viewer turns it off. Read at each load, so a change applies
* from the next file.
*/
var hardwareDecoding: Boolean
get() = preferences.getBoolean(KEY_HARDWARE_DECODING, true)
set(value) { preferences.edit().putBoolean(KEY_HARDWARE_DECODING, value).apply() }
/** The streaming cache's budget, in GiB. See [CACHE_BUDGET_CHOICES]. */
var cacheBudgetGib: Int
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
companion object {
/** The desktop's range, in the steps a phone's storage is bought in. */
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
/** `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing. */
const val DEFAULT_CACHE_BUDGET_GIB = 4
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
private const val NAME = "proton_stream_settings"
private const val KEY_WIFI_ONLY = "wifi_only"
private const val KEY_BACKGROUND_AUDIO = "background_audio"
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
}
}
@@ -25,6 +25,8 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord
@@ -33,12 +35,21 @@ import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord
data class AppUiState(
val loading: Boolean = true,
val refreshing: Boolean = false,
val query: String = "",
val titles: List<TitleRecord> = emptyList(),
/**
* The grid: [titles] ordered, each franchise folded into one tile when
* [grouped], and the shelves above it. Null until the bridge has answered,
* when the grid falls back to [titles] as they are.
*/
val arrangement: ArrangementRecord? = null,
val sort: LibrarySort = LibrarySort.NAME,
val grouped: Boolean = true,
val shares: List<ShareRecord> = emptyList(),
val offline: List<OfflineRecord> = emptyList(),
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
@@ -49,8 +60,16 @@ data class AppUiState(
),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null,
/** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null,
)
/**
* One episode's watch state as it was before a change the viewer can undo.
* Null [state] means it had never been played.
*/
data class WatchSnapshot(val shareId: String, val linkId: String, val state: WatchStateRecord?)
@OptIn(FlowPreview::class)
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
private val appContext = context.applicationContext
@@ -77,6 +96,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
/** Reorder the grid, or fold or unfold its franchises. */
fun arrange(sort: LibrarySort, grouped: Boolean) {
mutableState.update { it.copy(sort = sort, grouped = grouped) }
viewModelScope.launch { reloadLibrary(mutableState.value.query) }
}
fun search(query: String) {
mutableState.update { it.copy(query = query) }
searchQuery.value = query
@@ -185,7 +210,101 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
fun dismissMessage() = mutableState.update { it.copy(message = null) }
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
/**
* Every episode of a title watched or unwatched, with an Undo: a whole
* title's positions are what this throws away, and a mis-tap on a tile's
* menu should not cost them.
*/
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
val episodes = title.seasons.flatMap { it.episodes }
changeWatch(
episodes,
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
) { engine, episode, before ->
val duration = before?.durationSecs
engine.saveWatchState(
episode.shareId,
episode.linkId,
if (watched) duration ?: 0.0 else 0.0,
duration,
watched,
)
}
}
/**
* Off Continue watching by forgetting where the episode stopped — what the
* desktop does — with an Undo that puts the position back.
*/
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
changeWatch(
listOf(episode),
"${title.displayName} removed from Continue watching",
) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
}
}
/**
* Off the history page: the position and the watched mark both go, which
* is "never played", with an Undo.
*/
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
changeWatch(
listOf(episode),
"${episode.label} of ${title.displayName} removed from history",
) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
}
}
/** Put back what the last undoable change replaced. */
fun undo() {
val snapshots = mutableState.value.undo ?: return
mutableState.update { it.copy(message = null, undo = null) }
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
snapshots.forEach { snapshot ->
val state = snapshot.state
engine.saveWatchState(
snapshot.shareId,
snapshot.linkId,
state?.positionSecs ?: 0.0,
state?.durationSecs,
state?.watched ?: false,
)
}
}
}.onFailure(::reportError)
reload()
}
}
private fun changeWatch(
episodes: List<EpisodeRecord>,
message: String,
change: (uniffi.pstr_android.AndroidEngine, EpisodeRecord, WatchStateRecord?) -> Unit,
) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
episodes.map { episode ->
val before = engine.watchState(episode.shareId, episode.linkId)
change(engine, episode, before)
WatchSnapshot(episode.shareId, episode.linkId, before)
}
}
}.onSuccess { snapshots ->
mutableState.update { it.copy(message = message, undo = snapshots) }
}.onFailure(::reportError)
reload()
}
}
fun reportError(error: Throwable) {
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
@@ -341,9 +460,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
val current = mutableState.value
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
engine.library(query),
engine.arrangement(query, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
engine.storageUsage(),
@@ -354,6 +476,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
loading = false,
shares = result.shares,
titles = result.titles,
arrangement = result.arrangement,
offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
@@ -366,10 +489,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
private suspend fun reloadLibrary(query: String) {
val current = mutableState.value
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.sort, current.grouped)
}
}.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
@@ -381,6 +510,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private data class Reloaded(
val shares: List<ShareRecord>,
val titles: List<TitleRecord>,
val arrangement: ArrangementRecord,
val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord,
@@ -0,0 +1,139 @@
package io.narl.protonstream.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import uniffi.pstr_android.EpisodeRecord
/**
* A tab's page: its own name in an app bar, over whatever the page holds.
*
* One bar for the whole app said "proton-stream" on every page, which is a band
* of height spent naming the app the viewer is already in. The bar draws under
* the status bar itself, which is why the shell hands pages no top padding;
* [content] gets the rest of [padding] (the navigation bar and the mini
* transport) to apply as it scrolls.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun TabPage(
title: String,
padding: PaddingValues,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
// Pinned: the bar stays, and takes the container tint once the page has
// scrolled under it, so the edge between the two is still visible.
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
colors = pageBarColors(),
scrollBehavior = scroll,
)
Box(Modifier.weight(1f)) { content(padding) }
}
}
/**
* The page's own background until something scrolls under the bar, then the
* container tint. A bar in `surface` from the start read as a band across the
* top of a page drawn in `background`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
)
/**
* The shared-element scopes the library-to-title switch runs in, or null where
* there is none — the two-pane layout, a screenshot, a preview. Locals rather
* than parameters, because the poster that animates is three calls deep in the
* grid and nothing between needs to know.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
internal val LocalSharedTransition = staticCompositionLocalOf<SharedTransitionScope?> { null }
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { null }
/**
* A title's art as one element across the switch: the poster tapped in the
* grid grows into the backdrop of the page it opens, and shrinks back on the
* way out, so the page is visibly *that* title's rather than a new screen.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.sharedArt(key: String): Modifier {
val transition = LocalSharedTransition.current ?: return this
val visibility = LocalPaneVisibility.current ?: return this
return with(transition) {
this@sharedArt.sharedBounds(rememberSharedContentState("art/$key"), visibility)
}
}
@Composable
internal fun EmptyState(title: String, body: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(body, style = MaterialTheme.typography.bodyMedium)
}
}
}
/**
* Where Play on a title lands: the most recently played part-watched episode,
* else the first unwatched one, else the first — the desktop's `next_up`.
*/
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0
internal fun formatBytes(bytes: ULong): String {
val value = bytes.toDouble()
return when {
value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024))
value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024))
value >= 1024 -> "%.1f KiB".format(value / 1024)
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}" }
}
}
@@ -0,0 +1,243 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
/** One show's saved episodes, and the show itself where the library has it. */
private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List<OfflineRecord>)
@Composable
internal fun DownloadsScreen(
state: AppUiState,
onRemove: (OfflineRecord) -> Unit,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
val downloads by work.observeAsState(emptyList())
// Reading retained metadata on every WorkInfo transition also hydrates
// paused/failed/cancelled entries after WorkManager history is pruned.
val retained = remember(downloads) { DownloadStateStore(context).records() }
// Which show each saved file is from. The offline record knows its episode
// but not its show, and the library is the only thing that does.
val groups = remember(state.offline, state.titles) {
val shows = state.titles.flatMap { title ->
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
}.toMap()
state.offline
.groupBy { shows["${it.shareId}/${it.linkId}"] }
.map { (title, files) ->
OfflineGroup(title?.displayName ?: "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(
modifier = Modifier.fillMaxSize().padding(body),
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (state.offline.isNotEmpty()) {
item(key = "summary") {
Text(
"${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " +
"${formatBytes(state.offline.sumOf { it.size })} on this device",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
// What is still arriving comes first: it is the part of the page
// that changes, and the part with something to do.
if (retained.isNotEmpty()) {
item(key = "in-progress") { SectionHeading("In progress") }
items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download ->
PartialRow(download, onPause, onResume, onDeletePartial)
}
}
// Under the show they belong to, as on desktop: a saved season is
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
groups.forEach { group ->
item(key = "group/${group.name}") {
SectionHeading(
group.name,
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
formatBytes(group.files.sumOf { it.size }),
)
}
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
SavedRow(file, group.title, onDelete = { deleting = file })
}
}
}
}
deleting?.let { file ->
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete this download?") },
text = {
Text(
"${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
ListItem(
leadingContent = {
RemoteArtwork(
episode?.stillUrl ?: title?.backdropUrl,
title?.name ?: file.linkId,
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
fallback = episode?.thumbnailSource,
labelled = episode == null,
)
},
headlineContent = {
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
},
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
/**
* A download not yet finished: how far it has got, why it stopped if it did,
* and pause or resume beside cancel.
*/
@Composable
private fun PartialRow(
download: RetainedDownload,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
) {
val active = download.status == RetainedDownload.STATUS_RUNNING ||
download.status == RetainedDownload.STATUS_QUEUED
ListItem(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Column {
Text(
listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
).joinToString(" · "),
)
if (download.total > 0L) {
AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (active) {
IconButton(onClick = { onPause(download) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
} else {
IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
}
}
// What has arrived so far is discarded; nothing finished is.
IconButton(onClick = { onDeletePartial(download) }) {
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -0,0 +1,183 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentProgress
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
/** One played episode, with the title it belongs to and its place in that title's playlist. */
internal data class Played(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
/**
* Every episode that has been started or finished, newest first — the
* desktop's `Library::history`, over the records the library already holds.
*
* A row marked unwatched again sits at position zero, which is "never played"
* for every purpose here, so it is left out. Progress stands in for the
* position: it is non-zero exactly when the position is.
*/
internal fun history(titles: List<TitleRecord>): List<Played> =
titles.flatMap { title ->
title.seasons.flatMap(SeasonRecord::episodes).withIndex()
.filter { (_, episode) -> episode.watched || (episode.progress ?: 0.0) > 0.0 }
.map { (index, episode) -> Played(title, episode, index) }
}.sortedByDescending { it.episode.lastPlayed }
/**
* "Today", "Yesterday", a weekday within the last week, then a date — how the
* history page heads each day.
*/
internal fun dayHeading(day: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = when {
day == today -> "Today"
day == today.minusDays(1) -> "Yesterday"
day.isAfter(today.minusDays(7)) -> day.dayOfWeek.getDisplayName(TextStyle.FULL, locale)
day.year == today.year -> day.format(DateTimeFormatter.ofPattern("d MMMM", locale))
else -> day.format(DateTimeFormatter.ofPattern("d MMMM yyyy", locale))
}
@Composable
internal fun HistoryScreen(
state: AppUiState,
onPlay: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit,
onRemove: (TitleRecord, EpisodeRecord) -> Unit,
padding: PaddingValues,
zone: ZoneId = remember { ZoneId.systemDefault() },
today: LocalDate = LocalDate.now(zone),
) {
val days = remember(state.titles) {
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
}
if (days.isEmpty()) {
TabPage("History", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) {
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
}
}
return
}
TabPage("History", padding) { body ->
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
days.forEach { (day, played) ->
item(key = "day/$day") {
Text(
dayHeading(day, today),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
)
}
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
HistoryRow(
entry,
onPlay = { onPlay(entry.title, entry.index) },
onTitle = { onTitle(entry.title) },
onRemove = { onRemove(entry.title, entry.episode) },
)
}
}
}
}
}
/**
* The still plays the episode, the text opens its title, and the cross takes
* it off the page — the same three targets the desktop row has.
*/
@Composable
private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, onRemove: () -> Unit) {
val episode = entry.episode
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.width(128.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onPlay),
) {
RemoteArtwork(
episode.stillUrl ?: entry.title.backdropUrl,
episode.label,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
}
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
Text(
entry.title.displayName,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
episode.providerName?.let { "${episode.label} · $it" } ?: episode.label,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
if (episode.watched) "Finished" else episode.stoppedAt(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRemove) {
Icon(Icons.Default.Close, contentDescription = "Remove from history")
}
}
}
/** "Stopped at 12:04 of 23:40", or as much of it as is known. */
private fun EpisodeRecord.stoppedAt(): String {
val at = resumeAt ?: return "Started"
val duration = progress?.takeIf { it > 0.0 }?.let { at / it }
return "Stopped at ${clock(at)}" + (duration?.let { " of ${clock(it)}" } ?: "")
}
private fun clock(seconds: Double): String {
val total = seconds.toInt().coerceAtLeast(0)
val hours = total / 3_600
return if (hours > 0) "%d:%02d:%02d".format(hours, total % 3_600 / 60, total % 60)
else "%d:%02d".format(total / 60, total % 60)
}
@@ -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),
)
}
@@ -0,0 +1,706 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.background
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Brush
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import java.time.LocalDate
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.download.DownloadCoordinator
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentProgress
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
/**
* How many shows the continue-watching shelf holds.
*
* A shelf is a shortcut, not a second library: past about this many it is
* quicker to search for the thing than to scroll the shelf looking for it.
*/
private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
private data class Tile(val title: TitleRecord, val folded: Int)
/** A row above the grid: titles that share a director, a studio or a genre. */
private data class Shelf(val label: String, val titles: List<TitleRecord>)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun LibraryScreen(
state: AppUiState,
onSearch: (String) -> Unit,
onResume: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit,
onRefresh: () -> Unit,
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
padding: PaddingValues,
onMatchChanged: () -> Unit = {},
onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
// What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) }
var matching by remember { mutableStateOf<TitleRecord?>(null) }
// Most recently played first, one episode per show: a shelf that lists four
// episodes of the same series is a shelf with room for nothing else.
val resumable = remember(state.titles) {
state.titles.mapNotNull { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
playlist.withIndex()
.filter { it.value.resumeAt != null && !it.value.watched }
.maxByOrNull { it.value.lastPlayed }
?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}
// Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them.
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
val closeSearch = {
searching = false
onSearch("")
}
BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back
// only at the top: the page says what it is on arrival, then gets out of
// the way.
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else {
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive public link under Shares, then refresh.",
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
)
}
}
}
menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
onResume(title, nextUpIndex(playlist))
}
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
if (allWatched) "Mark unwatched" else "Mark watched",
) { onSetTitleWatched(title, !allWatched) }
// The same search the title page offers, for the tile that is
// plainly wrong — the wrong poster is what the grid shows.
MenuRow(Icons.Default.Edit, "Change match") { matching = title }
}
}
matching?.let { title ->
ChangeMatchDialog(
title = title,
onDismiss = { matching = null },
onChanged = {
matching = null
onMatchChanged()
},
onError = onError,
)
}
menuResume?.let { entry ->
TileMenu(
entry.title.displayName,
entry.episode.label,
onDismiss = { menuResume = null },
) {
MenuRow(Icons.Default.PlayArrow, "Resume") { onResume(entry.title, entry.index) }
MenuRow(Icons.Default.Info, "Open") { onTitle(entry.title) }
MenuRow(Icons.Default.RemoveCircleOutline, "Remove from Continue watching") {
onForgetPosition(entry.title, entry.episode)
}
}
}
}
/**
* The grid's order, and whether a franchise is one tile or one per title —
* the same choices the desktop offers beside its grid, behind one icon here
* because a phone's bar has room for three.
*/
@Composable
private fun ArrangeMenu(sort: LibrarySort, grouped: Boolean, onArrange: (LibrarySort, Boolean) -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort library")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
LibrarySort.entries.forEach { option ->
DropdownMenuItem(
text = { Text(option.label()) },
leadingIcon = {
if (option == sort) Icon(Icons.Default.Check, contentDescription = null)
},
onClick = {
open = false
onArrange(option, grouped)
},
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("One tile per franchise") },
leadingIcon = {
if (grouped) Icon(Icons.Default.Check, contentDescription = null)
},
onClick = {
open = false
onArrange(sort, !grouped)
},
)
}
}
}
/** What each order is called. The desktop says the same. */
private fun LibrarySort.label() = when (this) {
LibrarySort.NAME -> "A – Z"
LibrarySort.RECENT -> "Recently watched"
LibrarySort.ADDED -> "Recently added"
LibrarySort.RELEASE -> "Newest"
LibrarySort.RATING -> "Highest rated"
LibrarySort.POPULARITY -> "Most popular"
}
/**
* The bar while searching: back closes the search and clears it, and the field
* takes the keyboard as soon as it opens, since opening it is asking to type.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchBar(query: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
TopAppBar(
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
}
},
title = {
// A filled pill rather than an outlined form field: search is how
// the page is navigated, not a value being entered.
TextField(
value = query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth().padding(end = 8.dp).focusRequester(focus),
singleLine = true,
shape = CircleShape,
placeholder = { Text("Search library") },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
}
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
},
colors = pageBarColors(),
)
}
/**
* A long press's menu, from the bottom of the screen where the thumb already
* is. Each row closes it before doing its thing.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TileMenu(
heading: String,
caption: String,
onDismiss: () -> Unit,
rows: @Composable MenuScope.() -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.padding(bottom = 24.dp)) {
Text(
heading,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 24.dp),
)
Text(
caption,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
)
MenuScope(onDismiss).rows()
}
}
}
private class MenuScope(val dismiss: () -> Unit)
@Composable
private fun MenuScope.MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(label) },
leadingContent = { Icon(icon, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { dismiss(); onClick() }.padding(horizontal = 8.dp),
)
}
@Composable
private fun LibraryGrid(
state: AppUiState,
resumable: List<Resumable>,
onResume: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit,
onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> Unit,
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
val byKey = remember(state.titles) { state.titles.associateBy { it.key } }
val tiles = remember(state.titles, state.arrangement) {
state.arrangement?.tiles?.mapNotNull { tile -> byKey[tile.key]?.let { Tile(it, tile.folded.toInt()) } }
?: state.titles.map { Tile(it, 0) }
}
val shelves = remember(state.titles, state.arrangement) {
state.arrangement?.shelves.orEmpty()
.map { shelf -> Shelf(shelf.label, shelf.keys.mapNotNull(byKey::get)) }
.filter { it.titles.isNotEmpty() }
}
// Posters, three across on a phone. Cropped backdrops at full width fitted
// two titles to a screen, which is a library read one row at a time.
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),
) {
// Above the grid, and only when there is something in it: what a viewer
// opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest.
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
title,
resume = resumable.firstOrNull { it.title.key == title.key },
onPlay = { onResume(title, it) },
onOpen = { onTitle(title) },
)
}
}
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text("Continue watching", style = MaterialTheme.typography.titleMedium)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(top = 10.dp),
) {
items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
ContinueCard(
entry,
onClick = { onResume(entry.title, entry.index) },
onLongClick = { onResumeMenu(entry) },
)
}
}
}
}
}
if (state.query.isBlank()) {
shelves.forEach { shelf ->
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(top = 10.dp),
) {
items(shelf.titles, key = { it.key }) { title ->
PosterTile(
title,
onClick = { onTitle(title) },
onLongClick = { onTitleMenu(title) },
modifier = Modifier.width(SHELF_TILE_WIDTH),
shared = false,
)
}
}
}
}
}
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
}
}
}
items(tiles, key = { it.title.key }) { tile ->
PosterTile(
tile.title,
onClick = { onTitle(tile.title) },
onLongClick = { onTitleMenu(tile.title) },
folded = tile.folded,
)
}
}
}
/**
* 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))
}
}
}
}
/** A shelf's covers: a little narrower than the grid's, so the row reads as a row. */
private val SHELF_TILE_WIDTH = 112.dp
/** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18
/**
* The title the banner shows: the one watched last, or with nothing part
* watched, one of the titles the provider has a backdrop for, turning over
* once a day — the desktop's `featured`.
*/
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
resumable.firstOrNull()?.let { return it.title }
val pictured = titles.filter { it.backdropUrl != null }
if (pictured.isEmpty()) return null
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
}
/** Large art behind the name, rating, genres and three lines of synopsis. */
@Composable
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
),
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text(
title.displayName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
listOfNotNull(
title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
).joinToString(" · ").ifEmpty { title.caption() },
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
)
title.overview?.let {
Text(
it.trim(),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play")
}
TonalButton(onClick = onOpen) { Text("More info") }
}
}
}
}
/** A part-watched episode: its still, where it was left, and what is left of it. */
@Composable
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
Column(
Modifier
.width(220.dp)
.clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.displayName,
Modifier.fillMaxSize(),
fallback = entry.episode.thumbnailSource,
)
entry.episode.progress?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
}
Text(
entry.title.displayName,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
Text(
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* One title: its poster, and its name and size under it rather than in a card.
*
* [folded] is how many more titles of its franchise the tile stands for, said
* in the corner. [shared] is whether the poster grows into the title page —
* only one copy of a title on screen can, and a shelf may hold the same title
* as the grid.
*/
@Composable
private fun PosterTile(
title: TitleRecord,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
folded: Int = 0,
shared: Boolean = true,
) {
Column(
modifier
.clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
Box {
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.displayName,
(if (shared) Modifier.sharedArt(title.key) else Modifier)
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium),
fallback = title.thumbnailSource,
)
if (folded > 0) {
Text(
"+$folded",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(Color.Black.copy(alpha = 0.7f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
Spacer(Modifier.height(6.dp))
Text(
title.displayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// Two lines, not one: at a large font scale "2023 · 7 episodes" does
// not fit a poster's width, and one line cut it to "2023 · 7".
Text(
title.caption(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
/** "2023 · 11 episodes", or "1988 · Film" — never "1 episodes". */
private fun TitleRecord.caption(): String = listOfNotNull(
(metadataYear ?: year)?.toString(),
when {
(metadataKind ?: kind) == TitleType.FILM && episodeCount == 1uL -> "Film"
episodeCount == 1uL -> "1 episode"
else -> "$episodeCount episodes"
},
).joinToString(" · ")
/**
* "12 min left", from where the episode was left and how far through that is.
* Null when either is unknown — a guess would be worse than no figure.
*/
private fun EpisodeRecord.timeLeft(): String? {
val at = resumeAt ?: return null
val fraction = progress?.takeIf { it > 0.0 && it < 1.0 } ?: return null
val minutes = ((at / fraction - at) / 60).toInt()
return if (minutes < 1) "under a minute left" else "$minutes min left"
}
File diff suppressed because it is too large. Load diff
@@ -6,8 +6,13 @@ import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,12 +21,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -64,7 +76,9 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one.
* one. Until either answers, the box pulses as a skeleton; when neither has
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it.
*/
@Composable
internal fun RemoteArtwork(
@@ -72,19 +86,98 @@ internal fun RemoteArtwork(
description: String,
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
labelled: Boolean = true,
) {
val context = LocalContext.current
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, fallback) {
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
val enabled = LocalArtworkLoading.current
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
LaunchedEffect(url, fallback, enabled) {
if (!enabled) {
image = ArtworkLoad.Missing
return@LaunchedEffect
}
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
}
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let {
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
when (val loaded = image) {
ArtworkLoad.Pending -> Skeleton(modifier)
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
}
}
private sealed interface ArtworkLoad {
data object Pending : ArtworkLoad
data object Missing : ArtworkLoad
data class Loaded(val image: ImageBitmap) : ArtworkLoad
}
/**
* Whether [RemoteArtwork] fetches anything at all.
*
* Off in the screenshot tests: there is no bridge on the host, and whether a
* failed fetch lands before or after the capture would decide between a
* skeleton and a placeholder in the committed image.
*/
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
/**
* Something being loaded: the surface tint, breathing.
*
* A spinner per tile is a grid of spinners; a shape where the poster will be
* says what is coming and where, and settles the layout before it arrives.
*/
@Composable
internal fun Skeleton(modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.55f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "skeleton alpha",
)
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
}
/**
* Art for a title that has none: the accent as a gradient, and its initials.
*
* A black card with a play triangle on it said nothing about which title it
* was, so a grid of unmatched titles was a grid of identical cards. Each name
* sets where on the accent ramp its gradient starts, so neighbours differ
* while every one stays in the theme's colours.
*/
@Composable
internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) {
val scheme = MaterialTheme.colorScheme
val brush = remember(name, scheme.primary, scheme.secondary) {
val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f)
Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f)))
}
BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) {
if (!labelled) return@BoxWithConstraints
val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() }
Text(
initials(name),
color = Color.White.copy(alpha = 0.9f),
fontSize = size,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
/**
* "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
* capitalised words, so particles and articles in a romanised name do not
* take a slot. A name with no capitals uses its first two words.
*/
internal fun initials(name: String): String {
val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true }
val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words }
return picked.take(2).joinToString("") { it.first().uppercase() }
}
/**
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
*
@@ -0,0 +1,623 @@
package io.narl.protonstream.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ManageSearch
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.border
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp
import io.narl.protonstream.settings.SettingsStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.FlavorChoice
import uniffi.pstr_android.AppearanceRecord
import uniffi.pstr_android.TitleNamesChoice
import uniffi.pstr_android.AccentChoice
import uniffi.pstr_android.PlaybackPrefsRecord
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import io.narl.protonstream.ui.theme.AccentProgress
@Composable
internal fun SettingsScreen(
state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
onMatchAgain: () -> Unit,
onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit,
padding: PaddingValues,
initialPage: SettingsPage? = null,
// A parameter so the screenshot tests can pin it: read from the package,
// every version bump changed the committed image of this page.
version: String = appVersion(),
/** The library's names changed, and it has to be read again to show them. */
onNamesChanged: () -> Unit = {},
) {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
// Playback preferences are the shared store's, not this app's: they are the
// same file the desktop client reads, so a language chosen on one is the
// language the other starts in.
var prefs by remember { mutableStateOf<PlaybackPrefsRecord?>(null) }
val scope = rememberCoroutineScope()
fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) {
val next = change(prefs ?: return)
prefs = next
scope.launch(Dispatchers.IO) {
runCatching { NativeRuntime.engine().setPlaybackPrefs(next) }
}
}
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { prefs = it }
}
var confirmDelete by remember { mutableStateOf(false) }
var showMetadata by remember { mutableStateOf(false) }
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
// One row per area on the first page, each opening its own page: grouped
// on one page, the storage controls sat a long scroll below playback, and
// every setting added made the rest harder to find.
var page by rememberSaveable { mutableStateOf(initialPage) }
BackHandler(enabled = page != null) { page = null }
val back: @Composable () -> Unit = {
IconButton(onClick = { page = null }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
}
}
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 ->
SettingSwitch(
"Autoplay next episode",
"Starts the next episode when the credits begin",
current.autoplayNext,
) { on -> update { it.copy(autoplayNext = on) } }
// Off is the safer default: a mis-named chapter then costs a tap on
// Skip rather than a scene.
SettingSwitch(
"Skip openings and endings",
"Uses the release's chapters; off shows a Skip button instead",
current.autoSkip,
) { on -> update { it.copy(autoSkip = on) } }
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
update { it.copy(subtitles = on) }
}
// Preferences, not filters: a file with nothing in the language
// plays its own default track. A show that has been given its
// own choice keeps it.
LanguageSetting("Audio language", current.audioLanguage) { tag ->
update { it.copy(audioLanguage = tag) }
}
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
update { it.copy(subtitleLanguage = tag) }
}
}
SettingSwitch(
"Hardware decoding",
"Turn off if video shows green or torn frames",
hardwareDecoding,
) {
hardwareDecoding = it
settings.hardwareDecoding = it
}
SettingSwitch(
"Background audio",
"Keeps playing with the app closed, from the notification",
backgroundAudio,
) {
backgroundAudio = it
settings.backgroundAudio = it
}
}
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
wifiOnly = it
settings.wifiOnly = it
// Constraints are baked in at enqueue time, so a queue that already
// exists keeps the policy it was queued under until it is re-issued.
DownloadCoordinator.applyNetworkPolicy(context)
}
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("Streaming cache")
Text(
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AccentProgress(
progress = {
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
.toFloat().coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
)
// What has been watched is kept up to this much, so going back over
// a scene costs no download. A lower choice frees the difference at
// once. Wrapping, so five chips survive a narrow phone at a large
// font scale.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
FilterChip(
selected = gib == cacheBudgetGib,
onClick = {
cacheBudgetGib = gib
settings.cacheBudgetGib = gib
scope.launch(Dispatchers.IO) {
runCatching {
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
}
}
},
label = { Text("$gib GiB") },
)
}
}
}
// The cache is rebuildable, so it goes without asking. Offline episodes
// are a choice the viewer made, so that one asks.
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
SettingAction(
"Delete all offline episodes",
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
" · ${formatBytes(state.storage.offlineBytes)}" +
(
state.storage.partialBytes.takeIf { it > 0uL }
?.let { " · ${formatBytes(it)} unfinished" }
.orEmpty()
),
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
onClick = { confirmDelete = true },
)
}
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker(onNamesChanged) }
}
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
SettingAction(
"Metadata enrichment",
if (state.metadataSettings.enabled) {
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
} else {
"Off, the privacy default"
},
onClick = { showMetadata = true },
)
// Every title looked up again, matched ones included: the way out of a
// library the provider answered wrong, which otherwise stays wrong for
// as long as the match is remembered.
SettingAction(
"Match everything again",
"Looks every title up again, hand-picked matches excepted",
enabled = state.metadataSettings.enabled && !state.refreshing,
onClick = onMatchAgain,
)
}
SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) {
ListItem(
headlineContent = { Text("Proton Stream") },
supportingContent = { Text("Version $version") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
}
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Delete all offline episodes?") },
text = {
Text(
"${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " +
"will be removed from this device. Watch history is kept, and anything " +
"deleted can be downloaded again.",
)
},
confirmButton = {
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
},
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
)
}
legalDocument?.let { document ->
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
}
if (showMetadata) {
MetadataSettingsDialog(
current = state.metadataSettings,
onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key ->
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
)
}
}
/** The areas the first page lists, each a page of its own. */
internal enum class SettingsPage(val title: String, val icon: ImageVector) {
Playback("Playback", Icons.Default.PlayCircle),
Storage("Downloads and storage", Icons.Default.Storage),
Appearance("Appearance", Icons.Default.Palette),
Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch),
About("About", Icons.Default.Info),
}
/** One area's page: its name, a way back, and its rows. */
@Composable
private fun SettingsSubPage(
page: SettingsPage,
padding: PaddingValues,
back: @Composable () -> Unit,
rows: @Composable ColumnScope.() -> Unit,
) {
TabPage(page.title, padding, navigationIcon = back) { body ->
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
rows()
Spacer(Modifier.height(24.dp))
}
}
}
/** The installed version, as the launcher and the store show it. */
@Composable
private fun appVersion(): String {
val context = LocalContext.current
return remember {
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
.getOrNull() ?: "unknown"
}
}
/** A switch with its one line of explanation; the whole row toggles it. */
@Composable
private fun SettingSwitch(headline: String, supporting: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
ListItem(
headlineContent = { Text(headline) },
supportingContent = { Text(supporting) },
trailingContent = { Switch(checked = checked, onCheckedChange = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onChecked),
)
}
/** A row that does something when tapped: opens a dialog, clears a cache. */
@Composable
private fun SettingAction(
headline: String,
supporting: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val colors = ListItemDefaults.colors(containerColor = Color.Transparent)
ListItem(
headlineContent = { Text(headline) },
supportingContent = { Text(supporting) },
colors = if (enabled) {
colors
} else {
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
)
},
modifier = Modifier.clickable(enabled = enabled, onClick = onClick),
)
}
@Composable
private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
) {
var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option ->
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
}
}
Text(
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
else "Film and television; requires a free TMDB API key.",
style = MaterialTheme.typography.bodySmall,
)
if (provider == MetadataProvider.TMDB) {
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
OutlinedTextField(
apiKey,
{ apiKey = it },
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
)
}
}
},
confirmButton = {
AccentButton(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
) { Text("Save") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
internal fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB"
}
private data class LegalDocument(val title: String, val assetPath: String)
@Composable
private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) {
val context = LocalContext.current
var contents by remember(document.assetPath) { mutableStateOf("Loading…") }
LaunchedEffect(document.assetPath) {
contents = runCatching {
withContext(Dispatchers.IO) {
context.assets.open(document.assetPath).bufferedReader().use { it.readText() }
}
}.getOrElse { error -> "Unable to load this document: ${error.message}" }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(document.title) },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
item { Text(contents, style = MaterialTheme.typography.bodySmall) }
}
},
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChecked)
}
}
/**
* Flavour, accent and gradients — the same three the desktop client offers.
*
* Every colour is resolved by Rust, so a swatch here is the colour the app will
* actually paint rather than an approximation of it, and the choice is stored in
* the file both clients read.
*/
@Composable
private fun AppearancePicker(onNamesChanged: () -> Unit) {
val scope = rememberCoroutineScope()
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
suspend fun repaint(next: AppearanceRecord, store: Boolean) {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
if (store) engine.setAppearance(next)
val palette = engine.previewPalette(next)
// Every accent as it would look in *this* flavour: a swatch row
// that keeps Mocha's pastels while Latte is selected is a row
// that lies about what the next tap does.
val row = AccentChoice.entries.associateWith { accent ->
Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt())
}
palette to row
}
}.onSuccess { (palette, row) ->
choice = next
swatches = row
AppearanceState.apply(palette)
}
}
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } }
.onSuccess { repaint(it, store = false) }
}
val current = choice ?: return
Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
items(FlavorChoice.entries.toList(), key = { it.name }) { flavor ->
val selected = flavor == current.flavor
if (selected) {
AccentButton(onClick = {}) { Text(flavor.label()) }
} else {
EdgedButton(onClick = {
scope.launch { repaint(current.copy(flavor = flavor), store = true) }
}) { Text(flavor.label()) }
}
}
}
Text("Accent", style = MaterialTheme.typography.bodyMedium)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) {
items(AccentChoice.entries.toList(), key = { it.name }) { accent ->
val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(swatch)
.border(
width = if (accent == current.accent) 3.dp else 1.dp,
color = if (accent == current.accent) {
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.outline
},
shape = CircleShape,
)
.clickable {
scope.launch { repaint(current.copy(accent = accent), store = true) }
},
)
}
}
Text("Title names", style = MaterialTheme.typography.bodyMedium)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
items(TitleNamesChoice.entries.toList(), key = { it.name }) { names ->
if (names == current.names) {
AccentButton(onClick = {}) { Text(names.label()) }
} else {
EdgedButton(onClick = {
scope.launch {
repaint(current.copy(names = names), store = true)
onNamesChanged()
}
}) { Text(names.label()) }
}
}
}
Text(
"What a matched title is called. Its other names still find it in search.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
scope.launch { repaint(current.copy(gradients = on), store = true) }
}
Text(
"Off is the safer setting on a panel that bands: a slow ramp across a wide bar " +
"shows every step it is drawn from, and flat is better than striped.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 14.dp),
)
}
/** What each name setting is called. The desktop client says the same. */
private fun TitleNamesChoice.label() = when (this) {
TitleNamesChoice.LIBRARY -> "As in the share"
TitleNamesChoice.ENGLISH -> "English"
TitleNamesChoice.ROMAJI -> "Romaji"
}
/** What each palette family is called. The desktop client says the same. */
private fun FlavorChoice.label() = when (this) {
FlavorChoice.PROTON -> "Proton"
FlavorChoice.LATTE -> "Catppuccin Latte"
FlavorChoice.FRAPPE -> "Catppuccin Frappé"
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
FlavorChoice.MOCHA -> "Catppuccin Mocha"
FlavorChoice.PERSONA5 -> "Persona 5"
}
@@ -0,0 +1,284 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FolderShared
import androidx.compose.ui.graphics.Color
@Composable
internal fun SharesScreen(
shares: List<ShareRecord>,
onAdd: (String, String, String?) -> Unit,
onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit,
onRemove: (String) -> Unit,
incomingLink: String?,
onIncomingLinkTaken: () -> Unit,
padding: PaddingValues,
) {
var showAdd by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
var removing by remember { mutableStateOf<ShareRecord?>(null) }
TabPage("Shares", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) {
if (shares.isEmpty()) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
items(shares, key = { it.id }) { share ->
ShareRow(
share = share,
onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share },
onRemove = { removing = share },
)
}
}
}
// Where a thumb is, and the one thing this page is for. The share
// sheet and a tapped link land in the same form.
ExtendedFloatingActionButton(
onClick = { showAdd = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Add share") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
}
if (showAdd || incomingLink != null) {
AddShareDialog(
initialUrl = incomingLink.orEmpty(),
onDismiss = { showAdd = false; onIncomingLinkTaken() },
onAdd = onAdd,
)
}
repairing?.let { share ->
RepairShareDialog(
share = share,
onDismiss = { repairing = null },
onRepair = { url, password -> onRepair(share.id, url, password) },
)
}
removing?.let { share ->
// Asked, because it cannot be undone: the link's secret, its offline
// files and its watch positions all go (`Catalog::remove_share`).
AlertDialog(
onDismissRequest = { removing = null },
title = { Text("Remove ${share.name}?") },
text = {
Text(
"Its titles leave the library, and its offline episodes and watch " +
"progress are deleted. Adding the link again starts it fresh.",
)
},
confirmButton = {
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
},
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
)
}
}
/**
* One share: its name, how it is unlocked, and its actions behind a menu.
*
* Three differently styled controls beside the name squeezed it into a column
* a word wide; one icon leaves the row its width.
*/
@Composable
private fun ShareRow(
share: ShareRecord,
onRefresh: () -> Unit,
onRepair: () -> Unit,
onRemove: () -> Unit,
) {
var menu by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
supportingContent = {
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link")
},
leadingContent = {
Icon(
Icons.Default.FolderShared,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Actions for ${share.name}")
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
// One share, not the library: a link that has just had files
// added should not cost a walk of every other one.
DropdownMenuItem(
text = { Text("Refresh") },
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
onClick = { menu = false; onRefresh() },
)
// Not a remove: a share whose secret has become unreadable
// cannot be removed either, since removal deletes a secret
// the store can no longer touch.
DropdownMenuItem(
text = { Text("Re-enter link") },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
onClick = { menu = false; onRepair() },
)
DropdownMenuItem(
text = { Text("Remove") },
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
onClick = { menu = false; onRemove() },
)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
/**
* Re-enter the link for a share the app can no longer decrypt its secret for.
*
* It has to be the *same* link — a different token is a different share, and
* repointing this one at it would leave every catalog row and every offline file
* describing something that is not there. Rust enforces that; this only says so.
*/
@Composable
private fun RepairShareDialog(
share: ShareRecord,
onDismiss: () -> Unit,
onRepair: (String, String?) -> Unit,
) {
var url by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Re-enter link for ${share.name}") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"The stored credentials for this share cannot be read — usually after a " +
"screen-lock change or a device restore. Entering the same link again " +
"restores access without losing the library or anything downloaded.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
url,
{ url = it },
label = { Text("Public share URL") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
OutlinedTextField(
password,
{ password = it },
label = { Text("Custom password (optional)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
AccentButton(
onClick = { onRepair(url.trim(), password); onDismiss() },
enabled = url.isNotBlank(),
) { Text("Restore") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun AddShareDialog(
initialUrl: String,
onDismiss: () -> Unit,
onAdd: (String, String, String?) -> Unit,
) {
var name by remember { mutableStateOf("") }
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
OutlinedTextField(
url,
{ url = it },
label = { Text("Public share URL") },
singleLine = true,
// The URL fragment is a secret. Password input disables
// IME learning/suggestions while normal long-press paste
// remains available.
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
OutlinedTextField(
password,
{ password = it },
label = { Text("Custom password (optional)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
AccentButton(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
enabled = name.isNotBlank() && url.isNotBlank(),
) { Text("Add") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -0,0 +1,706 @@
package io.narl.protonstream.ui
import android.content.Intent
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.FilterChip
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
internal fun TitleScreen(
title: TitleRecord,
playerReady: Boolean,
onPlay: (List<EpisodeRecord>, Int) -> Unit,
onBack: () -> Unit,
onPreferenceError: (Throwable) -> Unit,
onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues,
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
) {
val context = LocalContext.current
var showMatch by remember(title.key) { mutableStateOf(false) }
var overviewOpen by remember(title.key) { mutableStateOf(false) }
// Predictive back: the page shrinks towards the library as the gesture
// is dragged, and springs back if it is let go of, so the viewer sees
// where back goes before committing to it.
var backProgress by remember { mutableFloatStateOf(0f) }
PredictiveBackHandler { gesture ->
try {
gesture.collect { backProgress = it.progress }
onBack()
} catch (cancelled: CancellationException) {
backProgress = 0f
throw cancelled
}
}
// Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
val nextUp = remember(playlist) { nextUpIndex(playlist) }
val upNext = playlist.getOrNull(nextUp)
// One season on screen at a time, opened on the one Play would land in: a
// forty-episode show as stacked sections is one long scroll to the part
// that matters.
var seasonIndex by remember(title.key) {
mutableStateOf(
title.seasons.indexOfFirst { season -> season.episodes.any { it.linkId == upNext?.linkId } }
.coerceAtLeast(0),
)
}
val season = title.seasons.getOrNull(seasonIndex)
// Live download state per episode, so a row can show progress and be paused
// without a trip to the Downloads tab.
val work by WorkManager.getInstance(context)
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
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(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(key = "backdrop") {
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
1f to MaterialTheme.colorScheme.background,
),
),
)
IconButton(
onClick = onBack,
modifier = Modifier
.statusBarsPadding()
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
}
item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
// The other names it goes by: the share's when the shown one
// is the provider's, and the original-language one.
listOfNotNull(
title.name.takeIf { it != title.displayName },
title.originalName?.takeIf { it != title.displayName && it != title.canonicalName },
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
listOfNotNull(
title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) },
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
// Who made it, and whether more is coming — the desktop's
// line of facts, on a line of its own so neither wraps.
listOfNotNull(
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
title.directors.firstOrNull()?.let { "dir. $it" },
airingNote(title),
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
// The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which
// episode of forty it meant.
AccentButton(
onClick = { onPlay(playlist, nextUp) },
enabled = playerReady && playlist.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
when {
!playerReady -> "Player loading…"
upNext == null -> "Play"
upNext.resumeAt != null -> "Resume ${upNext.label}"
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
playlist.size == 1 -> "Play"
else -> "Play ${upNext.label}"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Everything else as labelled icons in one row: four actions of
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
onPlay(playlist, 0)
}
}
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
DownloadCoordinator.enqueue(context, playlist)
}
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes
// to check that the thing the app matched is what they have.
title.externalUrl?.let { url ->
TitleAction(
Icons.AutoMirrored.Filled.OpenInNew,
title.metadataProvider?.displayName() ?: "Provider",
Modifier.weight(1f),
) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}
}
}
title.overview?.let { overview ->
// Three lines, then a tap for the rest: a synopsis with its
// source notes ran to a screen and a half on a phone.
Text(
overview.trim(),
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 12.dp)
.clickable { overviewOpen = !overviewOpen },
)
}
// Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it
// is filed as.
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
Text(
(title.genres + title.tags.take(4)).joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
}
// Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
if (franchise.size > 1) {
item(key = "franchise") {
Column(Modifier.padding(top = 12.dp)) {
Text(
"In this franchise",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
items(franchise, key = { it.key }) { member ->
FranchiseTile(member, here = member.key == title.key) { onOpenTitle(member) }
}
}
}
}
}
item(key = "seasons") {
Row(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
}
}
season?.let {
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
}
}
}
}
season?.let { shown ->
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
Box(Modifier.padding(horizontal = side)) {
EpisodeRow(
episode = episode,
numbering = episode.numbering(shown.number, position),
position = position,
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
}
}
}
}
}
if (showMatch) {
ChangeMatchDialog(
title = title,
onDismiss = { showMatch = false },
onChanged = { showMatch = false; onMetadataChanged() },
onError = onPreferenceError,
)
}
}
/**
* One part of the franchise: its cover, what it is and when. This title's own
* is marked rather than left out, so the row says where it sits in the story.
*/
@Composable
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
Column(
Modifier
.width(96.dp)
.clip(MaterialTheme.shapes.medium)
.clickable(enabled = !here, onClick = onClick),
) {
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.displayName,
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium)
.then(
if (here) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium)
} else {
Modifier
},
),
fallback = title.thumbnailSource,
)
Text(
title.displayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (here) FontWeight.SemiBold else null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
listOfNotNull(
title.formatLabel ?: if (title.kind == TitleType.FILM) "Film" else "Series",
(title.metadataYear ?: title.year)?.toString(),
).joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
/**
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
* no date yet, else nothing.
*/
private fun airingNote(title: TitleRecord): String? {
val at = title.nextAiringAt
val episode = title.nextEpisode
if (at != null && episode != null) {
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
}
return if (title.airing) "airing" else null
}
/** How wide the page's text and controls run on a window wider than a phone. */
private val READABLE_WIDTH = 720.dp
/** One of the title's secondary actions: an icon with its name under it. */
@Composable
private fun TitleAction(
icon: ImageVector,
label: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Column(
modifier
.clip(MaterialTheme.shapes.medium)
.clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(icon, contentDescription = null)
Text(
label,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
/**
* `S03E38`, `E38`, or the row's place in the season.
*
* Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's
* fallback to the season folder's number. The last case is deliberately *not*
* printed as an episode number — nothing in the name numbered this file, and
* `#4` says "fourth in the list" where `E04` would be a claim about the show.
* Some ordinal is required regardless: a season of rows that all read the same
* is a season the viewer cannot navigate at all.
*/
private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String {
val season = this.season ?: fallbackSeason
val number = this.number
return when {
season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt())
number != null -> "E%02d".format(number.toInt())
else -> "#${position + 1}"
}
}
/**
* One episode: play it, see where it was left, and drive its download.
*
* The row *is* the play button — the still, the number and the name are one
* target, which is what lets the trailing controls be two icons in a fixed
* column rather than a second row of buttons under every episode.
*/
@Composable
private fun EpisodeRow(
episode: EpisodeRecord,
numbering: String,
position: Int,
download: RetainedDownload?,
playerReady: Boolean,
onPlay: () -> Unit,
onDownload: () -> Unit,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onSetWatched: (Boolean) -> Unit,
) {
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED
val heading = episode.heading(position)
Column(
Modifier
.fillMaxWidth()
.clickable(enabled = playerReady, onClick = onPlay)
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// With metadata off the provider has no still, and Proton's own
// thumbnail is a frame of this very episode. Where it was left sits
// on the still's lower edge, the way every streaming app shows it.
Box(
Modifier
.width(128.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small),
contentAlignment = Alignment.Center,
) {
RemoteArtwork(
episode.stillUrl,
heading,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
labelled = false,
)
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
// Seen episodes stay legible but stop competing with the
// one the viewer has not watched yet.
color = if (episode.watched) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
listOfNotNull(
numbering,
episode.size?.let(::formatBytes),
episode.airDate,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
// Watched is a judgement the viewer is allowed to overrule: a
// half-watched episode they are done with, or one the 90 % rule
// marked seen because they sat through the credits.
IconButton(onClick = { onSetWatched(!episode.watched) }) {
Icon(
if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
tint = if (episode.watched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
)
}
// One slot, always the same width, whatever state the download
// is in — including "there is nothing to say", where an empty
// box keeps the rows above and below it aligned.
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
when {
episode.offline -> Icon(
Icons.Default.DownloadDone,
contentDescription = "Saved offline",
tint = MaterialTheme.colorScheme.tertiary,
)
running -> IconButton(onClick = { onPause(download!!) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
download != null -> IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.Download, contentDescription = "Resume download")
}
else -> IconButton(onClick = onDownload) {
Icon(Icons.Default.Download, contentDescription = "Download")
}
}
}
}
episode.providerOverview?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
}
download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
AccentProgress(
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
fill = solid(MaterialTheme.colorScheme.tertiary),
)
Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " +
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
internal fun ChangeMatchDialog(
title: TitleRecord,
onDismiss: () -> Unit,
onChanged: () -> Unit,
onError: (Throwable) -> Unit,
) {
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
var searching by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Change match") },
text = {
Column {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
searching = true
scope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) }
}.onSuccess { options = it }.onFailure(onError)
searching = false
}
}) { Text(if (searching) "Searching…" else "Search") }
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
EdgedButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError)
}
},
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) {
Column(Modifier.fillMaxWidth()) {
Text(option.name, fontWeight = FontWeight.SemiBold)
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
}
}
}
}
}
},
confirmButton = {
QuietButton(onClick = {
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget match") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
)
}
@@ -168,11 +168,21 @@ internal fun AccentProgress(
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier, buffered: Float = 0f) {
val brush = accentBrush()
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
// What is already read ahead, lighter than the rest and under the
// played part: a jump that lands in it costs no fetch.
val ahead = size.width * buffered.coerceIn(0f, 1f)
if (ahead >= size.height) {
drawRoundRect(
color = Color.White.copy(alpha = 0.55f),
size = Size(ahead, size.height),
cornerRadius = radius,
)
}
val done = size.width * state.coercedValueAsFraction
if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
@@ -29,7 +29,7 @@ internal val ProtonShapes = Shapes(
/** The radius `pstr-app` draws everything at. */
internal const val CORNER = 8f
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
internal const val CORNER_TIGHT = 4f
/** A bottom sheet, which has no desktop counterpart. */
@@ -2,9 +2,28 @@ package io.narl.protonstream.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import io.narl.protonstream.R
import androidx.compose.ui.unit.sp
/**
* Inter, the desktop client's face, from the same two files.
*
* Only Regular and SemiBold ship, as on the desktop. A `Medium` role resolves
* to Regular by the usual weight matching rather than to a synthesised bold,
* which is how the desktop draws it too.
*
* Above [ProtonTypography] on purpose: top-level properties initialise in
* file order, and a ramp built first would capture this as null and fall back
* to the system face without a word.
*/
internal val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_semibold, FontWeight.SemiBold),
)
/**
* The type ramp, which is the desktop client's type ramp.
*
@@ -72,4 +91,5 @@ private fun role(size: Float, weight: FontWeight) = TextStyle(
fontSize = size.sp,
lineHeight = (size * 1.35f).sp,
fontWeight = weight,
fontFamily = Inter,
)
@@ -0,0 +1,20 @@
<!--
The launcher tile: packaging/io.narl.proton-stream.svg's face gradient, on
the full 108dp canvas because the launcher cuts its own shape out of it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="0" android:startY="0"
android:endX="108" android:endY="108">
<item android:offset="0" android:color="#FFA48BFF" />
<item android:offset="0.55" android:color="#FF7552FF" />
<item android:offset="1" android:color="#FF5B36F2" />
</gradient>
</aapt:attr>
</path>
</vector>
@@ -1,6 +1,34 @@
<!--
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
revision is fetched in. The paths are in the SVG's 128-unit space; the group
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
It centres x=70, between the glyph's centroid (66) and its box (73): the
SVG's own centre, 64, leaves it sitting visibly right in a round mask.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" />
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" />
<group
android:pivotX="70" android:pivotY="62"
android:scaleX="0.6923" android:scaleY="0.6923"
android:translateX="-16" android:translateY="-8">
<group android:translateY="3">
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path android:fillColor="#4D2E1596" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
</group>
<group>
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path 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">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="0" android:startY="36"
android:endX="0" android:endY="88">
<item android:offset="0" android:color="#FFFFFFFF" />
<item android:offset="1" android:color="#FFECE6FF" />
</gradient>
</aapt:attr>
</path>
</group>
</group>
</vector>
@@ -0,0 +1,12 @@
<!-- The foreground's glyph as one flat shape, for themed launcher icons. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<group
android:pivotX="70" android:pivotY="62"
android:scaleX="0.6923" android:scaleY="0.6923"
android:translateX="-16" android:translateY="-8">
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
</group>
</vector>
@@ -1,13 +1,16 @@
<!--
The status-bar glyph. Android tints small icons to a single colour and drops
everything that is not opaque, so this is the launcher mark reduced to one
white silhouette rather than the two-tone drawable.
everything that is not opaque, so this is the launcher's sliced play mark as
one white silhouette, scaled from the SVG's 128-unit space onto 24dp.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
<group
android:pivotX="70" android:pivotY="62"
android:scaleX="0.37" android:scaleY="0.37"
android:translateX="-58" android:translateY="-50">
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
</group>
</vector>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
+7 -1
View File
@@ -1,3 +1,9 @@
<resources>
<string name="app_name">proton-stream</string>
<!-- Settings, recents, notifications and the share sheet: room for the full name. -->
<string name="app_name">Proton Stream</string>
<!--
Under the launcher icon, where about ten characters fit before the
launcher cuts the label off; "proton-stream" showed as "proton-st…".
-->
<string name="launcher_name">Stream</string>
</resources>
@@ -0,0 +1,37 @@
package io.narl.protonstream
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class IncomingShareLinkTest {
private val link = "https://drive.proton.me/urls/ABC123#s3cr3t"
@Test
fun `a viewed share link is taken whole, fragment included`() {
assertEquals(link, shareLinkFrom(Intent.ACTION_VIEW, link, null))
}
@Test
fun `a viewed url that is not an https share link is ignored`() {
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "https://drive.proton.me/u/0/", null))
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "http://drive.proton.me/urls/ABC123#s3cr3t", null))
}
@Test
fun `a shared message yields the link inside it`() {
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "Season two is up: $link enjoy"))
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "(<$link>)"))
}
@Test
fun `a shared message without a link yields nothing`() {
assertNull(shareLinkFrom(Intent.ACTION_SEND, null, "no link here"))
}
@Test
fun `other actions are ignored`() {
assertNull(shareLinkFrom(Intent.ACTION_MAIN, link, link))
}
}
@@ -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,58 @@
package io.narl.protonstream.ui
import java.time.LocalDate
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
class HistoryTest {
private fun episode(link: String, watched: Boolean, progress: Double?, lastPlayed: Long) = EpisodeRecord(
shareId = "s", volumeId = "v", linkId = link, name = "$link.mkv", label = link, detail = "$link.mkv",
season = 1u, number = null, size = null, progress = progress, resumeAt = progress?.let { it * 1000 },
watched = watched, offline = false, providerName = null, providerOverview = null, stillUrl = null,
airDate = null, lastPlayed = lastPlayed,
)
private fun title(key: String, vararg episodes: EpisodeRecord) = TitleRecord(
key = key, name = key, year = null, kind = TitleType.SERIES, watchedCount = 0uL,
episodeCount = episodes.size.toULong(), canonicalName = null, originalName = null, overview = null,
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())),
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
franchise = emptyList(),
)
@Test
fun `history is every started or finished episode, newest first, across titles`() {
val titles = listOf(
title(
"a",
episode("a1", watched = true, progress = 1.0, lastPlayed = 30),
// Marked unwatched again: a row at zero, which is not a play.
episode("a2", watched = false, progress = 0.0, lastPlayed = 40),
episode("a3", watched = false, progress = null, lastPlayed = 0),
),
title("b", episode("b1", watched = false, progress = 0.4, lastPlayed = 50)),
)
val played = history(titles)
assertEquals(listOf("b1", "a1"), played.map { it.episode.linkId })
assertEquals(0, played.last().index)
}
@Test
fun `days are headed relative to today, then by date`() {
val today = LocalDate.of(2026, 10, 3) // a Saturday
val english = Locale.ENGLISH
assertEquals("Today", dayHeading(today, today, english))
assertEquals("Yesterday", dayHeading(today.minusDays(1), today, english))
assertEquals("Monday", dayHeading(LocalDate.of(2026, 9, 28), today, english))
assertEquals("26 September", dayHeading(LocalDate.of(2026, 9, 26), today, english))
assertEquals("3 October 2025", dayHeading(LocalDate.of(2025, 10, 3), today, english))
}
}
@@ -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)) }
}
}
@@ -0,0 +1,316 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import androidx.test.core.app.ApplicationProvider
import androidx.work.testing.WorkManagerTestInitHelper
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonFallback
import io.narl.protonstream.ui.theme.ProtonStreamTheme
import java.time.LocalDate
import java.time.ZoneOffset
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
/**
* Every screen, drawn from fixed records, against a committed image.
*
* The screens are about to be redesigned one by one, and a layout regression —
* a row that wraps, a button pushed off the edge — compiles and passes every
* other test. Here it is a changed PNG in review. No artwork is loaded: the
* bridge is not available on the host, so every tile shows its generated
* placeholder, which is also what a title without metadata looks like on a
* phone.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
class ScreenshotTest {
@get:Rule val compose = createComposeRule()
@Before
fun setUp() {
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
// The palette is process-wide and the sandbox outlives a test, so a
// light capture would otherwise repaint every one after it.
AppearanceState.apply(ProtonFallback)
}
private fun captureLight(name: String, content: @Composable () -> Unit) {
AppearanceState.apply(LATTE)
capture(name, content)
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
ProtonStreamTheme {
CompositionLocalProvider(LocalArtworkLoading provides false) {
Surface(color = MaterialTheme.colorScheme.background) { content() }
}
}
}
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
}
@Test
fun `the library on a phone`() = capture("library-phone") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `a title in the light palette`() = captureLight("title-phone-light") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `a title on a tablet`() = capture("title-tablet") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the history page on a tablet`() = capture("history-tablet") {
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
}
@Test
fun `the library at a large font scale`() = capture("library-phone-large-font") {
// Robolectric has no font-scale qualifier; the density is what Compose
// reads it from, so the scale is set there.
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
}
@Test
fun `an empty library`() = capture("library-empty") {
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library while the catalog loads`() = capture("library-loading") {
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `a title with seasons`() = capture("title-phone") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
}
@Test
fun `the shares page`() = capture("shares-phone") {
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
}
@Test
fun `the settings page`() = capture("settings-phone") {
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
fun `the history page`() = capture("history-phone") {
// Fixed day and zone: "Today" and "Yesterday" are relative, and an
// image that changes with the date is no baseline.
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
}
@Test
fun `the downloads page with a season saved`() = capture("downloads-phone") {
val saved = SERIES.seasons.first().episodes.take(2).map {
OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it)
}
// One title: the fixtures reuse link ids across titles, so with the
// whole library the episodes would be filed under whichever came last.
DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues())
}
@Test
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
}
private companion object {
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
val LATTE = PaletteRecord(
background = 0xFFEFF1F5u,
surface = 0xFFE6E9EFu,
sunken = 0xFFDCE0E8u,
card = 0xFFCCD0DAu,
cardHover = 0xFFBCC0CCu,
border = 0xFFBCC0CCu,
text = 0xFF4C4F69u,
muted = 0xFF6C6F85u,
accent = 0xFF732FCCu,
accentAlt = 0xFF1856D1u,
accentDim = 0xFFC4BBE4u,
onAccent = 0xFFEFF1F5u,
danger = 0xFFD20F39u,
elevated = 0xFFAAAEBDu,
dangerDim = 0xFFE7D1D5u,
light = true,
)
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
// `detail` is the filename unless the filename named the episode,
// which is what the bridge sends.
val file = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt())
return episodeRecord(season, number, file, watched, progress)
}
fun episodeRecord(season: UInt, number: UInt, file: String, watched: Boolean, progress: Double?) = EpisodeRecord(
shareId = "share",
volumeId = "volume",
linkId = "s${season}e$number",
name = file,
label = "S%02dE%02d".format(season.toInt(), number.toInt()),
detail = file,
season = season,
number = number,
size = 1_300_000_000uL,
progress = progress,
resumeAt = progress?.let { it * 1440 },
watched = watched,
offline = false,
providerName = if (number == 1u) "Mother and Children" else null,
providerOverview = null,
stillUrl = null,
airDate = null,
// 2026-09-21 for the part-watched one, the day before for the rest.
lastPlayed = when {
progress != null -> 1_790_000_000L
watched -> 1_789_900_000L - number.toLong() * 600
else -> 0L
},
)
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
key = key,
name = name,
year = 2023u,
kind = TitleType.SERIES,
watchedCount = seasons.sumOf { s -> s.episodes.count { it.watched } }.toULong(),
episodeCount = seasons.sumOf { it.episodes.size }.toULong(),
canonicalName = null,
originalName = null,
overview = overview,
metadataProvider = null,
metadataId = null,
metadataYear = null,
metadataKind = null,
posterUrl = null,
backdropUrl = null,
rating = 8.4,
genres = listOf("Drama", "Mystery"),
providerEpisodeCount = null,
externalUrl = null,
manualMatch = false,
seasons = seasons,
displayName = name,
wideUrl = null,
formatLabel = null,
seasonLabel = null,
studios = emptyList(),
directors = emptyList(),
tags = emptyList(),
airing = false,
nextEpisode = null,
nextAiringAt = null,
franchise = emptyList(),
)
val SERIES = title(
"oshi-no-ko",
"Oshi no Ko",
listOf(
SeasonRecord(1u, "Season 1", (1u..4u).map { episode(1u, it, watched = it < 3u) }),
SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }),
),
overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.",
).copy(
formatLabel = "TV",
seasonLabel = "Spring 2023",
studios = listOf("Doga Kobo"),
directors = listOf("Daisuke Hiramaki"),
tags = listOf("Idol", "Reincarnation"),
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
)
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
val SEQUEL = title("oshi-no-ko-film", "Oshi no Ko: The Final Act", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 2024u, formatLabel = "Film", franchise = SERIES.franchise)
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 1988u)
val LIBRARY = AppUiState(
loading = false,
titles = listOf(
FILM,
SERIES,
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
),
// Bleach stands for one more title of its franchise, and one shelf
// sits above the grid: what the bridge's arrangement would give.
arrangement = ArrangementRecord(
tiles = listOf("akira", "ano-hana", "bleach", "oshi-no-ko").map {
TileRecord(it, if (it == "bleach") 1u else 0u)
},
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
),
shares = listOf(
ShareRecord("anime", "anime", true),
ShareRecord("films", "films", false),
),
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+5
View File
@@ -14,6 +14,7 @@ androidx-test-core = "1.7.0"
androidx-test-junit = "1.3.0"
androidx-test-runner = "1.7.0"
ktlint-plugin = "13.1.0"
roborazzi = "1.76.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
@@ -41,9 +42,13 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
+430 -99
View File
@@ -11,12 +11,13 @@ use std::sync::{Arc, OnceLock};
use parking_lot::Mutex;
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
use pstr_core::browse::Sort;
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
use pstr_core::chapters::{Chapter, ChapterRole};
use pstr_core::config::AppDirs;
use pstr_core::library::{Episode, Library, Title, TitleKind};
use pstr_core::metadata::{
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
};
use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType};
@@ -175,6 +176,55 @@ pub struct AppearanceRecord {
pub flavor: FlavorChoice,
pub accent: AccentChoice,
pub gradients: bool,
/// Which name a title goes by. Shared with the desktop.
pub names: TitleNamesChoice,
}
/// The shared `pstr_core::metadata::TitleNames`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum TitleNamesChoice {
/// The share's folder name.
Library,
English,
Romaji,
}
/// The shared `pstr_core::browse::Sort`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibrarySort {
Name,
Recent,
Added,
Release,
Rating,
Popularity,
}
/// One tile of the library grid.
#[derive(Debug, Clone, uniffi::Record)]
pub struct TileRecord {
/// The title drawn.
pub key: String,
/// How many other titles of its franchise it stands for.
pub folded: u32,
}
/// One row of titles that share a director, studio or genre.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ShelfRecord {
pub label: String,
pub keys: Vec<String>,
}
/// The library grid, ordered and folded, and the shelves above it — what
/// `pstr_core::browse` and `pstr_core::shelves` make of the library, as keys
/// into [`AndroidEngine::library`]'s titles.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ArrangementRecord {
pub tiles: Vec<TileRecord>,
/// Empty while searching: a shelf of titles the search did not find reads
/// as the search not working.
pub shelves: Vec<ShelfRecord>,
}
/// One flavour and accent resolved into colours, packed as `0xAARRGGBB`.
@@ -264,6 +314,10 @@ pub struct MatchRecord {
pub genres: Vec<String>,
pub episode_count: Option<u32>,
pub external_url: Option<String>,
/// `TitleDetails` as JSON, opaque to Kotlin: carried so that a match
/// picked by hand keeps the studios, relations and dates search found,
/// rather than losing them on the way back across the bridge.
pub details: Option<String>,
}
#[derive(Debug, Clone, uniffi::Record)]
@@ -323,6 +377,26 @@ pub struct TitleRecord {
pub external_url: Option<String>,
pub manual_match: bool,
pub seasons: Vec<SeasonRecord>,
/// The name to show, as the viewer's name setting has it.
pub display_name: String,
/// The widest art there is: fanart, else the provider's banner.
pub wide_url: Option<String>,
/// `TV`, `Film`, `OVA` — the provider's format, said for a person.
pub format_label: Option<String>,
/// `Spring 2013`.
pub season_label: Option<String>,
pub studios: Vec<String>,
pub directors: Vec<String>,
pub tags: Vec<String>,
/// Still airing.
pub airing: bool,
/// The next episode to air and when, Unix seconds — only while that is
/// still ahead.
pub next_episode: Option<u32>,
pub next_airing_at: Option<i64>,
/// Every title of its franchise, in release order, its own key included;
/// empty for a title that stands alone.
pub franchise: Vec<String>,
}
/// Playback preferences that outlive one file, one title and one launch.
@@ -717,7 +791,6 @@ struct Connection {
generation: u64,
library: Arc<SharedLibrary>,
open_failures: Vec<String>,
#[allow(dead_code)]
source: StreamSource,
}
@@ -754,6 +827,9 @@ pub struct AndroidEngine {
/// between one episode and the next, so age — not just a share-store
/// mutation — is a reason to reauthenticate before opening anything.
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
/// The streaming cache's budget in bytes, for the next connection and,
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
cache_budget: AtomicU64,
}
/// How long a visitor session is assumed good for without a refresh.
@@ -798,6 +874,7 @@ impl AndroidEngine {
share_generation: AtomicU64::new(0),
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
session_refreshed: Mutex::new(HashMap::new()),
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
}))
}
@@ -945,23 +1022,70 @@ impl AndroidEngine {
/// table scans, a `Library::build` and a full record conversion per
/// keystroke.
pub fn library(&self, search: Option<String>) -> Result<Vec<TitleRecord>, BridgeError> {
let titles = self.all_title_records()?;
let needle = search.unwrap_or_default().trim().to_lowercase();
if needle.is_empty() {
return Ok(titles);
}
Ok(titles
.into_iter()
.filter(|title| {
title.name.to_lowercase().contains(&needle)
|| title.seasons.iter().any(|season| {
season
.episodes
.iter()
.any(|episode| episode.name.to_lowercase().contains(&needle))
let query = search.unwrap_or_default();
self.with_library(|cached| {
if query.trim().is_empty() {
return cached.titles.clone();
}
let found: std::collections::HashSet<&str> =
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.into_iter()
.map(|title| title.key.as_str())
.collect();
cached
.titles
.iter()
.filter(|title| found.contains(title.key.as_str()))
.cloned()
.collect()
})
}
/// The library grid for `search`, in `sort` order, each franchise folded
/// into one tile when `grouped` — and the shelves above it. Keys into
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge
/// once.
pub fn arrangement(
&self,
search: Option<String>,
sort: LibrarySort,
grouped: bool,
) -> Result<ArrangementRecord, BridgeError> {
let query = search.unwrap_or_default();
self.with_library(|cached| {
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query);
let tiles = pstr_core::browse::arrange(
&titles,
pstr_core::browse::Order {
sort: sort_choice(sort),
grouped,
names: cached.names,
metadata: &cached.metadata,
added: &cached.added,
},
);
let shelves = if query.trim().is_empty() {
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
.into_iter()
.map(|shelf| ShelfRecord {
label: shelf.label,
keys: shelf.keys,
})
})
.collect())
.collect()
} else {
Vec::new()
};
ArrangementRecord {
tiles: tiles
.iter()
.map(|tile| TileRecord {
key: titles[tile.lead].key.clone(),
folded: (tile.members.len() - 1) as u32,
})
.collect(),
shelves,
}
})
}
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
@@ -1127,13 +1251,27 @@ impl AndroidEngine {
title_key: String,
preferences: TrackPreferencesRecord,
) -> Result<(), BridgeError> {
let catalog = self.catalog.lock();
let existing = catalog
.title_track_prefs(&title_key)
.map_err(BridgeError::from_display)?
.unwrap_or_default();
let audio_language = normalized_language(preferences.audio_language);
let subtitle_language = normalized_language(preferences.subtitle_language);
// This bridge carries languages only. A track title stored beside one
// stays for as long as the language it was picked under does.
let preferences = TitleTrackPrefs {
audio_language: normalized_language(preferences.audio_language),
subtitle_language: normalized_language(preferences.subtitle_language),
audio_title: existing
.audio_title
.filter(|_| existing.audio_language == audio_language),
subtitle_title: existing
.subtitle_title
.filter(|_| existing.subtitle_language == subtitle_language),
audio_language,
subtitle_language,
subtitles: preferences.subtitles,
};
self.catalog
.lock()
catalog
.set_title_track_prefs(&title_key, &preferences)
.map_err(BridgeError::from_display)
}
@@ -1163,8 +1301,14 @@ impl AndroidEngine {
}
pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> {
pstr_core::appearance::save(&self.dirs, &appearance_choice(appearance))
.map_err(BridgeError::from_display)
// The record has no grid shape — Android's is always covers — so the
// desktop's is kept as it was stored.
let stored = pstr_core::appearance::load(&self.dirs).unwrap_or_default();
let appearance = Appearance {
tiles: stored.tiles,
..appearance_choice(appearance)
};
pstr_core::appearance::save(&self.dirs, &appearance).map_err(BridgeError::from_display)
}
/// The colours the stored choice resolves to.
@@ -1265,6 +1409,22 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)?
}
/// Set how much the streaming cache may hold. An open connection's cache
/// evicts down to it at once; a lower budget is a request for the space
/// back, not only a limit on what comes next.
pub fn set_stream_cache_budget(&self, bytes: u64) {
self.cache_budget.store(bytes, Ordering::Release);
let source = self
.connection
.lock()
.as_ref()
.map(|open| open.source.clone());
if let Some(source) = source {
self.runtime
.spawn(async move { source.set_disk_budget(bytes).await });
}
}
/// Drop the cached blocks. Rebuildable by definition: this is the one thing
/// here that can be reclaimed without losing something the viewer chose.
pub fn clear_block_cache(&self) -> Result<u64, BridgeError> {
@@ -1387,44 +1547,29 @@ impl AndroidEngine {
impl AndroidEngine {
/// One enrichment pass over the whole library.
///
/// Two kinds of work, and the difference is a request saved: a title that
/// already has a good match but no episode list needs the episode request
/// only. Both run fanned out under a semaphore, and a provider error counts
/// against the run rather than ending it — one rate-limited title must not
/// leave the other thirty unenriched. This mirrors the desktop engine's
/// `run_match`, which is the reference for the behaviour.
/// What to do per title is decided by `pstr_meta::service::plan`, the same
/// as on the desktop. The work runs fanned out under a semaphore, and a
/// provider error counts against the run rather than ending it — one
/// rate-limited title must not leave the other thirty unenriched. This
/// mirrors the desktop engine's `run_match`, which is the reference for the
/// behaviour.
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
let service = Arc::new(self.metadata_service()?);
let provider = service.provider();
let (titles, stored) = self.titles_and_metadata()?;
let listed = self
let enriched = self
.catalog
.lock()
.episode_metadata_ages()
.enrichment_ages()
.map_err(BridgeError::from_display)?;
let mut pending: Vec<Work> = Vec::new();
for title in titles {
let record = stored.get(&title.key);
// A hand-picked entry is never searched for again, not even by
// "match again" — that button means "the automatic answers are
// wrong", and re-deciding the one title the viewer already fixed by
// hand is the opposite of what they asked for. Its episode list is
// still fetched below if it is missing.
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
pending.push(Work::Match(title));
continue;
}
let has_episodes = listed
.get(&title.key)
.is_some_and(|(asked, _)| *asked == provider);
if let Some(found) = record.and_then(|record| record.metadata.clone())
&& !has_episodes
{
pending.push(Work::Episodes(title, Box::new(found)));
}
}
let pending = pstr_meta::service::plan(
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
let mut lookups = tokio::task::JoinSet::new();
@@ -1433,32 +1578,15 @@ impl AndroidEngine {
let permits = Arc::clone(&permits);
lookups.spawn(async move {
let _permit = permits.acquire().await.ok()?;
Some(match work {
Work::Match(title) => match service.record(&title).await {
Ok(record) => {
// Only for a title that matched: there is no id to
// ask about otherwise.
let episodes = match &record.metadata {
Some(found) => service.title_episodes(&title, found).await,
None => Vec::new(),
};
Ok((record.title_key.clone(), Some(record), episodes))
}
Err(error) => Err(error.to_string()),
},
Work::Episodes(title, found) => {
let episodes = service.title_episodes(&title, &found).await;
Ok((title.key, None, episodes))
}
})
Some(service.work(&work).await.map_err(|error| error.to_string()))
});
}
let mut summary = MatchSummary::default();
while let Some(joined) = lookups.join_next().await {
match joined.map_err(BridgeError::from_display)? {
Some(Ok((title_key, record, episodes))) => {
if let Some(record) = &record {
Some(Ok(outcome)) => {
if let Some(record) = &outcome.record {
if record.metadata.is_some() {
summary.matched += 1;
} else {
@@ -1469,16 +1597,16 @@ impl AndroidEngine {
log::warn!("store metadata for {}: {error}", record.title_key);
}
}
if !episodes.is_empty() {
summary.episodes += episodes.len() as u32;
let stored = self.catalog.lock().set_episode_metadata(
&title_key,
if let Some(enrichment) = &outcome.enrichment {
summary.episodes += enrichment.episodes.len() as u32;
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
provider,
now(),
&episodes,
enrichment,
);
if let Err(error) = stored {
log::warn!("store episodes for {title_key}: {error}");
log::warn!("store enrichment for {}: {error}", outcome.title_key);
}
}
}
@@ -1506,13 +1634,19 @@ impl AndroidEngine {
}
/// Every title as a bridge record, rebuilt only when the catalog moved.
fn all_title_records(&self) -> Result<Vec<TitleRecord>, BridgeError> {
/// Run `read` over the library, its metadata and its records, rebuilt
/// only when the catalog or the name setting has changed since.
fn with_library<T>(&self, read: impl FnOnce(&CachedLibrary) -> T) -> Result<T, BridgeError> {
let names = pstr_core::appearance::load(&self.dirs)
.unwrap_or_default()
.names;
let catalog = self.catalog.lock();
let writes = catalog.writes();
if let Some(cached) = self.library_cache.lock().as_ref()
&& cached.writes == writes
&& cached.names == names
{
return Ok(cached.titles.clone());
return Ok(read(cached));
}
let files = catalog.all_files().map_err(BridgeError::from_display)?;
@@ -1526,26 +1660,61 @@ impl AndroidEngine {
let offline = catalog
.all_offline_files()
.map_err(BridgeError::from_display)?;
let first_seen = catalog.first_seen().map_err(BridgeError::from_display)?;
drop(catalog);
let library = Library::build(files, &watch);
let all: Vec<&Title> = library.titles.iter().collect();
let franchises: HashMap<String, Vec<String>> = pstr_core::franchise::group(&all, &metadata)
.into_iter()
.filter(|franchise| franchise.members.len() > 1)
.flat_map(|franchise| {
let keys: Vec<String> = franchise
.members
.iter()
.map(|&index| all[index].key.clone())
.collect();
keys.clone().into_iter().map(move |key| (key, keys.clone()))
})
.collect();
let now = now();
let titles: Vec<TitleRecord> = library
.titles
.iter()
.map(|title| {
title_record(
let mut record = title_record(
title,
&offline,
metadata.get(&title.key),
guides.get(&title.key),
)
);
record.display_name =
pstr_core::browse::display_name(title, &metadata, names).to_owned();
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
if let Some(details) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.details.as_ref())
&& let Some((episode, at)) = details.next_airing(now)
{
record.next_episode = Some(episode);
record.next_airing_at = Some(at);
}
record
})
.collect();
*self.library_cache.lock() = Some(CachedLibrary {
let added = library.added_at(&first_seen);
let cached = CachedLibrary {
writes,
titles: titles.clone(),
});
Ok(titles)
names,
library,
metadata,
added,
titles,
};
let answer = read(&cached);
*self.library_cache.lock() = Some(cached);
Ok(answer)
}
fn titles_and_metadata(
@@ -1709,8 +1878,10 @@ impl AndroidEngine {
let opener = Arc::new(LibraryOpener::new(Arc::clone(&library)));
let source = StreamSource::new(
opener,
StreamConfig::default()
.with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache())),
StreamConfig::default().with_disk_cache(
DiskCacheConfig::new(self.dirs.block_cache())
.with_budget(self.cache_budget.load(Ordering::Acquire)),
),
)
.await
.map_err(BridgeError::from_display)?;
@@ -2302,17 +2473,14 @@ fn normalized_language(language: Option<String>) -> Option<String> {
/// The library conversion, valid while the catalog has not been written to.
struct CachedLibrary {
writes: u64,
names: TitleNames,
library: Library,
metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file.
added: HashMap<String, i64>,
titles: Vec<TitleRecord>,
}
/// One title's share of an enrichment pass.
enum Work {
/// Search for it, and take its episodes if it matches.
Match(Title),
/// It is already matched; only the episode list is missing.
Episodes(Title, Box<TitleMetadata>),
}
/// How many provider lookups may be in flight at once.
///
/// Deliberately small. Both providers rate-limit by client, and a phone
@@ -2364,9 +2532,29 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
Accent::Red => AccentChoice::Red,
},
gradients: appearance.gradients,
names: match appearance.names {
TitleNames::Library => TitleNamesChoice::Library,
TitleNames::English => TitleNamesChoice::English,
TitleNames::Romaji => TitleNamesChoice::Romaji,
},
}
}
fn sort_choice(sort: LibrarySort) -> Sort {
match sort {
LibrarySort::Name => Sort::Name,
LibrarySort::Recent => Sort::Recent,
LibrarySort::Added => Sort::Added,
LibrarySort::Release => Sort::Release,
LibrarySort::Rating => Sort::Rating,
LibrarySort::Popularity => Sort::Popularity,
}
}
/// How many shelves the library shows above the grid — fewer than the
/// desktop's four, because on a phone each is a screen's height.
const SHELVES: usize = 3;
fn appearance_choice(record: AppearanceRecord) -> Appearance {
Appearance {
flavor: match record.flavor {
@@ -2389,6 +2577,12 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
AccentChoice::Red => Accent::Red,
},
gradients: record.gradients,
names: match record.names {
TitleNamesChoice::Library => TitleNames::Library,
TitleNamesChoice::English => TitleNames::English,
TitleNamesChoice::Romaji => TitleNames::Romaji,
},
..Appearance::default()
}
}
@@ -2462,6 +2656,7 @@ fn title_record(
guide: Option<&EpisodeGuide>,
) -> TitleRecord {
let metadata = record.and_then(|record| record.metadata.as_ref());
let details = metadata.and_then(|found| found.details.as_ref());
TitleRecord {
key: title.key.clone(),
name: title.name.clone(),
@@ -2486,6 +2681,17 @@ fn title_record(
provider_episode_count: metadata.and_then(|metadata| metadata.episodes),
external_url: metadata.and_then(|metadata| metadata.url.clone()),
manual_match: record.is_some_and(|record| record.manual),
display_name: metadata.map_or_else(|| title.name.clone(), |found| found.name.clone()),
wide_url: metadata.and_then(|found| found.wide_art().map(str::to_owned)),
format_label: details.and_then(|details| details.format_label().map(str::to_owned)),
season_label: details.and_then(|details| details.season_label()),
studios: details.map_or_else(Vec::new, |details| details.studios.clone()),
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
airing: details.is_some_and(|details| details.is_airing()),
next_episode: None,
next_airing_at: None,
franchise: Vec::new(),
seasons: title
.seasons
.iter()
@@ -2545,6 +2751,10 @@ fn match_record(metadata: TitleMetadata) -> MatchRecord {
genres: metadata.genres,
episode_count: metadata.episodes,
external_url: metadata.url,
details: metadata
.details
.as_ref()
.and_then(|details| serde_json::to_string(details).ok()),
}
}
@@ -2563,6 +2773,10 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata {
genres: record.genres,
episodes: record.episode_count,
url: record.external_url,
details: record
.details
.as_deref()
.and_then(|details| serde_json::from_str(details).ok()),
}
}
@@ -2626,6 +2840,7 @@ mod tests {
flavor,
accent: AccentChoice::Mauve,
gradients: true,
names: super::TitleNamesChoice::English,
}
}
@@ -2938,6 +3153,11 @@ mod tests {
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
episodes: Some(26),
url: Some("https://anilist.co/anime/1".to_owned()),
details: Some(pstr_core::metadata::TitleDetails {
studios: vec!["Sunrise".to_owned()],
related: vec!["5".to_owned()],
..Default::default()
}),
};
assert_eq!(title_metadata(match_record(metadata.clone())), metadata);
@@ -2975,6 +3195,117 @@ mod tests {
assert_eq!(pstr_android_stream_size(handle), -1);
}
/// The arrangement folds a franchise into its series, and the name
/// setting — stored in the file the desktop reads — renames the tile
/// without anything in the catalog changing.
#[test]
fn the_library_folds_a_franchise_and_follows_the_name_setting() {
use pstr_core::catalog::CatalogNode;
use pstr_core::metadata::{MetadataRecord, TitleDetails};
let root = std::env::temp_dir().join(format!(
"pstr-android-arrangement-{}-{}",
std::process::id(),
super::now()
));
let engine = AndroidEngine::new(
AndroidPaths {
config: root.join("config").to_string_lossy().into_owned(),
data: root.join("data").to_string_lossy().into_owned(),
cache: root.join("cache").to_string_lossy().into_owned(),
},
Box::<MemorySecrets>::default(),
)
.expect("engine");
let file = |link: &str, name: &str| CatalogNode {
share_id: "s".into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.into(),
is_folder: false,
media_type: None,
size: None,
active_revision_id: None,
parsed: pstr_core::naming::parse(name),
};
let matched = |key: &str, id: &str, name: &str, kind, related: &str| MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: id.into(),
name: name.into(),
original_name: None,
overview: None,
year: None,
kind,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: Some(TitleDetails {
related: vec![related.into()],
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
};
{
let mut catalog = engine.catalog.lock();
catalog
.replace_share(
"s",
&[
file("a", "Sousou no Frieren S01E01.mkv"),
file("b", "Made in Abyss Dawn of the Deep Soul.mkv"),
],
)
.expect("crawl");
catalog
.set_metadata(&matched(
"sousou no frieren",
"1",
"Frieren",
TitleKind::Series,
"2",
))
.expect("match");
catalog
.set_metadata(&matched(
"made in abyss dawn of the deep soul",
"2",
"Dawn of the Deep Soul",
TitleKind::Film,
"1",
))
.expect("match");
}
let arranged = engine
.arrangement(None, super::LibrarySort::Name, true)
.expect("arrange");
assert_eq!(arranged.tiles.len(), 1);
assert_eq!(arranged.tiles[0].key, "sousou no frieren");
assert_eq!(arranged.tiles[0].folded, 1);
let titles = engine.library(Some("frieren".into())).expect("library");
assert_eq!(titles.len(), 1);
assert_eq!(titles[0].display_name, "Frieren");
assert_eq!(titles[0].franchise.len(), 2);
let mut appearance = engine.appearance().expect("appearance");
appearance.names = super::TitleNamesChoice::Library;
engine.set_appearance(appearance).expect("store");
let titles = engine.library(Some("frieren".into())).expect("library");
assert_eq!(titles[0].display_name, "Sousou no Frieren");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn share_mutations_cleanup_even_when_secret_deletion_fails() {
let unique = format!(
+6
View File
@@ -2,3 +2,9 @@
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
disable_java_cleaner = true
[bindings.swift]
# The iOS client (`ios/`) compiles the generated file straight into the app
# target and reaches the C side through its bridging header, so the names are
# what that header and `ios/project.yml` expect.
module_name = "PstrBridge"
+17
View File
@@ -24,8 +24,25 @@ tracing.workspace = true
tracing-subscriber.workspace = true
eframe.workspace = true
egui.workspace = true
egui-phosphor.workspace = true
image.workspace = true
sha2.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
directories.workspace = true
chrono.workspace = true
souvlaki.workspace = true
[dev-dependencies]
skrifa.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
dbus.workspace = true
[target.'cfg(windows)'.dependencies]
# Named pipes, for handing a second launch's request to the open window.
tokio = { workspace = true, features = ["net"] }
windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] }
tauri-winrt-notification.workspace = true
raw-window-handle = "0.6"
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+6 -1
View File
@@ -92,7 +92,12 @@ impl Harness {
.context("eframe was built without a glow context")?;
// SAFETY: `CreationContext` is handed to us on the UI thread with the
// context current, which is the same guarantee `App::ui` gives.
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?;
let video = unsafe {
VideoSurface::new(Arc::clone(&player), gl, {
let ctx = cc.egui_ctx.clone();
move || ctx.request_repaint()
})
}?;
Ok(Self {
player,
+1296 -120
View File
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
//! Settings only the desktop app has.
//!
//! Kept out of [`pstr_core::prefs::PlaybackPrefs`] on purpose: that one is
//! shared with Android through a bridge record, and a field Android neither
//! shows nor keeps would be reset to its default every time Android saved.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// What the settings page offers for the streaming cache, in GiB.
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
// A file from an older version, or edited by hand, missing a field: the rest
// still counts, and the missing one takes its default.
#[serde(default)]
pub struct DesktopPrefs {
/// Let mpv decode on the GPU. Off is for a driver that shows green frames
/// or nothing; it costs CPU, not correctness.
pub hardware_decoding: bool,
/// How much decrypted video the disk cache may keep.
pub cache_gib: u32,
}
impl Default for DesktopPrefs {
fn default() -> Self {
Self {
hardware_decoding: true,
cache_gib: 4,
}
}
}
impl DesktopPrefs {
pub fn cache_bytes(&self) -> u64 {
u64::from(self.cache_gib.clamp(1, 64)) << 30
}
}
/// The prefs and where they live.
pub struct DesktopPrefsFile {
path: PathBuf,
/// False when the file was there and would not parse. Such a file is never
/// overwritten — it is far more likely a bug than something to replace —
/// so changes then last only for the session.
writable: bool,
}
impl DesktopPrefsFile {
pub fn load(path: PathBuf) -> (Self, DesktopPrefs) {
match pstr_core::config::read_json::<DesktopPrefs>(&path) {
Ok(prefs) => (
Self {
path,
writable: true,
},
prefs.unwrap_or_default(),
),
Err(error) => {
tracing::warn!("read the desktop settings: {error}");
(
Self {
path,
writable: false,
},
DesktopPrefs::default(),
)
}
}
}
pub fn save(&self, prefs: &DesktopPrefs) -> pstr_core::Result<()> {
if !self.writable {
tracing::warn!(
"not saving the desktop settings over {}, which would not parse",
self.path.display()
);
return Ok(());
}
pstr_core::config::write_json(&self.path, prefs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_missing_a_field_keeps_the_rest() {
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
assert_eq!(prefs.cache_gib, 8);
assert!(prefs.hardware_decoding);
}
#[test]
fn an_unparseable_file_is_never_overwritten() {
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
std::fs::write(&path, "{ not json").unwrap();
let (file, prefs) = DesktopPrefsFile::load(path.clone());
assert_eq!(prefs, DesktopPrefs::default());
file.save(&DesktopPrefs::default()).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
let _ = std::fs::remove_file(path);
}
}
File diff suppressed because it is too large. Load diff
+185
View File
@@ -0,0 +1,185 @@
//! Keeping the screen on while a film plays.
//!
//! mpv does this itself for a window of its own (`stop-screensaver`), but here
//! the picture is drawn into the app's window through the render API, and mpv
//! has no window to hold the screensaver off for. So the app asks.
//!
//! Best effort, like the media controls: a desktop that does not answer means
//! the screen may blank during a film, which is what happened before this.
/// Holds the screensaver off while [`Inhibitor::set`] says so.
pub struct Inhibitor {
held: bool,
#[cfg(target_os = "linux")]
orders: Option<std::sync::mpsc::Sender<bool>>,
}
impl Inhibitor {
pub fn new() -> Self {
Self {
held: false,
#[cfg(target_os = "linux")]
orders: linux::spawn(),
}
}
/// Hold or release. Called every frame; acts only on a change.
pub fn set(&mut self, hold: bool) {
if hold == self.held {
return;
}
self.held = hold;
#[cfg(target_os = "linux")]
if let Some(orders) = &self.orders {
let _ = orders.send(hold);
}
#[cfg(windows)]
windows::set(hold);
}
}
impl Default for Inhibitor {
fn default() -> Self {
Self::new()
}
}
impl Drop for Inhibitor {
fn drop(&mut self) {
self.set(false);
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::sync::mpsc::{Sender, channel};
use std::time::Duration;
use dbus::Path;
use dbus::arg::PropMap;
use dbus::blocking::Connection;
const TIMEOUT: Duration = Duration::from_secs(2);
const REASON: &str = "Playing a video";
/// What is holding the screensaver off, and how to let go of it.
enum Hold {
/// The desktop portal: released by closing the request it returned.
Portal(Path<'static>),
/// The older `org.freedesktop.ScreenSaver`: released by its cookie.
ScreenSaver(u32),
}
/// A thread of its own, holding the session bus connection. The hold is
/// tied to that connection — closing it releases the hold — so it has to
/// outlive any one call, and a D-Bus call that stalls must not stall a
/// frame.
pub fn spawn() -> Option<Sender<bool>> {
let (orders, receiver) = channel::<bool>();
std::thread::Builder::new()
.name("inhibit".into())
.spawn(move || {
let connection = match Connection::new_session() {
Ok(connection) => connection,
Err(error) => {
tracing::debug!("no session bus to inhibit the screensaver on: {error}");
return;
}
};
let mut hold = None;
for wanted in receiver {
match (wanted, hold.take()) {
(true, None) => hold = acquire(&connection),
(false, Some(held)) => release(&connection, held),
(_, held) => hold = held,
}
}
if let Some(held) = hold {
release(&connection, held);
}
})
.inspect_err(|error| tracing::debug!("inhibit thread: {error}"))
.ok()?;
Some(orders)
}
fn acquire(connection: &Connection) -> Option<Hold> {
// The portal first: it is what works under Flatpak and on GNOME, which
// does not implement the screensaver interface's `Inhibit`.
let portal = connection.with_proxy(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
TIMEOUT,
);
let mut options = PropMap::new();
options.insert(
"reason".into(),
dbus::arg::Variant(Box::new(REASON.to_owned())),
);
// 8 is "idle": the screen blanking or locking for want of input.
let answer: Result<(Path<'static>,), _> = portal.method_call(
"org.freedesktop.portal.Inhibit",
"Inhibit",
("", 8u32, options),
);
match answer {
Ok((request,)) => return Some(Hold::Portal(request)),
Err(error) => tracing::debug!("portal inhibit: {error}"),
}
let screensaver = connection.with_proxy(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
TIMEOUT,
);
let answer: Result<(u32,), _> = screensaver.method_call(
"org.freedesktop.ScreenSaver",
"Inhibit",
("proton-stream", REASON),
);
match answer {
Ok((cookie,)) => Some(Hold::ScreenSaver(cookie)),
Err(error) => {
tracing::debug!("screensaver inhibit: {error}");
None
}
}
}
fn release(connection: &Connection, hold: Hold) {
let result: Result<(), dbus::Error> = match hold {
Hold::Portal(request) => connection
.with_proxy("org.freedesktop.portal.Desktop", request, TIMEOUT)
.method_call("org.freedesktop.portal.Request", "Close", ()),
Hold::ScreenSaver(cookie) => connection
.with_proxy(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
TIMEOUT,
)
.method_call("org.freedesktop.ScreenSaver", "UnInhibit", (cookie,)),
};
if let Err(error) = result {
tracing::debug!("release the screensaver: {error}");
}
}
}
#[cfg(windows)]
mod windows {
use windows_sys::Win32::System::Power::{
ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, SetThreadExecutionState,
};
pub fn set(hold: bool) {
let flags = if hold {
ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED
} else {
ES_CONTINUOUS
};
// SAFETY: takes flags by value and touches nothing of ours.
unsafe {
SetThreadExecutionState(flags);
}
}
}
+350
View File
@@ -0,0 +1,350 @@
//! One window at a time.
//!
//! A second launch hands what it was started with — a share link, or nothing
//! — to the window already open, and exits. Without this, a second click on
//! the launcher opened a second window on the same catalog: two engines
//! writing the same watch state, two crawls of the same shares, and two films
//! playing over each other.
//!
//! The channel between the two is a Unix socket in the user's runtime
//! directory, or a named pipe on Windows. Anything that goes wrong setting it
//! up is logged and the app runs on without it: a second window is a nuisance,
//! and a launch that refuses to start because of this is a bug.
use std::sync::mpsc::{Receiver, Sender, channel};
use pstr_core::config::AppDirs;
/// What a later launch asks of the window already open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Request {
/// Come to the front.
Raise,
/// Come to the front, with this share link in the Add form.
AddShare(String),
}
impl Request {
/// What a launch was started with: the first argument that is a share
/// link, so `proton-stream 'https://drive.proton.me/urls/…#…'` adds it.
pub fn from_args(args: impl IntoIterator<Item = String>) -> Self {
args.into_iter()
.map(|arg| arg.trim().to_owned())
.find(|arg| pstr_core::shares::share_token(arg).is_ok())
.map_or(Self::Raise, Self::AddShare)
}
/// One line on the wire.
fn encode(&self) -> String {
match self {
Self::Raise => "raise\n".to_owned(),
Self::AddShare(url) => format!("add {url}\n"),
}
}
/// The other end of [`Request::encode`]. Anything else is ignored rather
/// than guessed at — and a link that is not a share link is not one to
/// put in the form.
fn decode(line: &str) -> Option<Self> {
let line = line.trim();
if line == "raise" {
return Some(Self::Raise);
}
let url = line.strip_prefix("add ")?.trim();
pstr_core::shares::share_token(url)
.is_ok()
.then(|| Self::AddShare(url.to_owned()))
}
}
/// How long a launch spends handing its request over before giving up on it.
const HAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// The most a request may be. A share link is a few hundred bytes.
const MOST: u64 = 8 * 1024;
/// The outcome of [`claim`].
pub enum Claim {
/// This is the window. The listener answers later launches.
First(Listener),
/// A window was already open and has the request. This launch is done.
Handed,
}
/// Become the one window, or hand `request` to the one there is.
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
platform::claim(runtime, dirs, request)
}
/// Requests from later launches, until the process ends.
pub struct Listener {
inner: Option<platform::Inner>,
}
impl Listener {
/// Start answering. `first` is this launch's own request, which arrives
/// through the same channel as the rest so there is one place to act on it.
pub fn serve(
self,
runtime: &tokio::runtime::Runtime,
ctx: &egui::Context,
first: Request,
) -> Receiver<Request> {
let (sender, receiver) = channel();
if first != Request::Raise {
let _ = sender.send(first);
}
if let Some(inner) = self.inner {
platform::serve(
inner,
runtime,
Deliver {
sender,
ctx: ctx.clone(),
},
);
}
receiver
}
}
/// Where a received request goes: the app's queue, and a repaint so it is
/// acted on now rather than when the pointer next moves.
struct Deliver {
sender: Sender<Request>,
ctx: egui::Context,
}
impl Deliver {
fn line(&self, text: &str) {
let Some(request) = Request::decode(text) else {
tracing::debug!("instance: ignored a request that did not parse");
return;
};
let _ = self.sender.send(request);
self.ctx.request_repaint();
}
}
#[cfg(unix)]
mod platform {
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
pub struct Inner(UnixListener);
/// In the runtime directory where there is one: it is the user's alone and
/// emptied at logout, which is exactly the life a socket should have.
fn socket(dirs: &AppDirs) -> PathBuf {
directories::BaseDirs::new()
.and_then(|base| base.runtime_dir().map(std::path::Path::to_path_buf))
.unwrap_or_else(|| dirs.cache.clone())
.join("io.narl.proton-stream.sock")
}
pub fn claim(_: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
let path = socket(dirs);
if let Ok(mut stream) = UnixStream::connect(&path) {
let _ = stream.set_write_timeout(Some(HAND_TIMEOUT));
match stream.write_all(request.encode().as_bytes()) {
Ok(()) => return Claim::Handed,
Err(error) => tracing::warn!("instance: hand over to the open window: {error}"),
}
}
// Nobody answered, so a socket file still there is left over from a
// window that did not exit cleanly.
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(listener) => listener,
Err(error) => {
tracing::warn!("instance: listen at {}: {error}", path.display());
return Claim::First(Listener { inner: None });
}
};
// The runtime directory is private already; the cache fallback is
// not necessarily, and a share link is a secret.
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
Claim::First(Listener {
inner: Some(Inner(listener)),
})
}
pub fn serve(inner: Inner, _: &tokio::runtime::Runtime, deliver: Deliver) {
let spawned = std::thread::Builder::new()
.name("instance".into())
.spawn(move || {
for stream in inner.0.incoming() {
let Ok(stream) = stream else { continue };
let _ = stream.set_read_timeout(Some(HAND_TIMEOUT));
let mut text = String::new();
if stream.take(MOST).read_to_string(&mut text).is_ok() {
deliver.line(&text);
}
}
});
if let Err(error) = spawned {
tracing::warn!("instance thread: {error}");
}
}
}
#[cfg(windows)]
mod platform {
use std::hash::{BuildHasher, Hasher};
use std::io::Write;
use tokio::io::AsyncReadExt;
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
pub struct Inner {
name: String,
first: NamedPipeServer,
}
/// The pipe's name. Pipe names are machine-wide, so a fixed one could be
/// taken first by another account on the same machine, which would then
/// be handed this user's share links. The name carries a random part kept
/// in this user's own config directory, which nobody else can read.
fn pipe(dirs: &AppDirs) -> String {
let path = dirs.config.join("instance.key");
let key = std::fs::read_to_string(&path)
.ok()
.map(|key| key.trim().to_owned())
.filter(|key| key.len() == 32 && key.bytes().all(|b| b.is_ascii_hexdigit()))
.unwrap_or_else(|| {
// `RandomState` is seeded from the OS's random source, once
// per process and stepped per instance after that.
let mut key = String::new();
for _ in 0..2 {
let word = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
key.push_str(&format!("{word:016x}"));
}
if let Err(error) = std::fs::write(&path, &key) {
tracing::warn!("instance: store the pipe key: {error}");
}
key
});
format!(r"\\.\pipe\io.narl.proton-stream.{key}")
}
fn server(name: &str, first: bool) -> std::io::Result<NamedPipeServer> {
ServerOptions::new()
.first_pipe_instance(first)
.reject_remote_clients(true)
.access_outbound(false)
.create(name)
}
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
let name = pipe(dirs);
// Registering a pipe with tokio needs its reactor.
let _entered = runtime.enter();
match server(&name, true) {
Ok(first) => Claim::First(Listener {
inner: Some(Inner { name, first }),
}),
// Taken: there is a window. Hand it the request.
Err(_) => {
let started = std::time::Instant::now();
loop {
match std::fs::OpenOptions::new().write(true).open(&name) {
Ok(mut pipe) => {
if let Err(error) = pipe.write_all(request.encode().as_bytes()) {
tracing::warn!("instance: hand over to the open window: {error}");
}
return Claim::Handed;
}
// Every instance busy with another launch: wait a turn.
Err(_) if started.elapsed() < HAND_TIMEOUT => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(error) => {
tracing::warn!("instance: reach the open window: {error}");
return Claim::First(Listener { inner: None });
}
}
}
}
}
}
pub fn serve(inner: Inner, runtime: &tokio::runtime::Runtime, deliver: Deliver) {
runtime.spawn(async move {
let Inner { name, mut first } = inner;
loop {
if first.connect().await.is_err() {
continue;
}
let connected = first;
// The next instance is up before this one is read, so a launch
// right behind this one finds a pipe to connect to.
first = match server(&name, false) {
Ok(next) => next,
Err(error) => {
tracing::warn!("instance: reopen the pipe: {error}");
return;
}
};
let mut text = String::new();
let mut limited = connected.take(MOST);
let read = limited.read_to_string(&mut text);
if let Ok(Ok(_)) = tokio::time::timeout(HAND_TIMEOUT, read).await {
deliver.line(&text);
}
}
});
}
}
#[cfg(not(any(unix, windows)))]
mod platform {
use super::{AppDirs, Claim, Deliver, Listener, Request};
pub struct Inner;
pub fn claim(_: &tokio::runtime::Runtime, _: &AppDirs, _: &Request) -> Claim {
Claim::First(Listener { inner: None })
}
pub fn serve(_: Inner, _: &tokio::runtime::Runtime, _: Deliver) {}
}
#[cfg(test)]
mod tests {
use super::*;
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#fragmentsecret";
#[test]
fn a_launch_with_a_share_link_asks_for_it_to_be_added() {
let args = ["--verbose".to_owned(), format!(" {LINK} ")];
assert_eq!(Request::from_args(args), Request::AddShare(LINK.to_owned()));
}
#[test]
fn a_launch_with_no_share_link_only_raises_the_window() {
let args = ["https://example.com/".to_owned()];
assert_eq!(Request::from_args(args), Request::Raise);
assert_eq!(Request::from_args(Vec::new()), Request::Raise);
}
#[test]
fn a_request_survives_the_wire() {
for request in [Request::Raise, Request::AddShare(LINK.to_owned())] {
assert_eq!(Request::decode(&request.encode()), Some(request));
}
}
#[test]
fn a_line_that_is_not_a_request_is_ignored() {
assert_eq!(Request::decode("add https://example.com/"), None);
assert_eq!(Request::decode("open the pod bay doors"), None);
}
}
+6
View File
@@ -20,9 +20,15 @@
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
pub mod app;
pub mod desktop_prefs;
pub mod engine;
pub mod inhibit;
pub mod instance;
pub mod media;
pub mod notify;
pub mod pacing;
pub mod playback;
pub mod theme;
pub mod ui;
pub mod video;
pub mod window;
+23 -3
View File
@@ -31,10 +31,30 @@ fn main() -> anyhow::Result<()> {
);
let dirs = AppDirs::ensure().context("resolve app directories")?;
// Before the window: a second launch hands its request to the first and
// never opens one.
let request = pstr_app::instance::Request::from_args(std::env::args().skip(1));
let instance = match pstr_app::instance::claim(&runtime, &dirs, &request) {
pstr_app::instance::Claim::First(listener) => listener,
pstr_app::instance::Claim::Handed => {
tracing::info!("handed over to the window already open");
return Ok(());
}
};
let window = pstr_app::window::WindowState::load(&dirs.window_file());
let mut viewport = egui::ViewportBuilder::default();
// X11 and Windows take the icon from the window. Wayland ignores this and
// looks the app id up in the installed desktop entry instead.
match eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon-256.png")) {
Ok(icon) => viewport = viewport.with_icon(icon),
Err(error) => tracing::debug!("window icon: {error}"),
}
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
viewport: viewport
.with_title("proton-stream")
.with_inner_size([1180.0, 780.0])
.with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
.with_maximized(window.is_some_and(|state| state.maximized))
.with_min_inner_size([720.0, 480.0])
.with_app_id("io.narl.proton-stream"),
..Default::default()
@@ -48,7 +68,7 @@ fn main() -> anyhow::Result<()> {
"proton-stream",
options,
Box::new(move |cc| {
pstr_app::app::App::new(cc, runtime, dirs)
pstr_app::app::App::new(cc, runtime, dirs, instance, request)
.map(|app| Box::new(app) as Box<dyn eframe::App>)
.map_err(|error| error.into())
}),
+170
View File
@@ -0,0 +1,170 @@
//! The desktop's view of what is playing: media keys, the media widget in the
//! panel, the lock screen.
//!
//! MPRIS on Linux, the System Media Transport Controls on Windows, through
//! `souvlaki`. Both are optional in the strongest sense — a session with no
//! D-Bus, or a desktop with nothing listening, plays exactly as before — so
//! every failure here is a debug line and nothing more.
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
SeekDirection,
};
use crate::app::{Action, Adjacent};
use crate::playback::{Command, Playback};
/// How far one press of a seek key moves, when the desktop does not say.
const SEEK_SECONDS: f64 = 10.0;
/// What was last told to the desktop, so it is told again only on a change.
#[derive(Debug, Clone, PartialEq)]
struct Told {
/// What the metadata was built from: the player, whether its duration was
/// known yet, and the cover. Any of them changing means sending it again.
item: Option<(u64, bool, Option<String>)>,
paused: bool,
/// Where the playhead was, to the second. A desktop interpolates between
/// updates, so this only has to be sent when the playhead jumps.
second: i64,
}
pub struct MediaSession {
controls: Option<MediaControls>,
events: Receiver<MediaControlEvent>,
told: Option<Told>,
}
impl MediaSession {
/// Register with the desktop. `window` is the handle SMTC needs on
/// Windows; elsewhere it is not used.
pub fn new(ctx: &egui::Context, window: Option<*mut std::ffi::c_void>) -> Self {
let (sender, events) = channel();
let config = PlatformConfig {
display_name: "proton-stream",
dbus_name: "proton_stream",
hwnd: window,
};
let ctx = ctx.clone();
let controls = MediaControls::new(config)
.and_then(|mut controls| {
controls.attach(move |event| {
// The press arrives on the D-Bus thread; it is acted on in
// the next frame, which this is what asks for.
let _ = sender.send(event);
ctx.request_repaint();
})?;
Ok(controls)
})
.inspect_err(|error| tracing::debug!("media controls unavailable: {error:?}"))
.ok();
Self {
controls,
events,
told: None,
}
}
/// Turn the presses since the last frame into actions.
pub fn drain(&mut self, playback: Option<&Playback>, actions: &mut Vec<Action>) {
while let Ok(event) = self.events.try_recv() {
if let Some(action) = action_for(event, playback) {
actions.push(action);
}
}
}
/// Tell the desktop what is playing, if that changed since the last frame.
///
/// `cover` is a `file://` URL to the title's artwork when it is on disk.
pub fn show(&mut self, playback: Option<&Playback>, cover: Option<&str>) {
let Some(controls) = self.controls.as_mut() else {
return;
};
let told = Told {
item: playback.map(|playback| {
(
playback.id,
playback.duration.is_some(),
cover.map(str::to_owned),
)
}),
paused: playback.is_none_or(|playback| playback.paused),
second: playback.map_or(0, |playback| playback.position as i64),
};
let previous = self.told.replace(told.clone());
// Within the second, or one second on from it: playback carrying on,
// which the desktop already shows without being told.
if let Some(previous) = &previous
&& previous.item == told.item
&& previous.paused == told.paused
&& (0..=1).contains(&(told.second - previous.second))
{
return;
}
let Some(playback) = playback else {
if let Err(error) = controls.set_playback(MediaPlayback::Stopped) {
tracing::debug!("media controls: {error:?}");
}
return;
};
if previous.is_none_or(|previous| previous.item != told.item) {
let target = &playback.target;
let episode = target.caption();
let metadata = MediaMetadata {
title: Some(if episode.is_empty() {
&target.title_name
} else {
&episode
}),
album: Some(&target.title_name),
artist: None,
cover_url: cover,
duration: playback.duration.map(Duration::from_secs_f64),
};
if let Err(error) = controls.set_metadata(metadata) {
tracing::debug!("media controls: {error:?}");
}
}
let progress = Some(MediaPosition(Duration::from_secs_f64(
playback.position.max(0.0),
)));
let state = if playback.paused {
MediaPlayback::Paused { progress }
} else {
MediaPlayback::Playing { progress }
};
if let Err(error) = controls.set_playback(state) {
tracing::debug!("media controls: {error:?}");
}
}
}
/// What a press means here. `None` for the ones with nothing to act on.
fn action_for(event: MediaControlEvent, playback: Option<&Playback>) -> Option<Action> {
let playback = playback?;
let seek = |direction: SeekDirection, seconds: f64| {
Action::Player(Command::SeekBy(match direction {
SeekDirection::Forward => seconds,
SeekDirection::Backward => -seconds,
}))
};
Some(match event {
MediaControlEvent::Toggle => Action::Player(Command::TogglePause),
MediaControlEvent::Play if playback.paused => Action::Player(Command::TogglePause),
MediaControlEvent::Pause if !playback.paused => Action::Player(Command::TogglePause),
MediaControlEvent::Next => Action::PlayAdjacent(Adjacent::Next),
MediaControlEvent::Previous => Action::PlayAdjacent(Adjacent::Previous),
MediaControlEvent::Stop => Action::LeavePlayer,
MediaControlEvent::Seek(direction) => seek(direction, SEEK_SECONDS),
MediaControlEvent::SeekBy(direction, by) => seek(direction, by.as_secs_f64()),
MediaControlEvent::SetPosition(MediaPosition(at)) => {
Action::Player(Command::SeekTo(at.as_secs_f64()))
}
_ => return None,
})
}
+190
View File
@@ -0,0 +1,190 @@
//! Desktop notifications, for things that finish while nobody is looking.
//!
//! Only when the window does not have focus: a viewer who is looking at the
//! app already has the toast, and a second copy of it from the panel is noise.
//! That rule is the caller's — see `App::notify`.
//!
//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on
//! macOS yet.
//!
//! A Windows toast is shown under an application id, and Windows drops a toast
//! whose id it does not know without a word. The id is registered for the
//! current user under `HKCU\Software\Classes\AppUserModelId` — the same
//! registration the Windows App SDK makes for an app with no package — which
//! works for the MSI and the portable zip alike. If that write fails, the
//! toast goes out under PowerShell's id instead, which Windows always knows: a
//! notification that names the wrong program beats one that never appears.
use std::path::Path;
/// Sends notifications from a thread of its own, so a notification daemon
/// that is slow to answer never costs a frame.
pub struct Notifier {
#[cfg(any(target_os = "linux", windows))]
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
}
impl Notifier {
/// `cache` is where Windows is pointed for the toast's icon.
pub fn new(cache: &Path) -> Self {
let _ = cache;
Self {
#[cfg(target_os = "linux")]
orders: linux::spawn(),
#[cfg(windows)]
orders: windows::spawn(cache.join("notification-icon.png")),
}
}
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
#[cfg(any(target_os = "linux", windows))]
if let Some(orders) = &self.orders {
let _ = orders.send((summary.into(), body.into()));
}
#[cfg(not(any(target_os = "linux", windows)))]
let _ = (summary.into(), body.into());
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::sync::mpsc::{Sender, channel};
use std::time::Duration;
use dbus::arg::PropMap;
use dbus::blocking::Connection;
pub fn spawn() -> Option<Sender<(String, String)>> {
let (orders, receiver) = channel::<(String, String)>();
std::thread::Builder::new()
.name("notify".into())
.spawn(move || {
// Connected on first use rather than at startup: most sessions
// never send one.
let mut connection: Option<Connection> = None;
for (summary, body) in receiver {
if connection.is_none() {
connection = Connection::new_session()
.inspect_err(|error| tracing::debug!("notifications: {error}"))
.ok();
}
let Some(connection) = &connection else {
continue;
};
let proxy = connection.with_proxy(
"org.freedesktop.Notifications",
"/org/freedesktop/Notifications",
Duration::from_secs(2),
);
let answer: Result<(u32,), _> = proxy.method_call(
"org.freedesktop.Notifications",
"Notify",
(
"proton-stream",
0u32,
"io.narl.proton-stream",
summary,
body,
Vec::<String>::new(),
PropMap::new(),
-1i32,
),
);
if let Err(error) = answer {
tracing::debug!("notification: {error}");
}
}
})
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
.ok()?;
Some(orders)
}
}
#[cfg(windows)]
mod windows {
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Sender, channel};
use tauri_winrt_notification::Toast;
/// The id toasts are shown under, and the key it is registered at.
const APP_ID: &str = "io.narl.proton-stream";
const ICON: &[u8] = include_bytes!("../assets/icon-256.png");
pub fn spawn(icon: PathBuf) -> Option<Sender<(String, String)>> {
let (orders, receiver) = channel::<(String, String)>();
std::thread::Builder::new()
.name("notify".into())
.spawn(move || {
// Registered on first use rather than at startup: most
// sessions never send one.
let mut app_id: Option<&str> = None;
for (summary, body) in receiver {
let id = *app_id.get_or_insert_with(|| {
let _ = std::fs::write(&icon, ICON);
if register(&icon) {
APP_ID
} else {
Toast::POWERSHELL_APP_ID
}
});
let shown = Toast::new(id).title(&summary).text1(&body).show();
if let Err(error) = shown {
tracing::debug!("notification: {error}");
}
}
})
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
.ok()?;
Some(orders)
}
/// Tell Windows the app id's display name and icon. Per user, no rights
/// needed, and written again every session so a moved install points at
/// a live icon.
fn register(icon: &Path) -> bool {
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey,
RegCreateKeyExW, RegSetValueExW,
};
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(std::iter::once(0)).collect()
}
let path = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"));
let mut key: HKEY = std::ptr::null_mut();
// SAFETY: every pointer is to a live, NUL-terminated buffer or an out
// parameter on this stack frame, and the key is closed before return.
unsafe {
let created = RegCreateKeyExW(
HKEY_CURRENT_USER,
path.as_ptr(),
0,
std::ptr::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
);
if created != 0 {
tracing::debug!("register the notification id: error {created}");
return false;
}
let icon = icon.to_string_lossy();
let mut written = true;
for (name, value) in [("DisplayName", "Proton Stream"), ("IconUri", icon.as_ref())] {
let name = wide(name);
let value = wide(value);
let bytes = u32::try_from(value.len() * 2).unwrap_or(u32::MAX);
let set =
RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), bytes);
written &= set == 0;
}
RegCloseKey(key);
written
}
}
}
+110 -8
View File
@@ -110,6 +110,16 @@ impl PlaybackTarget {
/// The line under the title while this plays: the numbering, and the
/// episode's name when a provider has given one.
/// How to name this file to someone not looking at its title page:
/// the title and the episode, or the file name when there is no title.
pub fn caption_or_name(&self) -> String {
match (self.title_name.is_empty(), self.caption()) {
(false, caption) if !caption.is_empty() => format!("{} — {caption}", self.title_name),
(false, _) => self.title_name.clone(),
(true, _) => self.name.clone(),
}
}
pub fn caption(&self) -> String {
match &self.episode_name {
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
@@ -120,7 +130,7 @@ impl PlaybackTarget {
}
/// What the UI can ask the player to do.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
pub enum Command {
TogglePause,
SeekBy(f64),
@@ -134,6 +144,13 @@ pub enum Command {
/// every file it loads afterwards, so picking Japanese audio for one
/// episode picks it for the next one too.
SelectTrack(TrackKind, Option<i64>),
/// Move subtitles, as a percentage of the window's height. See
/// [`Playback::lift_subtitles`].
SetSubtitlePosition(f64),
/// 1.0 is normal speed.
SetSpeed(f64),
/// Save the current frame to this file.
Screenshot(std::path::PathBuf),
Stop,
}
@@ -162,6 +179,11 @@ pub struct Playback {
/// 0–100, as mpv has it.
pub volume: f64,
pub muted: bool,
/// Playback speed, 1.0 being normal.
pub speed: f64,
/// Where subtitles were last put, so they are only moved when that changes
/// rather than once a frame.
subtitle_position: f64,
/// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them.
@@ -179,6 +201,10 @@ pub struct Playback {
/// A seek was issued and the picture has not come back yet — the thing
/// worth showing a spinner for on a link this slow.
pub seeking: bool,
/// The end of what mpv has read ahead, in seconds from the start.
pub buffered: Option<f64>,
/// Playback is held waiting for data.
pub buffering: bool,
}
impl Playback {
@@ -196,10 +222,9 @@ impl Playback {
target: PlaybackTarget,
stream: VideoStream,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<Self, String> {
let prefs = engine.playback_prefs();
let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?;
let (player, video) = build_player(engine, &target, &prefs, gl)?;
let (commands, orders) = channel::<Command>();
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
@@ -234,6 +259,8 @@ impl Playback {
paused: false,
volume: prefs.volume,
muted: prefs.muted,
subtitle_position: 100.0,
speed: prefs.speed,
tracks: Vec::new(),
chapters: Vec::new(),
roles: Vec::new(),
@@ -242,10 +269,30 @@ impl Playback {
duration: None,
loaded: false,
seeking: false,
buffered: None,
buffering: false,
target,
})
}
/// Keep subtitles clear of `covered` points at the bottom of a picture
/// `height` points tall — the controls, while they are up.
///
/// Without it the line being read sits under the seek bar for as long as
/// the mouse is moving, which is exactly when someone is looking at the
/// screen.
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
let position = if covered > 0.0 && height > 0.0 {
(100.0 * (1.0 - f64::from(covered / height))).round()
} else {
100.0
};
if position != self.subtitle_position {
self.subtitle_position = position;
self.send(Command::SetSubtitlePosition(position));
}
}
/// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool {
self.video.is_some()
@@ -329,6 +376,8 @@ impl Playback {
PlayerEvent::Paused(paused) => self.paused = *paused,
PlayerEvent::Seek => self.seeking = true,
PlayerEvent::PlaybackRestart => self.seeking = false,
PlayerEvent::Buffered(end) => self.buffered = Some(*end),
PlayerEvent::Buffering(held) => self.buffering = *held,
_ => {}
}
}
@@ -365,7 +414,6 @@ fn build_player(
target: &PlaybackTarget,
prefs: &PlaybackPrefs,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
let title = format!("proton-stream — {}", target.name);
@@ -377,6 +425,7 @@ fn build_player(
// controller would be a second set of controls over the same file.
on_screen_controller: false,
default_keybindings: false,
hardware_decoding: engine.desktop_prefs().hardware_decoding,
..from_prefs(prefs, target.track_prefs.as_deref())
};
@@ -386,8 +435,12 @@ fn build_player(
// SAFETY: called from `App::ui`, where eframe has made this
// context current, and the surface is dropped from the same
// place — see `video.rs`.
match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) }
{
match unsafe {
VideoSurface::new(Arc::clone(&player), Arc::clone(gl), {
let engine = engine.clone();
move || engine.wake()
})
} {
Ok(video) => return Ok((player, Some(video))),
Err(error) => {
tracing::warn!("embed the video: {error}; falling back to a window");
@@ -406,6 +459,7 @@ fn build_player(
let config = PlayerConfig {
window_title: title,
hardware_decoding: engine.desktop_prefs().hardware_decoding,
..from_prefs(prefs, target.track_prefs.as_deref())
};
let player = Player::new(engine.runtime().clone(), config)
@@ -428,6 +482,9 @@ fn from_prefs(
.and_then(|p| p.subtitle_language.clone())
.or_else(|| prefs.subtitle_language.clone()),
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
// Carried from file to file, like the volume: somebody who watches at
// 1.25 watches the next episode at 1.25 too.
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
..PlayerConfig::default()
}
}
@@ -487,8 +544,12 @@ fn run(
PlayerEvent::FileLoaded => {
duration = player.duration();
// The first moment there is a track list to read: mpv has
// demuxed the file and made its own default selection, which
// is what the menus have to open showing.
// demuxed the file and made its own default selection. Put the
// title's own choice over it before the menus are told, so they
// open showing what is actually playing.
if let Some(show) = target.track_prefs.as_deref() {
restore_tracks(&player, show);
}
emit_tracks(&engine, id, &player);
// Only now is there a timeline to seek within.
if !resumed && let Some(at) = target.resume_at {
@@ -565,6 +626,9 @@ fn apply_command(
Command::SeekTo(seconds) => player.seek_to(seconds),
Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted),
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
Command::SetSpeed(speed) => player.set_speed(speed),
Command::Screenshot(path) => player.screenshot(&path),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
Command::Stop => {
let _ = player.quit();
@@ -611,6 +675,44 @@ fn select_track(
Ok(())
}
/// Put back the tracks this title was last watched with.
///
/// `alang`/`slang` already steered mpv's pick by language. This is for what a
/// language cannot say — which of two English subtitle tracks, or an audio track
/// with a name and no tag — so only a remembered *title* acts here; a title with
/// nothing but a language keeps mpv's selection, which honours it already.
fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
let tracks = player.tracks();
let wanted = [
(
TrackKind::Audio,
show.audio_language.as_deref(),
show.audio_title.as_deref(),
true,
),
(
TrackKind::Subtitle,
show.subtitle_language.as_deref(),
show.subtitle_title.as_deref(),
show.subtitles,
),
];
for (kind, language, title, enabled) in wanted {
if !enabled || title.is_none() {
continue;
}
let Some(track) = pstr_player::pick_track(&tracks, kind, language, title) else {
continue;
};
let playing = tracks
.iter()
.any(|candidate| candidate.kind == kind && candidate.id == track && candidate.selected);
if !playing && let Err(error) = player.select_track(kind, Some(track)) {
tracing::warn!("restore {} track {track}: {error}", kind.label());
}
}
}
/// Tell the UI what the file contains and what of it is playing.
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
engine.emit(Event::Player {
+205 -15
View File
@@ -35,6 +35,12 @@ pub const CARD_WIDTH: f32 = 232.0;
/// Video stills are 16:9. A poster shape would letterbox every one of them.
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.0;
/// The poster grid's minimum width: narrower than a still, because a cover is
/// read by its art rather than by detail, and six across a laptop screen is
/// what makes a wall of covers look like a shelf.
pub const POSTER_WIDTH: f32 = 168.0;
/// Covers are 2:3.
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
/// The type ramp, named once.
///
@@ -84,38 +90,171 @@ impl Role {
}
}
/// Headings are set in the semibold cut; everything below them in the
/// regular one.
pub fn family(self) -> egui::FontFamily {
match self {
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
_ => egui::FontFamily::Proportional,
}
}
/// For the hand-painted parts, which lay text out through a `Painter` and
/// never see a `RichText`.
pub fn font(self) -> egui::FontId {
egui::FontId::proportional(self.size())
egui::FontId::new(self.size(), self.family())
}
/// For the parts that go through egui's widgets.
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).size(self.size())
egui::RichText::new(text)
.size(self.size())
.family(self.family())
}
}
/// Install a real CJK fallback before the default egui fonts. egui ships a
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
/// CJK families. Native desktops already provide one, so use it when present.
pub fn install_font_fallbacks(ctx: &egui::Context) {
/// The spacing scale. Gaps between things come from here rather than being
/// picked per call site, so two neighbouring groups are 12 apart or 16 apart
/// and never 14 in one place and 15 in the next.
pub mod space {
/// Between lines that belong to one thing: a title and its caption.
pub const XXS: f32 = 2.0;
pub const XS: f32 = 4.0;
/// Between controls in one cluster.
pub const S: f32 = 6.0;
pub const M: f32 = 8.0;
/// Between clusters on one row, and between rows of a form.
pub const L: f32 = 12.0;
/// Between the groups on a page.
pub const XL: f32 = 16.0;
/// Between sections.
pub const XXL: f32 = 24.0;
}
/// Corner radii, as `egui::CornerRadius::same` takes them.
pub mod radius {
/// A progress bar, or a strip along an edge.
pub const BAR: u8 = 3;
/// A badge over artwork. Android's `Shape.kt` has the same number.
pub const BADGE: u8 = 4;
/// A small control inside something else: a menu entry, a chip.
pub const SM: u8 = 6;
/// Everything else either client paints: cards, buttons, the tab pill.
pub const MD: u8 = 8;
/// Something that floats: a row card, a dialog, a toast, the hero.
pub const LG: u8 = 12;
}
/// How long things take to change, in seconds, for `Context::animate_*`.
pub mod motion {
/// A press, which has to answer before the finger is off the button.
pub const PRESS: f32 = 0.05;
/// A hover arriving and leaving.
pub const HOVER: f32 = 0.12;
/// A control changing state: a switch flipping, the tab pill travelling,
/// one page giving way to the next.
pub const STATE: f32 = 0.15;
/// Chrome fading in and out: the player's controls, a toast.
pub const FADE: f32 = 0.18;
/// Artwork arriving over its placeholder. Slower, because it replaces
/// something rather than adding to it, and a fast swap reads as a flicker.
pub const ARRIVE: f32 = 0.24;
/// Fast out, slow in: the curve every hand-driven transition uses.
pub fn ease_out(t: f32) -> f32 {
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
}
}
/// The UI typeface, bundled rather than taken from the system so a title
/// sets the same on every desktop. Inter, under the OFL — the licence is
/// beside the files.
///
/// Both files have their private use area mappings removed, because the
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
/// own alternate glyphs, and as the first font in the family it won: the
/// refresh arrow drew as an accented letter. The glyphs themselves are still
/// in the files; only the character map entries are gone. Strip them again
/// after any Inter update (fontTools: drop every `cmap` subtable entry in
/// U+E000..=U+F8FF).
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
/// The family the headings are set in. egui has no notion of weight, so the
/// semibold cut is a family of its own.
pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into())
}
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
/// symbols Inter does not carry. `cjk` goes last, when there is one.
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
use egui::FontFamily::{Monospace, Proportional};
let mut fonts = egui::FontDefinitions::default();
fonts
.font_data
.insert("inter".into(), Arc::new(egui::FontData::from_static(INTER)));
fonts.font_data.insert(
"inter-semibold".into(),
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
);
fonts
.families
.entry(Proportional)
.or_default()
.insert(0, "inter".into());
// Right behind Inter. The icons are in the private use area, which the
// bundled Inter no longer maps, so neither font shadows the other.
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
if let Some(cjk) = cjk {
let name = "system-cjk-fallback".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(cjk));
for family in [Proportional, Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
}
let fallbacks = fonts.families[&Proportional]
.iter()
.filter(|name| *name != "inter")
.cloned();
let semibold_chain = std::iter::once("inter-semibold".to_owned())
.chain(fallbacks)
.collect();
fonts.families.insert(semibold(), semibold_chain);
fonts
}
/// Install the bundled fonts now, and a CJK fallback when the system has one.
///
/// The bundled ones are compiled in and cost nothing to hand over. The CJK
/// fallback is on a thread of its own: `fc-match` and reading a font file of
/// tens of megabytes held the first frame back by a visible moment, and every
/// name that needs the fallback is still loading from the catalog by then
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
/// and native desktops already provide one.
pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(None));
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("fonts".into())
.spawn(move || install_cjk_fallback(&ctx));
if let Err(error) = spawned {
tracing::debug!("font fallback thread: {error}");
}
}
fn install_cjk_fallback(ctx: &egui::Context) {
let Some((path, index)) = system_cjk_font() else {
return;
};
let Ok(bytes) = std::fs::read(path) else {
return;
};
let mut fonts = egui::FontDefinitions::default();
let mut data = egui::FontData::from_owned(bytes);
data.index = index;
let name = "system-cjk-fallback".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(data));
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
ctx.set_fonts(fonts);
ctx.set_fonts(font_definitions(Some(data)));
ctx.request_repaint();
}
#[cfg(target_os = "linux")]
@@ -358,6 +497,36 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett
painter.add(textured(rect, corner_radius, &texture));
}
/// A rounded rectangle running from one colour to another, at `opacity`.
///
/// For colours that are not the palette's — a title's own, on a tile with no
/// picture. Drawn flat when the viewer has ramps off, like every other ramp.
pub fn ramp_fill(
painter: &egui::Painter,
rect: Rect,
corner_radius: CornerRadius,
(from, to): (Color32, Color32),
direction: Direction,
opacity: f32,
) {
let tint = Color32::WHITE.gamma_multiply(opacity);
if !ramps_on() {
painter.rect_filled(
rect,
corner_radius,
mix(from, to, 0.5).gamma_multiply(opacity),
);
return;
}
let texture = ramp(painter.ctx(), from, to, direction);
painter.add(
egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture(
texture.id(),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
),
);
}
/// A ramp from `color` to nothing, for laying artwork into the page.
///
/// The other ramps here run between two opaque colours; this one runs down its
@@ -524,7 +693,7 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
visuals.selection.stroke = Stroke::new(1.0, palette.text);
visuals.hyperlink_color = palette.accent;
let radius = CornerRadius::same(8);
let radius = CornerRadius::same(radius::MD);
visuals.widgets.noninteractive.bg_fill = palette.surface;
visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
@@ -686,6 +855,27 @@ const RAMP_STOPS: usize = 64;
mod tests {
use super::*;
#[test]
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _;
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
let shadowed: Vec<u32> = font
.charmap()
.mappings()
.map(|(codepoint, _)| codepoint)
.filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
.collect();
assert!(
shadowed.is_empty(),
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
shadowed.len(),
shadowed[0]
);
}
}
#[test]
fn the_shipped_default_is_the_palette_that_was_here_before() {
let resolved = Palette::resolve(Appearance::default());
+197 -135
View File
@@ -5,70 +5,120 @@ use crate::engine::{DownloadItem, DownloadState};
use crate::{theme, ui};
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
ui.heading("Downloads");
ui.label(ui::muted(
"Pause and Cancel keep complete downloaded blocks. Delete partial discards them.",
));
ui.add_space(12.0);
if downloads.is_empty() {
ui.label("No offline downloads.");
ui::empty_state(
ui,
"No downloads",
"Download an episode, a season or a whole show from its page, and it plays \
without a connection.",
);
return;
}
let mut start = 0;
while start < downloads.len() {
let title_key = &downloads[start].target.title_key;
let title = &downloads[start].target.title_name;
let end = downloads[start..]
.iter()
.position(|item| item.target.title_key != *title_key)
.map_or(downloads.len(), |offset| start + offset);
let group = &downloads[start..end];
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
let total: u64 = group.iter().map(|item| item.total).sum();
let completed = group
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.set_max_width(960.0);
summary(ui, downloads);
ui.add_space(theme::space::L);
ui.label(theme::Role::Section.rich(title).strong());
let totals_known = group.iter().all(|item| item.total > 0);
ui.horizontal(|ui| {
if totals_known {
ui.add(
egui::ProgressBar::new(fraction(downloaded, total))
.desired_width(260.0)
.show_percentage(),
);
} else {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted("Calculating size…"));
let mut start = 0;
while start < downloads.len() {
let title_key = &downloads[start].target.title_key;
let end = downloads[start..]
.iter()
.position(|item| item.target.title_key != *title_key)
.map_or(downloads.len(), |offset| start + offset);
group(ui, &downloads[start..end], actions);
ui.add_space(theme::space::L);
start = end;
}
ui.label(ui::muted(format!(
"{} / {} · {completed}/{} files",
bytes(downloaded),
if !totals_known {
"calculating…".to_owned()
} else {
bytes(total)
},
group.len()
)));
});
for item in group {
row(ui, item, actions);
}
/// The line under the heading: how much, how fast, how long.
fn summary(ui: &mut egui::Ui, downloads: &[DownloadItem]) {
ui::section(ui, "Downloads");
let running: Vec<_> = downloads
.iter()
.filter(|item| item.state == DownloadState::Running)
.collect();
let rate: f64 = running.iter().map(|item| item.rate).sum();
let left: u64 = downloads
.iter()
.filter(|item| {
matches!(
item.state,
DownloadState::Queued | DownloadState::Running | DownloadState::Paused
)
})
.map(|item| item.total.saturating_sub(item.downloaded))
.sum();
let mut parts = vec![format!(
"{} available offline",
ui::library::plural(
downloads
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count(),
"file"
)
)];
if !running.is_empty() {
parts.push(format!("{}/s", size(rate as u64)));
}
if left > 0 {
parts.push(format!("{} to go", size(left)));
if rate > 0.0 {
parts.push(format!("about {}", ui::format_time(left as f64 / rate)));
}
ui.add_space(12.0);
start = end;
}
ui.label(ui::muted(parts.join(" · ")));
ui.label(ui::muted(
"Pausing or cancelling keeps every block already downloaded.",
));
}
/// One title's downloads, under a bar for all of them together.
fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
let title = &group[0].target.title_name;
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
let total: u64 = group.iter().map(|item| item.total).sum();
let completed = group
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count();
let totals_known = group.iter().all(|item| item.total > 0);
ui.horizontal(|ui| {
ui.label(theme::Role::Subhead.rich(title).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(ui::muted(if totals_known {
format!(
"{completed} of {} · {} of {}",
group.len(),
size(downloaded),
size(total)
)
} else {
format!("{completed} of {} · working out the size…", group.len())
}));
});
});
if totals_known && group.len() > 1 {
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
}
ui.add_space(theme::space::S);
for item in group {
row(ui, item, actions);
}
}
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
@@ -78,85 +128,109 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
item.target.subtitle.as_str()
};
ui.add_sized(
[180.0, 18.0],
egui::Label::new(egui::RichText::new(caption).strong()).truncate(),
[72.0, 18.0],
egui::Label::new(egui::RichText::new(caption).monospace()).truncate(),
)
.on_hover_text(&item.target.name);
let status = match &item.state {
DownloadState::Queued => "Queued".to_owned(),
DownloadState::Running => "Downloading".to_owned(),
DownloadState::Paused => "Paused".to_owned(),
DownloadState::Completed => "Available offline".to_owned(),
DownloadState::Cancelled => "Cancelled · partial kept".to_owned(),
DownloadState::Failed(error) => format!("Failed: {error}"),
};
let (status, colour) = status(item);
ui.add_sized(
[240.0, 18.0],
egui::Label::new(ui::muted(status)).truncate(),
[220.0, 18.0],
egui::Label::new(theme::Role::Caption.rich(status).color(colour)).truncate(),
);
if item.total > 0 {
ui.add(
egui::ProgressBar::new(item.percent())
.desired_width(140.0)
.show_percentage(),
);
ui.label(ui::muted(format!(
"{} / {}",
bytes(item.downloaded),
bytes(item.total)
)));
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| match item.state {
DownloadState::Queued | DownloadState::Running => {
if ui
.button("Cancel")
.on_hover_text("Stop and keep partial")
.clicked()
{
actions.push(Action::CancelDownload(item.key.clone()));
}
if ui.button("Pause").clicked() {
actions.push(Action::PauseDownload(item.key.clone()));
}
}
DownloadState::Paused => {
if ui
.button("Cancel")
.on_hover_text("Stop and keep partial")
.clicked()
{
actions.push(Action::CancelDownload(item.key.clone()));
}
if ui.button("Resume").clicked() {
actions.push(Action::ResumeDownload(item.key.clone()));
}
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui.button("Delete partial…").clicked() {
actions.push(Action::RemoveDownload(item.key.clone(), true));
}
if ui.button("Resume / Retry").clicked() {
actions.push(Action::ResumeDownload(item.key.clone()));
}
}
DownloadState::Completed => {
if ui
.button("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.clicked()
{
actions.push(Action::RemoveDownload(item.key.clone(), false));
}
}
},
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
buttons(ui, item, actions);
if item.total > 0 && item.state != DownloadState::Completed {
ui.add_space(theme::space::M);
ui.label(ui::muted(format!(
"{} / {}",
size(item.downloaded),
size(item.total)
)));
let width = (ui.available_width() - 8.0).clamp(40.0, 320.0);
ui::progress_bar(ui, item.percent(), width);
}
});
});
});
ui.add_space(theme::space::XS);
}
/// What a row says about itself, and in what colour.
fn status(item: &DownloadItem) -> (String, egui::Color32) {
match &item.state {
DownloadState::Queued => ("Waiting".to_owned(), theme::muted()),
DownloadState::Running => {
let mut text = format!("{}/s", size(item.rate as u64));
if let Some(eta) = item.eta() {
text.push_str(&format!(" · {} left", ui::format_time(eta)));
}
(text, theme::text())
}
DownloadState::Paused => ("Paused".to_owned(), theme::muted()),
DownloadState::Completed => ("Available offline".to_owned(), theme::muted()),
DownloadState::Cancelled => ("Stopped · progress kept".to_owned(), theme::muted()),
DownloadState::Failed(error) => (format!("Failed: {error}"), theme::danger()),
}
}
fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
let key = || item.key.clone();
match item.state {
DownloadState::Queued | DownloadState::Running => {
if ui
.button("Cancel")
.on_hover_text("Stop, and keep what has downloaded")
.clicked()
{
actions.push(Action::CancelDownload(key()));
}
if ui.button("Pause").clicked() {
actions.push(Action::PauseDownload(key()));
}
}
DownloadState::Paused => {
if ui
.button("Cancel")
.on_hover_text("Stop, and keep what has downloaded")
.clicked()
{
actions.push(Action::CancelDownload(key()));
}
if ui.button("Resume").clicked() {
actions.push(Action::ResumeDownload(key()));
}
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui
.button("Delete")
.on_hover_text("Discard what has downloaded so far")
.clicked()
{
actions.push(Action::RemoveDownload(key(), true));
}
if ui
.button(if matches!(item.state, DownloadState::Failed(_)) {
"Retry"
} else {
"Resume"
})
.clicked()
{
actions.push(Action::ResumeDownload(key()));
}
}
DownloadState::Completed => {
if ui
.button("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.clicked()
{
actions.push(Action::RemoveDownload(key(), false));
}
}
}
}
fn fraction(downloaded: u64, total: u64) -> f32 {
@@ -167,18 +241,6 @@ fn fraction(downloaded: u64, total: u64) -> f32 {
}
}
fn bytes(value: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * KIB;
const GIB: f64 = 1024.0 * MIB;
let value = value as f64;
if value >= GIB {
format!("{:.1} GiB", value / GIB)
} else if value >= MIB {
format!("{:.1} MiB", value / MIB)
} else if value >= KIB {
format!("{:.1} KiB", value / KIB)
} else {
format!("{value:.0} B")
}
fn size(bytes: u64) -> String {
ui::format_size(i64::try_from(bytes).unwrap_or(i64::MAX))
}
+238
View File
@@ -0,0 +1,238 @@
//! Everything that has been played, newest first, under a heading per day.
//!
//! Continue watching answers "what was I in the middle of"; this answers
//! "what was that episode on Tuesday". It is read straight off the watch
//! state, so there is nothing to keep in step: an episode is here because it
//! has a position or is marked watched, and leaves when it has neither.
use chrono::{DateTime, Local, NaiveDate, TimeZone};
use pstr_core::library::{Episode, Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::theme;
use crate::ui::{self, Art};
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
/// How far back the page goes. Further than anyone scrolls; the cap is there
/// so a library with years of history does not lay out every row of it.
const MOST: usize = 400;
/// Where one entry lives in [`Library::titles`]: title, season, episode.
pub type Entry = (usize, usize, usize);
/// The entries of `library.history()`, as positions, so they can be kept
/// between frames and only rebuilt when the library changes.
pub fn entries(library: &Library) -> Vec<Entry> {
let mut position = std::collections::HashMap::new();
for (t, title) in library.titles.iter().enumerate() {
for (s, season) in title.seasons.iter().enumerate() {
for (e, episode) in season.episodes.iter().enumerate() {
position.insert(std::ptr::from_ref(episode), (t, s, e));
}
}
}
library
.history()
.into_iter()
.filter_map(|(_, episode)| position.get(&std::ptr::from_ref(episode)).copied())
.collect()
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
entries: &[Entry],
actions: &mut Vec<Action>,
) {
if entries.is_empty() {
ui::empty_state(
ui,
"Nothing watched yet",
"Episodes and films you play show up here, newest first, so the one from \
last week is easy to find again.",
);
return;
}
let today = Local::now().date_naive();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.set_max_width(960.0);
let mut day = None;
for &(t, s, e) in entries.iter().take(MOST) {
let Some(title) = library.titles.get(t) else {
continue;
};
let Some(episode) = title
.seasons
.get(s)
.and_then(|season| season.episodes.get(e))
else {
continue;
};
let Some(at) = Local.timestamp_opt(episode.last_played(), 0).single() else {
continue;
};
if day != Some(at.date_naive()) {
if day.is_some() {
ui.add_space(theme::space::L);
}
day = Some(at.date_naive());
ui::section(ui, &day_label(at.date_naive(), today));
}
row(ui, art, title, episode, at, actions);
}
});
}
fn row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
episode: &Episode,
at: DateTime<Local>,
actions: &mut Vec<Action>,
) {
let row_id = ui.id().with(("history", &episode.node.link_id));
let frame = egui::Frame::new()
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(8));
let mut prepared = frame.begin(ui);
{
let ui = &mut prepared.content_ui;
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
ui::title::still(
ui,
art,
title,
episode,
episode.is_watched(),
STILL,
actions,
);
ui.add_space(theme::space::M);
let remove = 40.0;
let text_width = (ui.available_width() - remove).max(120.0);
ui.vertical(|ui| {
ui.set_width(text_width);
ui.spacing_mut().item_spacing.y = theme::space::XXS;
ui.add_space(theme::space::XS);
let name = ui
.add(
egui::Label::new(theme::Role::Body.rich(&title.name).color(theme::text()))
.sense(egui::Sense::click())
.truncate(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Show this title");
if name.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
// A film's only "episode" is its file, and its filename says
// nothing the title above it does not.
if title.kind != TitleKind::Film {
let episode_name = art
.episode(&title.key, episode)
.and_then(|found| found.name.clone())
.unwrap_or_else(|| episode.detail().to_owned());
let line = match episode.numbering() {
Some(numbering) => format!("{numbering} · {episode_name}"),
None => episode_name,
};
ui.add(egui::Label::new(ui::muted(line)).truncate())
.on_hover_text(&episode.node.name);
}
ui.label(ui::muted(status(episode, at)));
});
let cluster = egui::vec2(ui.available_width(), STILL.y);
let layout = egui::Layout::right_to_left(egui::Align::Center);
ui.allocate_ui_with_layout(cluster, layout, |ui| {
let remove = ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.on_hover_text(if episode.is_watched() {
"Remove from history — marks it unwatched"
} else {
"Remove from history — forgets where you stopped"
});
if remove.clicked() {
actions.push(Action::RemoveFromHistory {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
});
}
});
});
}
let response = prepared.allocate_space(ui);
let lit =
ui.ctx()
.animate_bool_with_time(row_id, response.contains_pointer(), theme::motion::HOVER);
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
prepared.paint(ui);
ui.add_space(theme::space::XXS);
}
/// The grey line under the names: how far it got, and when.
fn status(episode: &Episode, at: DateTime<Local>) -> String {
let when = at.format("%H:%M");
if episode.is_watched() {
return format!("Watched · {when}");
}
match (
episode.resume_at(),
episode.watch.and_then(|watch| watch.duration_secs),
) {
(Some(position), Some(duration)) => format!(
"Stopped at {} of {} · {when}",
ui::format_time(position),
ui::format_time(duration)
),
(Some(position), None) => format!("Stopped at {} · {when}", ui::format_time(position)),
_ => format!("Played · {when}"),
}
}
/// The heading over a day's entries: "Today", "Yesterday", a weekday within
/// the last week, and a date before that — with the year only when it is not
/// this one.
fn day_label(day: NaiveDate, today: NaiveDate) -> String {
use chrono::Datelike;
match (today - day).num_days() {
0 => "Today".into(),
1 => "Yesterday".into(),
2..=6 => day.format("%A").to_string(),
_ if day.year() == today.year() => day.format("%-d %B").to_string(),
_ => day.format("%-d %B %Y").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn a_day_is_named_relative_to_today_for_the_last_week_and_dated_before_that() {
let today = date(2026, 10, 2);
assert_eq!(day_label(today, today), "Today");
assert_eq!(day_label(date(2026, 10, 1), today), "Yesterday");
assert_eq!(day_label(date(2026, 9, 28), today), "Monday");
assert_eq!(day_label(date(2026, 9, 25), today), "25 September");
assert_eq!(day_label(date(2025, 12, 24), today), "24 December 2025");
}
}
+536 -48
View File
@@ -1,61 +1,387 @@
//! The library page: what to watch, as a wall of stills.
use pstr_core::appearance::Tiles;
use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::app::{Action, Filter, LibraryView, Page, Sort};
use crate::playback::PlaybackTarget;
use crate::theme;
use crate::ui::Art;
use crate::ui::{self, Card};
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
search: &str,
actions: &mut Vec<Action>,
) {
/// What the page lists, and what it was asked for.
pub struct Shelves<'a> {
pub library: &'a Library,
/// The search and the shelf, already worked out — see [`LibraryView`].
pub view: &'a LibraryView,
/// Whether the catalog has been read yet.
pub loaded: bool,
pub search: &'a str,
/// The grid's shape.
pub tiles: Tiles,
}
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
let Shelves {
library,
view,
loaded,
search,
tiles,
} = shelves;
if !loaded {
return placeholder(ui);
}
if library.is_empty() {
return empty_state(ui, actions);
}
let matches = library.search(search);
let pick = |indices: &[usize]| -> Vec<&Title> {
indices
.iter()
.filter_map(|&index| library.titles.get(index))
.collect()
};
let matches = pick(&view.matches);
let searching = !search.trim().is_empty();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if !searching {
let resumable = library.continue_watching();
if let Some(title) = featured(library, view, art.metadata, today()) {
hero(ui, art, title, actions);
ui.add_space(theme::space::XL);
}
let resumable = pick(&view.resumable);
if !resumable.is_empty() {
ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions);
ui.add_space(16.0);
ui.add_space(theme::space::XL);
}
// Only over the whole library: under a filter, a shelf of
// titles the grid below is hiding reads as the filter not
// working.
if view.filter == Filter::All {
for (label, indices) in &view.shelves {
ui::section(ui, label);
shelf_row(ui, art, label, &pick(indices), tiles, actions);
ui.add_space(theme::space::XL);
}
}
}
ui::section(
ui,
&if searching {
format!(
"{} matching {:?}",
plural(matches.len(), "title"),
search.trim()
)
} else {
plural(matches.len(), "title")
},
);
ui.horizontal(|ui| {
ui::section(
ui,
&if searching {
format!(
"{} matching {:?}",
plural(matches.len(), "title"),
search.trim()
)
} else {
plural(matches.len(), "title")
},
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
shelf_controls(ui, view, actions);
});
});
ui.add_space(theme::space::S);
if matches.is_empty() {
ui.add_space(8.0);
ui.label(ui::muted("Nothing here by that name."));
ui.add_space(theme::space::M);
ui.label(ui::muted(if searching {
"Nothing here by that name."
} else {
"Nothing in the library fits that filter."
}));
return;
}
grid(ui, art, &matches, actions);
grid(ui, art, &matches, view, tiles, actions);
});
}
/// Days since the epoch: what the featured title turns over on.
fn today() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() / 86_400)
}
/// The title the banner shows: whatever was watched last, so the top of the
/// page is one click from carrying on. With nothing part-watched, one of the
/// titles a provider has a backdrop for, turning over once a day — the same
/// one every time the page is opened today, rather than a new one per frame.
fn featured<'a>(
library: &'a Library,
view: &LibraryView,
metadata: &std::collections::HashMap<String, pstr_core::metadata::MetadataRecord>,
day: u64,
) -> Option<&'a Title> {
if let Some(title) = view
.resumable
.first()
.and_then(|&index| library.titles.get(index))
{
return Some(title);
}
let pictured: Vec<&Title> = library
.titles
.iter()
.filter(|title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| found.wide_art().is_some())
})
.collect();
if pictured.is_empty() {
return None;
}
pictured
.get((day % pictured.len() as u64) as usize)
.copied()
}
/// The banner across the top of the library: one title, large, with what it
/// is and a way to play it.
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
let height = (ui.ctx().content_rect().height() * 0.42).clamp(220.0, 360.0);
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height),
egui::Sense::click(),
);
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.wide(title);
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
let painter = ui.painter().with_clip_rect(rect);
painter.rect_filled(rect, radius, theme::card());
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
picture.is_some(),
theme::motion::ARRIVE,
);
if art_in < 1.0 {
ui::placeholder(&painter, rect, "", 1.0 - art_in);
}
if let Some((texture, _)) = &picture {
painter.add(
egui::epaint::RectShape::filled(
rect,
radius,
egui::Color32::WHITE.gamma_multiply(art_in),
)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
);
}
// The same two coats as the title page's band, for the same reason: a
// still is arbitrary, and the text over it has to read whatever it is.
if theme::ramps_on() {
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.25));
let text_side = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + rect.width() * 0.7, rect.bottom()),
);
painter.add(theme::fade_shape(
ui.ctx(),
text_side,
theme::background(),
theme::Direction::Horizontal,
false,
));
let bottom =
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 90.0), rect.max);
painter.add(theme::fade_shape(
ui.ctx(),
bottom,
theme::background(),
theme::Direction::Vertical,
true,
));
} else {
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.82));
}
let inner = egui::Rect::from_min_max(
rect.min + egui::vec2(32.0, 0.0),
egui::pos2(
rect.left() + (rect.width() * 0.55).max(360.0),
rect.bottom() - 28.0,
),
);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|ui| {
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => {
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
}
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
ui.add_space(theme::space::S);
}
if ui.button("More info").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
});
ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
let mut job = egui::text::LayoutJob::simple(
overview.to_owned(),
theme::Role::Body.font(),
theme::text(),
ui.available_width(),
);
job.wrap.max_rows = 3;
ui.label(job);
ui.add_space(theme::space::M);
}
let mut facts = vec![subtitle(title)];
if let Some(found) = &found {
if let Some(rating) = found.rating {
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
facts.extend(found.genres.iter().take(3).cloned());
}
ui.label(ui::muted(facts.join(" · ")));
ui.add_space(theme::space::XXS);
ui.add(
egui::Label::new(
theme::Role::Display
.rich(art.name(title))
.strong()
.color(theme::text()),
)
.truncate(),
);
},
);
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
/// The filter pills, the order and the grouping, at the right of the grid's
/// heading.
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
// Right to left: the order first, so it ends up last.
let sort = egui::ComboBox::from_id_salt("library-sort")
.selected_text(view.sort.label())
.width(150.0)
.show_ui(ui, |ui| {
let mut picked = None;
for sort in Sort::ALL {
if ui
.selectable_label(view.sort == sort, sort.label())
.clicked()
{
picked = Some(sort);
}
}
picked
});
if let Some(Some(sort)) = sort.inner {
actions.push(Action::SetShelf(view.filter, sort));
}
ui.add_space(theme::space::S);
let mut grouped = view.grouped;
if ui
.toggle_value(&mut grouped, egui_phosphor::regular::STACK)
.on_hover_text(if view.grouped {
"One tile per franchise. Click to show every title"
} else {
"Every title. Click to fold each franchise into one tile"
})
.changed()
{
actions.push(Action::SetGrouped(grouped));
}
ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented(
ui,
view.filter,
&[
(Filter::All, "All"),
(Filter::Series, "Series"),
(Filter::Films, "Films"),
(Filter::Watching, "Watching"),
(Filter::Unwatched, "Not started"),
],
) {
actions.push(Action::SetShelf(filter, view.sort));
}
}
/// One shelf: titles that share something, scrolling sideways, in the grid's
/// own shape.
fn shelf_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
label: &str,
titles: &[&Title],
tiles: Tiles,
actions: &mut Vec<Action>,
) {
let (width, aspect) = tile_shape(tiles);
egui::ScrollArea::horizontal()
.id_salt(("shelf", label))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in titles {
let picture = match tiles {
Tiles::Posters => art.poster(title),
Tiles::Stills => art.of(title),
};
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(title),
subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
width,
aspect,
},
);
tile_menu(&response, title, false, actions);
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
});
});
}
/// The minimum width and the picture's shape of a tile.
fn tile_shape(tiles: Tiles) -> (f32, f32) {
match tiles {
Tiles::Posters => (theme::POSTER_WIDTH, theme::POSTER_ASPECT),
Tiles::Stills => (theme::CARD_WIDTH, theme::CARD_ASPECT),
}
}
/// The top shelf: one card per part-watched title, scrolling sideways.
fn continue_row(
ui: &mut egui::Ui,
@@ -85,22 +411,25 @@ fn continue_row(
})
.unwrap_or_else(|| episode.label());
let clicked = ui::card(
let picture = art.of(title);
let response = ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
art: picture,
name: art.name(title),
subtitle: remaining,
progress: episode.progress().map(|value| value as f32),
badge: episode.numbering(),
// A shelf that scrolls sideways has no right edge
// to reach, so nothing to flex to.
// to reach, so nothing to flex to. Always a
// still: it is a frame from where they stopped.
width: theme::CARD_WIDTH,
aspect: theme::CARD_ASPECT,
},
)
.clicked();
);
tile_menu(&response, title, true, actions);
if clicked {
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
@@ -109,32 +438,69 @@ fn continue_row(
}
/// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
let grid = ui::columns(ui.available_width());
for row in titles.chunks(grid.columns) {
fn grid(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
view: &LibraryView,
tiles: Tiles,
actions: &mut Vec<Action>,
) {
let (minimum, aspect) = tile_shape(tiles);
let grid = ui::columns_of(ui.available_width(), minimum);
let row_height = ui::card_height(grid.width, aspect);
for (row, chunk) in titles.chunks(grid.columns).enumerate() {
// A row scrolled out of view is only its height. Laying it out anyway
// costs a subtitle and an artwork lookup per card per frame, and while
// a film plays under the transport bar a frame is every frame.
let slot = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), row_height),
);
if !ui.is_rect_visible(slot) {
// The spacing `horizontal` would have added after itself, too, or
// the page grows and shrinks under the scroll bar as rows come and
// go.
ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP);
continue;
}
ui.horizontal(|ui| {
// The gap the width was divided around, so what is drawn matches
// what was measured. egui's default item spacing is narrower, and
// the difference times the column count is a visible drift towards
// the left edge.
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in row {
let clicked = ui::card(
for (column, title) in chunk.iter().enumerate() {
let picture = match tiles {
Tiles::Posters => art.poster(title),
Tiles::Stills => art.of(title),
};
let folded = view
.matches
.get(row * grid.columns + column)
.and_then(|index| view.folded.get(index))
.copied();
let response = ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: subtitle(title),
art: picture,
name: art.name(title),
subtitle: match folded {
Some(more) => format!("{} · +{more} more", subtitle(title)),
None => subtitle(title),
},
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
// What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise.
badge: None,
// card; a second `Film` over the poster is noise. A
// folded franchise says how much is under it.
badge: folded.map(|more| format!("+{more}")),
width: grid.width,
aspect,
},
)
.clicked();
);
tile_menu(&response, title, false, actions);
if clicked {
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
@@ -143,6 +509,80 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
}
}
/// What a right-click on a tile offers: the things otherwise one page away.
///
/// `continuing` is the Continue watching shelf, where the tile can also be
/// taken off the shelf.
fn tile_menu(
response: &egui::Response,
title: &Title,
continuing: bool,
actions: &mut Vec<Action>,
) {
response.context_menu(|ui| {
if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => format!("Resume {numbering}"),
(Some(_), None) => "Resume".to_owned(),
(None, _) => "Play".to_owned(),
};
if ui.button(label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
}
if ui.button("Open").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
ui.separator();
let all_watched = title.watched_count() == title.episode_count();
if ui
.button(if all_watched {
"Mark unwatched"
} else {
"Mark watched"
})
.clicked()
{
actions.push(Action::SetTitleWatched {
key: title.key.clone(),
watched: !all_watched,
});
}
if continuing
&& let Some(episode) = title.resume()
&& ui
.button("Remove from Continue watching")
.on_hover_text("Forget where you stopped in it")
.clicked()
{
actions.push(Action::ForgetPosition {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
});
}
let count = title.episode_count();
let download = if count == 1 {
"Download".to_owned()
} else {
format!("Download all {count}")
};
if ui.button(download).clicked() {
actions.push(Action::MakeOffline(
title
.episodes()
.map(|episode| PlaybackTarget::new(title, episode))
.collect(),
));
}
ui.separator();
if ui.button("Change match…").clicked() {
actions.push(Action::OpenMatcher(title.key.clone()));
}
});
}
/// The grey line under a card: what it is, how much there is, and how much is
/// left.
///
@@ -168,6 +608,37 @@ fn subtitle(title: &Title) -> String {
}
}
/// The page before the catalog has been read: the shape of a grid, with
/// nothing in it yet.
///
/// Not the empty state. The catalog is on disk and is read in a fraction of a
/// second, and telling every launch "Nothing in the library yet" for that
/// fraction is a flash of something false.
fn placeholder(ui: &mut egui::Ui) {
let grid = ui::columns(ui.available_width());
let height = ui::card_height(grid.width, theme::CARD_ASPECT);
ui::section(ui, "Library");
for _ in 0..2 {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for _ in 0..grid.columns {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(grid.width, height), egui::Sense::hover());
let image = egui::Rect::from_min_size(
rect.min,
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
);
ui.painter().rect_filled(
image,
egui::CornerRadius::same(theme::radius::MD),
theme::card(),
);
}
});
ui.add_space(theme::CARD_GAP);
}
}
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
ui.vertical_centered(|ui| {
ui.add_space(120.0);
@@ -176,11 +647,11 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
.rich("Nothing in the library yet")
.strong(),
);
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.label(ui::muted(
"Add a Proton Drive share link, and everything playable behind it shows up here.",
));
ui.add_space(18.0);
ui.add_space(theme::space::XL);
if ui::accent_button(ui, "Add a share").clicked() {
actions.push(Action::Goto(Page::Shares));
}
@@ -225,6 +696,23 @@ mod tests {
);
}
#[test]
fn the_banner_features_the_title_watched_last_and_nothing_without_a_picture() {
let mut second = title(TitleKind::Film, None);
second.key = "second".into();
let library = Library {
titles: vec![title(TitleKind::Film, None), second],
};
let metadata = std::collections::HashMap::new();
let mut view = LibraryView::default();
assert!(featured(&library, &view, &metadata, 0).is_none());
view.resumable = vec![1];
assert_eq!(
featured(&library, &view, &metadata, 0).map(|title| title.key.as_str()),
Some("second")
);
}
#[test]
fn plurals_read_naturally() {
assert_eq!(plural(1, "title"), "1 title");
+13 -14
View File
@@ -43,7 +43,7 @@ pub fn show(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(egui::CornerRadius::same(10)),
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(620.0);
@@ -58,21 +58,21 @@ pub fn show(
"Everything {} answers with, in its own order — nothing is scored here.",
provider.label()
)));
ui.add_space(10.0);
ui.add_space(theme::space::L);
search_row(ui, matcher, actions);
ui.add_space(6.0);
ui.add_space(theme::space::S);
current_row(ui, matcher, current.as_ref(), actions);
ui.add_space(10.0);
ui.add_space(theme::space::L);
if let Some(error) = &matcher.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(6.0);
ui.add_space(theme::space::S);
}
results(ui, matcher, art, actions);
ui.add_space(12.0);
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
@@ -178,7 +178,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
if row(ui, art, found).clicked() {
actions.push(Action::ChooseMatch(Box::new(found.clone())));
}
ui.add_space(4.0);
ui.add_space(theme::space::XS);
}
});
}
@@ -188,7 +188,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
let response = egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(6))
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -204,12 +204,12 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
} else {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(4),
egui::CornerRadius::same(theme::radius::BADGE),
theme::card_hover(),
);
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.label(
theme::Role::Body
@@ -241,7 +241,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
if response.hovered() {
ui.painter().rect_stroke(
response.rect,
egui::CornerRadius::same(6),
egui::CornerRadius::same(theme::radius::SM),
egui::Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Inside,
);
@@ -258,9 +258,8 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
.or_else(|| found.backdrop_url.clone())?;
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
let engine = art.engine;
let requested = key.clone();
art.posters
.texture(key, || engine.request_poster(requested, url))
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
@@ -280,7 +279,7 @@ fn facts(found: &TitleMetadata) -> String {
parts.push(ui::library::plural(episodes as usize, "episode"));
}
if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}"));
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
parts.join(" · ")
}
+402 -57
View File
@@ -4,21 +4,25 @@
//! applies them after the frame is drawn.
pub mod downloads;
pub mod history;
pub mod library;
pub mod matcher;
pub mod player;
pub mod settings;
pub mod shares;
pub mod shortcuts;
pub mod title;
pub mod toast;
pub mod transport;
pub mod widgets;
use std::collections::HashMap;
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
use crate::engine::{Engine, ImageCache, thumbnail_key};
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme;
/// Everything the pages need to draw one title's picture.
@@ -36,8 +40,12 @@ pub struct Art<'a> {
pub metadata: &'a HashMap<String, MetadataRecord>,
/// What they said about the episodes under those titles.
pub episodes: &'a HashMap<String, EpisodeGuide>,
/// Which name a title goes by.
pub names: TitleNames,
}
pub use pstr_core::browse::display_name as title_name;
impl Art<'_> {
/// What the provider says about one file of a title, matched on the
/// numbering its name states.
@@ -48,6 +56,80 @@ impl Art<'_> {
.get(episode.node.parsed.season, number)
}
/// A still for one episode: the provider's, else Proton's own thumbnail of
/// the file. Only asked for by rows that are on screen.
pub fn still(&mut self, title_key: &str, episode: &Episode) -> Option<egui::TextureHandle> {
let url = self
.episode(title_key, episode)
.and_then(|found| found.still_url.clone());
if let Some(url) = url {
let key = format!("still:{}", url);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_poster(key.clone(), url))
{
return Some(texture);
}
}
let node = &episode.node;
let engine = self.engine;
self.thumbs
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
}
/// The name to show for `title`. See [`title_name`].
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
title_name(title, self.metadata, self.names)
}
/// A 2:3 cover for `title`, for the poster grid; whatever [`Art::of`]
/// has when there is none.
pub fn poster(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.poster_url.clone());
if let Some(url) = url {
let key = poster_key(&title.key);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_poster(key.clone(), url))
{
return Some((texture, ArtShape::Portrait));
}
}
self.of(title)
}
/// Art for a strip across the whole window, at full size: fanart, else
/// AniList's banner, else whatever [`Art::of`] has.
///
/// Its own texture rather than the tile's: a 640 px tile picture
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
/// would be most of the texture budget.
pub fn wide(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.wide_art())
.map(str::to_owned);
if let Some(url) = url {
let key = wide_key(&title.key);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_art(key.clone(), url, WIDE_MAX_EDGE))
{
return Some((texture, ArtShape::Landscape));
}
}
self.of(title)
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
@@ -62,12 +144,9 @@ impl Art<'_> {
.and_then(|metadata| metadata.tile_art())
{
let engine = self.engine;
let key = title.key.clone();
let url = url.to_string();
if let Some(texture) = self
.posters
.texture(title.key.clone(), || engine.request_poster(key, url))
{
if let Some(texture) = self.posters.texture(&title.key, || {
engine.request_poster(title.key.clone(), url.to_string())
}) {
return Some((texture, shape));
}
}
@@ -76,11 +155,22 @@ impl Art<'_> {
let engine = self.engine;
let texture = self
.thumbs
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?;
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))?;
Some((texture, ArtShape::Landscape))
}
}
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
/// title key itself.
pub fn poster_key(title_key: &str) -> String {
format!("poster:{title_key}")
}
/// The texture key of a title's full-size wide art.
pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}")
}
/// `1:03:47`, or `4:12` for anything under an hour.
pub fn format_time(seconds: f64) -> String {
if !seconds.is_finite() || seconds < 0.0 {
@@ -95,6 +185,12 @@ pub fn format_time(seconds: f64) -> String {
}
}
/// `1.25×`, or `1×`.
pub fn format_speed(speed: f64) -> String {
let text = format!("{speed:.2}");
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
}
/// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
@@ -113,14 +209,14 @@ pub fn format_size(bytes: i64) -> String {
/// A section title with a rule under it.
pub fn section(ui: &mut egui::Ui, text: &str) {
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.label(
theme::Role::Section
.rich(text)
.strong()
.color(theme::text()),
);
ui.add_space(2.0);
ui.add_space(theme::space::XXS);
}
/// Small grey text, for everything that is context rather than content.
@@ -133,6 +229,184 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
}
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
///
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
let id = egui::Id::new("library-search");
let width = 240.0;
let height = ui.spacing().interact_size.y;
let (outer, _) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
let has_focus = ui.memory(|memory| memory.has_focus(id));
let lit = ui
.ctx()
.animate_bool_with_time(id.with("lit"), has_focus, theme::motion::HOVER);
let radius = CornerRadius::same((height / 2.0) as u8);
ui.painter().rect_filled(outer, radius, theme::card());
ui.painter().rect_stroke(
outer,
radius,
Stroke::new(1.0, theme::card_hover().lerp_to_gamma(theme::accent(), lit)),
egui::StrokeKind::Inside,
);
// A magnifier: a ring and a handle, so no font has to have the glyph.
let ink = theme::muted().lerp_to_gamma(theme::text(), lit);
let lens = egui::pos2(outer.left() + 16.0, outer.center().y - 1.0);
ui.painter().circle_stroke(lens, 5.0, Stroke::new(1.5, ink));
ui.painter().line_segment(
[lens + Vec2::new(3.6, 3.6), lens + Vec2::new(7.0, 7.0)],
Stroke::new(1.5, ink),
);
let clear_width = if search.is_empty() { 0.0 } else { 26.0 };
let field_rect = Rect::from_min_max(
egui::pos2(outer.left() + 28.0, outer.top()),
egui::pos2(outer.right() - 8.0 - clear_width, outer.bottom()),
);
let response = ui.put(
field_rect,
egui::TextEdit::singleline(search)
.id(id)
.frame(egui::Frame::NONE)
.hint_text(if has_focus {
"Titles and filenames"
} else {
"Search Ctrl+F"
})
.vertical_align(egui::Align::Center)
.desired_width(field_rect.width()),
);
if focus {
response.request_focus();
}
if response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Escape)) {
search.clear();
response.surrender_focus();
}
if !search.is_empty() {
let clear = Rect::from_center_size(
egui::pos2(outer.right() - 18.0, outer.center().y),
Vec2::splat(18.0),
);
let response = ui.interact(clear, id.with("clear"), Sense::click());
let ink = if response.hovered() {
theme::text()
} else {
theme::muted()
};
let c = clear.center();
let d = 3.5;
ui.painter().line_segment(
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
Stroke::new(1.5, ink),
);
ui.painter().line_segment(
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink),
);
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Clear (Esc)")
.clicked()
{
search.clear();
}
}
}
/// A thin bar in the accent, for how far something has got.
///
/// egui's own progress bar is one flat colour with the percentage written over
/// it; this is the same bar the tiles and the seek bar use, so a download reads
/// as part of the same app.
pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover());
if ui.is_rect_visible(rect) {
let radius = CornerRadius::same(theme::radius::BAR);
ui.painter().rect_filled(rect, radius, theme::card_hover());
let mut done = rect;
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
if done.width() > 0.5 {
theme::accent_fill(ui.painter(), done, radius, 0.0);
}
}
response
}
/// A centred message for a page with nothing on it: what is missing, and what
/// to do about it.
pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) {
ui.vertical_centered(|ui| {
ui.add_space(96.0);
ui.label(theme::Role::Title.rich(heading).strong());
ui.add_space(theme::space::S);
ui.label(muted(body));
});
}
/// The danger colour as a button: for the one choice in a dialog that cannot be
/// taken back.
pub fn danger_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
ui.add(
egui::Button::new(egui::RichText::new(text).color(theme::on_accent()))
.fill(theme::danger()),
)
}
/// What a viewer answered a [`confirm`] dialog with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Answer {
/// Still open.
Pending,
Confirmed,
/// Cancelled — by the button, Escape, or a click beside the dialog.
Declined,
}
/// A question with two answers, over everything else.
///
/// For the actions that destroy something the viewer cannot get back from
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
/// the click.
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
let mut answer = Answer::Pending;
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(420.0);
ui.label(
theme::Role::Heading
.rich(heading)
.strong()
.color(theme::text()),
);
ui.add_space(theme::space::S);
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
ui.add_space(theme::space::XL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if danger_button(ui, verb).clicked() {
answer = Answer::Confirmed;
}
if ui.button("Cancel").clicked() {
answer = Answer::Declined;
}
});
});
if answer == Answer::Pending && modal.should_close() {
answer = Answer::Declined;
}
answer
}
/// What [`filled`] paints behind its label.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fill {
@@ -176,15 +450,17 @@ pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<u
// the pill from overshooting when it moves between tabs of different
// lengths — both edges arrive at the same time.
let ctx = ui.ctx();
let left = ctx.animate_value_with_time(id.with("left"), target.left(), 0.16);
let right = ctx.animate_value_with_time(id.with("right"), target.right(), 0.16);
let left =
ctx.animate_value_with_time(id.with("left"), target.left(), theme::motion::STATE);
let right =
ctx.animate_value_with_time(id.with("right"), target.right(), theme::motion::STATE);
let rect = Rect::from_min_max(
egui::pos2(left, target.top()),
egui::pos2(right, target.bottom()),
);
ui.painter().set(
pill,
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(8)),
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
);
}
@@ -225,17 +501,21 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
return response;
}
let radius = CornerRadius::same(8);
let radius = CornerRadius::same(theme::radius::MD);
// Eased rather than switched, so the veil arrives over a few frames instead
// of appearing whole the moment the pointer crosses the edge. The press is
// quicker than the hover: a click should feel like it registered, not like
// it was considered.
let ctx = ui.ctx().clone();
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let press = ctx.animate_bool_with_time(
response.id.with("press"),
response.is_pointer_button_down_on(),
0.05,
theme::motion::PRESS,
);
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
@@ -279,6 +559,20 @@ pub struct Card<'a> {
/// sideways-scrolling shelf has no edge to reach and passes
/// [`theme::CARD_WIDTH`].
pub width: f32,
/// The picture's height over its width: [`theme::CARD_ASPECT`] for a
/// still, [`theme::POSTER_ASPECT`] for a cover.
pub aspect: f32,
}
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
/// before the name is laid out — and because a grid whose rows are each a
/// different height by title length reads as broken.
const CARD_TEXT_HEIGHT: f32 = 54.0;
/// How tall [`card`] draws a tile of this width and shape, so a grid can step
/// over rows it does not draw.
pub fn card_height(width: f32, aspect: f32) -> f32 {
(width * aspect).round() + CARD_TEXT_HEIGHT
}
/// Draw one tile and report whether it was clicked.
@@ -291,15 +585,20 @@ pub struct Card<'a> {
/// reads as deliberate in a way a stretched poster does not.
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = card.width;
let image_height = (width * theme::CARD_ASPECT).round();
// Two lines of name plus one of subtitle. Fixed, because the tile is
// allocated before the name is laid out — and because a grid whose rows are
// each a different height by title length reads as broken.
let text_height = 54.0;
let radius = CornerRadius::same(8);
let image_height = (width * card.aspect).round();
// A cover fitted into a still-shaped tile is drawn whole; into a tile its
// own shape, it fills it like anything else.
let fit = card.aspect < 1.0;
let text_height = CARD_TEXT_HEIGHT;
let radius = CornerRadius::same(theme::radius::MD);
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
// Reached with the arrow keys or Tab: bring it into view, before the
// early return below would skip a tile that is just off screen.
if response.gained_focus() {
response.scroll_to_me(None);
}
if !ui.is_rect_visible(rect) {
return response;
}
@@ -309,15 +608,21 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
// ride it, so they arrive together rather than each snapping on its own.
// egui drives this from wall-clock time, so it is the same speed whatever
// the frame rate — and it needs no state kept here.
let hover =
ui.ctx()
.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12);
// Focus lights a tile the way the pointer does, so the keyboard can be
// followed across the grid.
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered() || response.has_focus(),
theme::motion::HOVER,
);
// The picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle.
let art_in = ui
.ctx()
.animate_bool_with_time(response.id.with("art"), card.art.is_some(), 0.22);
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
card.art.is_some(),
theme::motion::ARRIVE,
);
let painter = ui.painter();
if hover > 0.0 {
@@ -328,13 +633,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
// Under the picture, not instead of it: it is what shows through while the
// art fades in, and it is the whole tile when there is no art to come.
if art_in < 1.0 {
painter.text(
image_rect.center(),
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::muted().gamma_multiply(1.0 - art_in),
);
placeholder(painter, image_rect, card.name, 1.0 - art_in);
}
// White because a textured rect *multiplies* its fill by the texture; the
@@ -348,7 +647,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
.with_texture(texture.id(), cover_uv(size, image_rect.size())),
);
}
Some((texture, ArtShape::Portrait)) => {
Some((texture, ArtShape::Portrait)) if fit => {
painter.add(
egui::epaint::RectShape::filled(
contain_rect(texture.size_vec2(), image_rect),
@@ -361,6 +660,14 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
),
);
}
Some((texture, ArtShape::Portrait)) => {
painter.add(
egui::epaint::RectShape::filled(image_rect, radius, ink).with_texture(
texture.id(),
cover_uv(texture.size_vec2(), image_rect.size()),
),
);
}
None => {}
}
@@ -374,7 +681,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
);
painter.rect_filled(
background,
CornerRadius::same(4),
CornerRadius::same(theme::radius::BADGE),
Color32::from_black_alpha(180),
);
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
@@ -470,7 +777,7 @@ fn contain_rect(image: Vec2, target: Rect) -> Rect {
}
/// UV rectangle that crops `image` to fill `target` without distorting it.
fn cover_uv(image: Vec2, target: Vec2) -> Rect {
pub fn cover_uv(image: Vec2, target: Vec2) -> Rect {
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
}
@@ -485,14 +792,55 @@ fn cover_uv(image: Vec2, target: Vec2) -> Rect {
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
}
/// Up to two letters, for a tile with no picture.
fn initials(name: &str) -> String {
name.split_whitespace()
.filter_map(|word| word.chars().next())
.filter(|character| character.is_alphanumeric())
.take(2)
.flat_map(char::to_uppercase)
.collect()
/// What a tile with no picture shows: a gradient of its own, and its name.
///
/// Two grey letters on a grey card was the whole tile for any title nothing
/// knew a picture for — a wall of them read as a page that had failed to load.
/// The colours come from the name, so a title wears the same ones every time
/// and two titles side by side rarely match.
///
/// The hash is FNV-1a: stable across runs and platforms, which the standard
/// library's hasher deliberately is not.
pub(crate) fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opacity: f32) {
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
});
let hue = (hash % 360) as f32 / 360.0;
let light = theme::palette().light;
let (saturation, value) = if light { (0.25, 0.92) } else { (0.45, 0.34) };
let from: Color32 = egui::ecolor::Hsva::new(hue, saturation, value, 1.0).into();
let to: Color32 =
egui::ecolor::Hsva::new((hue + 0.12) % 1.0, saturation, value * 0.65, 1.0).into();
theme::ramp_fill(
painter,
rect,
CornerRadius::same(theme::radius::MD),
(from, to),
theme::Direction::Vertical,
opacity,
);
let ink = if light {
Color32::from_black_alpha(170)
} else {
Color32::from_white_alpha(215)
}
.gamma_multiply(opacity);
let mut job = egui::text::LayoutJob::simple(
name.to_owned(),
theme::Role::Heading.font(),
ink,
rect.width() - 28.0,
);
job.wrap.max_rows = 3;
job.wrap.overflow_character = Some('…');
job.halign = egui::Align::Center;
let galley = painter.layout_job(job);
painter.galley(
egui::pos2(rect.center().x, rect.center().y - galley.size().y / 2.0),
galley,
ink,
);
}
/// How a grid divides the width it was given.
@@ -510,9 +858,13 @@ pub struct Grid {
/// dead space at the right of every row, and on a wide window that reads as a
/// layout that failed to line up rather than as a margin.
pub fn columns(available: f32) -> Grid {
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor()
as usize)
.max(1);
columns_of(available, theme::CARD_WIDTH)
}
/// [`columns`], for cards at least `minimum` wide.
pub fn columns_of(available: f32, minimum: f32) -> Grid {
let columns =
(((available + theme::CARD_GAP) / (minimum + theme::CARD_GAP)).floor() as usize).max(1);
// The gaps come out of the width before it is split. Floored, because a
// fraction of a point times four columns is enough to push the last card
// onto a row of its own. A window too narrow for even one full card gets a
@@ -567,13 +919,6 @@ mod tests {
);
}
#[test]
fn initials_take_two_letters() {
assert_eq!(initials("Cowboy Bebop"), "CB");
assert_eq!(initials("Akira"), "A");
assert_eq!(initials(""), "");
}
#[test]
fn column_count_never_reaches_zero() {
assert_eq!(columns(0.0).columns, 1);
+506 -94
View File
@@ -66,6 +66,10 @@ const SKIP_PAD_X: f32 = 22.0;
/// on the full wash. See [`scrim_bottom`].
const SCRIM_ALPHA: f32 = 170.0;
const SCRIM_FADE: f32 = 88.0;
/// How wide the episode list is, at most. A narrow window gives it less.
const DRAWER_WIDTH: f32 = 380.0;
/// The stills in the episode list.
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
/// State the page keeps between frames.
///
@@ -109,6 +113,17 @@ pub struct Chrome<'a> {
pub up_next: Option<UpNextCard>,
/// Whether stepping to another episode is possible in either direction.
pub neighbours: Neighbours,
/// The title's episodes, when it has more than one — what the list down
/// the right of the picture shows.
pub episodes: Option<Episodes<'a>>,
}
/// The playing title's episodes, for the list beside the picture.
pub struct Episodes<'a> {
pub title: &'a pstr_core::library::Title,
pub art: ui::Art<'a>,
/// Whether the list is out.
pub open: bool,
}
/// What the end-of-episode card says, when there is one.
@@ -118,11 +133,21 @@ pub struct Chrome<'a> {
pub struct UpNextCard {
/// Seconds until the next episode starts.
pub seconds: f64,
/// The same, as a fraction of the whole countdown, for the ring.
pub left: f32,
/// Which episode that is.
pub caption: String,
/// Its still, once there is one.
pub still: Option<egui::TextureHandle>,
}
impl Overlay {
/// How much of the bottom of the window the controls take, the last time
/// they were drawn — so things floating over the picture can keep clear.
pub fn covered(&self) -> f32 {
self.chrome_height
}
/// Fold this frame's pointer into the overlay's timer.
pub fn observe(&mut self, ctx: &egui::Context) {
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
@@ -177,6 +202,7 @@ pub fn show(
overlay,
up_next,
neighbours,
mut episodes,
} = chrome_state;
let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap();
@@ -200,6 +226,8 @@ pub fn show(
if !picture {
waiting(ui, playback, rect);
} else if playback.buffering || playback.seeking {
stalled(ui, rect);
}
// Click anywhere on the picture to pause, which is the one control that
@@ -208,10 +236,18 @@ pub fn show(
actions.push(Action::Player(Command::TogglePause));
}
if response.double_clicked() {
// The first half of a double click already paused, and a viewer who
// double-clicked asked for fullscreen, not for a pause — so the second
// half takes the pause back. Waiting out the double-click delay before
// pausing would make every single click feel late instead.
actions.push(Action::Player(Command::TogglePause));
actions.push(Action::ToggleFullscreen);
}
let visible = overlay.visible(&ctx, playback);
let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
// The controls stay up while the list is out: it hangs off the button in
// the title strip, and is read at leisure.
let visible = overlay.visible(&ctx, playback) || drawer_open;
// Above the chrome when it is up, at the bottom corner when it is not —
// and clear of it either way, which is what the measured height buys.
let float_above = if visible {
@@ -226,16 +262,37 @@ pub fn show(
None => skip(ui, playback, rect, float_above, actions),
}
if visible {
overlay.chrome_height = chrome(
ui,
playback,
neighbours,
rect,
overlay.chrome_height,
actions,
);
// Last frame's height, like everything else that has to clear the controls.
playback.lift_subtitles(
if visible { overlay.chrome_height } else { 0.0 },
rect.height(),
);
// Faded rather than switched, both ways: controls that blink out under a
// reading eye are more distracting than the ones that were there.
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, theme::motion::FADE);
if fade > 0.0 {
overlay.chrome_height = ui
.scope(|ui| {
ui.multiply_opacity(fade);
chrome(
ui,
playback,
neighbours,
episodes.as_ref().map(|list| list.open),
rect,
overlay.chrome_height,
actions,
)
})
.inner;
}
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
} else {
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
}
if !visible {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
// The controls have to disappear on their own, and nothing else is
// going to cause a frame while a film plays without the mouse moving.
@@ -303,6 +360,18 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
);
}
/// A spinner over a picture held waiting for data. Without it, a frozen frame
/// with the controls hidden looks the same as a hang.
fn stalled(ui: &mut egui::Ui, rect: Rect) {
let disc = Rect::from_center_size(rect.center(), Vec2::splat(64.0));
ui.painter()
.circle_filled(disc.center(), 32.0, Color32::from_black_alpha(140));
ui.put(
Rect::from_center_size(disc.center(), Vec2::splat(36.0)),
egui::Spinner::new().size(36.0).color(Color32::WHITE),
);
}
/// The screen between two episodes: one has ended and the next is opening.
///
/// It keeps a way out, because opening a file over a public link can take
@@ -335,7 +404,7 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
if ui.button("Back").clicked() {
actions.push(Action::LeavePlayer);
}
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.add(egui::Spinner::new().size(14.0));
},
);
@@ -428,60 +497,114 @@ fn up_next_card(
bottom_margin: f32,
actions: &mut Vec<Action>,
) {
let size = Vec2::new(330.0, 104.0);
const STILL: Vec2 = Vec2::new(128.0, 72.0);
let size = Vec2::new(400.0, 104.0);
floating(ui, rect, size, bottom_margin, |ui| {
egui::Frame::new()
.fill(Color32::from_black_alpha(215))
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(14, 12))
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(12))
.show(ui, |ui| {
ui.set_width(size.x - 28.0);
ui.set_width(size.x - 24.0);
ui.horizontal(|ui| {
ui.label(
theme::Role::Caption
.rich("Up next")
.strong()
.color(Color32::from_white_alpha(200)),
// The still, with the countdown running round it: what is
// coming and when, read in one glance.
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
let painter = ui.painter();
let radius = CornerRadius::same(theme::radius::SM);
match &card.still {
Some(texture) => {
painter.add(
egui::epaint::RectShape::filled(frame, radius, Color32::WHITE)
.with_texture(
texture.id(),
ui::cover_uv(texture.size_vec2(), frame.size()),
),
);
}
None => {
painter.rect_filled(frame, radius, Color32::from_white_alpha(20));
}
}
painter.rect_filled(frame, radius, Color32::from_black_alpha(90));
countdown_ring(painter, frame.center(), 22.0, card.left);
// Rounded up, so a ring that says "1" is never followed by
// a second of nothing happening.
painter.text(
frame.center(),
Align2::CENTER_CENTER,
card.seconds.ceil() as u32,
theme::Role::Body.font(),
Color32::WHITE,
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Rounded up, so a card that says "1s" is never
// followed by a second of nothing happening.
ui.label(
theme::Role::Caption
.rich(format!("in {}s", card.seconds.ceil() as u32))
.color(theme::accent()),
);
});
});
ui.add(
egui::Label::new(
theme::Role::Body
.rich(&card.caption)
.strong()
.color(Color32::WHITE),
)
.truncate(),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Play now").clicked() {
if response.on_hover_text("Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
ui.add_space(6.0);
if ui
.button("Watch till the end")
.on_hover_text("Play this one out — nothing will be skipped")
.clicked()
{
actions.push(Action::WatchToEnd);
}
ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.label(
theme::Role::Caption
.rich("Up next")
.strong()
.color(Color32::from_white_alpha(200)),
);
ui.add(
egui::Label::new(
theme::Role::Body
.rich(&card.caption)
.strong()
.color(Color32::WHITE),
)
.truncate(),
);
ui.add_space(theme::space::S);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
ui.add_space(theme::space::S);
if ui
.button("Watch till the end")
.on_hover_text("Play this one out — nothing will be skipped")
.clicked()
{
actions.push(Action::WatchToEnd);
}
});
});
});
});
});
}
/// A ring that empties clockwise from twelve o'clock as `left` runs to zero.
fn countdown_ring(painter: &egui::Painter, center: egui::Pos2, radius: f32, left: f32) {
painter.circle_stroke(
center,
radius,
egui::Stroke::new(3.0, Color32::from_white_alpha(60)),
);
let left = left.clamp(0.0, 1.0);
if left <= 0.0 {
return;
}
let steps = (64.0 * left).ceil().max(2.0) as usize;
let sweep = std::f32::consts::TAU * left;
let points: Vec<egui::Pos2> = (0..=steps)
.map(|step| {
let angle = -std::f32::consts::FRAC_PI_2 + sweep * step as f32 / steps as f32;
center + radius * Vec2::new(angle.cos(), angle.sin())
})
.collect();
painter.add(egui::Shape::line(
points,
egui::Stroke::new(3.0, theme::accent()),
));
}
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
///
/// The inset matches the controls' own padding rather than being its own
@@ -520,6 +643,7 @@ fn chrome(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
drawer: Option<bool>,
rect: Rect,
previous_height: f32,
actions: &mut Vec<Action>,
@@ -535,7 +659,7 @@ fn chrome(
.add(
egui::Button::new(theme::Role::Label.rich("Library"))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(96.0, 32.0)),
)
.on_hover_text("Keep playing, and go back to the library (Esc)")
@@ -543,7 +667,7 @@ fn chrome(
{
actions.push(Action::LeavePlayer);
}
ui.add_space(14.0);
ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.label(
theme::Role::Heading
@@ -559,18 +683,56 @@ fn chrome(
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
if ui
.add(
egui::Button::new(theme::Role::Subhead.rich("⛶"))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.min_size(Vec2::new(38.0, 32.0)),
egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
egui_phosphor::regular::CORNERS_IN
} else {
egui_phosphor::regular::CORNERS_OUT
}))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Fullscreen (F)")
.on_hover_text(if fullscreen {
"Leave fullscreen (F)"
} else {
"Fullscreen (F)"
})
.clicked()
{
actions.push(Action::ToggleFullscreen);
}
if let Some(open) = drawer {
ui.add_space(theme::space::S);
let fill = if open {
theme::accent()
} else {
Color32::from_black_alpha(160)
};
let ink = if open {
theme::on_accent()
} else {
Color32::WHITE
};
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::LIST_BULLETS)
.color(ink),
)
.fill(fill)
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Episodes (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
}
}
});
},
);
@@ -609,29 +771,225 @@ fn chrome(
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
}
/// The gradient behind the title strip, fading downwards out of the top edge.
///
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient
/// primitive, and at this size the banding is invisible.
fn scrim_top(ui: &egui::Ui, rect: Rect) {
const BANDS: usize = 10;
let painter = ui.painter();
let band = rect.height() / BANDS as f32;
for index in 0..BANDS {
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32;
let strip = Rect::from_min_size(
egui::pos2(rect.left(), rect.top() + band * index as f32),
Vec2::new(rect.width(), band + 1.0),
);
painter.rect_filled(
strip,
CornerRadius::ZERO,
// Squared, so the fade is dark where the text is and gone well
// before the middle of the picture — a linear ramp over a strip
// this tall greys the whole top third of the frame.
Color32::from_black_alpha((fraction * fraction * 225.0) as u8),
);
/// Which file the episode list last scrolled to, in egui's memory, so it
/// scrolls to the playing one once when it opens rather than every frame.
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
/// The title's episodes, down the right of the picture between the title
/// strip and the controls. A season at a time, opened on the playing one.
fn episode_drawer(
ctx: &egui::Context,
list: &mut Episodes<'_>,
playback: &Playback,
rect: Rect,
chrome_height: f32,
actions: &mut Vec<Action>,
) {
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
let area = Rect::from_min_max(
egui::pos2(rect.right() - width - 16.0, rect.top() + TITLE_HEIGHT),
egui::pos2(rect.right() - 16.0, rect.bottom() - chrome_height - 8.0),
);
if area.height() < 160.0 {
return;
}
let title = list.title;
let target = &playback.target;
let is_playing = |episode: &pstr_core::library::Episode| {
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
};
let playing_season = title
.seasons
.iter()
.position(|season| season.episodes.iter().any(is_playing))
.unwrap_or(0);
let season_id = egui::Id::new(("episode-drawer-season", &title.key));
let mut season = ctx
.data(|data| data.get_temp::<usize>(season_id))
.unwrap_or(playing_season)
.min(title.seasons.len().saturating_sub(1));
let margin = theme::space::L;
egui::Area::new(egui::Id::new("episode-drawer"))
.order(egui::Order::Foreground)
.fixed_pos(area.min)
.show(ctx, |ui| {
egui::Frame::new()
.fill(theme::surface().gamma_multiply(0.96))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(margin as i8))
.shadow(theme::tile_shadow(1.0))
.show(ui, |ui| {
ui.set_width(area.width() - 2.0 * margin);
ui.set_height(area.height() - 2.0 * margin);
ui.horizontal(|ui| {
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.on_hover_text("Close (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
}
});
});
if title.seasons.len() > 1 {
ui.add_space(theme::space::XS);
let labels: Vec<(usize, String)> = title
.seasons
.iter()
.enumerate()
.map(|(index, season)| (index, season.label()))
.collect();
let choices: Vec<(usize, &str)> = labels
.iter()
.map(|(index, label)| (*index, label.as_str()))
.collect();
egui::ScrollArea::horizontal()
.id_salt("drawer-seasons")
.show(ui, |ui| {
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
season = picked;
ctx.data_mut(|data| data.insert_temp(season_id, picked));
}
});
}
ui.add_space(theme::space::S);
let Some(shown) = title.seasons.get(season) else {
return;
};
egui::ScrollArea::vertical()
.id_salt("drawer-episodes")
.auto_shrink([false, false])
.show(ui, |ui| {
for episode in &shown.episodes {
let row = drawer_row(
ui,
&mut list.art,
title,
episode,
is_playing(episode),
actions,
);
if is_playing(episode) {
let scrolled = ctx.data(|data| {
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
});
if scrolled.as_deref() != Some(target.link_id.as_str()) {
row.scroll_to_me(Some(egui::Align::Center));
ctx.data_mut(|data| {
data.insert_temp(
egui::Id::new(DRAWER_SCROLLED),
target.link_id.clone(),
)
});
}
}
}
});
});
});
}
/// One episode in the list: its still, which plays it, and what it is.
fn drawer_row(
ui: &mut egui::Ui,
art: &mut ui::Art<'_>,
title: &pstr_core::library::Title,
episode: &pstr_core::library::Episode,
playing: bool,
actions: &mut Vec<Action>,
) -> egui::Response {
let frame = egui::Frame::new()
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(6))
.fill(if playing {
theme::accent_dim()
} else {
Color32::TRANSPARENT
});
let response = frame
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
ui::title::still(
ui,
art,
title,
episode,
episode.is_watched(),
DRAWER_STILL,
actions,
);
ui.add_space(theme::space::M);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = theme::space::XXS;
let name = art
.episode(&title.key, episode)
.and_then(|found| found.name.clone())
.unwrap_or_else(|| episode.detail().to_owned());
if let Some(numbering) = episode.numbering() {
ui.label(
egui::RichText::new(numbering)
.monospace()
.color(theme::muted()),
);
}
let mut job = egui::text::LayoutJob::simple(
name,
theme::Role::Label.font(),
if episode.is_watched() && !playing {
theme::muted()
} else {
theme::text()
},
ui.available_width(),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
let state = if playing {
Some("Playing".to_owned())
} else if episode.is_watched() {
Some("Watched".to_owned())
} else {
episode
.resume_at()
.map(|at| format!("Stopped at {}", ui::format_time(at)))
};
if let Some(state) = state {
ui.label(theme::Role::Caption.rich(state).color(if playing {
theme::accent()
} else {
theme::muted()
}));
}
});
});
})
.response;
ui.add_space(theme::space::XXS);
response
}
/// The gradient behind the title strip, fading downwards out of the top edge.
fn scrim_top(ui: &egui::Ui, rect: Rect) {
// Squared, so the fade is dark where the text is and gone well before the
// middle of the picture — a linear ramp over a strip this tall greys the
// whole top third of the frame.
fade(ui.painter(), rect, |t| {
let fraction = 1.0 - t;
fraction * fraction * 225.0
});
}
/// The wash behind the controls: flat over the whole strip, fading in above it.
@@ -642,27 +1000,81 @@ fn scrim_top(ui: &egui::Ui, rect: Rect) {
/// strip itself is one flat wash, and the ramp happens in the empty picture
/// above it, where there is nothing to obscure.
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
const BANDS: usize = 12;
let painter = ui.painter();
painter.rect_filled(
strip,
CornerRadius::ZERO,
Color32::from_black_alpha(SCRIM_ALPHA as u8),
);
let above = Rect::from_min_max(
egui::pos2(strip.left(), strip.top() - SCRIM_FADE),
egui::pos2(strip.right(), strip.top()),
);
// Squared again: the wash should meet the strip at full strength and be
// gone within a finger's width of picture above it.
fade(painter, above, |t| t * t * SCRIM_ALPHA);
}
let band = SCRIM_FADE / BANDS as f32;
for index in 0..BANDS {
let fraction = (index + 1) as f32 / BANDS as f32;
let rect = Rect::from_min_size(
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32),
Vec2::new(strip.width(), band + 1.0),
);
painter.rect_filled(
rect,
CornerRadius::ZERO,
// Squared again: the wash should meet the strip at full strength
// and be gone within a finger's width of picture above it.
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8),
);
/// Black over `rect`, at the alpha `curve` gives for each fraction of the way
/// down it.
///
/// One mesh with a colour per row of vertices, which the GPU interpolates
/// between. It replaced a stack of flat bands, and the bands were what showed:
/// each overlapped the next by a pixel so no gap could open between them, and
/// every overlap was a line of double darkness across the picture.
fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
/// Enough rows that the curve between them reads as a curve.
const ROWS: u32 = 16;
let mut mesh = egui::Mesh::default();
for row in 0..=ROWS {
let t = row as f32 / ROWS as f32;
let y = egui::lerp(rect.top()..=rect.bottom(), t);
let colour = Color32::from_black_alpha(curve(t).clamp(0.0, 255.0) as u8);
mesh.colored_vertex(egui::pos2(rect.left(), y), colour);
mesh.colored_vertex(egui::pos2(rect.right(), y), colour);
if row > 0 {
let base = (row - 1) * 2;
mesh.add_triangle(base, base + 1, base + 2);
mesh.add_triangle(base + 1, base + 3, base + 2);
}
}
painter.add(egui::Shape::mesh(mesh));
}
/// What a key just did, in a pill near the top of the picture, for a moment.
///
/// Returns whether it is still showing.
pub fn osd(ctx: &egui::Context, text: &str, at: f64) -> bool {
/// How long it stays, and how much of that is fading out.
const SECONDS: f64 = 0.9;
const FADE: f64 = 0.25;
let age = ctx.input(|input| input.time) - at;
if age >= SECONDS {
return false;
}
let opacity = ((SECONDS - age) / FADE).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("osd"),
));
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Title.font(), Color32::WHITE);
let size = galley.size() + Vec2::new(36.0, 18.0);
let screen = ctx.content_rect();
let frame = Rect::from_center_size(
egui::pos2(screen.center().x, screen.top() + TITLE_HEIGHT + 40.0),
size,
);
painter.rect_filled(
frame,
CornerRadius::same((size.y / 2.0) as u8),
Color32::from_black_alpha((190.0 * opacity) as u8),
);
painter.galley_with_override_text_color(
frame.min + Vec2::new(18.0, 9.0),
galley,
Color32::WHITE.gamma_multiply(opacity),
);
ctx.request_repaint();
true
}
+369 -154
View File
@@ -5,10 +5,11 @@
//! that manages Proton Drive links, and a page that is half link management and
//! half preferences is two pages.
use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
use crate::theme;
use crate::ui;
@@ -20,9 +21,17 @@ use crate::ui;
#[derive(Debug, Clone, Copy)]
pub struct Prefs {
pub autoplay: bool,
pub auto_skip: bool,
pub appearance: Appearance,
pub desktop: DesktopPrefs,
/// What the streaming cache holds, once the shares are open.
pub cache_used: Option<u64>,
}
/// The widest the settings column gets. Rows that run the width of a 2K window
/// put a switch a whole screen away from what it switches.
const COLUMN: f32 = 760.0;
pub fn show(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
@@ -33,13 +42,22 @@ pub fn show(
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
appearance_form(ui, prefs.appearance, actions);
ui.add_space(28.0);
playback_form(ui, prefs.autoplay, actions);
ui.add_space(28.0);
metadata_form(ui, settings, api_key, actions);
// Centred, so a wide window frames the column rather than leaving it
// against one edge with the rest of the page empty.
let width = ui.available_width().min(COLUMN);
let margin = ((ui.available_width() - width) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(margin);
ui.vertical(|ui| {
ui.set_width(width);
ui.add_space(theme::space::M);
appearance_form(ui, prefs.appearance, actions);
playback_form(ui, prefs, actions);
storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions);
about(ui);
});
});
});
}
@@ -49,19 +67,14 @@ pub fn show(
/// there is no "apply" — because the preview *is* the app. A swatch row that
/// only changed a small square would be asking someone to imagine the result.
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
ui::section(ui, "Appearance");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Palette"));
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
ui.add_space(theme::space::L);
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
ui.add_space(theme::space::M);
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL {
if ui
.radio(appearance.flavor == flavor, flavor.label())
if palette_card(ui, appearance, flavor)
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
@@ -72,35 +85,158 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
}));
}
}
});
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 0.0;
ui.add_space(12.0);
ui.label(ui::muted("Accent"));
ui.horizontal_wrapped(|ui| {
for accent in Accent::ALL {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance {
accent,
..appearance
}));
}
ui::widgets::settings_row(ui, "Accent", None, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// Right to left, so reversed to read in the order they are listed.
for accent in Accent::ALL.into_iter().rev() {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance {
accent,
..appearance
}));
}
});
ui.add_space(12.0);
let mut gradients = appearance.gradients;
if ui
.checkbox(&mut gradients, "Fade the accent into a second colour")
.on_hover_text(
"Off draws it flat — better on a panel that bands a gradient into stripes",
)
.changed()
{
actions.push(Action::SetAppearance(Appearance {
gradients,
..appearance
}));
}
});
ui::widgets::settings_row(
ui,
"Library tiles",
Some("Covers, or 16:9 stills. Covers are the art every matched title has."),
|ui| {
let choices: Vec<(Tiles, &str)> = Tiles::ALL
.iter()
.map(|tiles| (*tiles, tiles.label()))
.collect();
if let Some(tiles) = ui::widgets::segmented(ui, appearance.tiles, &choices) {
actions.push(Action::SetAppearance(Appearance {
tiles,
..appearance
}));
}
},
);
ui::widgets::settings_row(
ui,
"Title names",
Some("The name a matched title goes by. Its other names still find it in search."),
|ui| {
let choices: Vec<(TitleNames, &str)> = TitleNames::ALL
.iter()
.map(|names| (*names, names.label()))
.collect();
if let Some(names) = ui::widgets::segmented(ui, appearance.names, &choices) {
actions.push(Action::SetAppearance(Appearance {
names,
..appearance
}));
}
},
);
let mut gradients = appearance.gradients;
ui::widgets::settings_row(
ui,
"Gradient accent",
Some(
"Fade the accent into a second colour. Off draws it flat, which suits a panel that bands gradients into stripes.",
),
|ui| {
if ui::widgets::toggle(ui, &mut gradients, "").changed() {
actions.push(Action::SetAppearance(Appearance {
gradients,
..appearance
}));
}
},
);
});
}
/// One palette, as a miniature of the app wearing it: page, a card on it, a
/// line of text and the accent.
fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> egui::Response {
let size = egui::vec2(132.0, 92.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
flavor,
..appearance
});
let selected = appearance.flavor == flavor;
let hover =
ui.ctx()
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
let painter = ui.painter();
let radius = egui::CornerRadius::same(theme::radius::MD);
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
painter.rect_filled(preview, radius, palette.background);
let bar = egui::Rect::from_min_size(preview.min, egui::vec2(size.x, 12.0));
painter.rect_filled(
bar,
egui::CornerRadius {
nw: 8,
ne: 8,
sw: 0,
se: 0,
},
palette.surface,
);
let tile = |x: f32| {
egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0))
};
for x in [8.0, 49.0, 90.0] {
painter.rect_filled(
tile(x),
egui::CornerRadius::same(theme::radius::BAR),
palette.card,
);
}
let pill =
egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0));
theme::swatch_fill(
painter,
pill,
egui::CornerRadius::same(theme::radius::BAR),
&palette,
);
painter.hline(
(preview.left() + 8.0)..=(preview.left() + 60.0),
preview.top() + 50.0,
egui::Stroke::new(2.0, palette.text),
);
painter.hline(
(preview.left() + 8.0)..=(preview.left() + 40.0),
preview.top() + 56.0,
egui::Stroke::new(2.0, palette.muted),
);
let ring = if selected {
egui::Stroke::new(2.0, theme::accent())
} else {
egui::Stroke::new(
1.0,
theme::card_hover().lerp_to_gamma(theme::muted(), hover),
)
};
painter.rect_stroke(preview, radius, ring, egui::StrokeKind::Outside);
painter.text(
egui::pos2(rect.left() + 2.0, preview.bottom() + 8.0),
egui::Align2::LEFT_TOP,
flavor.label(),
theme::Role::Label.font(),
if selected {
theme::text()
} else {
theme::muted()
},
);
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// One accent, drawn in the colours it would actually produce.
@@ -109,23 +245,22 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
/// the row previews what clicking it would give rather than what is on screen.
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
let selected = appearance.accent == accent;
let size = egui::Vec2::new(52.0, 26.0);
let size = egui::Vec2::splat(26.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
accent,
..appearance
});
let radius = egui::CornerRadius::same(6);
let radius = egui::CornerRadius::same(13);
theme::swatch_fill(ui.painter(), rect, radius, &palette);
if selected {
// A ring rather than a tick: the tick would have to be drawn in an
// ink that reads on eight different fills.
ui.painter().rect_stroke(
rect.expand(2.0),
egui::CornerRadius::same(8),
ui.painter().circle_stroke(
rect.center(),
rect.width() / 2.0 + 3.0,
egui::Stroke::new(2.0, theme::text()),
egui::StrokeKind::Outside,
);
}
}
@@ -134,34 +269,120 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The handful of playback settings that are not per-file.
/// The playback settings that are not per-file.
///
/// Volume, audio language and subtitle language are all set from the player
/// itself, where a viewer can hear the result — only autoplay has no natural
/// home there, because by the time it matters the episode is over.
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
ui::section(ui, "Playback");
/// Volume, audio language, subtitle language and speed are all set from the
/// player itself, where a viewer can hear the result.
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Playback",
Some(
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
),
|ui| {
let mut autoplay = prefs.autoplay;
ui::widgets::settings_row(
ui,
"Play the next episode",
Some("When an episode ends, start the next one. Never after a file that failed."),
|ui| {
if ui::widgets::toggle(ui, &mut autoplay, "").changed() {
actions.push(Action::SetAutoplay(autoplay));
}
},
);
let mut auto_skip = prefs.auto_skip;
ui::widgets::settings_row(
ui,
"Skip openings and credits",
Some(
"Jump past chapters named as an opening, ending or preview, rather than offering a button. Chapter names are the release's guess, so this can skip a scene.",
),
|ui| {
if ui::widgets::toggle(ui, &mut auto_skip, "").changed() {
actions.push(Action::SetAutoSkip(auto_skip));
}
},
);
let mut desktop = prefs.desktop;
ui::widgets::settings_row(
ui,
"Hardware decoding",
Some(
"Decode on the graphics card. Turn off if video shows green frames or nothing at all; it costs CPU, not quality. From the next file.",
),
|ui| {
if ui::widgets::toggle(ui, &mut desktop.hardware_decoding, "").changed() {
actions.push(Action::SetDesktopPrefs(desktop));
}
},
);
},
);
}
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
/// The streaming cache: how big it may get, and a way to empty it.
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Storage",
Some(
"Video already fetched is kept on disk, so watching it again or seeking back costs nothing. Downloads are separate and never count towards this.",
),
|ui| {
let budget = ui::format_size(prefs.desktop.cache_bytes() as i64);
let used = match prefs.cache_used {
Some(used) => format!("{} of {budget} in use", ui::format_size(used as i64)),
None => format!("Up to {budget}"),
};
ui::widgets::settings_row(ui, "Streaming cache", Some(&used), |ui| {
let choices: Vec<(u32, String)> = CACHE_CHOICES
.iter()
.map(|&gib| (gib, format!("{gib} GiB")))
.collect();
let labels: Vec<(u32, &str)> = choices
.iter()
.map(|(gib, label)| (*gib, label.as_str()))
.collect();
if let Some(cache_gib) =
ui::widgets::segmented(ui, prefs.desktop.cache_gib, &labels)
{
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
cache_gib,
..prefs.desktop
}));
}
});
ui::widgets::settings_row(
ui,
"Clear the streaming cache",
Some("Frees the space now. Whatever plays next is fetched again."),
|ui| {
if ui.button("Clear").clicked() {
actions.push(Action::ClearStreamCache);
}
},
);
},
);
}
let mut enabled = autoplay;
if ui
.checkbox(&mut enabled, "Play the next episode automatically")
.on_hover_text("Only when an episode reaches its end, never after a failure")
.changed()
{
actions.push(Action::SetAutoplay(enabled));
}
ui.label(ui::muted(
"Volume, audio language and subtitles are set from the player, and remembered \
from there.",
));
/// What this is, and which version.
fn about(ui: &mut egui::Ui) {
ui::widgets::settings_group(ui, "About", None, |ui| {
ui::widgets::settings_row(
ui,
"proton-stream",
Some("Streams Proton Drive share links without an account or a download step."),
|ui| {
ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION"))));
},
);
ui::widgets::settings_row(ui, "Keyboard shortcuts", None, |ui| {
ui.label(ui::muted("press ?"));
});
});
}
/// Enrichment: the switch, the provider, and the cost of turning it on.
@@ -176,94 +397,88 @@ fn metadata_form(
api_key: &mut String,
actions: &mut Vec<Action>,
) {
ui::section(ui, "Posters and descriptions");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 6.0;
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.",
));
ui.add_space(10.0);
the provider you choose, over HTTPS, each time a new one appears. With AniList, \
the ids it matched — not the titles — then go to ani.zip for episode names and \
wide artwork.",
));
ui.add_space(theme::space::L);
let mut changed = false;
let mut enabled = settings.enabled;
if ui
.checkbox(&mut enabled, "Look up posters and descriptions")
.changed()
{
settings.enabled = enabled;
changed = true;
let mut changed = false;
let mut enabled = settings.enabled;
if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() {
settings.enabled = enabled;
changed = true;
}
if settings.enabled {
ui.add_space(theme::space::L);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
.clicked()
&& settings.provider != provider
{
settings.provider = provider;
changed = true;
}
}
if settings.enabled {
ui.add_space(10.0);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
.clicked()
&& settings.provider != provider
{
settings.provider = provider;
changed = true;
}
}
if settings.provider.needs_api_key() {
ui.add_space(10.0);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
ui.add_space(12.0);
if settings.provider.needs_api_key() {
ui.add_space(theme::space::L);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
}
if ui
.button("Match everything again")
.on_hover_text("Ask about every title, including ones already matched")
.clicked()
{
actions.push(Action::MatchTitles { force: true });
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
} else {
ui.add_space(6.0);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
});
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
}
if ui
.button("Match everything again")
.on_hover_text("Ask about every title, including ones already matched")
.clicked()
{
actions.push(Action::MatchTitles { force: true });
}
});
} else {
ui.add_space(theme::space::S);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
));
}
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
ui.add_space(theme::space::L);
});
}
+137 -30
View File
@@ -7,44 +7,59 @@
//! typed.
use pstr_core::Share;
use pstr_core::library::Library;
use crate::app::{Action, ShareForm};
use crate::theme;
use crate::ui;
pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
pub fn show(
ui: &mut egui::Ui,
shares: &[Share],
library: &Library,
form: &mut ShareForm,
actions: &mut Vec<Action>,
) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui::section(ui, "Shares");
if shares.is_empty() {
ui.label(ui::muted("No shares yet."));
ui.label(ui::muted(
"No shares yet. Add one below and its contents become your library.",
));
}
for share in shares {
share_row(ui, share, actions);
let titles = library
.titles
.iter()
.filter(|title| title.share_ids.contains(&share.id))
.count();
share_row(ui, share, titles, actions);
}
ui.add_space(22.0);
add_form(ui, form, actions);
ui.add_space(theme::space::XXL);
add_form(ui, shares, form, actions);
});
}
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new(&share.name).strong());
let detail = if share.has_custom_password {
format!("{} · custom password", share.id)
} else {
share.id.clone()
};
ui.label(ui::muted(detail));
// What is in it, rather than what it is called inside the
// app. The id is still a hover away for a bug report.
let mut detail = ui::library::plural(titles, "title");
if share.has_custom_password {
detail.push_str(" · password protected");
}
ui.label(ui::muted(detail)).on_hover_text(&share.id);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -61,20 +76,20 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
});
});
});
ui.add_space(6.0);
ui.add_space(theme::space::S);
}
fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>) {
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
ui::section(ui, "Add a share");
ui.label(ui::muted(
"Paste the whole link, including everything after the # — that part is the key that \
decrypts it, and it is stored in your system keyring rather than on disk.",
));
ui.add_space(10.0);
ui.add_space(theme::space::L);
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
@@ -85,20 +100,45 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
.hint_text("Anime")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.label(ui::muted("Link"));
ui.add(
egui::TextEdit::singleline(&mut form.url)
.password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
let paste = 60.0;
let field = egui::Id::new("share-link");
ui.add(
egui::TextEdit::singleline(&mut form.url)
.id(field)
.password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(ui.available_width() - paste),
);
// The link is masked, so pasting is the only sensible way in —
// and a button says that more plainly than the field does.
if ui
.add_sized(
[paste - 8.0, ui.spacing().interact_size.y],
egui::Button::new("Paste"),
)
.clicked()
{
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
// The paste lands in whatever has focus, so give it some.
ui.memory_mut(|memory| memory.request_focus(field));
}
});
// Said while typing, not after a refused submit: everything this
// checks is knowable from the text alone.
let problem = link_problem(&form.url, shares);
if let Some(problem) = &problem {
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
}
ui.add_space(theme::space::M);
ui.checkbox(&mut form.has_password, "The link asks for a password");
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
if form.has_password {
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.add(
egui::TextEdit::singleline(&mut form.password)
.password(true)
@@ -107,11 +147,17 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
);
}
ui.add_space(12.0);
ui.add_space(theme::space::L);
let ready = !form.name.trim().is_empty()
&& !form.url.trim().is_empty()
&& (!form.has_password || !form.password.is_empty());
&& problem.is_none()
&& (!form.has_password || !form.password.is_empty())
&& !form.sending;
if let Some(error) = &form.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
ui.horizontal(|ui| {
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
@@ -127,9 +173,70 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
.filter(|password| !password.is_empty()),
});
}
if !ready {
if form.sending {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("adding…"));
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
ui.label(ui::muted("A name and a link are needed."));
}
});
});
}
/// What is wrong with a link as typed, if anything.
///
/// Nothing for an empty box — that is "not done yet", which the button already
/// says.
fn link_problem(url: &str, shares: &[Share]) -> Option<String> {
let url = url.trim();
if url.is_empty() {
return None;
}
match pstr_core::shares::share_token(url) {
Ok(token) if shares.iter().any(|share| share.token == token) => {
Some("That share is already added.".into())
}
Ok(_) => None,
Err(error) => Some(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn share(token: &str) -> Share {
Share {
id: format!("share-{token}"),
name: "anime".into(),
token: token.into(),
has_custom_password: false,
}
}
#[test]
fn an_empty_link_is_not_yet_a_problem() {
assert_eq!(link_problem(" ", &[]), None);
}
#[test]
fn a_link_without_its_key_is_refused_while_typing() {
assert!(link_problem("https://drive.proton.me/urls/ABC123", &[]).is_some());
assert!(link_problem("https://example.com/", &[]).is_some());
assert_eq!(
link_problem("https://drive.proton.me/urls/ABC123#key", &[]),
None
);
}
#[test]
fn a_share_already_added_is_refused_while_typing() {
assert!(
link_problem(
"https://drive.proton.me/urls/ABC123#key",
&[share("ABC123")]
)
.is_some()
);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! The list of keyboard shortcuts, on `?`.
//!
//! Written out here rather than generated from the handlers, because the
//! handlers are split between the player page and every other page, and what a
//! viewer needs is one list grouped by where they are.
use crate::theme;
use crate::ui;
const GROUPS: &[(&str, &[(&str, &str)])] = &[
(
"Anywhere",
&[
("Ctrl F or /", "Search the library"),
("F5 or Ctrl R", "Crawl every share again"),
(
"Ctrl 1 – 5",
"Library, History, Shares, Downloads, Settings",
),
("Ctrl ,", "Settings"),
("Ctrl V", "Paste a share link to add it"),
("Tab or arrows", "Move between titles and episodes"),
("Enter", "Open or play what is highlighted"),
("Alt ← or mouse back", "Back"),
("?", "This list"),
],
),
(
"Playing",
&[
("Space or K", "Pause and play"),
("← / →", "Back 10 s, forward 30 s"),
("↑ / ↓", "Volume"),
("Ctrl S", "Save the frame to Pictures/proton-stream"),
("M", "Mute"),
("N / P", "Next and previous episode"),
("[ / ]", "Slower and faster"),
("S", "Skip the opening or the credits"),
("E", "The list of episodes"),
("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"),
],
),
];
/// Draw the list. Returns whether it asked to be closed.
pub fn show(ctx: &egui::Context) -> bool {
let mut close = false;
let modal = egui::Modal::new(egui::Id::new("shortcuts"))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(20))
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(460.0);
ui.horizontal(|ui| {
ui.label(theme::Role::Heading.rich("Keyboard shortcuts").strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
close = true;
}
});
});
for (heading, keys) in GROUPS {
ui.add_space(theme::space::L);
ui.label(ui::muted(heading.to_uppercase()));
ui.add_space(theme::space::XS);
egui::Grid::new(*heading)
.num_columns(2)
.spacing([18.0, 8.0])
.show(ui, |ui| {
for (key, what) in *keys {
ui.label(
theme::Role::Label
.rich(*key)
.monospace()
.color(theme::accent()),
);
ui.label(theme::Role::Body.rich(*what));
ui.end_row();
}
});
}
});
close || modal.should_close()
}
File diff suppressed because it is too large. Load diff
+267
View File
@@ -0,0 +1,267 @@
//! Short messages in the corner, for a few seconds.
//!
//! They replaced a single status line along the bottom of the window, which had
//! three problems a stack does not: a second message wiped the first before it
//! could be read, an error looked like any other line in a slightly different
//! grey, and the line took a strip of the page with it every time it appeared.
use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::app::Action;
use crate::theme;
/// How long a message stays up.
const SECONDS: f64 = 5.0;
/// How long an error stays up. Longer: it is more likely to be read twice.
const ERROR_SECONDS: f64 = 9.0;
/// How many fit at once. A burst beyond that drops the oldest.
const MOST: usize = 4;
/// How long one takes to arrive and to leave.
const FADE: f64 = theme::motion::FADE as f64;
const WIDTH: f32 = 340.0;
pub struct Toast {
id: u64,
text: String,
error: bool,
at: f64,
/// A button on the toast — "Undo" — and what it does.
action: Option<(&'static str, Action)>,
}
/// The messages on screen.
#[derive(Default)]
pub struct Toasts {
items: Vec<Toast>,
next: u64,
}
impl Toasts {
pub fn push(&mut self, now: f64, text: impl Into<String>, error: bool) {
self.push_inner(now, text.into(), error, None);
}
/// A message with a button on it, which runs `action` when pressed.
pub fn push_with(
&mut self,
now: f64,
text: impl Into<String>,
label: &'static str,
action: Action,
) {
self.push_inner(now, text.into(), false, Some((label, action)));
}
fn push_inner(
&mut self,
now: f64,
text: String,
error: bool,
action: Option<(&'static str, Action)>,
) {
// The same message again — a retry that failed the same way — restarts
// the one already up rather than stacking a copy of it.
if let Some(existing) = self.items.iter_mut().find(|toast| toast.text == text) {
existing.at = now;
existing.error = error;
existing.action = action;
return;
}
self.next += 1;
self.items.push(Toast {
id: self.next,
text,
error,
at: now,
action,
});
if self.items.len() > MOST {
self.items.remove(0);
}
}
/// Draw the stack above `bottom` points from the window's bottom edge, and
/// drop whatever has expired or been closed. Returns the action of a
/// toast whose button was pressed.
pub fn show(&mut self, ctx: &egui::Context, bottom: f32) -> Option<Action> {
let now = ctx.input(|input| input.time);
let pointer = ctx.input(|input| input.pointer.hover_pos());
let mut closed = None;
let mut pressed = None;
let mut hovered = false;
let mut y = ctx.content_rect().bottom() - bottom - 16.0;
let right = ctx.content_rect().right() - 16.0;
// Newest at the bottom, nearest where the eye already is.
for toast in self.items.iter().rev() {
let life = lifetime(toast.error);
let age = now - toast.at;
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32;
let opacity = theme::motion::ease_out(arriving.min(leaving));
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
.order(egui::Order::Foreground)
.pivot(Align2::RIGHT_BOTTOM)
.fixed_pos(egui::pos2(right + (1.0 - opacity) * 24.0, y))
.interactable(true)
.show(ctx, |ui| {
ui.set_opacity(opacity);
card(ui, toast)
});
match area.inner {
Pressed::Close => closed = Some(toast.id),
Pressed::Action => pressed = Some(toast.id),
Pressed::Nothing => {}
}
if pointer.is_some_and(|at| area.response.rect.contains(at)) {
hovered = true;
}
y -= area.response.rect.height() + 8.0;
}
if let Some(id) = closed {
self.items.retain(|toast| toast.id != id);
}
// Pressed once, and gone: an "Undo" left up after it ran is an
// invitation to undo the undo, which it does not do.
let action = pressed.and_then(|id| {
let index = self.items.iter().position(|toast| toast.id == id)?;
self.items.remove(index).action.map(|(_, action)| action)
});
// Under the pointer, nothing leaves: someone is reading it.
if hovered {
for toast in &mut self.items {
let life = lifetime(toast.error);
toast.at = toast.at.max(now - life + 1.5);
}
}
self.items
.retain(|toast| now - toast.at < lifetime(toast.error));
if !self.items.is_empty() {
// Enough frames to animate the edges and to notice an expiry.
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
action
}
}
fn lifetime(error: bool) -> f64 {
if error { ERROR_SECONDS } else { SECONDS }
}
/// Which of a toast's buttons was pressed.
enum Pressed {
Nothing,
Close,
Action,
}
/// One message. Returns which of its buttons was pressed.
fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
let mut pressed = Pressed::Nothing;
let edge = if toast.error {
theme::danger()
} else {
theme::accent()
};
egui::Frame::new()
.fill(theme::surface())
.stroke(Stroke::new(1.0, theme::card_hover()))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin {
left: 16,
right: 10,
top: 10,
bottom: 10,
})
.shadow(egui::Shadow {
offset: [0, 6],
blur: 18,
spread: 0,
color: Color32::from_black_alpha(90),
})
.show(ui, |ui| {
ui.set_width(WIDTH);
let frame = ui.max_rect();
// The buttons first, from the right, so the text wraps in what
// they leave rather than pushing them off the card.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let (rect, response) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::click());
let ink = if response.hovered() {
theme::text()
} else {
theme::muted()
};
let d = 4.0;
let c = rect.center();
ui.painter().line_segment(
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
Stroke::new(1.5, ink),
);
ui.painter().line_segment(
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink),
);
if response.on_hover_text("Dismiss").clicked() {
pressed = Pressed::Close;
}
if let Some((label, _)) = &toast.action {
ui.add_space(theme::space::XS);
if ui
.add(
egui::Button::new(
theme::Role::Label
.rich(*label)
.strong()
.color(theme::accent()),
)
.frame(false),
)
.clicked()
{
pressed = Pressed::Action;
}
}
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.add(
egui::Label::new(theme::Role::Label.rich(&toast.text).color(theme::text()))
.wrap(),
);
});
});
// A strip down the leading edge says which kind it is before a word
// of it is read.
let strip = egui::Rect::from_min_max(
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
);
ui.painter()
.rect_filled(strip, CornerRadius::same(theme::radius::BAR), edge);
});
pressed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_repeated_message_restarts_rather_than_stacks() {
let mut toasts = Toasts::default();
toasts.push(0.0, "could not open", true);
toasts.push(3.0, "could not open", true);
assert_eq!(toasts.items.len(), 1);
assert_eq!(toasts.items[0].at, 3.0);
}
#[test]
fn a_burst_keeps_only_the_newest() {
let mut toasts = Toasts::default();
for index in 0..10 {
toasts.push(0.0, format!("message {index}"), false);
}
assert_eq!(toasts.items.len(), MOST);
assert_eq!(toasts.items.last().unwrap().text, "message 9");
}
}
+255 -100
View File
@@ -56,7 +56,7 @@ pub fn full(
) {
seek_bar(ui, playback, actions, 6.0);
times(ui, playback, true);
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_FULL);
@@ -65,7 +65,7 @@ pub fn full(
// hand reaches for it.
step_button(
ui,
"⏮",
egui_phosphor::regular::SKIP_BACK,
"Previous (P)",
neighbours.previous,
actions,
@@ -76,14 +76,21 @@ pub fn full(
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
play_pause(ui, playback, actions, 44.0);
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
});
step_button(
ui,
egui_phosphor::regular::SKIP_FORWARD,
"Next (N)",
neighbours.next,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
},
);
ui.add_space(12.0);
if playback.seeking || !playback.loaded {
ui.add_space(theme::space::L);
if playback.seeking || playback.buffering || !playback.loaded {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted(if playback.loaded {
ui.label(ui::muted(if playback.loaded && playback.seeking {
"seeking"
} else {
"buffering"
@@ -93,91 +100,153 @@ pub fn full(
// Right cluster: everything that is a choice rather than a move.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(8.0);
ui.add_space(theme::space::M);
speed_menu(ui, playback, actions);
ui.add_space(theme::space::M);
chapter_menu(ui, playback, actions);
ui.add_space(8.0);
ui.add_space(theme::space::M);
volume(ui, playback, actions);
});
});
}
/// The strip under the library: what is playing, and back to it.
/// How big the live picture in the mini-player is drawn.
pub const MINI_PICTURE: Vec2 = Vec2::new(112.0, 63.0);
pub fn mini(
ui: &mut egui::Ui,
playback: &Playback,
picture: Option<egui::TextureId>,
neighbours: Neighbours,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_MINI);
// The way back to the picture. First, and a real button rather than a
// label, because leaving the player page with Esc is otherwise a
// one-way trip: playback carries on with nowhere to watch it.
if ui
.add(
egui::Button::new(theme::Role::Label.rich("Back to video"))
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(8)),
)
.on_hover_text("Show the picture again")
.clicked()
{
actions.push(Action::Goto(Page::Player));
// The way back to the picture, first, because leaving the player page
// with Esc is otherwise a one-way trip: playback carries on with
// nowhere to watch it. The picture itself when there is one — it is
// both the reminder that something is playing and the button.
match picture {
Some(texture) => {
let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
let painter = ui.painter();
let radius = CornerRadius::same(theme::radius::SM);
painter.rect_filled(rect, radius, Color32::BLACK);
painter.add(
egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
texture,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
),
);
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
if hover > 0.0 {
painter.rect_filled(
rect,
radius,
Color32::from_black_alpha((110.0 * hover) as u8),
);
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Expand",
theme::Role::Caption.font(),
Color32::WHITE.gamma_multiply(hover),
);
painter.rect_stroke(
rect,
radius,
egui::Stroke::new(2.0, theme::accent().gamma_multiply(hover)),
egui::StrokeKind::Inside,
);
}
if response.on_hover_text("Show the picture again").clicked() {
actions.push(Action::Goto(Page::Player));
}
}
None => {
if ui
.add(
egui::Button::new(theme::Role::Label.rich("Back to video"))
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(theme::radius::MD)),
)
.on_hover_text("Show the picture again")
.clicked()
{
actions.push(Action::Goto(Page::Player));
}
}
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
ui.vertical(|ui| {
// Back to what is playing: after ten minutes of browsing, the bar
// is the only thing that still knows which title this came from.
if ui
.add(
egui::Label::new(theme::Role::Body.rich(&playback.target.title_name).strong())
.sense(Sense::click()),
)
.on_hover_text("Show this title")
.clicked()
{
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
}
ui.label(ui::muted(playback.target.caption()));
});
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_MINI);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Stop").clicked() {
actions.push(Action::Player(Command::Stop));
}
ui.add_space(6.0);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
ui.vertical(|ui| {
// Back to what is playing: after ten minutes of browsing, the bar
// is the only thing that still knows which title this came from.
if ui
.add(
egui::Label::new(
theme::Role::Body.rich(&playback.target.title_name).strong(),
)
.sense(Sense::click()),
)
.on_hover_text("Show this title")
.clicked()
{
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
}
ui.label(ui::muted(playback.target.caption()));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Stop").clicked() {
actions.push(Action::Player(Command::Stop));
}
ui.add_space(theme::space::S);
step_button(
ui,
egui_phosphor::regular::SKIP_FORWARD,
"Next (N)",
neighbours.next,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
},
);
play_pause(ui, playback, actions, 34.0);
step_button(
ui,
egui_phosphor::regular::SKIP_BACK,
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
ui.add_space(theme::space::M);
track_menus(ui, playback, actions);
ui.add_space(theme::space::S);
volume(ui, playback, actions);
if playback.seeking || playback.buffering || !playback.loaded {
ui.add_space(theme::space::S);
ui.add(egui::Spinner::new().size(14.0));
}
});
});
play_pause(ui, playback, actions, 34.0);
step_button(
ui,
"⏮",
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
ui.add_space(8.0);
track_menus(ui, playback, actions);
ui.add_space(6.0);
volume(ui, playback, actions);
if playback.seeking || !playback.loaded {
ui.add_space(6.0);
ui.add(egui::Spinner::new().size(14.0));
}
ui.add_space(theme::space::XS);
seek_bar(ui, playback, actions, 5.0);
times(ui, playback, false);
});
});
ui.add_space(4.0);
seek_bar(ui, playback, actions, 5.0);
times(ui, playback, false);
}
/// The one button whose position should never move.
@@ -359,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
}
let icon = if playback.muted || playback.volume <= 0.0 {
"🔇"
egui_phosphor::regular::SPEAKER_SLASH
} else if playback.volume < 50.0 {
egui_phosphor::regular::SPEAKER_LOW
} else {
"🔊"
egui_phosphor::regular::SPEAKER_HIGH
};
if ui
.button(icon)
.button(theme::Role::Subhead.rich(icon))
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.clicked()
{
@@ -429,6 +500,32 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
}
}
/// How fast it plays. Says so on the button only when it is not normal speed,
/// because that is the one case worth noticing at a glance.
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let label = if (playback.speed - 1.0).abs() < 1e-6 {
"Speed".to_owned()
} else {
format!("Speed · {}", ui::format_speed(playback.speed))
};
ui.menu_button(label, |ui| {
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
if ui
.selectable_label(
(playback.speed - speed).abs() < 1e-6,
ui::format_speed(speed),
)
.clicked()
{
actions.push(Action::SetSpeed(speed));
ui.close();
}
}
})
.response
.on_hover_text("Playback speed ([ and ])");
}
/// Jump to a chapter. Only drawn for a file that has more than one, which for
/// most releases means anime and not much else.
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
@@ -470,66 +567,101 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
let hovered = response.hovered() || response.dragged();
let track = Rect::from_center_size(
rect.center(),
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }),
);
let active = response.hovered() || response.dragged();
// Grows rather than jumps when the pointer arrives.
let grow =
ui.ctx()
.animate_bool_with_time(response.id.with("grow"), active, theme::motion::HOVER);
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
let radius = CornerRadius::same(theme::radius::BAR);
let painter = ui.painter();
painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52));
// From the palette rather than a fixed grey: the same bar sits on the
// video's dark scrim and on a light theme's transport strip.
painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35));
if let Some(progress) = playback.progress() {
let duration = playback.duration.filter(|duration| *duration > 0.0);
// What is already read ahead, a shade lighter than the empty track: on a
// link this slow, whether a jump lands in it is the difference between an
// instant seek and a fetch.
if let (Some(duration), Some(end)) = (duration, playback.buffered)
&& end > playback.position
{
let from = (playback.position / duration).clamp(0.0, 1.0) as f32;
let to = (end / duration).clamp(0.0, 1.0) as f32;
let buffered = Rect::from_min_max(
egui::pos2(track.left() + track.width() * from, track.top()),
egui::pos2(track.left() + track.width() * to, track.bottom()),
);
painter.rect_filled(buffered, radius, theme::muted().gamma_multiply(0.30));
}
let pointer = response
.interact_pointer_pos()
.or_else(|| response.hover_pos())
.map(|position| ((position.x - track.left()) / track.width()).clamp(0.0, 1.0));
// Where the pointer would land, lighter than what has been played: the bar
// shows the jump before it is made.
if let (Some(fraction), true) = (pointer, active) {
let mut ghost = track;
ghost.set_width(track.width() * fraction);
painter.rect_filled(ghost, radius, theme::muted().gamma_multiply(0.45));
}
// While dragging, the knob follows the pointer even though the seek waits
// for the release — see below. A knob that stays put under a moving hand
// reads as a bar that is not listening.
let shown = match (response.dragged(), pointer) {
(true, Some(fraction)) => Some(fraction),
_ => playback.progress(),
};
if let Some(progress) = shown {
let mut played = track;
played.set_width(track.width() * progress);
// The gradient is sized to the *played* part, not to the whole bar, so
// the far end of it arrives at the playhead rather than at the end of
// the file. A bar that only ever shows the first fifth of its own ramp
// does not read as a gradient at all.
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0);
theme::accent_fill(painter, played, radius, 0.0);
painter.circle_filled(
egui::pos2(played.right(), track.center().y),
if hovered { 8.0 } else { 6.0 },
5.0 + 3.0 * grow,
Color32::WHITE,
);
}
// Seeking needs a duration: without one there is nothing for a fraction of
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else {
let Some(duration) = duration else {
return;
};
// Chapter marks, over the bar rather than under it: an opening is two
// minutes of a twenty-four minute file, and the point of the mark is to be
// able to aim just past it.
// Chapter marks, cut into the bar rather than drawn on it: an opening is
// two minutes of a twenty-four minute file, and the point of the mark is to
// be able to aim just past it.
for chapter in playback.chapters.iter().skip(1) {
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
let x = track.left() + track.width() * fraction;
painter.rect_filled(
Rect::from_min_size(
egui::pos2(x - 1.0, track.top() - 1.0),
Vec2::new(2.0, track.height() + 2.0),
egui::pos2(x - 1.5, track.top() - 1.0),
Vec2::new(3.0, track.height() + 2.0),
),
CornerRadius::ZERO,
Color32::from_black_alpha(180),
Color32::from_black_alpha(200),
);
}
// On release, not on every frame of the drag: each seek cancels outstanding
// read-ahead and refetches, so a dragged bar that seeks continuously spends
// the whole drag throwing away blocks it just paid for.
if let Some(position) = response.interact_pointer_pos()
if let Some(fraction) = pointer
&& (response.clicked() || response.drag_stopped())
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
}
if response.hovered()
&& let Some(position) = response.hover_pos()
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
let at = fraction * duration;
if let (Some(fraction), true) = (pointer, active) {
let at = f64::from(fraction) * duration;
// The chapter under the pointer, when there is one: "42:10 · OP" tells
// a viewer what they are about to land in, which is the whole reason to
// aim at a particular part of the bar.
@@ -541,6 +673,29 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
),
None => ui::format_time(at),
};
response.clone().on_hover_text_at_pointer(hint);
bubble(
ui,
egui::pos2(track.left() + track.width() * fraction, track.top() - 10.0),
&hint,
);
}
}
/// A small label floating above a point, kept inside the window.
fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) {
let painter = ui.ctx().layer_painter(egui::LayerId::new(
egui::Order::Tooltip,
egui::Id::new("seek-bubble"),
));
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Label.font(), Color32::WHITE);
let size = galley.size() + Vec2::new(16.0, 8.0);
let screen = ui.ctx().content_rect();
let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0);
let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size);
painter.rect_filled(
frame,
CornerRadius::same(theme::radius::SM),
Color32::from_black_alpha(215),
);
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
}
+369
View File
@@ -0,0 +1,369 @@
//! Controls egui does not have, or has in a shape that does not fit.
//!
//! Each is painted by hand for the same reason [`super::accent_button`] is: an
//! egui widget takes one flat colour, and what makes these readable is either a
//! gradient or a state drawn as a shape rather than as a fill.
use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::theme;
/// An on/off switch with its label beside it, for settings.
///
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
/// on a card, which is where every setting lives, an unchecked box was not
/// there at all. A switch says "off" with a shape, not with a fill that has to
/// stand out from what is behind it.
pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
const TRACK: Vec2 = Vec2::new(34.0, 20.0);
let galley = ui.painter().layout_no_wrap(
text.to_owned(),
theme::Role::Body.font(),
Color32::PLACEHOLDER,
);
let gap = if text.is_empty() { 0.0 } else { 10.0 };
let size = Vec2::new(
TRACK.x + gap + galley.size().x,
TRACK
.y
.max(galley.size().y)
.max(ui.spacing().interact_size.y),
);
let (rect, mut response) = ui.allocate_exact_size(size, Sense::click());
if response.clicked() {
*on = !*on;
response.mark_changed();
}
response
.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *on, text));
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, *on, theme::motion::STATE);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let track = egui::Rect::from_min_size(
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
TRACK,
);
let radius = CornerRadius::same((TRACK.y / 2.0) as u8);
let painter = ui.painter();
// Off is an outlined track; on is the accent filling it. Both are drawn
// through the switch's travel, so the fill arrives with the knob.
painter.rect_filled(track, radius, theme::card_hover());
painter.rect_stroke(
track,
radius,
Stroke::new(1.0, theme::muted().gamma_multiply(0.6 + 0.4 * hover)),
egui::StrokeKind::Inside,
);
if t > 0.0 {
let mut lit = track;
lit.set_width(TRACK.y + (TRACK.x - TRACK.y) * t);
theme::accent_fill(painter, lit, radius, 0.0);
}
let knob = egui::lerp(
(track.left() + TRACK.y / 2.0)..=(track.right() - TRACK.y / 2.0),
t,
);
let ink = theme::muted().lerp_to_gamma(Color32::WHITE, t);
painter.circle_filled(egui::pos2(knob, track.center().y), TRACK.y / 2.0 - 3.0, ink);
if response.has_focus() {
painter.rect_stroke(
track.expand(2.0),
CornerRadius::same((TRACK.y / 2.0 + 2.0) as u8),
Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Outside,
);
}
let position = egui::pos2(track.right() + gap, rect.center().y - galley.size().y / 2.0);
painter.galley(position, galley, theme::text());
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A round mark that is either a filled tick or an empty ring: "watched".
///
/// The checkbox it replaced had the same invisible-when-off problem as the one
/// [`toggle`] replaced, and a list of episodes is the one place where the
/// unwatched state is the one that most needs to be seen.
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
const SIZE: f32 = 20.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let painter = ui.painter();
let center = rect.center();
let radius = SIZE / 2.0 - 1.0;
if t > 0.0 {
painter.circle_filled(center, radius, theme::accent().gamma_multiply(t));
}
painter.circle_stroke(
center,
radius,
Stroke::new(
1.5,
theme::muted()
.lerp_to_gamma(theme::text(), hover)
.gamma_multiply(1.0 - t),
),
);
// The tick is drawn in both states: faintly, under the pointer, while
// unwatched — so the mark says what clicking it will do.
let ink = if watched {
theme::on_accent()
} else {
theme::muted().gamma_multiply(hover)
};
if watched || hover > 0.0 {
let points = [
center + Vec2::new(-4.5, 0.2),
center + Vec2::new(-1.4, 3.3),
center + Vec2::new(4.6, -3.2),
];
painter.line(points.to_vec(), Stroke::new(1.8, ink));
}
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A group of settings: a heading, an optional line under it, and a card that
/// holds the rows.
///
/// The libadwaita preferences pattern, which is what `proton-drive-linux`
/// settles on: one card per subject, one row per setting, the setting's name
/// and what it does on the left and the control on the right — so the eye runs
/// down one edge for names and the other for state.
pub fn settings_group<R>(
ui: &mut egui::Ui,
heading: &str,
description: Option<&str>,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
ui.add_space(theme::space::XS);
ui.label(
theme::Role::Subhead
.rich(heading)
.strong()
.color(theme::text()),
);
if let Some(description) = description {
ui.add_space(theme::space::XXS);
ui.label(crate::ui::muted(description));
}
ui.add_space(theme::space::M);
let inner = egui::Frame::new()
.fill(theme::card())
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(16, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
add(ui)
})
.inner;
ui.add_space(theme::space::XXL);
inner
}
/// One row of a [`settings_group`]: what it is on the left, the control on the
/// right. Rows after the first get a hairline above them.
pub fn settings_row<R>(
ui: &mut egui::Ui,
title: &str,
subtitle: Option<&str>,
control: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let top = ui.cursor().top();
// The first row of a card starts at its top margin; anything below that
// has a row above it to be divided from.
if top > ui.max_rect().top() + 1.0 {
let y = top;
ui.painter().hline(
ui.max_rect().x_range(),
y,
Stroke::new(1.0, theme::card_hover()),
);
}
ui.add_space(theme::space::L);
let inner = ui
.horizontal(|ui| {
ui.set_min_height(28.0);
let text_width = (ui.available_width() * 0.62).max(160.0);
ui.vertical(|ui| {
ui.set_max_width(text_width);
ui.spacing_mut().item_spacing.y = 2.0;
ui.label(theme::Role::Body.rich(title).color(theme::text()));
if let Some(subtitle) = subtitle {
ui.add(egui::Label::new(crate::ui::muted(subtitle)).wrap());
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), control)
.inner
})
.inner;
ui.add_space(theme::space::L);
inner
}
/// A row of choices where exactly one is on: pills on a shared track.
///
/// Returns the one clicked, if it was not already the chosen one.
pub fn segmented<T: Copy + PartialEq>(
ui: &mut egui::Ui,
current: T,
choices: &[(T, &str)],
) -> Option<T> {
let mut picked = None;
egui::Frame::new()
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(3))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.horizontal(|ui| {
for (value, label) in choices {
let selected = *value == current;
let text = theme::Role::Label.rich(*label).color(if selected {
theme::on_accent()
} else {
theme::text()
});
let button = egui::Button::new(text)
.corner_radius(CornerRadius::same(theme::radius::SM))
.fill(if selected {
theme::accent()
} else {
Color32::TRANSPARENT
})
.stroke(Stroke::NONE);
if ui.add(button).clicked() && !selected {
picked = Some(*value);
}
}
});
});
picked
}
/// What a [`download_button`] shows.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DownloadGlyph {
/// Not downloaded: an arrow into a tray.
Download,
/// Coming down: a ring filling with how far it has got.
Progress(f32),
/// Stopped part way: the ring, still, with a pause mark.
Paused(f32),
/// Here: a tick.
Done,
}
/// A round icon button for one file's download.
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
const SIZE: f32 = 30.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if !ui.is_rect_visible(rect) {
return response;
}
let hover =
ui.ctx()
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
let painter = ui.painter();
let c = rect.center();
if hover > 0.0 {
painter.circle_filled(c, SIZE / 2.0, theme::card_hover().gamma_multiply(hover));
}
let ink = theme::muted().lerp_to_gamma(theme::text(), hover);
let stroke = Stroke::new(1.6, ink);
let ring = |painter: &egui::Painter, fraction: f32| {
let radius = 9.0;
painter.circle_stroke(c, radius, Stroke::new(2.0, theme::card_hover()));
let steps = 40;
let points: Vec<egui::Pos2> = (0..=((steps as f32 * fraction.clamp(0.0, 1.0)) as usize))
.map(|step| {
let angle = -std::f32::consts::FRAC_PI_2
+ std::f32::consts::TAU * step as f32 / steps as f32;
c + Vec2::angled(angle) * radius
})
.collect();
if points.len() > 1 {
painter.add(egui::Shape::line(points, Stroke::new(2.0, theme::accent())));
}
};
match glyph {
DownloadGlyph::Download => {
painter.line_segment([c + Vec2::new(0.0, -6.0), c + Vec2::new(0.0, 3.0)], stroke);
painter.line(
vec![
c + Vec2::new(-4.0, -1.0),
c + Vec2::new(0.0, 3.0),
c + Vec2::new(4.0, -1.0),
],
stroke,
);
painter.line_segment([c + Vec2::new(-6.0, 7.0), c + Vec2::new(6.0, 7.0)], stroke);
}
DownloadGlyph::Progress(fraction) => {
ring(painter, fraction);
// Keeps a frame coming while the ring has somewhere to go.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
if hover > 0.0 {
for x in [-2.5, 2.5] {
painter.line_segment(
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
Stroke::new(1.8, ink.gamma_multiply(hover)),
);
}
}
}
DownloadGlyph::Paused(fraction) => {
ring(painter, fraction);
for x in [-2.5, 2.5] {
painter.line_segment(
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
Stroke::new(1.8, ink),
);
}
}
DownloadGlyph::Done => {
painter.line(
vec![
c + Vec2::new(-5.0, 0.0),
c + Vec2::new(-1.5, 3.5),
c + Vec2::new(5.0, -3.5),
],
Stroke::new(2.0, theme::accent().lerp_to_gamma(ink, hover)),
);
}
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
.fill(theme::card_hover().gamma_multiply(0.8))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.show(ui, |ui| {
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
})
.response
}
+6 -4
View File
@@ -67,8 +67,10 @@ pub struct VideoSurface {
impl VideoSurface {
/// Attach a renderer to `player` on the current OpenGL context.
///
/// `ctx` is woken whenever mpv has a new frame; without that the picture
/// would only advance when the viewer moved the mouse.
/// `wake` is called whenever mpv has a new frame, and should ask the window
/// for one. Without it the picture would only advance when the viewer moved
/// the mouse. The app's goes through [`crate::engine::Engine::wake`], which
/// knows whether the picture is on screen to be drawn at all.
///
/// # Safety
///
@@ -78,11 +80,11 @@ impl VideoSurface {
pub unsafe fn new(
player: Arc<Player>,
gl: Arc<glow::Context>,
ctx: egui::Context,
wake: impl Fn() + Send + Sync + 'static,
) -> pstr_player::Result<Self> {
// SAFETY: forwarded from this function's own contract.
let mut renderer = unsafe { VideoRenderer::new(player)? };
renderer.on_update(move || ctx.request_repaint());
renderer.on_update(wake);
Ok(Self {
gl,
+124
View File
@@ -0,0 +1,124 @@
//! The window's size, kept between launches.
//!
//! Size and maximized only. Position is left to the window manager: Wayland
//! does not let a client choose it, and on X11 a remembered position on a
//! monitor that has since been unplugged opens the window off screen.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// What the next launch should open at.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct WindowState {
pub width: f32,
pub height: f32,
pub maximized: bool,
}
/// The smallest size worth restoring. Anything below is a window that was
/// being dragged about when it closed, or a file someone edited by hand.
const SMALLEST: [f32; 2] = [720.0, 480.0];
impl WindowState {
/// The saved state, if there is a sensible one. A missing or broken file
/// means the default size, never an error: this is a convenience.
pub fn load(path: &Path) -> Option<Self> {
let state: Self = pstr_core::config::read_json(path).ok().flatten()?;
state.sensible().then_some(state)
}
fn sensible(&self) -> bool {
self.width.is_finite()
&& self.height.is_finite()
&& self.width >= SMALLEST[0]
&& self.height >= SMALLEST[1]
}
/// Fold this frame's viewport into `last`.
///
/// A fullscreen window says nothing about how big the window should be, and
/// a maximized one says nothing about how big it should be once it is not,
/// so neither changes the size kept.
pub fn observe(ctx: &egui::Context, last: Option<Self>) -> Option<Self> {
let (size, maximized, fullscreen) = ctx.input(|input| {
let viewport = input.viewport();
(
viewport.inner_rect.map(|rect| rect.size()),
viewport.maximized.unwrap_or(false),
viewport.fullscreen.unwrap_or(false),
)
});
if fullscreen {
return last;
}
let (width, height) = match (maximized, last, size) {
(true, Some(last), _) => (last.width, last.height),
(_, _, Some(size)) => (size.x, size.y),
_ => return last,
};
let state = Self {
width,
height,
maximized,
};
state.sensible().then_some(state).or(last)
}
}
/// Writes the state when it changes, once, at exit.
pub struct WindowMemory {
path: PathBuf,
saved: Option<WindowState>,
pub current: Option<WindowState>,
}
impl WindowMemory {
pub fn new(path: PathBuf) -> Self {
let saved = WindowState::load(&path);
Self {
path,
saved,
current: saved,
}
}
pub fn observe(&mut self, ctx: &egui::Context) {
self.current = WindowState::observe(ctx, self.current);
}
pub fn save(&self) {
let Some(current) = self.current.filter(|current| Some(*current) != self.saved) else {
return;
};
if let Err(error) = pstr_core::config::write_json(&self.path, &current) {
tracing::debug!("save the window size: {error}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_window_smaller_than_the_minimum_is_not_restored() {
let path = std::env::temp_dir().join(format!("pstr-window-{}.json", std::process::id()));
let tiny = WindowState {
width: 200.0,
height: 100.0,
maximized: false,
};
pstr_core::config::write_json(&path, &tiny).unwrap();
assert_eq!(WindowState::load(&path), None);
let normal = WindowState {
width: 1400.0,
height: 900.0,
maximized: true,
};
pstr_core::config::write_json(&path, &normal).unwrap();
assert_eq!(WindowState::load(&path), Some(normal));
let _ = std::fs::remove_file(path);
}
}
+78 -31
View File
@@ -483,18 +483,19 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
)
.context("build the metadata provider")?;
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
let stored = catalog.all_metadata()?;
let enriched = catalog.enrichment_ages()?;
let mut pending: Vec<_> = library
.titles
.iter()
.filter(|title| {
force
|| !pstr_meta::service::is_usable(stored.get(&title.key), settings.provider)
})
.collect();
let mut pending = pstr_meta::service::plan(
library.titles,
&stored,
&enriched,
settings.provider,
service.has_details(),
force,
);
if let Some(limit) = limit {
pending.truncate(limit);
}
@@ -504,7 +505,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
return Ok(());
}
println!(
"matching {} titles against {}…",
"looking up {} titles against {}…",
pending.len(),
settings.provider.label()
);
@@ -513,32 +514,71 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// and the GUI already has the parallel path. What this command is
// for is reading the output.
let (mut matched, mut failed) = (0usize, 0usize);
for title in pending {
match service.record(title).await {
Ok(record) => {
match &record.metadata {
Some(found) => {
matched += 1;
println!(
" {:<40} → {} ({})",
title.name,
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {:<40} → no match", title.name),
}
catalog.set_metadata(&record)?;
}
for work in pending {
use pstr_meta::service::Work;
let name = &work.title().name;
// Whether this work asks for episodes at all — a refresh of an
// already-enriched title does not, and saying they were "not
// fetched" there reads as a failure.
let enriches = matches!(work, Work::Match(_) | Work::Refresh { enrich: true, .. });
let outcome = match service.work(&work).await {
Ok(outcome) => outcome,
Err(error) => {
failed += 1;
// Not stored: a failure to ask is not an answer, and the
// title has to stay askable. See `pstr_meta::service`.
println!(" {:<40} ! {error}", title.name);
println!(" {name:<40} ! {error}");
continue;
}
};
if let Some(record) = &outcome.record {
match &record.metadata {
Some(found) => {
matched += 1;
println!(
" {name:<40} → {} ({})",
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {name:<40} → no match"),
}
catalog.set_metadata(record)?;
}
match &outcome.enrichment {
Some(enrichment) => {
println!(
" {:<40} {} episodes{}",
"",
enrichment.episodes.len(),
if enrichment.backdrop_url.is_some() {
", fanart"
} else {
""
}
);
catalog.set_enrichment(
&outcome.title_key,
settings.provider,
unix_now(),
enrichment,
)?;
}
// Matched, but the episodes could not be asked for; the
// next run asks again.
None if enriches
&& outcome
.record
.as_ref()
.is_some_and(|r| r.metadata.is_some()) =>
{
println!(" {:<40} ! episodes not fetched", "");
}
None => {}
}
}
println!("{matched} matched, {failed} could not be looked up");
@@ -926,3 +966,10 @@ async fn play(
drop(opened.library);
Ok(())
}
/// Unix seconds, for the "when was this asked" columns.
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more