Compare commits

103 Commits
Author SHA1 Message Date
nvrl fb3a97b408 fix(ios): await the engine before stopping a crawl
NativeRuntime.engine() is async and throws on iOS, so the bare call in
stopCrawl failed the simulator build. Run it in a Task, as Android runs
it in a coroutine.
2026-10-13 18:37:47 +02:00
nvrl 04fc1f04fe chore(vers) bumped version to 1.5.0 2026-10-13 18:26:47 +02:00
nvrl aa0f4b1250 docs(android): mark B41's settings read as landed 2026-10-13 16:51:23 +02:00
nvrl c261f88c3f perf(android): decode posters at tile size, and keep disk reads off the main thread
- RemoteArtwork takes the edge it is drawn at; library tiles decode
  their posters at that size rather than at 1024 pixels, sampled down
  and then scaled so a poster never lands under its tile. The decoded
  cache's ceiling rises from 32 to 64 MiB, so a grid scrolled back over
  is still decoded.
- The title page decodes its download records on the IO dispatcher.
  WorkManager reports progress several times a second while a season
  downloads, and each report re-read every record on the main thread.
- The settings file is loaded on a background thread at startup, so the
  playback service's start callback no longer reads it from disk on the
  main thread (B41).
2026-10-13 16:51:16 +02:00
nvrl bf3fa19c70 feat(android,ios): read each library tile once, with its long press as an action
A poster's artwork carried the title's name and so did the text under
it, so a screen reader said the name twice. The artwork is now left out
where the text names it, and a folded franchise tile says how many more
it stands for. The long press that opens a tile's menu is offered as an
action, labelled on Android and as More on iOS, where tapAndHold also
makes the whole tile one button. On iOS the app behind a full-screen
player is hidden from VoiceOver, which walked the library behind the
picture.
2026-10-13 16:51:11 +02:00
nvrl 01eee78317 feat(android,ios): double-tap to seek, controls that fit upright, and a player TalkBack can use
- Double-tap the left or right third of the picture to seek ten seconds,
  with a mark showing the seek happened; the middle plays or pauses.
- A wide picture turns the phone's player to landscape by itself, once
  per episode, until the viewer uses the rotation button.
- Upright, the pickers past subtitles go behind a More menu instead of
  running off the edge. Controls are 48dp on Android and 44pt on iOS.
- With TalkBack or VoiceOver on, the controls stay up: a control that
  fades four seconds after it is reached cannot be found again. The tap
  layer is announced as Show or Hide controls.
2026-10-13 16:50:54 +02:00
nvrl 2546bab0a6 perf(android,ios): warm what an episode opens with, and the next one near the end
The bridge's open_stream started no fetch until mpv asked, so every
open was the head and then the tail, one after the other. It now warms
both edges as the desktop does, and the resume block too when the
episode resumes.

warm_episode fetches another episode's opening blocks into the cache
without playing it. Both phones call it for the next episode in the
last two minutes or once the credits start, as the desktop already
did, so autoplay starts from the cache.
2026-10-13 16:40:18 +02:00
nvrl 8d795c29e6 perf(stream,player,app): start a resumed episode at its place, with that block already coming
Resuming loaded the file from the start and sought on FileLoaded. By
then mpv's demuxer had begun reading ahead from the beginning, so the
blocks the seek wanted queued behind ones fetched to be thrown away.
Player::play_from sets mpv's start option instead, and puts it back
once the file has loaded.

VideoStream::warm_resume fetches, beside the two edge blocks, the block
the resume position falls in, estimated from the running time saved
with the watch state. A video's bitrate is uneven, so it can land a
block off; then it is one block for nothing, against a round trip saved
when it lands.
2026-10-13 16:40:18 +02:00
nvrl 47c482498b feat(app): a text size setting, kept on this computer
Settings > Appearance offers 90% to 150%, applied as egui's zoom factor
so controls grow with the text. Ctrl+plus and Ctrl+minus already zoomed
through egui; what they settle on is now kept too, so Settings shows it
and the next launch opens at it. It lives in the desktop-only settings:
what reads well depends on the screen, not on the viewer's other
devices.
2026-10-13 16:29:42 +02:00
nvrl 31bdd75559 feat(app): turn on AccessKit, and name the controls drawn as icons
eframe now builds with its accesskit feature, so the widget tree reaches
the platform screen reader. Buttons drawn as an icon glyph were read as
the icon font's private code point, and the hand-painted ones (play,
poster tiles, the watched mark, download rings, the seek bar) as
nothing. A Named trait gives them a name, and hint() makes the existing
hover text that name.
2026-10-13 16:29:36 +02:00
nvrl d43da778b6 perf(app): refresh only the resume lists when a playing episode saves its place
A position save every five seconds marked the whole library view stale,
which re-grouped franchises, re-picked shelves and re-arranged the grid
on every save. Only the history and Continue watching lists move then,
unless the grid itself is filtered or sorted by watching.
2026-10-13 16:29:21 +02:00
nvrl abb3e3aaf8 feat(core,app,android,ios): rename a share
ShareStore::rename changes only the name; the id that the catalog, watch
state and offline files are keyed by stays the same. Sync carries the
change as it does any other change to the share's register. The desktop
renames in the share's row. The phones rename from the share's menu.
Before, the name given at add time was permanent short of removing the
share.
2026-10-13 16:01:11 +02:00
nvrl 5b931111b3 feat(android,ios): play from Downloads, and say what a download waits for
- A finished download row on the phones plays when tapped and has an
  Open button for its title, as the desktop's does.
- iOS queued downloads say "Waiting for Wi-Fi" or "Waiting for a
  connection", as Android's already do.
- Android download workers post their notifications as one group, each
  named for its episode, under a "Downloading 12 episodes" summary.
  Before, a season made fourteen loose notifications that all said
  "Making episode available offline".
2026-10-13 15:48:59 +02:00
nvrl 5c0519a226 feat(android,ios): mark watched up to an episode, and undo an episode's tick
A long press on an episode row offers "Mark watched up to here", as a
right click on the desktop's tick does. It is for someone joining a show
partway. Ticking one episode now goes through the same path as a season,
so it gets the same Undo.
2026-10-13 15:48:59 +02:00
nvrl 5c15f45ace feat(app,player): choose audio and subtitle languages in desktop Settings
The phones have had this, and the desktop could only set languages from
the player, after a file had already opened in the wrong one. Settings
now has an audio language and a subtitle choice: off, the file's
default, or a language. The names come from the list pstr-player already
names tracks with, now exported as LANGUAGES.
2026-10-13 15:37:00 +02:00
nvrl eb67df0aa0 feat(core,app,android,ios): filter the library on every client, including by download
The library filter moves from the desktop into pstr_core::browse and
gains a Downloaded filter: titles with at least one episode kept on the
device. The bridge's arrangement() takes the filter. Android and iOS get
a row of filter chips above the grid. The choice is remembered per
device, and shelves stay hidden under a filter, as on the desktop.
2026-10-13 15:36:29 +02:00
nvrl fdf6b0359b feat(android,ios): stop a crawl from the phones, as the desktop can
The bridge gains stop_crawl(). A running crawl watches for it and returns
without an error. Shares already read stay stored, and a queue of new
shares stops instead of going on to the next. The activity banner shows
Stop while a crawl runs, because listing a large share can take minutes
and holds the connection the player wants.
2026-10-13 15:34:55 +02:00
nvrl 6ddbcea429 feat(android,ios): offer posters and synopses on the library, as the desktop does
The offer appears when metadata is off and there is a library to put
posters on. It says what turning metadata on sends, links to Settings
and never turns anything on itself. "Not now" is stored on the device.
The existing library screenshots now run with metadata on, as their
shelf implies, and a new screenshot covers the offer.
2026-10-13 15:15:28 +02:00
nvrl a79e41aa8a feat(app,android,ios): ask before forgetting matches or downloading a whole show
- Turning metadata off or switching provider forgets every stored answer,
  including matches chosen by hand. The desktop now asks first and says
  how many hand-picked matches go. The phones' settings dialog says so
  above Save.
- Forgetting a hand-picked match on the desktop, and any match on the
  phones, is asked about first.
- Downloading a season, a show, or everything from a library tile asks
  first, with the episode count and their size.
- Android confirmations that delete something use an error-toned text
  button rather than the filled accent, which read as the safe choice.
2026-10-13 15:07:46 +02:00
nvrl 89e55aac12 feat(core,app,android,ios): finish adding a first share where it pays off
- A link with a password of its own, added without one, now fails as
  "This link has a password of its own" instead of the handshake's
  refusal. The link's public flags are only asked for after a refusal.
  The desktop form turns its password toggle on.
- After the first share is added, all three clients go to the library.
  While that share is being listed, the empty library says so instead of
  offering to add a share.
- The desktop library offers posters and synopses once there is a
  library to put them on. The offer says what is sent, links to
  Settings, never turns anything on, and "Not now" is remembered.
2026-10-13 15:07:41 +02:00
nvrl 0f96cc4514 feat(app): go to the player page as soon as an episode is asked for
The click used to leave the viewer on the title page with a small line in
the bottom bar until the file had opened, which over a public link can be
seconds. The player page now comes up at once with the title's art dimmed
behind the episode's name and a spinner, and Back still works. A file that
fails to open goes back to the page it was asked from.
2026-10-13 14:47:10 +02:00
nvrl 9d72acce88 feat(android,ios): open the library the way it was last sorted
Sort order and franchise folding reset on every launch on both phones.
They are kept with the front end's own settings now, and Android reads
them before the first grid is drawn, off the main thread.
2026-10-12 23:06:24 +02:00
nvrl d154002109 feat(core,app): open the library the way it was last arranged
Filter, sort and franchise folding reset on every launch, so someone who
browses by Recently added chose it again each time. They are kept in the
desktop's own settings file, alongside the cache budget.
2026-10-12 22:57:12 +02:00
nvrl 690455f7dd feat(app,android): play from the Downloads page, and say why a download waits
- A finished download on the desktop could only be deleted from the
  Downloads page; watching it meant finding it in the library. Finished
  rows gain Play, from wherever the library says it was left, and Open.
- Android showed "Queued" for a download WorkManager was holding back
  on mobile data because of Wi-Fi only, or for want of any connection,
  which reads as stuck. Those now say "Waiting for Wi-Fi" and "Waiting
  for a connection".
2026-10-12 22:55:16 +02:00
nvrl 01c252b794 feat(android,ios): mark a whole season watched from the title page
Marking was one episode at a time on the title page, or the whole title
from the library's long-press menu, so joining a show at season three
meant ticking every earlier episode. The season row gains a mark button
beside its download one, with an Undo, through a new markEpisodes that
setTitleWatched now uses too. Episodes already in the asked-for state
are left alone, so Undo restores only what changed.

Season chips were keyed by label, and two seasons with one label is a
crash in a lazy list; they are keyed by position now.
2026-10-12 22:51:10 +02:00
nvrl 6d1943f2bd feat(app): mark a season, or everything up to an episode, watched
Marking was per episode on the title page, or the whole title from the
library's menu. Someone joining a show at season three had to tick every
earlier episode one by one. Season headers now have Mark season watched
(or unwatched), and right-clicking an episode's tick offers Mark watched
up to here. Both go through one MarkEpisodes action with an Undo, which
SetTitleWatched now uses too.
2026-10-12 22:43:27 +02:00
nvrl 5dfb717235 feat(app): re-enter a share's link on the desktop
A keyring that was reset, or a link password that changed, left a share
the desktop could only remove and add again, losing its catalog, watch
history and downloads. The phones have had a way back since B18; the
desktop's share rows now have Re-enter link, which stores the new
secret through ShareStore::replace_secrets, reopens the share and
crawls it.
2026-10-12 22:42:07 +02:00
nvrl 3ff55a9af9 fix(app): stop from the full player, and keep fullscreen and speed in step
- The full player overlay had no Stop: Esc leaves the page and keeps
  playing, so ending an episode took a trip to the bar underneath. It has
  a Stop button now, and stopping from fullscreen leaves fullscreen.
- `F` toggled the app's own idea of fullscreen, which the window manager
  could change behind it (F11, a title-bar double-click). The flag now
  follows the window.
- The speed menu stopped at 2x while `]` went to 3x.
2026-10-12 22:40:33 +02:00
nvrl 33da1563ee docs(android): stop describing Picture-in-Picture, and say what PLAN.md has not done
Picture-in-Picture was removed from the Android client, but B40 still
read as a live fix, B42 stayed open on a `pending` PiP case that no
longer exists, and two code comments still named it. PLAN.md marked
every recommendation done; its status now lists the ones a source
review found short.
2026-10-12 22:39:51 +02:00
nvrl 7994097a25 fix(ios): keep the screen awake under video and the lock screen honest
- The idle timer was recomputed only when Now Playing was republished,
  so minimising and then reopening a playing episode let the screen
  sleep under it. It now follows `minimized` too.
- With background audio off, leaving the app paused but left video on,
  so lock-screen Play started decoding into an unseen layer. Video goes
  off on the way out either way.
- Now Playing reported full speed through a stall, and its clock ran on.
  Buffering now reports a rate of zero.
- The lock screen gains ten-second skip forward and back.
2026-10-12 22:39:19 +02:00
nvrl 77a926cbf5 fix(app): ask before deleting offline copies
Delete season, Delete show, the episode's Done badge and the Downloads
page's Delete all went straight to removing the files, with no question
and no undo - gigabytes of a season a misclick away. They now share one
confirmation that names what goes and says the share keeps the files.
2026-10-12 22:38:40 +02:00
nvrl 603cc0c1a9 feat(core,app,android,ios): add a first share without a detour or a name to invent
The empty library said to add a link "under Shares, then refresh" with
nothing to press on the phones, and on the desktop only switched page.
All three now open the add form from there, the desktop with the caret
in the link field; and adding already crawls, so there is nothing to
refresh.

A share's name was required before the viewer had seen anything to name.
It is optional now: without one, ShareStore::add_verified takes the
shared folder's own name from the link it has just opened. The link
comes first in each form. The desktop form submits on Enter, and the
Android link field has an eye to check what was pasted.
2026-10-12 22:37:17 +02:00
nvrl 5807493a4a feat(core,app,android,ios): look at shares again on their own, and say when they were last looked at
No client crawled a share unless someone pressed Refresh, so a new
episode stayed out of the library until then, and nothing said how stale
the library was.

Catalog schema v11 adds share_crawls: when each share's last crawl
finished and was attempted, how many files it stored and how many were
new, and why the last attempt failed. A failure keeps the last good
crawl's numbers.

Every client now crawls, quietly, any share not looked at for six hours:
the desktop at launch and every half hour while open, the phones at
launch and on their existing catch-up timer. A quiet crawl says only
news ("anime: 3 new files") and leaves failures on the share's row
rather than in a toast.

Share rows on all three show "120 files - crawled 2 h ago", or the last
failure until a crawl succeeds.
2026-10-12 22:22:25 +02:00
nvrl a306d02cf1 feat(core,app,android,ios): keep a show in Continue watching after an episode ends
Continue watching listed only part-watched episodes, so finishing one -
the most ordinary moment to want the next - dropped the show from the
shelf. Title::continue_with now reads the episode touched last: a
part-watched one is resumed, a finished one offers the episode after it
("Next: S01E04"), and one reset to the start (what leaving the shelf
does) keeps the title off. A few seconds of an episode are passed over.

The phones computed their own shelf from episode records. The bridge now
exports the engine's choice as TitleRecord.continue_index, and Play on a
title lands there on every client.

Undo on the phones re-saved a watch state with a fresh time, which both
reordered the shelf and could not express "never played". A new
restore_watch_state puts the state back as it was read.
2026-10-12 22:05:05 +02:00
nvrl c6b0457c4d feat(app,android,ios): end an episode without chapters as well as one with them
The up-next countdown only ran over a credits chapter, so a file with no
chapters - most that are not anime - cut straight into the next episode
with no warning and no way to stay. Such a file now shows the same card
over its last ten seconds, counted on the file's own clock so it reaches
zero at the end of file, and its second button is Cancel rather than
Watch till the end.

On the phones, an episode that ends with nothing after it - the last one,
autoplay off, or cancelled - left a black screen. It now says so, with
Play next or Watch again, and Close.

Android also:
- saves the resume position from PlaybackService rather than the player
  screen. With background audio on the service outlives the activity, and
  a position only the screen kept stopped being saved when it went. A swipe
  from Recents saves and stops instead of playing on with nothing to
  return to.
- shows "Opening <episode>..." while the stream opens, where there was a
  black screen for as long as the share session and block list took.
2026-10-12 21:50:02 +02:00
nvrl c5490ab3da feat(app,android,ios): say when playback fails and offer to retry from there
A file mpv gave up on partway ended the desktop player without a word:
the page went back to the title and the film was simply gone. It now
leaves an error toast naming where it stopped, with Retry reopening the
episode at that position.

The phone players already showed an error, but its only button was
Dismiss, so a dropped connection meant backing out and finding the
episode again. They gain Retry, which opens a fresh stream (through the
session refresh) from where the failed one stopped. A stall that lasts
twenty seconds now says so and offers the same, rather than showing
"Buffering…" for ever.
2026-10-12 21:42:09 +02:00
nvrl 9e53a9bbdb fix(core,app,android): refresh an expired visitor session on the desktop too
The age check and the one retry behind a refresh (B22) lived only in the
Android bridge. The desktop opened streams, offline copies and crawls
against whatever session the share was opened with, so a window left open
for a few hours failed to play or recrawl until it was restarted.

The logic moves to pstr-core as SessionAges, owned by SharedLibrary and
reached through SharedLibrary::reauthenticating. Both engines go through
it for play, warm, offline copy and (desktop) crawl.
2026-10-12 21:32:05 +02:00
nvrl ce02bbc35a fix(vers) updated cargo + pkgbuild version 2026-10-12 19:10:58 +02:00
nvrl 0b9a9dab50 fix(ios,android): offer the right episode as up next while the next one opens
After "next episode" during the credits, the previous file keeps
playing until the new stream is open. Its chapters and position were
read against the already-advanced index, so the up-next card offered
the episode after the next one for those seconds. The card, the skip
offer and auto-skip now only act on readings of the open episode.
2026-10-12 18:36:33 +02:00
nvrl 73973841c2 feat(core,app,android,ios,cli): look up new titles after every crawl, show presses and work on iOS
Metadata was only fetched after adding a share (mobile) or from the
Settings buttons (desktop), so a show or episode that turned up in an
existing share stayed bare. Every crawl is now followed by a match run
when enrichment is on; `plan` makes no requests for a library that is
already matched. A stored episode guide is fetched again when a file
newer than it has no named entry, and daily for two weeks while an
aired episode is still unnamed.

Removing a share now carries its watch state to any other share holding
the same file, `pstr remove` drops the share's catalog rows, and the app
forgets catalog rows of shares no longer configured — an older `pstr
remove` left them, and every episode showed twice.

iOS, for a slow iPad:
- swipe back on a settings page uses its own edge strip; on the whole
  page it also pressed the row under the finger and opened the metadata
  sheet
- "Change match" from the tile menu opens once the menu has dismissed,
  not after a guessed 350 ms that a slow device missed
- the activity banner tracks each running line by itself (also Android),
  so overlapping work no longer leaves it spinning forever
- buttons, tiles and rows draw their press; marking watched shows at
  once; remove/repair share, sign out and deletions show the banner;
  the player says "Opening…" while the stream opens
- posters are read into a reserved buffer instead of a byte at a time
2026-10-12 18:36:33 +02:00
nvrl 2f3be1d193 chore(version): bump to 1.4.0
Minor. Sync now carries the whole library, not only watch positions:
shares with their links and passwords, playback, appearance and metadata
settings, the TMDB key, hand-picked matches and per-title tracks, so every
signed-in device shows the same library. Devices share it once all of them
run 1.4.0; an older build keeps syncing watch positions and ignores the
rest.

Android drops picture-in-picture. iOS fixes the stretched player after a
rotation or resize, the share that failed to add on the first try, and
settings that saved late or silently. Both phone clients now show what is
running and why a form was refused.

No catalog schema change since 1.3.0.
2026-10-09 03:15:55 +02:00
nvrl 8e42906a7f feat(core,app,android,cli,ios): sync the library and settings, verify a share before adding it
Sync carried only watch positions, so a second device started empty. The
per-installation history file now also carries per-key registers
(pstr-core::profile): every share with its link and custom password, the
playback, appearance and metadata settings, the TMDB key, hand-picked
matches and per-title track choices. Newer updated_at wins per key, a
removal travels as an empty register, and a first sync stamps at 0 so a
fresh install takes the library the other devices have instead of
overwriting it with defaults. Volume, mute and the device-only settings
(cache budget, Wi-Fi only, background audio, hardware decoding) stay out.
The secrets live only in the end-to-end encrypted Drive file, never in
shares.json. FORMAT_VERSION stays 1; an older build ignores the registers.

Adding a share stored it before opening the link, so a first failure left a
broken entry and every retry said "already configured". add_verified opens
the link first and stores nothing on failure, and only the new share is
crawled afterwards, so another broken share cannot fail the add.

Android drops picture-in-picture: the window sized the video wrongly and
background audio already covers leaving the app.

iOS sizes the Metal drawable on every layout, so the picture no longer
stretches after a rotation or a Split View resize. Settings show a save at
once rather than after the match run, and a failed save reverts and says
why. Both phone clients gain an activity banner naming what is running, and
the add-share, metadata and change-match forms show progress and errors in
place.
2026-10-09 03:15:39 +02:00
nvrl dc70cafbb7 chore(version): bump to 1.3.0
Minor. One new client: an iOS 17+ app, a SwiftUI port of the Android one
over the same Rust engine, published as an unsigned .ipa for SideStore and
AltStore. docs/IOS.md lists where iOS cannot follow Android: no
picture-in-picture, links come in through the protonstream:// scheme or
Paste link rather than the share sheet, and downloads run while the app
does.

No change to the Rust crates, the catalog schema or the desktop and Android
clients since 1.2.0.
2026-10-07 01:55:22 +02:00
nvrl a28135cd51 ci(release): publish the unsigned iOS .ipa
The release calls ios.yml after the version check, as it does android.yml,
and attaches proton-stream-<version>-ios.ipa next to the other artifacts and
in SHA256SUMS.
2026-10-07 01:55:22 +02:00
nvrl 4b9c01d52d fix(ios): clear the compiler warnings from the first CI build
previewPalette does not throw, and mpv's wid now gets the layer's address
as an explicit int64 instead of a pointer to a Swift reference.
2026-10-07 01:27:10 +02:00
nvrl 3356602951 fix(ios): give the test target the generated header path
`@testable import ProtonStream` imports the app's bridging header, which
includes PstrBridgeFFI.h from ios/build/generated.
2026-10-07 00:54:45 +02:00
nvrl 537217e70a fix(ios): make scripts/build-ios.sh executable
The iOS workflow runs it directly, and it was committed as 100644.
2026-10-07 00:34:48 +02:00
nvrl ed05a1e723 feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the
same pstr-android UniFFI surface, with no Rust changes: Library, title page,
History, Shares (public links and Drive folders), Downloads and Settings,
with the same strings, undo snackbars and two-pane layout on wide screens.

- Theme from the engine's palette (Material role names, Inter, the same
  type ramp and corners), repainted live from the appearance settings.
- Account sign-in with two-factor code, mailbox password and the Proton
  CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and
  watch-history sync every five minutes, after sign-in, on player close and
  when the app leaves the screen.
- Player over libmpv: resume, progress saved every 5 s, autoplay with the
  up-next card, skip and auto-skip from the chapter plan, track, speed,
  volume and episode pickers, landscape lock, mini transport, and
  lock-screen controls with interruption and route-change handling.
- Offline downloads with the Android record format, free-space floor,
  retries, transfer rate and the Wi-Fi-only policy; interrupted transfers
  resume at next launch.
- Config, data and download state are excluded from backup, as Android's
  allowBackup=false: a restore would bring shares back without their
  Keychain secrets and give two devices one sync identity.

Picture-in-picture, the share sheet and background downloads are not
possible the same way on iOS; docs/IOS.md lists why and what replaces them.

Adds XCTest ports of the Android unit tests, a `test` command to
scripts/build-ios.sh, the app icon and its render script, and an iOS
workflow on macos-15 that runs the tests and builds the unsigned .ipa.
2026-10-07 00:32:03 +02:00
nvrl da16ab0575 fix(core,app,android): keep the password out of storage and refuse key-only 2FA
Moves to proton-sdk / proton-drive-rs 0.7.3.

The stored Proton session now holds, per account key, the passphrase derived
from the mailbox password at sign-in, and resumes through
`ProtonDriveClient::with_key_passphrases`. The mailbox password — on a
single-password account, the login password — and the key salts are no longer
stored. A session in the earlier shape, which only development builds of
1.2.0 wrote, reads as signed out: signing in again replaces it.

An account whose only second factor is a security key is refused at sign-in
with a reason, rather than asked for a code that can only fail as a wrong one.
The code prompts now say plainly that security keys are not supported.
2026-10-06 15:01:37 +02:00
nvrl d9ca8f8d60 fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync
- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview
  in a child process of the app (`pstr-app::verify`), confined to
  https://*.proton.me, hands the token back and the sign-in is retried. Linux
  packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship
  WebView2Loader.dll, without which a -gnu build does not start.
- The desktop keyring splits a secret too long for one entry across several,
  so a Proton session with many key salts survives Windows' 1280-unit cap.
  Short secrets stay one entry, so every existing secret still reads.
- `WatchSync` runs against a small `HistoryDrive` trait and is tested end to
  end against an in-memory Drive: two devices, newer wins, no re-upload of an
  unchanged history, a missing file written again, an unreadable own file
  never overwritten, a newer-format file left alone.
- An installation's file unwritten for 90 days is retired by the next device
  to sync, after its entries are merged and carried in that device's file.
- Removing a share keeps its watch state: sync would bring it back from the
  other devices anyway, and re-adding the share picks up where it left off.
- Watch history syncs once more on desktop exit (bounded to 3 s) and, on
  Android, through a WorkManager job enqueued when the app leaves the screen.
- The second-factor prompts say security keys are not supported.
2026-10-06 14:30:15 +02:00
nvrl 26cccd5c63 chore(version): bump to 1.2.0
Minor. Two new features, both optional and both behind signing in to a Proton
account:

- Sign in with a Proton account (password, two-factor code, separate mailbox
  password) on desktop, Android and the CLI, and add folders of your own Drive
  — My Files, other devices, shared with me — as library shares next to public
  links. The session lives in the OS credential store, never in shares.json.
- Watch history follows you across devices. Each installation keeps its own
  file on a `proton-stream` device in your Drive and merges everyone else's
  per episode, newest position wins. It syncs at launch, after sign-in, when
  the player closes and every five minutes.

Also: Android offline downloads no longer look stuck (B64), and picking a
metadata match by hand refreshes the title's episodes and artwork.

The catalog schema is unchanged from 1.1.3. A new `sync.json` beside
`shares.json` holds this installation's sync id.
2026-10-06 13:19:52 +02:00
nvrl 0b2813cd42 feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history
A Proton account is now optional alongside public links. Signing in (password,
then a second-factor code and a mailbox password where the account has them;
a CAPTCHA gate is solved in the Android app) stores one session blob in the OS
credential store under `proton-account`, writing back every refreshed token
since refresh tokens are single-use. With an account, folders of the viewer's
own Drive — My Files, other devices, shared with me — can be added as shares
through the same `ShareClient` surface as a public link.

Watch history syncs through a `proton-stream` device in that Drive: one
`watch-history/<installation>.json` per installation, so no two devices ever
write the same file. Each sync reads the others and merges per episode, newer
`updated_at` wins, with a deterministic tie-break. It runs at launch, after
sign-in, whenever the player closes and every five minutes. A session that can
no longer refresh signs the app out instead of failing on every launch.

The CLI gains `login`, `logout`, `account`, `browse` and `sync`.

Also in this change:
- Android offline downloads no longer look stuck (B64): two transfer at once,
  through a copy stream with no read-ahead, with a smoothed transfer rate.
- Choosing a metadata match by hand now enriches the title too, instead of
  keeping the old match's episodes and artwork.

The catalog schema is unchanged.
2026-10-06 13:06:54 +02:00
nvrl 6cdab2fde1 chore(version): bump to 1.1.3
Patch. Fixes two Windows installer defects: an MSI upgrade no longer leaves
the app without libmpv until a second install (B62), and a taskbar pin keeps
its icon, which the exe now carries itself (B63). The catalog schema and
on-disk data are unchanged from 1.1.2.
2026-10-06 03:05:20 +02:00
nvrl 6239780978 fix(app,packaging): keep libmpv and the taskbar icon across Windows upgrades
An MSI upgrade left the app without libmpv-2.dll until it was installed a
second time (B62). Costing runs before RemoveExistingProducts, saw the old
DLL at the same version and skipped the copy, and removing the old product
then deleted it. Both installer definitions now set REINSTALLMODE=amus;
every file is private to the install directory.

A taskbar pin lost its icon after a while (B63). The exe carried no icon
resource, and the Start Menu shortcut's icon lived in the per-ProductCode
installer cache that each upgrade deletes. build.rs now compiles the .ico
into proton-stream.exe, and the shortcut has no Icon of its own (and in the
WiX build is no longer advertised), so both show the exe's icon.
2026-10-06 03:04:07 +02:00
nvrl 67cf3044f1 fix(android): keep the accent button's width and corners
Moving the gradient onto a row inside the button lost both: the button's
own row hands its children no minimum size, so a fillMaxWidth button
painted the ramp only behind its label, and with square corners.

The gradient goes back on the button, and the 48dp touch-target padding
is taken by a box around it instead, with the button inside told not to
add its own. The primary button now matches the other buttons' 40dp
container at every width. The screenshots are re-recorded for it.
2026-10-04 17:14:11 +02:00
nvrl 66be35c3d4 chore(version): bump to 1.1.2
Patch. The only change since 1.1.1 is on Android: the primary button on the
library hero no longer draws taller than the button beside it. The catalog
schema and on-disk data are unchanged from 1.1.1.
2026-10-04 16:55:38 +02:00
nvrl 046cfe62b8 fix(android): paint the accent button's gradient inside its container
The gradient sat on the outer modifier, which Material pads out to the
48dp minimum touch target, so the primary button drew 8dp taller than
the 40dp tonal button beside it on the library hero.
2026-10-04 16:45:25 +02:00
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
186 changed files with 30460 additions and 3487 deletions

No files matched your search

+148 -14
View File
@@ -3,7 +3,15 @@ name: Android
on: on:
push: push:
branches: [main] 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: pull_request:
paths: paths:
- 'android/**' - 'android/**'
@@ -31,8 +39,8 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
ANDROID_API: '36' ANDROID_API: '36'
ANDROID_BUILD_TOOLS: '37.0.0' ANDROID_BUILD_TOOLS: '37.0.0'
ANDROID_NDK: '29.0.14206865' ANDROID_NDK: '30.0.16248370'
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865 ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
jobs: jobs:
build: build:
@@ -74,14 +82,23 @@ jobs:
distribution: temurin distribution: temurin
java-version: '17' 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 - uses: android-actions/setup-android@v3
with:
packages: platform-tools
log-accepted-android-sdk-licenses: false
- name: Install native build prerequisites - name: Install native build prerequisites
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install --no-install-recommends \ sudo apt-get install --no-install-recommends \
autoconf automake build-essential cmake curl git gperf libtool \ autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm 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 - name: Install Android SDK and NDK
run: >- run: >-
@@ -90,12 +107,53 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}" "build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}" "ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable - id: toolchain
uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: '1.96' toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android targets: aarch64-linux-android,x86_64-linux-android
# 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 - 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 - uses: gradle/actions/setup-gradle@v4
with: with:
@@ -124,9 +182,15 @@ jobs:
- name: Android checks and artifacts - name: Android checks and artifacts
run: bash scripts/build-android.sh all run: bash scripts/build-android.sh all
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
signed-release: signed-release:
name: Signed release name: Signed release
if: false if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
needs: build needs: build
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env: env:
@@ -141,14 +205,23 @@ jobs:
distribution: temurin distribution: temurin
java-version: '17' 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 - uses: android-actions/setup-android@v3
with:
packages: platform-tools
log-accepted-android-sdk-licenses: false
- name: Install native build prerequisites - name: Install native build prerequisites
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install --no-install-recommends \ sudo apt-get install --no-install-recommends \
autoconf automake build-essential cmake curl git gperf libtool \ autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm 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 - name: Install Android SDK and NDK
run: >- run: >-
@@ -157,12 +230,53 @@ jobs:
"build-tools;${ANDROID_BUILD_TOOLS}" "build-tools;${ANDROID_BUILD_TOOLS}"
"ndk;${ANDROID_NDK}" "ndk;${ANDROID_NDK}"
- uses: dtolnay/rust-toolchain@stable - id: toolchain
uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: '1.96' toolchain: '1.96'
targets: aarch64-linux-android,x86_64-linux-android targets: aarch64-linux-android,x86_64-linux-android
# 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 - 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 - uses: gradle/actions/setup-gradle@v4
with: with:
@@ -188,10 +302,30 @@ jobs:
- name: Build signed APK and AAB - name: Build signed APK and AAB
run: bash scripts/build-android.sh signed run: bash scripts/build-android.sh signed
- if: steps.target.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: target
key: ${{ steps.target.outputs.cache-primary-key }}
# The APK bundles a GPL libmpv, so its corresponding source ships next to
# 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 - uses: actions/upload-artifact@v7
with: with:
name: android-signed name: android
path: | path: dist/android/*
android/app/build/outputs/apk/release/*.apk if-no-files-found: error
android/app/build/outputs/bundle/release/*.aab
- uses: actions/upload-artifact@v7
with:
name: android-bundle
path: android/app/build/outputs/bundle/release/*.aab
if-no-files-found: error if-no-files-found: error
+3 -3
View File
@@ -23,11 +23,11 @@ jobs:
- uses: Swatinem/rust-cache@v2.9.1 - uses: Swatinem/rust-cache@v2.9.1
# libmpv for pstr-player. # libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
- name: Install libmpv - name: Install libmpv and WebKitGTK
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libmpv-dev sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check
+71
View File
@@ -0,0 +1,71 @@
name: iOS
on:
push:
branches: [main]
workflow_call:
pull_request:
paths:
- 'ios/**'
- 'android/app/src/main/res/font/**'
- 'android/app/src/main/assets/licenses/**'
- 'crates/pstr-android/**'
- 'crates/pstr-core/**'
- 'crates/pstr-meta/**'
- 'crates/pstr-stream/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'scripts/build-ios.sh'
- '.github/workflows/ios.yml'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Unit tests and unsigned .ipa
runs-on: macos-15
steps:
- uses: actions/checkout@v7
- id: toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.96'
targets: aarch64-apple-ios,aarch64-apple-ios-sim
- name: Restore the Rust target tree
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
- name: Install XcodeGen
run: brew install xcodegen
- name: Bindings
run: scripts/build-ios.sh bindings
- name: Unit tests on the simulator
run: scripts/build-ios.sh test
# SideStore and AltStore re-sign whatever they install with the viewer's
# own Apple ID, so the release artifact is unsigned.
- name: Unsigned .ipa
run: |
scripts/build-ios.sh rust
scripts/build-ios.sh ipa
- uses: actions/upload-artifact@v7
with:
name: ios
path: dist/*.ipa
if-no-files-found: error
+35 -19
View File
@@ -20,9 +20,6 @@ permissions:
env: env:
CARGO_TERM_COLOR: always 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: jobs:
verify: verify:
@@ -60,11 +57,6 @@ jobs:
! grep -q '^\[patch\.crates-io\]' Cargo.toml || ! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; } { 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: linux:
name: Linux artifacts name: Linux artifacts
needs: verify needs: verify
@@ -84,7 +76,7 @@ jobs:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libmpv-dev rpm sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
- name: Gate - name: Gate
run: | run: |
@@ -148,25 +140,49 @@ jobs:
dist/*.msi dist/*.msi
if-no-files-found: error 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
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
# Apple ID when they install it.
ios:
name: iOS
needs: verify
uses: ./.github/workflows/ios.yml
publish: publish:
name: Publish desktop artifacts name: Publish
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
needs: [linux, windows] needs: [linux, windows, android, ios]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: write contents: write
steps: 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 - uses: actions/download-artifact@v8
with: 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
- uses: actions/download-artifact@v8
with:
name: ios
path: artifacts 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 - name: Checksums
run: | run: |
Generated
+1591 -56
View File
File diff suppressed because it is too large. Load diff
+16 -4
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.0" version = "1.5.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2 opt-level = 2
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.7.2" } proton-sdk = { version = "0.7.3" }
proton-drive-rs = { version = "0.7.2" } proton-drive-rs = { version = "0.7.3" }
# Internal crates. # Internal crates.
pstr-core = { path = "crates/pstr-core" } pstr-core = { path = "crates/pstr-core" }
@@ -54,6 +54,8 @@ directories = "5"
# Local dates for the history page. Already in the tree through the SDK. # Local dates for the history page. Already in the tree through the SDK.
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
sha2 = "0.10" sha2 = "0.10"
# The id a watch-history file is named by. Already in the tree through the SDK.
getrandom = "0.3"
# `bundled` so a Windows build needs no system SQLite. # `bundled` so a Windows build needs no system SQLite.
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
# The share URL fragment and the custom password are both secrets; they go in # The share URL fragment and the custom password are both secrets; they go in
@@ -71,8 +73,10 @@ keyring = { version = "3" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an # The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context # `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
# eframe is drawing into. # eframe is drawing into. `accesskit` hands the widget tree to the platform's
# screen reader (AT-SPI, UI Automation, NSAccessibility).
eframe = { version = "0.35", default-features = false, features = [ eframe = { version = "0.35", default-features = false, features = [
"accesskit",
"default_fonts", "default_fonts",
"glow", "glow",
"wayland", "wayland",
@@ -103,6 +107,14 @@ dbus = "0.9"
windows-sys = "0.61" windows-sys = "0.61"
# Toasts on Windows. Linux notifications go over the `dbus` above. # Toasts on Windows. Linux notifications go over the `dbus` above.
tauri-winrt-notification = "0.8" tauri-winrt-notification = "0.8"
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
embed-resource = "3"
# Proton's CAPTCHA page, which reports success by posting a message to its
# host, so it needs a webview this app controls. Run in a child process of its
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
rpassword = "7" rpassword = "7"
libloading = "0.8" libloading = "0.8"
lru = "0.18" lru = "0.18"
+187
View File
@@ -0,0 +1,187 @@
# 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.
A source review on 2026-10-12 found these still short of the text below:
- **Title page.** The backdrop scrolls away with the back button instead of
collapsing to a pinned bar, and the icon row has no mark-watched action.
- **Episode rows** show the file size where runtime was asked for.
- **Player episode list** is a bottom sheet in landscape too, not a side
panel.
- **Storage** has a usage bar for the streaming cache only; offline episodes
have none.
- **Metadata language** is still a free-text field.
- **Touch targets.** The player's icons are 40 dp, under the 48 dp floor.
- **Screenshots** do not cover the player, and only the library is taken at
font scale 1.5.
Share rows now say how many files the last crawl found, when it ran and why it
failed (section 2, Shares), as of the catalog's `share_crawls` table.
## 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.
+16 -6
View File
@@ -33,6 +33,10 @@ streaming implementation through UniFFI. It features a modern Material 3 interfa
an immersive fullscreen player with auto-hiding controls, and offline downloads. an immersive fullscreen player with auto-hiding controls, and offline downloads.
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md). Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
[`docs/IOS.md`](docs/IOS.md).
```bash ```bash
cargo run --release -p pstr-app # the app cargo run --release -p pstr-app # the app
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
@@ -72,8 +76,8 @@ against the SDK's own range download.
| Transcoding | None. mpv demuxes and decodes natively. | | Transcoding | None. mpv demuxes and decodes natively. |
| UI | egui/eframe on glow; mpv renders into the same GL context. | | 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. | | 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. | | 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, and an iOS 17+ port of it for sideloading. |
## Building ## Building
@@ -83,7 +87,9 @@ Rust 2024, MSRV 1.96.
cargo build --release cargo build --release
``` ```
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those `pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
On Windows the libmpv ones
come from the separate **mpv-dev** archive rather than mpv's player build, and come from the separate **mpv-dev** archive rather than mpv's player build, and
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from `libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
crates.io; nothing here builds against a sibling checkout. crates.io; nothing here builds against a sibling checkout.
@@ -97,6 +103,9 @@ Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain; UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing. see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust
target: `scripts/build-ios.sh all` writes `dist/proton-stream-<version>-ios.ipa`.
## Privacy ## Privacy
- The share URL fragment **is** the decryption password. It and any custom - The share URL fragment **is** the decryption password. It and any custom
@@ -119,9 +128,10 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
## License ## License
The shared Rust crates and desktop application are MIT licensed. The Android The shared Rust crates and desktop application are MIT licensed. The Android and
application and its combined native distribution are GPL-3.0-or-later; see iOS applications and their combined native distributions are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices. [`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and
the bundled-component notices.
The desktop application embeds the Inter typeface, under the SIL Open Font 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)), 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. and the Phosphor icon font, under MIT.
+2 -1
View File
@@ -7,11 +7,12 @@ distributions.
| Component | License | | Component | License |
|---|---| |---|---|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration | | 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 | | AndroidX and Jetpack Compose | Apache-2.0 |
| Kotlin | Apache-2.0 | | Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later | | JNA | Apache-2.0 or LGPL-2.1-or-later |
| UniFFI | MPL-2.0 | | UniFFI | MPL-2.0 |
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
| proton-stream shared Rust crates | MIT | | proton-stream shared Rust crates | MIT |
This summary is not a replacement for dependency license files. Before a 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.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ktlint) alias(libs.plugins.ktlint)
alias(libs.plugins.roborazzi)
} }
val repositoryRoot = rootProject.projectDir.parentFile val repositoryRoot = rootProject.projectDir.parentFile
@@ -46,7 +47,7 @@ android {
namespace = "io.narl.protonstream" namespace = "io.narl.protonstream"
compileSdk = 36 compileSdk = 36
buildToolsVersion = "37.0.0" buildToolsVersion = "37.0.0"
ndkVersion = "29.0.14206865" ndkVersion = "30.0.16248370"
defaultConfig { defaultConfig {
applicationId = "io.narl.protonstream" applicationId = "io.narl.protonstream"
@@ -142,6 +143,9 @@ android {
"GradleDependency", "GradleDependency",
"NewerVersionAvailable", "NewerVersionAvailable",
"AndroidGradlePluginVersion", "AndroidGradlePluginVersion",
// The same for the platform: raising targetSdk opts into new
// behaviour and needs its own pass on a device.
"OldTargetApi",
) )
sarifReport = true sarifReport = true
} }
@@ -176,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
val hostRustLibrary = repositoryRoot.resolve( val hostRustLibrary = repositoryRoot.resolve(
"target/debug/${System.mapLibraryName("pstr_android")}", "target/debug/${System.mapLibraryName("pstr_android")}",
) )
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
val buildLibmpvAndroid by tasks.registering(Exec::class) { val buildLibmpvAndroid by tasks.registering(Exec::class) {
group = "native" group = "native"
description = "Builds the pinned GPL libmpv and stages both Android ABIs." description = "Builds the pinned GPL libmpv and stages both Android ABIs."
workingDir(repositoryRoot) workingDir(repositoryRoot)
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath) commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh")) // No declared outputs: the script's STAMP is the up-to-date check. Gradle
outputs.files( // deletes the declared outputs of a task it has no history for, which on a
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") }, // fresh CI checkout threw away the tree just restored from the cache and
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") }, // rebuilt libmpv from source on every run.
stagedMpvDir.map { it.file("include/mpv/client.h") },
stagedMpvDir.map { it.file("REVISION") },
)
} }
val buildRustHost by tasks.registering(Exec::class) { val buildRustHost by tasks.registering(Exec::class) {
@@ -269,6 +269,11 @@ dependencies {
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.work.testing) 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.junit)
androidTestImplementation(libs.androidx.test.core) 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 // 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 // though the filter above drops every file in it. Without this, Gradle refuses
// the build for an undeclared dependency between the two tasks. // the build for an undeclared dependency between the two tasks.
+22 -2
View File
@@ -11,7 +11,9 @@
android:name=".ProtonStreamApplication" android:name=".ProtonStreamApplication"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/ic_launcher_foreground" android:enableOnBackInvokedCallback="true"
tools:targetApi="33"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ProtonStream"> android:theme="@style/Theme.ProtonStream">
@@ -29,15 +31,33 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:label="@string/launcher_name"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"> android:resizeableActivity="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
</application> </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 | | Component | License |
|---|---| |---|---|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration | | 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 | | AndroidX and Jetpack Compose | Apache-2.0 |
| Kotlin | Apache-2.0 | | Kotlin | Apache-2.0 |
| JNA | Apache-2.0 or LGPL-2.1-or-later | | JNA | Apache-2.0 or LGPL-2.1-or-later |
| UniFFI | MPL-2.0 | | UniFFI | MPL-2.0 |
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
| proton-stream reusable Rust crates | MIT | | proton-stream reusable Rust crates | MIT |
Before release, replace this dependency summary with an inventory generated Before release, replace this dependency summary with an inventory generated
+20 -7
View File
@@ -31,6 +31,8 @@ extern "C" {
namespace { namespace {
constexpr const char *kProtocol = "pstr"; 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 { struct StreamCookie {
uint64_t handle; uint64_t handle;
@@ -191,13 +193,16 @@ struct PlaybackState {
/// Between a seek being issued and playback actually resuming. The seek bar /// Between a seek being issued and playback actually resuming. The seek bar
/// has moved but the picture has not. /// has moved but the picture has not.
bool seeking = false; 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 /// True between `loadfile` being issued and mpv acknowledging it with
/// `START_FILE`. Everything mpv reports in that window still describes the /// `START_FILE`. Everything mpv reports in that window still describes the
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits — /// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
/// and must not be attributed to the file being loaded. /// and must not be attributed to the file being loaded.
bool loading = false; bool loading = false;
/// The picture's display size, once mpv has decoded enough to know it. /// The picture's display size, once mpv has decoded enough to know it.
/// Zero until then, and the aspect Picture-in-Picture is given. /// Zero until then.
double video_width = 0.0; double video_width = 0.0;
double video_height = 0.0; double video_height = 0.0;
/// Which loaded file this state describes. Bumped by every load, so a /// Which loaded file this state describes. Bumped by every load, so a
@@ -216,6 +221,7 @@ class Player {
option("vo", "libmpv"); option("vo", "libmpv");
option("force-window", "no"); option("force-window", "no");
option("video-timing-offset", "0"); 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 // `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
// an `ANativeWindow` the decoder is handed at init, and this player has // 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, // 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 // `hevc_mediacodec: Both surface and native_window are NULL` before
// falling back. The copy variants decode in hardware and hand back // falling back. The copy variants decode in hardware and hand back
// ordinary frames, which is what this render path can actually use. // ordinary frames, which is what this render path can actually use.
option("hwdec", "auto-copy-safe"); option("hwdec", kHardwareDecoding);
option("cache", "yes"); option("cache", "yes");
option("cache-secs", "30"); option("cache-secs", "30");
option("demuxer-readahead-secs", "30"); option("demuxer-readahead-secs", "30");
@@ -245,6 +251,7 @@ class Player {
observe("video-params/dh", 7, MPV_FORMAT_INT64); observe("video-params/dh", 7, MPV_FORMAT_INT64);
observe("paused-for-cache", 8, MPV_FORMAT_FLAG); observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
observe("cache-buffering-state", 9, MPV_FORMAT_INT64); 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 // Errors mpv reports about the file itself, rather than about the
// request that opened it. Without these a failed demux is silent. // request that opened it. Without these a failed demux is silent.
mpv_request_log_messages(mpv_, "error"); mpv_request_log_messages(mpv_, "error");
@@ -294,8 +301,12 @@ class Player {
} }
bool load(uint64_t handle, double start, const std::string &audio, 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; 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 // 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 — // 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 // 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, "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, "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, "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 { 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 { 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(); }); 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, [=] { return c_boundary<jboolean>(JNI_FALSE, [=] {
std::string audio_text; std::string audio_text;
std::string subtitle_text; std::string subtitle_text;
@@ -832,7 +844,8 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
auto *p = from(handle); auto *p = from(handle);
return static_cast<jboolean>( return static_cast<jboolean>(
p && p->load(static_cast<uint64_t>(stream), start, audio_text, 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_TRUE
: JNI_FALSE); : JNI_FALSE);
}); });
@@ -868,8 +881,8 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
state.video_width, state.video_height, state.video_width, state.video_height,
static_cast<jdouble>(state.end_reason), static_cast<jdouble>(state.end_reason),
state.buffering ? 1.0 : 0.0, state.cache_percent, state.buffering ? 1.0 : 0.0, state.cache_percent,
state.seeking ? 1.0 : 0.0}; state.seeking ? 1.0 : 0.0, state.cached_until};
constexpr jsize kFields = 13; constexpr jsize kFields = 14;
jdoubleArray result = env->NewDoubleArray(kFields); jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast<jdoubleArray>(nullptr); if (!result) return static_cast<jdoubleArray>(nullptr);
env->SetDoubleArrayRegion(result, 0, kFields, values); 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))
@@ -1,53 +1,40 @@
package io.narl.protonstream package io.narl.protonstream
import android.Manifest import android.Manifest
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Rational
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.WatchSyncWorker
import io.narl.protonstream.ui.ProtonStreamApp import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme import io.narl.protonstream.ui.theme.ProtonStreamTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uniffi.pstr_android.PaletteRecord import uniffi.pstr_android.PaletteRecord
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null) 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. * Whether a bind is outstanding, which is what has to be unbound.
* *
@@ -65,30 +52,10 @@ class MainActivity : ComponentActivity() {
} }
} }
/**
* The transport behind the buttons Android draws over the PiP window.
*
* A broadcast rather than a service intent: PiP is one of the states in
* which a background service start is refused, and the receiver is
* registered for as long as the activity that owns the window exists.
*/
private val pictureInPictureControls = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val host = playerHost.value ?: return
when (intent?.getStringExtra(EXTRA_CONTROL)) {
CONTROL_PLAY -> host.setPaused(false)
CONTROL_PAUSE -> host.setPaused(true)
CONTROL_NEXT -> host.onSkipNext?.invoke()
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
}
}
}
private val notificationPermission = registerForActivityResult( private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ } ) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Before anything draws. The palette decides the window's own // Before anything draws. The palette decides the window's own
@@ -97,6 +64,9 @@ class MainActivity : ComponentActivity() {
// Infallible by construction — an unreadable theme file resolves to the // Infallible by construction — an unreadable theme file resolves to the
// shipped default rather than throwing. // shipped default rather than throwing.
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed) 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() enableEdgeToEdge()
// Re-applied on every change, not just at startup: picking Latte from // 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 // the settings page has to turn the status bar's icons dark in the same
@@ -109,32 +79,19 @@ class MainActivity : ComponentActivity() {
playbackConnection, playbackConnection,
Context.BIND_AUTO_CREATE, Context.BIND_AUTO_CREATE,
) )
ContextCompat.registerReceiver(
this,
pictureInPictureControls,
IntentFilter(ACTION_PIP_CONTROL),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
if ( if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) { ) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
// Auto-enter has to be armed *before* the gesture that would use it:
// building the params inside onUserLeaveHint is too late for the swipe
// home that gesture navigation sends, which never calls it. Re-register
// whenever the picture's shape or the transport's does.
lifecycleScope.launch {
snapshotFlow { playerHost.value }
.flatMapLatest { host -> host?.state ?: flowOf(null) }
.map { state -> PictureInPictureShape.of(state) }
.distinctUntilChanged()
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
}
setContent { setContent {
ProtonStreamTheme { ProtonStreamTheme {
ProtonStreamApp(playerHost.value, inPictureInPicture.value) ProtonStreamApp(
playerHost.value,
shareLink = incomingShareLink.value,
onShareLinkTaken = { incomingShareLink.value = null },
)
} }
} }
} }
@@ -159,27 +116,14 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onUserLeaveHint() { // singleTask: a link opened while the app is running arrives here, not in
super.onUserLeaveHint() // a second activity.
// The backstop for launchers that still send this instead of arming override fun onNewIntent(intent: Intent) {
// auto-enter. Entering twice is not an error; not entering at all is. super.onNewIntent(intent)
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) { intent.shareLink()?.let { incomingShareLink.value = it }
runCatching {
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
}
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// The window is now a thumbnail with its own controls. Anything the
// player would draw over it — the transport, the title bar, the skip
// offer — is unreadable at that size and covers the picture.
inPictureInPicture.value = isInPictureInPictureMode
} }
override fun onDestroy() { override fun onDestroy() {
runCatching { unregisterReceiver(pictureInPictureControls) }
if (bound) { if (bound) {
unbindService(playbackConnection) unbindService(playbackConnection)
bound = false bound = false
@@ -189,89 +133,14 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
// Where the viewer stopped is what their other devices want next, and
// a process in the background may not live to the next timed sync.
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
if ( if (
!isChangingConfigurations && !isChangingConfigurations &&
!isInPictureInPictureMode &&
!SettingsStore(this).backgroundAudio !SettingsStore(this).backgroundAudio
) { ) {
playerHost.value?.stop() playerHost.value?.stop()
} }
} }
/** Everything the PiP window's shape depends on, and nothing that polls. */
private data class PictureInPictureShape(
val active: Boolean,
val paused: Boolean,
val width: Int,
val height: Int,
) {
companion object {
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
active = state != null && state.duration > 0.0 && !state.ended,
paused = state?.paused != false,
width = state?.videoWidth?.toInt() ?: 0,
height = state?.videoHeight?.toInt() ?: 0,
)
}
}
private fun PictureInPictureShape.params(): PictureInPictureParams =
PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio(width, height))
.setAutoEnterEnabled(active)
.setSeamlessResizeEnabled(false)
// The player owns the whole window, so the picture's rectangle is
// the window's. Without it the transition into PiP cross-fades from
// a screenshot instead of shrinking the video that is already there.
.setSourceRectHint(windowBounds())
.setActions(
listOf(
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
),
)
.build()
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
RemoteAction(
Icon.createWithResource(this, icon),
label,
label,
PendingIntent.getBroadcast(
this,
requestCode,
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
private companion object {
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
const val EXTRA_CONTROL = "control"
const val CONTROL_PLAY = "play"
const val CONTROL_PAUSE = "pause"
const val CONTROL_NEXT = "next"
const val CONTROL_PREVIOUS = "previous"
/**
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
* and refuses the request outright rather than clamping it — so a
* 2.40:1 scope release would take PiP away entirely.
*/
fun aspectRatio(width: Int, height: Int): Rational {
if (width <= 0 || height <= 0) return Rational(16, 9)
val ratio = width.toDouble() / height
return when {
ratio > MAX_RATIO -> Rational(239, 100)
ratio < 1 / MAX_RATIO -> Rational(100, 239)
else -> Rational(width, height)
}
}
const val MAX_RATIO = 2.39
}
} }
@@ -2,10 +2,15 @@ package io.narl.protonstream
import android.app.Application import android.app.Application
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
class ProtonStreamApplication : Application() { class ProtonStreamApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NativeRuntime.initialize(this) NativeRuntime.initialize(this)
// SharedPreferences reads its file on first use, and the first use was
// on the main thread — in the playback service's start callback among
// others. Read here, off it, and every later read is from memory.
Thread({ SettingsStore.preload(this) }, "settings-preload").start()
} }
} }
@@ -13,6 +13,8 @@ data class RetainedDownload(
val total: Long = 0, val total: Long = 0,
val status: String = STATUS_QUEUED, val status: String = STATUS_QUEUED,
val error: String? = null, val error: String? = null,
/** Recent transfer rate while running; 0 when unknown or not running. */
val bytesPerSecond: Long = 0,
) { ) {
val key: String get() = "$shareId\u001f$linkId" val key: String get() = "$shareId\u001f$linkId"
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
.put("total", record.total) .put("total", record.total)
.put("status", record.status) .put("status", record.status)
.put("error", record.error) .put("error", record.error)
.put("rate", record.bytesPerSecond)
.toString() .toString()
private fun decode(encoded: String): RetainedDownload { private fun decode(encoded: String): RetainedDownload {
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
total = json.optLong("total"), total = json.optLong("total"),
status = json.optString("status", RetainedDownload.STATUS_QUEUED), status = json.optString("status", RetainedDownload.STATUS_QUEUED),
error = json.optString("error").takeIf(String::isNotBlank), error = json.optString("error").takeIf(String::isNotBlank),
bytesPerSecond = json.optLong("rate"),
) )
} }
@@ -1,11 +1,15 @@
package io.narl.protonstream.download package io.narl.protonstream.download
import android.Manifest
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.content.Context import android.content.Context
import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.Data import androidx.work.Data
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
@@ -25,20 +29,39 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel() createNotificationChannel()
setForeground(createForegroundInfo(0))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share")) val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume")) val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link")) val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext) val store = DownloadStateStore(applicationContext)
setForeground(createForegroundInfo(label, null))
postSummary(store)
try {
return transfer(store, shareId, volumeId, linkId, label)
} finally {
postSummary(store)
}
}
private suspend fun transfer(
store: DownloadStateStore,
shareId: String,
volumeId: String,
linkId: String,
label: String,
): Result {
// Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means
// this one has a slot.
var retained = store.get(shareId, linkId) var retained = store.get(shareId, linkId)
?: RetainedDownload(shareId, volumeId, linkId, label) ?: RetainedDownload(shareId, volumeId, linkId, label)
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null) retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
store.put(retained) store.put(retained)
val observer = object : DownloadObserver { val observer = object : DownloadObserver {
private var reportedAt = 0L private var reportedAt = 0L
private var reportedPercent = -1 private var reportedPercent = -1
private val rate = TransferRate()
/** /**
* Called once per 4 MiB block — a dozen times a second on a fast * Called once per 4 MiB block — a dozen times a second on a fast
@@ -51,13 +74,14 @@ class OfflineDownloadWorker(
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt() val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val complete = total > 0UL && downloaded >= total val complete = total > 0UL && downloaded >= total
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) { if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
return return
} }
reportedAt = now reportedAt = now
reportedPercent = percent reportedPercent = percent
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong())) setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
setForegroundAsync(createForegroundInfo(percent)) setForegroundAsync(createForegroundInfo(label, percent))
// Under the store's lock: `pause()` is a concurrent writer, and // Under the store's lock: `pause()` is a concurrent writer, and
// this read-modify-write used to put RUNNING back over the // this read-modify-write used to put RUNNING back over the
// viewer's PAUSED and then report the download as cancelled. // viewer's PAUSED and then report the download as cancelled.
@@ -66,6 +90,7 @@ class OfflineDownloadWorker(
current.copy( current.copy(
downloaded = downloaded.toLong(), downloaded = downloaded.toLong(),
total = total.toLong(), total = total.toLong(),
bytesPerSecond = bytesPerSecond,
status = when (current.status) { status = when (current.status) {
RetainedDownload.STATUS_PAUSED, RetainedDownload.STATUS_PAUSED,
RetainedDownload.STATUS_CANCELLED, RetainedDownload.STATUS_CANCELLED,
@@ -114,12 +139,20 @@ class OfflineDownloadWorker(
) )
} }
private fun createForegroundInfo(progress: Int): ForegroundInfo { /**
* This episode's line, under [GROUP]: a season is fourteen workers, and
* fourteen loose notifications saying the same thing named nothing.
* [progress] is a percentage, or null while waiting for a download slot.
*/
private fun createForegroundInfo(label: String, progress: Int?): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground) .setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline") .setContentTitle(label)
.setProgress(100, progress, progress == 0) .setContentText(if (progress == null) "Waiting to download" else "$progress %")
.setProgress(100, progress ?: 0, progress == null || progress == 0)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP)
.build() .build()
return ForegroundInfo( return ForegroundInfo(
stableNotificationId(id), stableNotificationId(id),
@@ -128,6 +161,36 @@ class OfflineDownloadWorker(
) )
} }
/**
* The one line the group folds into — "Downloading 12 episodes" — or none
* once nothing is left to download. Posted as each worker starts and
* ends rather than on progress, which each episode's own line carries.
*/
private fun postSummary(store: DownloadStateStore) {
val manager = applicationContext.getSystemService(NotificationManager::class.java)
val left = store.records().count {
it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING
}
if (left == 0) {
manager.cancel(SUMMARY_ID)
return
}
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!allowed) return
manager.notify(
SUMMARY_ID,
NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle(if (left == 1) "Downloading 1 episode" else "Downloading $left episodes")
.setOnlyAlertOnce(true)
.setGroup(GROUP)
.setGroupSummary(true)
.build(),
)
}
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build() private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
companion object { companion object {
@@ -140,11 +203,45 @@ class OfflineDownloadWorker(
const val KEY_TOTAL = "total" const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3 private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "offline-downloads" private const val CHANNEL_ID = "offline-downloads"
private const val GROUP = "offline-downloads"
/** Below [stableNotificationId]'s range, so no episode's line replaces it. */
internal const val SUMMARY_ID = 1
/** Shortest gap between two identical-looking progress reports. */ /** Shortest gap between two identical-looking progress reports. */
private const val PROGRESS_INTERVAL_MS = 1_000L private const val PROGRESS_INTERVAL_MS = 1_000L
} }
} }
/**
* Bytes per second over roughly the last few seconds.
*
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
* steps seconds apart; a rate beside it is what tells a slow download from a
* stuck one. Smoothed, because one block that took a little longer than the
* last would otherwise halve the figure for a moment.
*/
internal class TransferRate {
private var lastBytes = -1L
private var lastAt = 0L
private var smoothed = 0.0
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
fun sample(bytes: Long, now: Long): Long {
if (lastBytes < 0 || bytes < lastBytes) {
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
val elapsed = now - lastAt
if (elapsed <= 0) return smoothed.toLong()
val instant = (bytes - lastBytes) * 1000.0 / elapsed
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
}
internal fun stableNotificationId(workId: UUID): Int = internal fun stableNotificationId(workId: UUID): Int =
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1) (workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
@@ -1,6 +1,7 @@
package io.narl.protonstream.native package io.narl.protonstream.native
import android.content.Context import android.content.Context
import io.narl.protonstream.settings.SettingsStore
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -56,5 +57,9 @@ object NativeRuntime {
} }
private fun createEngine(): AndroidEngine = 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, val cachePercent: Double = 0.0,
/** A seek has been issued but playback has not resumed at the new position. */ /** A seek has been issued but playback has not resumed at the new position. */
val seeking: Boolean = false, 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. */ /** Whether the picture is stopped for a reason the viewer did not choose. */
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
@@ -83,9 +85,9 @@ data class MpvPlaybackState(
/** /**
* What is on screen, for the surfaces that are not the player. * What is on screen, for the surfaces that are not the player.
* *
* The media session, its notification and Picture-in-Picture all have to name * The media session and its notification have to name the episode, and
* the episode, and none of them can reach the composition that knows it. The * neither can reach the composition that knows it. The player publishes this
* player publishes this once per episode and they read it. * once per episode and they read it.
*/ */
data class NowPlaying( data class NowPlaying(
val media: String, val media: String,
@@ -132,8 +134,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
/** /**
* Where a transport outside the player sends previous/next. * Where a transport outside the player sends previous/next.
* *
* The player owns which episode is open, so the notification, the lock * The player owns which episode is open, so the notification and the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both * screen cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives. * run on the main thread, which is where the player's state lives.
*/ */
var onSkipPrevious: (() -> Unit)? = null var onSkipPrevious: (() -> Unit)? = null
@@ -184,12 +186,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
audioLanguage: String?, audioLanguage: String?,
subtitleLanguage: String?, subtitleLanguage: String?,
subtitles: Boolean, subtitles: Boolean,
hardwareDecoding: Boolean,
) { ) {
check(size > 0uL) { "Cannot play an empty stream" } check(size > 0uL) { "Cannot play an empty stream" }
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val loaded = withOpenHandle { 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) { if (loaded) {
// The outgoing stream is released by mpv closing it, // The outgoing stream is released by mpv closing it,
// not from here: `loadfile` is asynchronous and its // not from here: `loadfile` is asynchronous and its
@@ -239,7 +245,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
*/ */
fun beginTransition() { mutableAdvancing.value = true } fun beginTransition() { mutableAdvancing.value = true }
/** Names the open episode for the media session, its notification and PiP. */ /** Names the open episode for the media session and its notification. */
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing } fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
fun setPaused(paused: Boolean) { fun setPaused(paused: Boolean) {
@@ -309,6 +315,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
buffering = values[BUFFERING] != 0.0, buffering = values[BUFFERING] != 0.0,
cachePercent = values[CACHE_PERCENT], cachePercent = values[CACHE_PERCENT],
seeking = values[SEEKING] != 0.0, seeking = values[SEEKING] != 0.0,
cachedUntil = values[CACHED_UNTIL],
) )
mutableState.value = next mutableState.value = next
// The listeners drive the media session and the player UI, // 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 nativeDestroy(handle: Long)
private external fun nativeAttachSurface(handle: Long, surface: Surface) private external fun nativeAttachSurface(handle: Long, surface: Surface)
private external fun nativeDetachSurface(handle: Long) 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 nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double) private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double) private external fun nativeVolume(handle: Long, volume: Double)
@@ -419,7 +434,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
companion object { companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */ /** 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 GENERATION = 6
private const val WIDTH = 7 private const val WIDTH = 7
private const val HEIGHT = 8 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 BUFFERING = 10
private const val CACHE_PERCENT = 11 private const val CACHE_PERCENT = 11
private const val SEEKING = 12 private const val SEEKING = 12
private const val CACHED_UNTIL = 13
init { init {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.narl.protonstream.MainActivity import io.narl.protonstream.MainActivity
import io.narl.protonstream.R import io.narl.protonstream.R
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.loadArtwork import io.narl.protonstream.ui.loadArtwork
import io.narl.protonstream.ui.loadThumbnail import io.narl.protonstream.ui.loadThumbnail
@@ -33,9 +35,11 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */ /** Owns libmpv so audio survives Activity recreation and rotation. */
class PlaybackService : Service() { class PlaybackService : Service() {
inner class PlaybackBinder : Binder() { inner class PlaybackBinder : Binder() {
val host: NativeMpvHost? get() = this@PlaybackService.host val host: NativeMpvHost? get() = this@PlaybackService.host
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
private var notified: NotificationShape? = null private var notified: NotificationShape? = null
/** The pending teardown, held so an episode transition can call it off. */ /** The pending teardown, held so an episode transition can call it off. */
private var retire: Job? = null private var retire: Job? = null
/** The open episode's latest reading, which [track] saves. */
private var lastReading: MpvPlaybackState? = null
private var lastSave = 0L
/** The episode's artwork, and the source key it was fetched for. */ /** The episode's artwork, and the source key it was fetched for. */
private var artwork: Pair<String, Bitmap>? = null private var artwork: Pair<String, Bitmap>? = null
private val binder = PlaybackBinder() private val binder = PlaybackBinder()
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
} }
mediaSession.isActive = true mediaSession.isActive = true
} }
onStateChanged = { updateSession(it) } onStateChanged = { updateSession(it); track(it) }
onExplicitStop = { retirePlayback() } onExplicitStop = { retirePlayback() }
} }
mediaSession = MediaSession(this, "proton-stream").apply { mediaSession = MediaSession(this, "proton-stream").apply {
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
return START_NOT_STICKY return START_NOT_STICKY
} }
/**
* Swiped away from Recents. With background audio on, this service would
* otherwise play on with no screen to return to and no autoplay, so it
* saves where the episode got to and stops.
*/
override fun onTaskRemoved(rootIntent: Intent?) {
lastReading?.let { reading ->
// Bounded and on this thread: the process may go as soon as the
// service stops, and a save launched into its scope would go with it.
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
}
host?.stop()
retirePlayback()
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() { override fun onDestroy() {
scope.cancel() scope.cancel()
runCatching { unregisterReceiver(becomingNoisy) } runCatching { unregisterReceiver(becomingNoisy) }
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} }
/**
* Keep the resume position from here rather than from the player screen.
*
* This service outlives the activity whenever background audio is on, and a
* position the screen alone kept stopped being saved the moment the screen
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
* change saves the outgoing episode's last reading before the new one's.
*/
private fun track(state: MpvPlaybackState) {
val media = state.media ?: return
if (state.duration <= 0.0) return
val previous = lastReading
lastReading = state
val now = SystemClock.elapsedRealtime()
val due = when {
previous != null && previous.media != media -> {
scope.launch(Dispatchers.IO) { save(previous) }
true
}
state.ended -> previous?.ended != true
state.paused -> previous?.paused != true
else -> now - lastSave >= SAVE_EVERY_MS
}
if (due) {
lastSave = now
scope.launch(Dispatchers.IO) { save(state) }
}
}
private suspend fun save(state: MpvPlaybackState) {
val media = state.media ?: return
// Played to the end, or near enough that what is left is credits. A
// failure or a stop also "ends", and recording either as watched would
// retire an episode nobody saw.
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
runCatching {
NativeRuntime.engine().saveWatchState(
media.substringBeforeLast(':'),
media.substringAfterLast(':'),
state.position.coerceIn(0.0, state.duration),
state.duration,
watched,
)
}
}
private fun updateSession(state: MpvPlaybackState) { private fun updateSession(state: MpvPlaybackState) {
refreshSession(state, host?.nowPlaying?.value) refreshSession(state, host?.nowPlaying?.value)
if (state.ended) scheduleRetire() else cancelRetire() if (state.ended) scheduleRetire() else cancelRetire()
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
private const val TRANSITION_GRACE_MS = 30_000L private const val TRANSITION_GRACE_MS = 30_000L
/** How far the volume drops for a navigation prompt or a notification. */ /** How far the volume drops for a navigation prompt or a notification. */
private const val DUCK_FACTOR = 0.3 private const val DUCK_FACTOR = 0.3
private const val SAVE_EVERY_MS = 5_000L
/** How long a swipe from Recents may hold the main thread to save. */
private const val SAVE_ON_EXIT_MS = 2_000L
/** Catppuccin Mocha mauve, the accent the app is themed with. */ /** Catppuccin Mocha mauve, the accent the app is themed with. */
private const val ACCENT = 0xFFCBA6F7.toInt() private const val ACCENT = 0xFFCBA6F7.toInt()
@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -12,13 +13,13 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Forward30 import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -47,6 +49,8 @@ import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -58,9 +62,15 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -70,12 +80,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentTrack import io.narl.protonstream.ui.theme.AccentTrack
@@ -83,6 +95,19 @@ import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.thumbnailSource 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.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -92,6 +117,7 @@ import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.skipOffer import uniffi.pstr_android.skipOffer
import kotlin.math.ceil
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** How long the controls stay up after a tap. */ /** How long the controls stay up after a tap. */
@@ -100,8 +126,17 @@ private const val CONTROLS_SECONDS = 4L
/** How long the "up next" card counts down before it loads the next episode. */ /** How long the "up next" card counts down before it loads the next episode. */
private const val UP_NEXT_SECONDS = 10 private const val UP_NEXT_SECONDS = 10
/** How far a double tap on either side of the picture seeks. */
private const val DOUBLE_TAP_SECONDS = 10
/** How long the double-tap seek mark stays up. */
private const val NUDGE_MS = 700L
/** How close to the end the next episode's opening blocks are fetched. */
private const val WARM_NEXT_SECONDS = 120.0
/** Past this much of an episode it counts as watched. */ /** Past this much of an episode it counts as watched. */
private const val WATCHED_FRACTION = 0.9 internal const val WATCHED_FRACTION = 0.9
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */ /** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
private const val MAX_VOLUME = 100f private const val MAX_VOLUME = 100f
@@ -112,23 +147,22 @@ private const val MAX_VOLUME = 100f
* Below it — a phone held upright — the transport row is already five controls * Below it — a phone held upright — the transport row is already five controls
* wide, and the device's own volume keys are the better answer. * wide, and the device's own volume keys are the better answer.
*/ */
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp private val VOLUME_SLIDER_MIN_WIDTH = 720.dp
/** Narrower than this, the pickers past subtitles go behind "More". */
private val INLINE_CONTROLS_MIN_WIDTH = 560.dp
/** /**
* The player, as its own screen on the activity's own window. * The player, as its own screen on the activity's own window.
* *
* Not a dialog: a dialog window has its own insets, cannot be told to draw into * Not a dialog: a dialog window has its own insets and cannot be told to draw
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the * into the display cutout. Owning the activity window is what makes
* activity window is what makes "fullscreen" mean the whole panel, notch * "fullscreen" mean the whole panel, notch included.
* included.
* *
* [episodes] is the title's episodes in display order and [index] the one that is * [episodes] is the title's episodes in display order and [index] the one that is
* open, which is what previous/next and autoplay walk. The index is hoisted so * open, which is what previous/next and autoplay walk. The index is hoisted so
* that it survives process recreation with the rest of the navigation state — * that it survives process recreation with the rest of the navigation state —
* this screen is destroyed and rebuilt by a dark-mode toggle like any other. * this screen is destroyed and rebuilt by a dark-mode toggle like any other.
*
* In [inPictureInPicture] the window is a thumbnail with the system's own
* controls over it, so this screen draws the picture and nothing else.
*/ */
@Composable @Composable
fun PlayerScreen( fun PlayerScreen(
@@ -136,7 +170,6 @@ fun PlayerScreen(
episodes: List<EpisodeRecord>, episodes: List<EpisodeRecord>,
index: Int, index: Int,
host: LibmpvHost?, host: LibmpvHost?,
inPictureInPicture: Boolean,
minimized: Boolean, minimized: Boolean,
onIndexChange: (Int) -> Unit, onIndexChange: (Int) -> Unit,
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit, onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
@@ -175,8 +208,16 @@ fun PlayerScreen(
} }
val key = mediaKey(episode) val key = mediaKey(episode)
val session = remember(key) { RustStreamSession(episode) } // Retry opens the episode again from scratch: a new stream, through the
// bridge's session refresh, from where the failed one stopped.
var attempt by remember(key) { mutableIntStateOf(0) }
var retryFrom by remember(key) { mutableStateOf<Double?>(null) }
val session = remember(key, attempt) { RustStreamSession(episode) }
var playbackError by remember(key) { mutableStateOf<String?>(null) } var playbackError by remember(key) { mutableStateOf<String?>(null) }
// From the click until mpv has the file: the share session, the revision
// and its block list all come first, and on a slow link that is seconds of
// black screen unless something says so.
var opening by remember(key, attempt) { mutableStateOf(true) }
LaunchedEffect(session, host) { LaunchedEffect(session, host) {
runCatching { runCatching {
@@ -195,7 +236,7 @@ fun PlayerScreen(
size = session.size(), size = session.size(),
// A finished episode restarts rather than resuming three // A finished episode restarts rather than resuming three
// seconds from its own credits. // seconds from its own credits.
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0, startPosition = retryFrom ?: watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
audioLanguage = show?.audioLanguage ?: global.audioLanguage, audioLanguage = show?.audioLanguage ?: global.audioLanguage,
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage, subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
subtitles = show?.subtitles ?: global.subtitles, subtitles = show?.subtitles ?: global.subtitles,
@@ -207,6 +248,7 @@ fun PlayerScreen(
nativeHost.play( nativeHost.play(
key, startup.nativeHandle, startup.size, startup.startPosition, key, startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles, startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
SettingsStore(context).hardwareDecoding,
) )
// After the load, because mpv keeps none of the three across one. // After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume) nativeHost.setVolume(startup.volume)
@@ -214,6 +256,7 @@ fun PlayerScreen(
nativeHost.setSpeed(startup.speed) nativeHost.setSpeed(startup.speed)
session.transferToPlayer() session.transferToPlayer()
}.onFailure { playbackError = it.message ?: "Playback failed" } }.onFailure { playbackError = it.message ?: "Playback failed" }
opening = false
} }
// Per episode: the stream Rust holds open. The surface is the screen's, not // Per episode: the stream Rust holds open. The surface is the screen's, not
@@ -251,12 +294,8 @@ fun PlayerScreen(
) )
} }
} }
LaunchedEffect(key) { // The five-second save is PlaybackService's, which outlives this screen;
while (true) { // these are the moments only the screen sees.
delay(5_000)
save()
}
}
DisposableEffect(key) { onDispose { save() } } DisposableEffect(key) { onDispose { save() } }
val next = episodes.getOrNull(index + 1) val next = episodes.getOrNull(index + 1)
@@ -272,16 +311,21 @@ fun PlayerScreen(
nativeHost.beginTransition() nativeHost.beginTransition()
onIndexChange(index - 1) 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. // What the media notification and the lock screen show. Neither can reach
// None of them can reach this composition, and all three have to name the // this composition, and both have to name the episode rather than the app.
// episode rather than the app.
LaunchedEffect(key, index, episodes.size, title.key) { LaunchedEffect(key, index, episodes.size, title.key) {
nativeHost.publish( nativeHost.publish(
NowPlaying( NowPlaying(
media = key, media = key,
title = episode.label, title = episode.label,
show = title.canonicalName ?: title.name, show = title.displayName,
detail = episode.detail, detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl, artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource, artworkFile = episode.thumbnailSource,
@@ -303,8 +347,10 @@ fun PlayerScreen(
// Autoplay fires on a clean end of file only, and only for this episode's // Autoplay fires on a clean end of file only, and only for this episode's
// own reading — never on a failure, and never on the outgoing file's. // own reading — never on a failure, and never on the outgoing file's.
// Cancelled from the end-of-file card: the viewer chose to stay.
var stay by remember(key) { mutableStateOf(false) }
LaunchedEffect(state.endReason, state.media) { LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance() if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance()
} }
// A file mpv could not play ends like any other. Saying so is the whole // A file mpv could not play ends like any other. Saying so is the whole
// difference between a broken episode and an app that silently skipped one. // difference between a broken episode and an app that silently skipped one.
@@ -313,14 +359,42 @@ fun PlayerScreen(
playbackError = "This episode could not be played." playbackError = "This episode could not be played."
} }
} }
// A stall that does not clear is a dropped connection far more often than
// a slow one, and "Buffering…" for ever offers no way out of it.
LaunchedEffect(state.buffering, key, attempt) {
if (state.buffering) {
delay(STALL_LIMIT_MS)
playbackError = "Still loading. The connection may have dropped."
}
}
// What mpv itself complained about, which the play call never sees: by the // What mpv itself complained about, which the play call never sees: by the
// time a header turns out to be unreadable it has long since returned. // time a header turns out to be unreadable it has long since returned.
val problem by nativeHost.problem.collectAsState() val problem by nativeHost.problem.collectAsState()
LaunchedEffect(problem) { problem?.let { playbackError = it } } LaunchedEffect(problem) { problem?.let { playbackError = it } }
val offer = remember(chapters, key, (state.position * 4).roundToInt()) { // Whether mpv's readings are this episode's. Not while the next one opens:
// the last one's credits play on until it is loaded, and read against the
// new index they offered the episode after the next as up next.
val playingThis = state.media == key
val offer = remember(chapters, key, playingThis, (state.position * 4).roundToInt()) {
if (!playingThis) return@remember null
runCatching { skipOffer(chapters, state.position) }.getOrNull() runCatching { skipOffer(chapters, state.position) }.getOrNull()
} }
// Near the end, fetch the next episode's opening blocks, so autoplay or
// "Play now" starts from the cache. The last two minutes or the credits,
// whichever comes first: late enough that stopping after one episode has
// not cost a download, early enough to matter.
val nearEnd = playingThis && state.duration > 0.0 && (
state.duration - state.position < WARM_NEXT_SECONDS ||
chapters.creditsStart?.let { state.position >= it } == true
)
LaunchedEffect(key, nearEnd) {
val upcoming = next ?: return@LaunchedEffect
if (!nearEnd) return@LaunchedEffect
runCatching {
NativeRuntime.engine().warmEpisode(upcoming.shareId, upcoming.volumeId, upcoming.linkId)
}
}
// Auto-skip is offered once per chapter: seeking back into an opening on // Auto-skip is offered once per chapter: seeking back into an opening on
// purpose must not be undone by the setting that skipped it. // purpose must not be undone by the setting that skipped it.
val skipped = remember(key) { mutableSetOf<Long>() } val skipped = remember(key) { mutableSetOf<Long>() }
@@ -331,31 +405,71 @@ fun PlayerScreen(
} }
var landscapeLocked by remember { mutableStateOf(false) } var landscapeLocked by remember { mutableStateOf(false) }
// Set by the rotation button: from then on the viewer decides.
var rotationChosen by remember { mutableStateOf(false) }
// A wide picture fills a phone only on its side, so it turns there by
// itself, once per episode, until the viewer picks for themselves.
LaunchedEffect(key, playingThis, state.videoWidth > state.videoHeight) {
if (!playingThis || rotationChosen || landscapeLocked) return@LaunchedEffect
if (state.videoHeight > 0.0 && state.videoWidth > state.videoHeight) {
landscapeLocked = activity?.lockLandscape() ?: false
}
}
var showControls by remember { mutableStateOf(true) } var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls, state.paused) { // TalkBack moves between controls by swiping, and a control that fades
if (showControls && !state.paused) { // four seconds after it is reached cannot be found again.
val exploring = rememberTouchExploration()
LaunchedEffect(exploring) { if (exploring) showControls = true }
LaunchedEffect(showControls, state.paused, exploring) {
if (showControls && !state.paused && !exploring) {
delay(CONTROLS_SECONDS * 1_000) delay(CONTROLS_SECONDS * 1_000)
showControls = false showControls = false
} }
} }
// Everything this screen draws over the picture is sized for a phone panel // Which side was double-tapped, shown for a moment so the seek is seen
// and unreadable in a thumbnail, and Android supplies its own transport // to have happened; the count makes a second tap on one side show again.
// there. Leaving PiP must find the controls up rather than mid-timeout. var nudge by remember { mutableStateOf<Pair<Int, Int>?>(null) }
val overlays = !inPictureInPicture LaunchedEffect(nudge) {
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true } if (nudge != null) {
delay(NUDGE_MS)
nudge = null
}
}
val current by rememberUpdatedState(state)
if (minimized) return if (minimized) return
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.clickable( // Double-tap a side to seek ten seconds, as every phone video
interactionSource = remember { MutableInteractionSource() }, // player has taught; the middle plays or pauses. A single tap
indication = null, // waits out the double-tap timeout, which is the price of both.
enabled = overlays, .pointerInput(host) {
onClick = { showControls = !showControls }, detectTapGestures(
), onTap = { showControls = !showControls },
onDoubleTap = { offset ->
val third = size.width / 3f
val seconds = when {
offset.x < third -> -DOUBLE_TAP_SECONDS
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
else -> 0
}
if (seconds == 0) {
host.setPaused(!current.paused)
} else {
host.seek((current.position + seconds).coerceAtLeast(0.0))
nudge = seconds to (nudge?.second ?: 0) + 1
}
},
)
}
.semantics {
onClick(label = if (showControls) "Hide controls" else "Show controls") {
showControls = !showControls
true
}
},
) { ) {
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -372,7 +486,7 @@ fun PlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = overlays && showControls, visible = showControls,
modifier = Modifier.align(Alignment.TopStart), modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -404,7 +518,7 @@ fun PlayerScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank)) listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "), .joinToString(" • "),
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,
@@ -429,7 +543,7 @@ fun PlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = overlays && showControls, visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -444,7 +558,13 @@ fun PlayerScreen(
hasNext = next != null, hasNext = next != null,
onPrevious = { retreat() }, onPrevious = { retreat() },
onNext = { advance() }, onNext = { advance() },
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false }, episodes = episodes,
playing = index,
onEpisode = { jumpTo(it) },
onRotate = {
rotationChosen = true
landscapeLocked = activity?.toggleLandscape() ?: false
},
landscapeLocked = landscapeLocked, landscapeLocked = landscapeLocked,
) )
} }
@@ -452,7 +572,7 @@ fun PlayerScreen(
// Drawn whether or not the rest of the controls are up: an opening lasts // Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody // ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time. // reaches in time.
offer?.takeIf { overlays && !autoSkip }?.let { skip -> offer?.takeIf { !autoSkip }?.let { skip ->
TonalButton( TonalButton(
onClick = { nativeHost.seek(skip.target) }, onClick = { nativeHost.seek(skip.target) },
modifier = Modifier modifier = Modifier
@@ -462,21 +582,59 @@ fun PlayerScreen(
) { Text(skip.label) } ) { Text(skip.label) }
} }
nudge?.let { (seconds, _) ->
Box(
Modifier
.align(if (seconds < 0) Alignment.CenterStart else Alignment.CenterEnd)
.padding(horizontal = 48.dp)
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) {
Text(if (seconds < 0) "−${-seconds} s" else "+$seconds s", color = Color.White)
}
}
// A file with no chapters has no credits to count over, so the card
// takes its last seconds instead: without it the next episode cut in
// with no warning and no way to stay. Counted on the file's own clock,
// so it reaches zero at the end of file, which is what advances.
val tailLeft = (state.duration - state.position).takeIf {
chapters.creditsStart == null && state.duration > UP_NEXT_SECONDS * 6 && it <= UP_NEXT_SECONDS
}
UpNextCard( UpNextCard(
next = next, next = next,
visible = overlays && autoplay && !state.paused && visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
chapters.creditsStart?.let { state.position >= it } == true, (tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
secondsLeft = tailLeft?.let { ceil(it).toInt() },
onPlayNow = { advance() }, onPlayNow = { advance() },
onStay = { stay = true },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp), .padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
) )
// Played to the end with nothing starting after it: the last episode,
// autoplay off, or the viewer stayed. A black screen said none of that.
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
EndCard(
finished = episode.label,
show = title.displayName,
next = next,
onPlayNext = { advance() },
// mpv unloads a file at its end, so this opens it again.
onReplay = { retryFrom = 0.0; attempt++ },
onClose = onClose,
modifier = Modifier.align(Alignment.Center),
)
}
// Only while nothing else explains the still picture: an error is the // Only while nothing else explains the still picture: an error is the
// more useful thing to say, and a spinner over it reads as "still // more useful thing to say, and a spinner over it reads as "still
// trying". // trying".
if (state.stalled && playbackError == null) { if ((opening || state.stalled) && playbackError == null) {
Column( Column(
Modifier Modifier
.align(Alignment.Center) .align(Alignment.Center)
@@ -488,10 +646,13 @@ fun PlayerScreen(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) )
if (state.buffering) { if (opening || state.buffering) {
Text( Text(
state.cachePercent.takeIf { it > 0.0 } when {
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…", opening -> "Opening ${episode.label}…"
state.cachePercent > 0.0 -> "Buffering… ${state.cachePercent.roundToInt()}%"
else -> "Buffering…"
},
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
@@ -512,14 +673,29 @@ fun PlayerScreen(
// Dismissible, because the message is not always fatal — a // Dismissible, because the message is not always fatal — a
// subtitle track that failed to load leaves an episode that // subtitle track that failed to load leaves an episode that
// plays perfectly well behind an error that used to be permanent. // plays perfectly well behind an error that used to be permanent.
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) { Row {
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer) QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
}
QuietButton(
onClick = {
retryFrom = state.position.takeIf { state.media == key && it > 0.0 } ?: retryFrom
playbackError = null
nativeHost.clearProblem()
attempt++
},
) {
Text("Retry", color = MaterialTheme.colorScheme.onErrorContainer)
}
} }
} }
} }
} }
} }
/** How long a stall lasts before the player offers to open the episode again. */
private const val STALL_LIMIT_MS = 20_000L
private data class Startup( private data class Startup(
val nativeHandle: ULong, val nativeHandle: ULong,
val size: ULong, val size: ULong,
@@ -564,6 +740,26 @@ private fun ImmersiveWindow(activity: Activity?) {
} }
} }
/** Lock to landscape. Reports whether it is. */
private fun Activity.lockLandscape(): Boolean {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
return true
}
/** Whether TalkBack, or anything else that explores by touch, is on. */
@Composable
private fun rememberTouchExploration(): Boolean {
val context = LocalContext.current
val manager = remember(context) { context.getSystemService(AccessibilityManager::class.java) }
var exploring by remember { mutableStateOf(manager?.isTouchExplorationEnabled == true) }
DisposableEffect(manager) {
val listener = AccessibilityManager.TouchExplorationStateChangeListener { exploring = it }
manager?.addTouchExplorationStateChangeListener(listener)
onDispose { manager?.removeTouchExplorationStateChangeListener(listener) }
}
return exploring
}
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */ /** Lock to landscape, or hand rotation back to the sensor. Reports which. */
private fun Activity.toggleLandscape(): Boolean { private fun Activity.toggleLandscape(): Boolean {
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
@@ -591,11 +787,15 @@ private fun PlayerControls(
hasNext: Boolean, hasNext: Boolean,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
episodes: List<EpisodeRecord>,
playing: Int,
onEpisode: (Int) -> Unit,
onRotate: () -> Unit, onRotate: () -> Unit,
landscapeLocked: Boolean, landscapeLocked: Boolean,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) } var choosing by remember { mutableStateOf<String?>(null) }
var showEpisodes by remember { mutableStateOf(false) }
var showChapters by remember { mutableStateOf(false) } var showChapters by remember { mutableStateOf(false) }
var showSpeeds by remember { mutableStateOf(false) } var showSpeeds by remember { mutableStateOf(false) }
// What the store says, so the dialog opens on the rate that is playing. // What the store says, so the dialog opens on the rate that is playing.
@@ -606,8 +806,15 @@ private fun PlayerControls(
} }
val duration = state.duration.toFloat().coerceAtLeast(1f) val duration = state.duration.toFloat().coerceAtLeast(1f)
val density = LocalDensity.current val density = LocalDensity.current
val roomForVolume = with(density) { val width = with(density) { LocalWindowInfo.current.containerSize.width.toDp() }
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH val roomForVolume = width >= VOLUME_SLIDER_MIN_WIDTH
// A phone in portrait has room for the transport and two more buttons at
// a thumb's size. The rest go behind "More" there rather than off the edge.
val roomForAll = width >= INLINE_CONTROLS_MIN_WIDTH
val toggleMute = {
host.setMuted(!state.muted)
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
Unit
} }
Column( Column(
@@ -638,7 +845,12 @@ private fun PlayerControls(
scrubbing = null scrubbing = null
}, },
valueRange = 0f..duration, 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()) ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
} }
@@ -664,42 +876,80 @@ private fun PlayerControls(
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext) CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (chapters.size > 1) { if (!roomForAll) {
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true } CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
} var more by remember { mutableStateOf(false) }
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true } Box {
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" } CompactIcon(Icons.Default.MoreVert, "More") { more = true }
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" } DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
CompactIcon( val entries = buildList {
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, if (episodes.size > 1) add(Triple(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true })
"Mute", if (chapters.size > 1) add(Triple(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true })
) { add(Triple(Icons.Default.Speed, "Playback speed") { showSpeeds = true })
host.setMuted(!state.muted) add(Triple(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" })
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) } add(
} Triple(
// The app's own volume, which is not the device's: a file if (state.muted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
// mastered quiet is turned up here and stays up for the next if (state.muted) "Unmute" else "Mute",
// one. Only where there is room for it — a phone in portrait toggleMute,
// has the system volume keys and no width to spare. ),
if (roomForVolume) { )
var dragging by remember { mutableStateOf<Float?>(null) } add(
Slider( Triple(
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME), if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
onValueChange = { dragging = it; host.setVolume(it.toDouble()) }, if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onValueChangeFinished = { onRotate,
val chosen = dragging?.toDouble() ),
dragging = null )
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } } }
}, entries.forEach { (icon, label, action) ->
valueRange = 0f..MAX_VOLUME, DropdownMenuItem(
modifier = Modifier.width(96.dp), text = { Text(label) },
leadingIcon = { Icon(icon, contentDescription = null) },
onClick = { more = false; action() },
)
}
}
}
} else {
if (episodes.size > 1) {
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
}
if (chapters.size > 1) {
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
}
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
CompactIcon(
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
if (state.muted) "Unmute" else "Mute",
onClick = toggleMute,
)
// The app's own volume, which is not the device's: a file
// mastered quiet is turned up here and stays up for the next
// one. Only where there is room for it — a phone in portrait
// has the system volume keys and no width to spare.
if (roomForVolume) {
var dragging by remember { mutableStateOf<Float?>(null) }
Slider(
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
onValueChangeFinished = {
val chosen = dragging?.toDouble()
dragging = null
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
},
valueRange = 0f..MAX_VOLUME,
modifier = Modifier.width(96.dp),
)
}
CompactIcon(
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate,
) )
} }
CompactIcon(
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate,
)
} }
} }
} }
@@ -716,6 +966,14 @@ private fun PlayerControls(
}, },
) )
} }
if (showEpisodes) {
EpisodeChooser(
episodes = episodes,
playing = playing,
onDismiss = { showEpisodes = false },
onSelect = { showEpisodes = false; onEpisode(it) },
)
}
if (showChapters) { if (showChapters) {
ChapterChooser( ChapterChooser(
chapters = chapters, chapters = chapters,
@@ -870,54 +1128,218 @@ 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. * The countdown into the next episode, shown once the credits run starts.
* *
* "Watch till the end" holds for the rest of the file: someone who wants the * "Watch till the end" holds for the rest of the file: someone who wants the
* post-credits scene should have to say so once, not once every ten seconds. * post-credits scene should have to say so once, not once every ten seconds.
*
* With [secondsLeft] the card counts down the end of a file that has no credits
* chapter, on the file's own clock; the end of file is what advances, and the
* second button is "Cancel", since there are no credits to watch.
*/ */
@Composable @Composable
private fun UpNextCard( private fun UpNextCard(
next: EpisodeRecord?, next: EpisodeRecord?,
visible: Boolean, visible: Boolean,
secondsLeft: Int?,
onPlayNow: () -> Unit, onPlayNow: () -> Unit,
onStay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var held by remember(next?.linkId) { mutableStateOf(false) } var held by remember(next?.linkId) { mutableStateOf(false) }
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) } var counted by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
val remaining = secondsLeft ?: counted
val upNext = next val upNext = next
val showing = visible && upNext != null && !held val showing = visible && upNext != null && !held
LaunchedEffect(showing) { LaunchedEffect(showing, secondsLeft == null) {
if (!showing) return@LaunchedEffect if (!showing || secondsLeft != null) return@LaunchedEffect
remaining = UP_NEXT_SECONDS counted = UP_NEXT_SECONDS
while (remaining > 0) { while (counted > 0) {
delay(1_000) delay(1_000)
remaining -= 1 counted -= 1
} }
onPlayNow() onPlayNow()
} }
// `showing` already implies a non-null episode, and the compiler knows it. // `showing` already implies a non-null episode, and the compiler knows it.
if (!showing) return 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( Column(
modifier modifier
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small) .width(280.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
.padding(12.dp), .padding(12.dp),
) {
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,
)
}
}
if (secondsLeft != null) {
QuietButton(onClick = { held = true; onStay() }, modifier = Modifier.padding(top = 4.dp)) {
Text("Cancel")
}
} else {
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
Text("Watch till the end")
}
}
}
}
/** What the player says once an episode ends and nothing starts after it. */
@Composable
private fun EndCard(
finished: String,
show: String,
next: EpisodeRecord?,
onPlayNext: () -> Unit,
onReplay: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier
.widthIn(max = 360.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
"Up next in ${remaining}s", if (next == null) "That was the last episode of $show" else "Finished $finished",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
Text(
upNext.label,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, style = MaterialTheme.typography.titleMedium,
overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
AccentButton(onClick = onPlayNow) { Text("Play now") } if (next != null) {
QuietButton(onClick = { held = true }) { Text("Watch till the end") } AccentButton(onClick = onPlayNext) { Text("Play next") }
} else {
QuietButton(onClick = onReplay) { Text("Watch again") }
}
QuietButton(onClick = onClose, modifier = Modifier.padding(start = 8.dp)) { Text("Close") }
} }
} }
} }
@@ -959,12 +1381,14 @@ private fun CompactIcon(
enabled: Boolean = true, enabled: Boolean = true,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) { // 48dp: the smallest target Android's accessibility guidance allows, and
// the size a thumb on a moving train actually hits.
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
Icon( Icon(
icon, icon,
contentDescription = description, contentDescription = description,
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f), tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp), modifier = Modifier.size(24.dp),
) )
} }
} }
@@ -92,6 +92,7 @@ interface LibmpvHost {
audioLanguage: String? = null, audioLanguage: String? = null,
subtitleLanguage: String? = null, subtitleLanguage: String? = null,
subtitles: Boolean = true, subtitles: Boolean = true,
hardwareDecoding: Boolean = true,
) )
/** Calls pstr_android_stream_release exactly once for a published handle. */ /** Calls pstr_android_stream_release exactly once for a published handle. */
@@ -1,6 +1,7 @@
package io.narl.protonstream.settings package io.narl.protonstream.settings
import android.content.Context import android.content.Context
import androidx.core.content.edit
/** /**
* The settings that are Android's alone. * The settings that are Android's alone.
@@ -20,9 +21,63 @@ class SettingsStore(context: Context) {
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true) get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() } set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
private companion object { /**
const val NAME = "proton_stream_settings" * Whether mpv may decode in hardware. Off is the way out for a device whose
const val KEY_WIFI_ONLY = "wifi_only" * MediaCodec decoder shows green or torn frames; it costs battery, so it is
const val KEY_BACKGROUND_AUDIO = "background_audio" * 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() }
/** How the library was last sorted, as a `LibrarySort` name; null for the default. */
var librarySort: String?
get() = preferences.getString(KEY_LIBRARY_SORT, null)
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } }
/** The library's offer of posters and synopses was turned down. */
var metadataOfferDismissed: Boolean
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
/** Which titles the library last showed, as a `LibraryFilter` name; null for all. */
var libraryFilter: String?
get() = preferences.getString(KEY_LIBRARY_FILTER, null)
set(value) { preferences.edit { putString(KEY_LIBRARY_FILTER, value) } }
/** Whether the library folds each franchise into one tile. */
var libraryGrouped: Boolean
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
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
/** Load the file into memory, so no later read waits on the disk. */
fun preload(context: Context) {
context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all
}
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"
private const val KEY_LIBRARY_SORT = "library_sort"
private const val KEY_LIBRARY_GROUPED = "library_grouped"
private const val KEY_LIBRARY_FILTER = "library_filter"
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
} }
} }
@@ -0,0 +1,56 @@
package io.narl.protonstream.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.AccountState
import java.util.concurrent.TimeUnit
/**
* One watch-history sync after the app leaves the screen.
*
* The model's timer only runs while the app is alive, and Android is free to
* kill a process in the background without notice — so the position the
* viewer stopped at would wait for the next launch to reach their other
* devices. Handed to WorkManager instead, it goes as soon as there is a
* network, even if the process is gone by then.
*/
class WatchSyncWorker(
appContext: Context,
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
val engine = NativeRuntime.engine()
return runCatching { engine.syncWatchHistory() }.fold(
onSuccess = { Result.success() },
onFailure = {
// Signed out by the engine — the session ended — is not
// something a retry fixes; a dropped connection is.
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
},
)
}
companion object {
private const val WORK_NAME = "watch-history-sync"
private const val MAX_ATTEMPTS = 3
/** Replacing: one pending sync covers every position saved before it runs. */
fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
}
}
@@ -0,0 +1,507 @@
package io.narl.protonstream.ui
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderShared
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.DriveEntryRecord
import uniffi.pstr_android.DrivePlaceRecord
import uniffi.pstr_android.PlaceType
import uniffi.pstr_android.verificationToken
/** What the account card and its dialogs can ask the model for. */
internal data class AccountActions(
val signIn: (String, String, String?) -> Unit,
val submitSecondFactor: (String) -> Unit,
val submitMailboxPassword: (String) -> Unit,
val cancelSignIn: () -> Unit,
val dismissVerification: () -> Unit,
val signOut: () -> Unit,
val syncNow: () -> Unit,
val addFolder: (String, String, String) -> Unit,
val onError: (Throwable) -> Unit,
)
/**
* The Proton account, at the top of the shares page: why one would sign in,
* or who is signed in and how watch-history sync is doing.
*/
@Composable
internal fun AccountCard(
account: AccountUiState,
actions: AccountActions,
onSignIn: () -> Unit,
onBrowse: () -> Unit,
) {
val state = account.state ?: return
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
if (state is AccountState.SignedIn) {
var menu by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
leadingContent = {
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
},
trailingContent = {
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Sync now") },
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
onClick = { menu = false; actions.syncNow() },
)
DropdownMenuItem(
text = { Text("Sign out") },
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
onClick = { menu = false; actions.signOut() },
)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
TonalButton(
onClick = onBrowse,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
Icon(Icons.Default.Folder, contentDescription = null)
Text("Add from your Drive", Modifier.padding(start = 8.dp))
}
} else {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Proton account", style = MaterialTheme.typography.titleMedium)
Text(
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
"Drive, so every device shows the same library and resumes where another left off, " +
"and folders of your Drive can join the library.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TonalButton(onClick = onSignIn) { Text("Sign in") }
}
}
}
}
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
internal fun syncLine(account: AccountUiState, now: Long): String {
account.syncError?.let { return "Did not sync: $it" }
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
val ago = when {
minutes < 1 -> "just now"
minutes < 60 -> "$minutes min ago"
else -> "${minutes / 60} h ago"
}
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
return "Synced $ago$applied"
}
/**
* Signing in, one step at a time: address and password, then a second-factor
* code or the mailbox password if the account has them, with Proton's CAPTCHA
* over the top when it asks for one.
*/
@Composable
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var mailbox by remember { mutableStateOf("") }
val state = account.state
LaunchedEffect(state) {
if (state is AccountState.SignedIn) onDismiss()
}
val dismiss = {
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
onDismiss()
}
AlertDialog(
onDismissRequest = dismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Sign in to Proton") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (state) {
AccountState.SecondFactor -> {
OutlinedTextField(
code,
{ code = it },
label = { Text("Code from your authenticator app") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
)
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
}
AccountState.MailboxPassword -> {
Text(
"This account has a separate mailbox password. It unlocks your files.",
style = MaterialTheme.typography.bodySmall,
)
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
}
else -> {
OutlinedTextField(
username,
{ username = it },
label = { Text("Address") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
)
PasswordField(password, { password = it }, "Password")
}
}
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
}
},
confirmButton = {
when (state) {
AccountState.SecondFactor -> AccentButton(
onClick = { actions.submitSecondFactor(code.trim()) },
enabled = code.isNotBlank() && !account.busy,
) { Text("Continue") }
AccountState.MailboxPassword -> AccentButton(
onClick = { actions.submitMailboxPassword(mailbox) },
enabled = mailbox.isNotEmpty() && !account.busy,
) { Text("Unlock") }
else -> AccentButton(
onClick = { actions.signIn(username.trim(), password, null) },
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
) { Text("Sign in") }
}
},
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
)
account.verificationUrl?.let { url ->
VerificationDialog(
url = url,
onToken = { token -> actions.signIn(username.trim(), password, token) },
onDismiss = actions.dismissVerification,
)
}
}
@Composable
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
value,
onChange,
label = { Text(label) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
/**
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
* recognise — a new network, a VPN exit.
*
* In a WebView rather than the browser because the page reports success by
* posting a message to its host, not by redirecting: a browser has nowhere to
* post it back to. The page targets `window.parent`, which for a top-level
* page is the page itself, so a listener on its own window hears it.
*/
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
@Composable
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Confirm you are human",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f).padding(start = 16.dp),
)
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val main = Handler(Looper.getMainLooper())
var answered = false
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(
object {
@JavascriptInterface
fun post(message: String) {
// Every message the page posts lands here,
// most of them its own chatter.
val token = verificationToken(message) ?: return
main.post {
if (!answered) {
answered = true
onToken(token)
}
}
}
},
"pstrVerification",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
}
override fun onPageFinished(view: WebView, url: String?) {
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
}
// The bridge above is reachable by whatever the
// frame shows, so the frame stays on Proton.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
!isProtonPage(request.url.scheme, request.url.host)
}
loadUrl(url)
}
},
)
}
}
}
/**
* Security keys need WebAuthn, which the sign-in here does not speak. An account
* with nothing else is refused before this form (`AccountStore::sign_in`).
*/
private const val SECURITY_KEY_NOTE =
"Security keys are not supported here — use the code from your authenticator app."
/** Installed once per page, at start and again at finish in case the first ran too early. */
private const val VERIFICATION_BRIDGE = """
if (!window.__pstrVerification) {
window.__pstrVerification = true;
window.addEventListener('message', function (event) {
try {
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
}
"""
/** Whether a page is Proton's own, over HTTPS. */
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
/**
* Walk the account's Drive and add a folder of it to the library.
*
* Full screen, because a Drive is deep and a dialog's height is a few rows of
* it. Listings are asked for here rather than through the model: they are
* this dialog's alone, and gone when it closes.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DriveBrowserDialog(
actions: AccountActions,
onDismiss: () -> Unit,
) {
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val current = trail.lastOrNull()?.second
LaunchedEffect(current) {
error = null
if (current == null) {
if (places == null) {
runCatching { NativeRuntime.engine().drivePlaces() }
.onSuccess { places = it }
.onFailure { error = it.message }
}
} else {
entries = null
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
.onSuccess { entries = it }
.onFailure { error = it.message }
}
}
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
trail.lastOrNull()?.first ?: "Your Drive",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
Icon(
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
)
}
},
)
},
bottomBar = {
// A folder already in the library is refused by the engine,
// with a message saying so.
val folder = current ?: return@Scaffold
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
AccentButton(
onClick = {
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
onDismiss()
},
) { Text("Add this folder") }
}
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
val loading = error == null && (if (current == null) places == null else entries == null)
when {
error != null -> EmptyState("Could not list this", error.orEmpty())
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
DriveRow(
icon = when (place.kind) {
PlaceType.MY_FILES -> Icons.Default.Storage
PlaceType.DEVICE -> Icons.Default.Computer
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
},
name = place.name,
detail = null,
onClick = { trail.add(place.name to place) },
)
}
}
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
DriveRow(
icon = when {
entry.isFolder -> Icons.Default.Folder
entry.isVideo -> Icons.Default.Movie
else -> Icons.Default.InsertDriveFile
},
name = entry.name,
detail = entry.size?.let(::formatBytes),
onClick = if (entry.isFolder) {
{
trail.add(
entry.name to DrivePlaceRecord(
PlaceType.MY_FILES,
entry.name,
entry.volumeId,
entry.linkId,
),
)
}
} else {
null
},
)
}
}
}
}
}
}
}
@Composable
private fun DriveRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
name: String,
detail: String?,
onClick: (() -> Unit)?,
) {
ListItem(
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = detail?.let { { Text(it) } },
leadingContent = {
Icon(
icon,
contentDescription = null,
tint = if (onClick != null) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
)
}
@@ -25,6 +25,11 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
@@ -33,12 +38,29 @@ import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.SignInOutcome
import uniffi.pstr_android.SyncRecord
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
data class AppUiState( data class AppUiState(
val loading: Boolean = true, val loading: Boolean = true,
val refreshing: Boolean = false, val refreshing: Boolean = false,
/** A crawl is running, which the banner offers to stop. */
val crawling: Boolean = false,
val query: String = "", val query: String = "",
val titles: List<TitleRecord> = emptyList(), 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 filter: LibraryFilter = LibraryFilter.ALL,
val sort: LibrarySort = LibrarySort.NAME,
val grouped: Boolean = true,
val shares: List<ShareRecord> = emptyList(), val shares: List<ShareRecord> = emptyList(),
val offline: List<OfflineRecord> = emptyList(), val offline: List<OfflineRecord> = emptyList(),
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord( val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
@@ -49,14 +71,62 @@ data class AppUiState(
), ),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL), val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null, val message: String? = null,
/**
* What is running in the background right now, said in a banner for as
* long as it runs: an add, a crawl, a match, a sync. Without it a
* minute-long crawl looked like a tap that did nothing.
*/
val activity: String? = null,
/** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null,
val account: AccountUiState = AccountUiState(),
) )
/** The Proton account: where a sign-in stands, and how sync is doing. */
data class AccountUiState(
/** Null until the bridge has said. */
val state: AccountState? = null,
/** A sign-in step is out and not answered yet. */
val busy: Boolean = false,
/** Why the last sign-in step was refused. */
val error: String? = null,
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
val verificationUrl: String? = null,
/** When watch history last synced, in epoch milliseconds. */
val syncedAt: Long? = null,
/** Positions the last sync took from other devices. */
val applied: Int = 0,
/** Why the last sync failed, until one succeeds. */
val syncError: String? = 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) @OptIn(FlowPreview::class)
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() { class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val mutableState = MutableStateFlow(AppUiState()) private val mutableState = MutableStateFlow(AppUiState())
private val searchQuery = MutableStateFlow("") private val searchQuery = MutableStateFlow("")
val state: StateFlow<AppUiState> = mutableState.asStateFlow() val state: StateFlow<AppUiState> = mutableState.asStateFlow()
/** Launch and coming back both ask; one crawl answers. */
private var catchingUp = false
/**
* Every banner line whose work is still running, oldest first. Touched on
* the main thread only, as everything in [viewModelScope] is.
*/
private val activities = mutableListOf<Pair<Long, String>>()
private var nextActivity = 0L
/** Crawls running now; main thread only, like [activities]. */
private var crawlsRunning = 0
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
private var crawlStopped = false
init { init {
if (!NativeRuntime.tlsReady) { if (!NativeRuntime.tlsReady) {
@@ -64,7 +134,37 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
it.copy(message = "Certificate verification is unavailable; Proton requests will fail") it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
} }
} }
reload() // The arrangement first, so the first grid is drawn the way the viewer
// left it rather than A–Z and then re-sorted under them.
viewModelScope.launch {
val (filter, sort, grouped) = withContext(Dispatchers.IO) {
SettingsStore(appContext).run {
Triple(
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
libraryGrouped,
)
}
}
mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
reload()
}
catchUp()
viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
.getOrDefault(AccountState.SignedOut)
updateAccount { it.copy(state = account) }
// While the app is alive, on top of after sign-in and whenever the
// player closes. Cheap when nothing changed: one folder listing,
// and no upload.
while (isActive) {
syncWatchHistory()
// A sync WatchSyncWorker ran while the app was away may have
// brought shares this one has not crawled.
catchUp()
delay(SYNC_INTERVAL_MS)
}
}
viewModelScope.launch { viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) } searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
} }
@@ -77,6 +177,29 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** Sort and fold the library, and keep both for the next launch. */
fun arrange(sort: LibrarySort, grouped: Boolean) {
mutableState.update { it.copy(sort = sort, grouped = grouped) }
viewModelScope.launch {
withContext(Dispatchers.IO) {
SettingsStore(appContext).apply {
librarySort = sort.name
libraryGrouped = grouped
}
}
reloadLibrary(mutableState.value.query)
}
}
/** Narrow the grid to some titles, and keep the choice for the next launch. */
fun filter(filter: LibraryFilter) {
mutableState.update { it.copy(filter = filter) }
viewModelScope.launch {
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
reloadLibrary(mutableState.value.query)
}
}
fun search(query: String) { fun search(query: String) {
mutableState.update { it.copy(query = query) } mutableState.update { it.copy(query = query) }
searchQuery.value = query searchQuery.value = query
@@ -84,14 +207,72 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) } busy("Refreshing the library…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } } mutableState.update { it.copy(message = null) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } runCatching { crawlShares(null) }
mutableState.update { it.copy(refreshing = false) } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload() }
reloadNow()
matchAfterCrawl()
} }
} }
/**
* Look up whatever a crawl brought that has no answer yet: a new show, or
* a new episode its stored guide cannot name. When everything already has
* one the bridge asks nothing, so this is cheap after every crawl.
*/
private suspend fun matchAfterCrawl() {
val settings = mutableState.value.metadataSettings
if (settings.enabled && settings.ready) match(force = false, quiet = true)
}
/**
* Say [what] in the activity banner while [work] runs.
*
* The banner shows the newest line still running. Work overlaps — a sync
* starts during a crawl and ends after it — so each line is removed by
* itself rather than by putting back whatever was showing before, which
* once brought back a finished crawl's line and spun it forever.
*/
private suspend fun busy(what: String, work: suspend () -> Unit) {
val token = nextActivity++
activities += token to what
showActivity()
try {
work()
} finally {
activities.removeAll { it.first == token }
showActivity()
}
}
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
private suspend fun crawlShares(id: String?) {
crawlsRunning++
mutableState.update { it.copy(crawling = true) }
try {
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
} finally {
crawlsRunning--
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
}
}
/**
* Stop the crawls running now. Shares already read stay in the library;
* the one being read keeps what it had.
*/
fun stopCrawl() {
crawlStopped = true
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
}
private fun showActivity() {
val newest = activities.lastOrNull()?.second
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
}
/** /**
* Recrawl one share rather than the whole library. * Recrawl one share rather than the whole library.
* *
@@ -102,11 +283,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
*/ */
fun refreshShare(id: String) { fun refreshShare(id: String) {
viewModelScope.launch { viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) } val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } busy("Reading $name…") {
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } mutableState.update { it.copy(message = null) }
mutableState.update { it.copy(refreshing = false) } runCatching { crawlShares(id) }
reload() .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reloadNow()
matchAfterCrawl()
} }
} }
@@ -119,26 +303,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* changed. * changed.
*/ */
fun matchTitles(force: Boolean) { fun matchTitles(force: Boolean) {
viewModelScope.launch { viewModelScope.launch { match(force) }
mutableState.update { it.copy(refreshing = true, message = null) } }
/**
* With [quiet], a run nobody asked for says nothing when it found nothing
* to do.
*/
private suspend fun match(force: Boolean, quiet: Boolean = false) {
busy("Looking up titles…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } } .onSuccess { summary ->
if (!quiet || !summary.isEmpty()) {
mutableState.update { it.copy(message = describe(summary)) }
}
}
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) } }
reload() reloadNow()
}
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
/**
* Add a link, and return why it was refused, if it was.
*
* The link is opened before anything is stored, so the form can stay up
* with the reason under it and a retry is a retry — not "already in the
* library" for a share that never opened. Only the new share is crawled:
* another share that fails to open must not make this one look broken.
*/
suspend fun addShare(name: String, url: String, password: String?): String? {
val share = runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
}
}.getOrElse { return it.message ?: "That link could not be added" }
reload()
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
return null
}
/**
* Crawl shares that are new here — added on this device or by sync — then
* match them when enrichment is on.
*/
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
if (ids.isEmpty()) return
val before = mutableState.value.titles.map { it.key }.toSet()
val failed = mutableListOf<String>()
crawlStopped = false
for (id in ids) {
if (crawlStopped) break
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
busy("Reading $name…") {
runCatching { crawlShares(id) }
.onFailure { failed += "$name: ${it.message}" }
}
}
reloadNow()
// A catch-up is the app's doing, not the viewer's: a failure goes on
// the share's row rather than in their face, and only news is said.
if (failed.isNotEmpty() && announce) {
mutableState.update { it.copy(message = failed.joinToString("\n")) }
return
}
matchAfterCrawl()
if (announce) {
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
} else {
val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL }
if (files > 0uL) {
mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") }
}
} }
} }
fun addShare(name: String, url: String, password: String?) { /**
* Crawl shares due a look: ones the catalog has nothing of — brought by a
* sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
* whose crawl never finished — and ones not crawled for six hours, so new
* episodes turn up without a pull to refresh.
*/
fun catchUp() {
if (catchingUp) return
catchingUp = true
viewModelScope.launch { viewModelScope.launch {
runCatching { try {
withContext(Dispatchers.IO) { val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
val engine = NativeRuntime.engine() .getOrDefault(emptyList())
engine.addShare(name, url, password?.takeIf(String::isNotBlank)) crawlAdded(ids, announce = false)
engine.crawl(null) } finally {
} catchingUp = false
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } } }
reload()
} }
} }
@@ -160,6 +418,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.crawl(id) engine.crawl(id)
} }
}.onFailure(::reportError) }.onFailure(::reportError)
reloadNow()
matchAfterCrawl()
}
}
/** Call a share something else. The library, history and downloads stay. */
fun renameShare(id: String, name: String) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } }
.onFailure(::reportError)
reload() reload()
} }
} }
@@ -185,28 +453,291 @@ 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) }
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
mutableState.update { it.copy(account = change(it.account)) }
/**
* Start signing in, or start again with [verificationToken] once the
* CAPTCHA from a first try is solved.
*/
fun signIn(username: String, password: String, verificationToken: String? = null) =
signInStep { engine -> engine.signIn(username, password, verificationToken) }
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
fun submitMailboxPassword(password: String) =
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
fun cancelSignIn() {
updateAccount { AccountUiState(state = AccountState.SignedOut) }
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
}
/** The CAPTCHA page closed without a token. */
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
private fun signInStep(
keepStepOnFailure: Boolean = false,
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
) {
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
.onSuccess { outcome ->
when (outcome) {
is SignInOutcome.Done -> {
updateAccount {
AccountUiState(state = AccountState.SignedIn(outcome.username))
}
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
syncWatchHistory()
reload()
}
SignInOutcome.SecondFactor ->
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
SignInOutcome.MailboxPassword ->
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
is SignInOutcome.HumanVerification ->
updateAccount { it.copy(verificationUrl = outcome.url) }
}
}
.onFailure { error ->
updateAccount {
it.copy(
busy = false,
error = error.message ?: "Sign-in failed",
// A refused code spends the half-made session.
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
)
}
}
}
}
fun signOut() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
.onFailure(::reportError)
updateAccount { AccountUiState(state = AccountState.SignedOut) }
reload()
}
}
/** Sync now, from a tap: says so when it is done. */
fun syncNow() {
viewModelScope.launch {
if (syncWatchHistory()) {
val applied = mutableState.value.account.applied
mutableState.update {
it.copy(
message = if (applied > 0) {
"Watch history synced, $applied from other devices"
} else {
"Watch history is up to date"
},
)
}
}
}
}
/**
* Pull other devices' positions in and push this one's out. Quiet: it runs
* on a timer, and a failure is shown on the account card rather than as a
* snackbar every five minutes of an offline evening. Returns whether it
* synced.
*/
private suspend fun syncWatchHistory(): Boolean {
if (mutableState.value.account.state !is AccountState.SignedIn) return false
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
.fold(
onSuccess = { report ->
if (report == null) return false
updateAccount {
it.copy(
syncedAt = System.currentTimeMillis(),
applied = report.applied.toInt(),
syncError = null,
)
}
takeSynced(report)
true
},
onFailure = { error ->
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
// Signed out by the engine: the session ended, and that is
// worth interrupting for, unlike a dropped connection.
if (state == AccountState.SignedOut) reportError(error)
false
},
)
}
/**
* What another device changed: its shares crawled or dropped here, its
* settings repainted.
*/
private suspend fun takeSynced(report: SyncRecord) {
if (report.sharesRemoved.isNotEmpty()) {
withContext(Dispatchers.IO) {
val downloads = DownloadStateStore(appContext)
report.sharesRemoved.forEach { id ->
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
downloads.removeShare(id)
}
}
}
if (report.settingsChanged) AppearanceState.reload()
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
reloadNow()
}
if (report.sharesAdded.isNotEmpty()) {
crawlAdded(report.sharesAdded, announce = false)
val count = report.sharesAdded.size
mutableState.update {
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
}
}
}
/** After the player closes: where it stopped is what another device wants next. */
fun playerClosed() {
reload()
viewModelScope.launch { syncWatchHistory() }
}
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
}.onSuccess { share ->
reload()
crawlAdded(listOf(share.id), announce = true)
}.onFailure(::reportError)
}
}
/**
* 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) =
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
/**
* Some episodes seen or unseen — a season, or a whole title — with an
* Undo; [what] names them in the message.
*/
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
changeWatch(
episodes.filter { it.watched != watched },
"$what 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 ->
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
}
}
}.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) { fun reportError(error: Throwable) {
mutableState.update { it.copy(message = error.message ?: "Unexpected error") } mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
} }
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) { /**
viewModelScope.launch { * Store the enrichment settings, and return why not, if they were not.
runCatching { *
withContext(Dispatchers.IO) { * The page shows the new settings as soon as they are stored; the match
val engine = NativeRuntime.engine() * run that follows can take minutes and says so in the activity banner.
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) { */
engine.setMetadataApiKey(provider, apiKey) suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
} val stored = runCatching {
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true)) withContext(Dispatchers.IO) {
if (enabled) engine.matchTitles(false) else null val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
} }
}.onSuccess { summary -> engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } } engine.metadataSettings()
}.onFailure(::reportError) }
reload() }.getOrElse { return it.message ?: "The settings could not be saved" }
} mutableState.update { it.copy(metadataSettings = stored) }
reloadNow()
if (enabled) viewModelScope.launch { match(force = false) }
return null
} }
fun reloadAfterMetadataChange() = reload() fun reloadAfterMetadataChange() = reload()
@@ -289,24 +820,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* at its duration where one is known, so the progress bar agrees with the * at its duration where one is known, so the progress bar agrees with the
* tick. * tick.
*/ */
fun setWatched(episode: EpisodeRecord, watched: Boolean) { /** One episode's tick, with the same Undo a season's has. */
viewModelScope.launch { fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
engine.saveWatchState(
episode.shareId,
episode.linkId,
if (watched) duration ?: 0.0 else 0.0,
duration,
watched,
)
}
}.onFailure(::reportError)
reload()
}
}
fun removeAllOffline() { fun removeAllOffline() {
viewModelScope.launch { viewModelScope.launch {
@@ -333,43 +848,60 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
private fun reload() { private fun reload() {
viewModelScope.launch { viewModelScope.launch { reloadNow() }
runCatching { }
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() /** [reload], for a caller that goes on to read what it loaded. */
// Here rather than inside `library()`, which is read-only private suspend fun reloadNow() {
// and cached: a full reload is the one place that has runCatching {
// already paid for a walk of the offline files. withContext(Dispatchers.IO) {
engine.pruneOfflineFiles() val engine = NativeRuntime.engine()
Reloaded( // Here rather than inside `library()`, which is read-only
engine.shares(), // and cached: a full reload is the one place that has
engine.library(mutableState.value.query.takeIf(String::isNotBlank)), // already paid for a walk of the offline files.
engine.offlineFiles(), engine.pruneOfflineFiles()
engine.metadataSettings(), val current = mutableState.value
engine.storageUsage(), val query = current.query.takeIf(String::isNotBlank)
) Reloaded(
} engine.shares(),
}.onSuccess { result -> engine.library(query),
mutableState.update { it.copy( engine.arrangement(query, current.filter, current.sort, current.grouped),
loading = false, engine.offlineFiles(),
shares = result.shares, engine.metadataSettings(),
titles = result.titles, engine.storageUsage(),
offline = result.offline, )
metadataSettings = result.metadataSettings, }
storage = result.storage, }.onSuccess { result ->
) } mutableState.update { it.copy(
} loading = false,
.onFailure { error -> shares = result.shares,
mutableState.update { it.copy(loading = false, message = error.message) } titles = result.titles,
} arrangement = result.arrangement,
} offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
) }
}
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
} }
private suspend fun reloadLibrary(query: String) { private suspend fun reloadLibrary(query: String) {
val current = mutableState.value
runCatching { runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) } withContext(Dispatchers.IO) {
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } } val engine = NativeRuntime.engine()
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
}
}.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
private companion object {
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
} }
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory { class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
@@ -381,6 +913,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private data class Reloaded( private data class Reloaded(
val shares: List<ShareRecord>, val shares: List<ShareRecord>,
val titles: List<TitleRecord>, val titles: List<TitleRecord>,
val arrangement: ArrangementRecord,
val offline: List<OfflineRecord>, val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord, val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord, val storage: StorageUsageRecord,
@@ -0,0 +1,240 @@
package io.narl.protonstream.ui
import android.net.ConnectivityManager
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.foundation.layout.padding
import androidx.compose.material3.AlertDialog
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.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.platform.LocalContext
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
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, action: Pair<String, () -> Unit>? = null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
Text(
body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
action?.let { (label, onClick) ->
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
}
}
}
}
/**
* Where Play on a title lands: what Continue watching offers
* ([TitleRecord.continueIndex]), else 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>, continueIndex: UInt? = null): Int =
continueIndex?.toInt()?.takeIf { it in playlist.indices }
?: 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"
}
}
/**
* Download episodes, asking first when it is more than one: a season or a
* show is tens of gigabytes, and one tap should not fill a phone. The
* question says how many and how big. Call it in composition; it draws the
* dialog where it is called and returns what starts the download.
*/
@Composable
internal fun rememberDownload(): (List<EpisodeRecord>, String) -> Unit {
val context = LocalContext.current
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, String>?>(null) }
asking?.let { (episodes, what) ->
val bytes = episodes.sumOf { it.size ?: 0uL }
AlertDialog(
onDismissRequest = { asking = null },
title = { Text("Download ${episodes.size} episodes of $what?") },
text = {
Text(
"They are kept on this device for watching without a connection" +
(if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") +
". Delete them from Downloads.",
)
},
confirmButton = {
AccentButton(onClick = {
asking = null
DownloadCoordinator.enqueue(context, episodes)
}) { Text("Download") }
},
dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } },
)
}
return remember(context) {
{ episodes, what ->
val wanted = episodes.filterNot(EpisodeRecord::offline)
if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted)
}
}
}
/**
* One line under a download that has not finished: "Running · 120.0 MiB of
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
* [waitingFor], what it is really waiting on.
*/
internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
?: download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
download.bytesPerSecond
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
?.let { "${formatBytes(it.toULong())}/s" },
).joinToString(" · ")
/**
* Why a queued download is not running, when it is not merely a free slot:
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
* changes — the download's own updates are the clock.
*/
@Composable
internal fun rememberQueueReason(key: Any?): String? {
val context = LocalContext.current
return remember(key) {
val network = context.getSystemService(ConnectivityManager::class.java)
when {
network?.activeNetwork == null -> "Waiting for a connection"
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
else -> null
}
}
}
/**
* 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,261 @@
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.clickable
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.material.icons.outlined.Info
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.DangerButton
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,
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
onOpen: (TitleRecord) -> Unit = {},
) {
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 }, onPlay = onPlay, onOpen = onOpen)
}
}
}
}
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 = {
DangerButton(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,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
) {
val episode = file.episode
// What a download is for: tapping it plays it, from where it was left,
// without going back through the library to find it.
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
ListItem(
modifier = if (title != null && index >= 0) {
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
} else {
Modifier
},
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 = {
Row {
title?.let {
IconButton(onClick = { onOpen(it) }) {
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
}
}
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(downloadStatusLine(download, rememberQueueReason(download)))
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,815 @@
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 androidx.compose.material3.Surface
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.QuietButton
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.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.FilterChip
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.clearAndSetSemantics
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.LibraryFilter
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,
/** When the title was last played, which orders the shelf. */
val lastPlayed: Long,
)
/** 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)
/** The desktop's names for the filters, from `browse::Filter::label`. */
internal fun LibraryFilter.label(): String = when (this) {
LibraryFilter.ALL -> "All"
LibraryFilter.SERIES -> "Series"
LibraryFilter.FILMS -> "Films"
LibraryFilter.WATCHING -> "Watching"
LibraryFilter.UNWATCHED -> "Not started"
LibraryFilter.DOWNLOADED -> "Downloaded"
}
/** The offer of posters and synopses, and its two answers. */
internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit)
@Composable
private fun MetadataOfferCard(offer: MetadataOffer) {
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
Text("Add posters and synopses?", style = MaterialTheme.typography.titleSmall)
Text(
"Turning it on in Settings sends the titles in your library — not your files, and " +
"nothing about what you have watched — to AniList or TMDB.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, end = 8.dp),
)
Row(Modifier.align(Alignment.End)) {
QuietButton(onClick = offer.onDismiss) { Text("Not now") }
QuietButton(onClick = offer.onOpenSettings) { Text("Open settings") }
}
}
}
}
/** 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 = { _, _ -> },
onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {},
) {
val download = rememberDownload()
val context = LocalContext.current
// Posters and synopses, offered once there is a library to put them on
// and never turned on unasked: switching them on sends title names out.
var offerDismissed by remember { mutableStateOf(SettingsStore(context).metadataOfferDismissed) }
// 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.
// What to go on with is the engine's call (`continueIndex`): the
// part-watched episode, or the one after the episode finished last.
val resumable = remember(state.titles) {
state.titles.mapNotNull { title ->
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
playlist.getOrNull(index)?.let { episode ->
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
}
}.sortedByDescending { it.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()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
)
}
}
}
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, title.continueIndex))
}
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
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,
onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null,
) {
// 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.
if (metadataOffer != null && state.query.isBlank()) {
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
}
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()) {
// Only over the whole library: under a filter, a shelf of titles
// the grid below is hiding reads as the filter not working. The
// bridge leaves them out too; this covers the frame before it.
shelves.takeIf { state.filter == LibraryFilter.ALL }.orEmpty().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,
)
}
}
}
}
}
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Column {
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(LibraryFilter.entries, key = { it.name }) { filter ->
FilterChip(
selected = filter == state.filter,
onClick = { onFilter(filter) },
label = { Text(filter.label()) },
)
}
}
}
}
if (tiles.isEmpty() && state.filter != LibraryFilter.ALL) {
item(key = "filtered-out", span = { GridItemSpan(maxLineSpan) }) {
Text(
"Nothing in the library fits that filter.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
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, title.continueIndex)) }) {
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, onLongClickLabel = "More for this episode"),
) {
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
// Named by the text under it; read as well, the name came twice.
RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.displayName,
Modifier.fillMaxSize().clearAndSetSemantics {},
fallback = entry.episode.thumbnailSource,
maxEdge = 220.dp,
)
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(
if (entry.episode.resumeAt == null) {
"Next: ${entry.episode.label}"
} else {
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, onLongClickLabel = "More for this title"),
) {
Box {
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.displayName,
(if (shared) Modifier.sharedArt(title.key) else Modifier)
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium)
.clearAndSetSemantics {},
fallback = title.thumbnailSource,
// The tile's height, at the widest a grid column gets.
maxEdge = 280.dp,
)
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)
.semantics { contentDescription = "and $folded more" },
)
}
}
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 android.util.LruCache
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -16,12 +21,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -64,7 +77,9 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
* *
* [fallback] is used only when there is no provider artwork or it fails to * [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare * load, which — with metadata off — is the common case rather than the rare
* one. * one. Until either answers, the box pulses as a skeleton; when neither has
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it.
*/ */
@Composable @Composable
internal fun RemoteArtwork( internal fun RemoteArtwork(
@@ -72,19 +87,102 @@ internal fun RemoteArtwork(
description: String, description: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null, fallback: ThumbnailSource? = null,
labelled: Boolean = true,
maxEdge: Dp? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) } val enabled = LocalArtworkLoading.current
LaunchedEffect(url, fallback) { // Decoded at the size it is drawn: a grid tile is a few hundred pixels,
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap() // and decoding its poster at 1024 holds four times the memory for nothing.
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
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, edge) ?: loadThumbnail(fallback, edge))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
} }
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) { when (val loaded = image) {
image?.let { ArtworkLoad.Pending -> Skeleton(modifier)
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
} ?: Text("▶", style = MaterialTheme.typography.displaySmall) 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. * The same HTTPS-only fetch behind the library grid, reusable off the composition.
* *
@@ -93,8 +191,8 @@ internal fun RemoteArtwork(
* loader's protocol checks and exactly this disk cache, so the artwork the shade * loader's protocol checks and exactly this disk cache, so the artwork the shade
* shows costs no second download. * shows costs no second download.
*/ */
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested -> internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
decoded.get(requested) ?: withContext(Dispatchers.IO) { decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
runCatching { runCatching {
val uri = URI(requested) val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" } require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
@@ -104,7 +202,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
val cached = directory.resolve("$digest.img") val cached = directory.resolve("$digest.img")
val fresh = !cached.isFile val fresh = !cached.isFile
val bytes = if (fresh) download(uri) else cached.readBytes() val bytes = if (fresh) download(uri) else cached.readBytes()
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" } val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "Artwork is not a decodable image" }
if (fresh) { if (fresh) {
// Written only once it has decoded. Caching the bytes first is // Written only once it has decoded. Caching the bytes first is
// what turns one hostile poster into a crash on every launch // what turns one hostile poster into a crash on every launch
@@ -117,7 +215,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
cached.setLastModified(System.currentTimeMillis()) cached.setLastModified(System.currentTimeMillis())
} }
bitmap bitmap
}.getOrNull()?.also { decoded.put(requested, it) } }.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
} }
} }
@@ -131,21 +229,21 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
* share or a session that has to be reopened is transient, and the next * share or a session that has to be reopened is transient, and the next
* composition should try again. * composition should try again.
*/ */
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? { internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? {
val file = source ?: return null val file = source ?: return null
val key = file.key val key = file.key
decoded.get(key)?.let { return it } decoded.get(sized(key, edge))?.let { return it }
if (key in missing) return null if (key in missing) return null
val fetched = withContext(Dispatchers.IO) { val fetched = withContext(Dispatchers.IO) {
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) } runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
} }
val bytes = fetched.getOrElse { return null } val bytes = fetched.getOrElse { return null }
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } } val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } }
if (bitmap == null) { if (bitmap == null) {
missing.add(key) missing.add(key)
return null return null
} }
decoded.put(key, bitmap) decoded.put(sized(key, edge), bitmap)
return bitmap return bitmap
} }
@@ -164,28 +262,43 @@ private fun download(uri: URI): ByteArray {
} }
/** /**
* Decode within a bound the display can actually use. * Decode with its longest edge at most [edge] pixels.
* *
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what * [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB * it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
* `ARGB_8888` allocation on a device that will not give it. The bounds pass * `ARGB_8888` allocation on a device that will not give it. The bounds pass
* costs no allocation and settles a sample size before anything is committed. * costs no allocation and settles a sample size before anything is committed.
*
* Sampling halves, so it stops while the image is still at least [edge]; the
* decoder scales the rest of the way, which a poster sampled straight to under
* its tile would show as blur.
*/ */
private fun decodeSampled(bytes: ByteArray): Bitmap? { private fun decodeSampled(bytes: ByteArray, edge: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight) val longest = maxOf(bounds.outWidth, bounds.outHeight)
if (longest <= 0) return null if (longest <= 0) return null
var sample = 1 var sample = 1
while (longest / sample > MAX_ART_EDGE) sample *= 2 while (longest / (sample * 2) >= edge) sample *= 2
val sampled = longest / sample
return BitmapFactory.decodeByteArray( return BitmapFactory.decodeByteArray(
bytes, bytes,
0, 0,
bytes.size, bytes.size,
BitmapFactory.Options().apply { inSampleSize = sample }, BitmapFactory.Options().apply {
inSampleSize = sample
if (sampled > edge) {
inScaled = true
inDensity = sampled
inTargetDensity = edge
}
},
) )
} }
/** The decoded cache's key for one image at one size. */
private fun sized(key: String, edge: Int) = "$key#$edge"
/** /**
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first. * Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
* *
@@ -222,7 +335,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
* is a full-resolution decode per item. * is a full-resolution decode per item.
*/ */
private val decoded = object : LruCache<String, Bitmap>( private val decoded = object : LruCache<String, Bitmap>(
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(), (Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(),
) { ) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
} }
@@ -0,0 +1,647 @@
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.DangerButton
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,
/** Resolves to why the settings were not stored, or null once they are. */
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
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 = {
DangerButton(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 = onSaveMetadata,
)
}
}
/** 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: suspend (Boolean, MetadataProvider, String, String) -> String?,
) {
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("") }
var saving by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
val forgets = current.enabled && (!enabled || provider != current.provider)
AlertDialog(
onDismissRequest = { if (!saving) 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,
)
}
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
// Said before Save rather than after: what is forgotten
// includes matches chosen by hand, which no look-up redoes.
if (forgets) {
Text(
"Saving forgets every poster, description and match stored now, " +
"including ones you chose by hand.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
AccentButton(
enabled = !needsKey && !saving,
onClick = {
saving = true
refusal = null
scope.launch {
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
saving = false
if (refusal == null) onDismiss()
}
},
) { Text(if (saving) "Saving…" else "Save") }
},
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { 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,433 @@
package io.narl.protonstream.ui
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Row
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.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
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.DangerButton
import io.narl.protonstream.ui.theme.TonalButton
import androidx.compose.material.icons.filled.Edit
import uniffi.pstr_android.AccountState
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.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.ui.graphics.Color
@Composable
internal fun SharesScreen(
shares: List<ShareRecord>,
/** Resolves to why the link was refused, or null once it is added. */
onAdd: suspend (String, String, String?) -> String?,
onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit,
onRemove: (String) -> Unit,
incomingLink: String?,
onIncomingLinkTaken: () -> Unit,
padding: PaddingValues,
account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null,
onRename: (String, String) -> Unit = { _, _ -> },
) {
var showAdd by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
var signingIn by remember { mutableStateOf(false) }
var browsing 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() && account.state == null) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
if (accountActions != null) {
item(key = "account") {
AccountCard(
account,
accountActions,
onSignIn = { signingIn = true },
onBrowse = { browsing = true },
)
}
}
if (shares.isEmpty()) {
item(key = "empty") {
Text(
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
items(shares, key = { it.id }) { share ->
ShareRow(
share = share,
onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share },
onRemove = { removing = share },
onRename = { renaming = 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,
)
}
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
account.state == AccountState.MailboxPassword)
) {
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
}
if (accountActions != null && browsing) {
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
}
repairing?.let { share ->
RepairShareDialog(
share = share,
onDismiss = { repairing = null },
onRepair = { url, password -> onRepair(share.id, url, password) },
)
}
renaming?.let { share ->
var name by remember(share.id) { mutableStateOf(share.name) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text("Rename ${share.name}") },
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
confirmButton = {
AccentButton(enabled = name.isNotBlank(), onClick = {
renaming = null
onRename(share.id, name.trim())
}) { Text("Save") }
},
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
)
}
removing?.let { share ->
// Asked, because it cannot be undone: the link's secret and its
// offline files go. Watch positions stay (`Catalog::remove_share`).
AlertDialog(
onDismissRequest = { removing = null },
title = { Text("Remove ${share.name}?") },
text = {
Text(
"Its titles leave the library and its offline episodes are deleted. " +
"Watch progress is kept, so adding the link again picks up where you left off.",
)
},
confirmButton = {
DangerButton(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.
*/
/** "120 files · crawled 2 h ago", or that it has not been yet. */
internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String {
val at = share.crawledAt ?: return "Not crawled yet"
val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" }
return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ")
}
/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */
internal fun ago(at: Long, now: Long): String {
val seconds = (now - at).coerceAtLeast(0)
return when {
seconds < 60 -> "just now"
seconds < 3_600 -> "${seconds / 60} min ago"
seconds < 86_400 -> "${seconds / 3_600} h ago"
seconds < 172_800 -> "1 day ago"
else -> "${seconds / 86_400} days ago"
}
}
@Composable
private fun ShareRow(
share: ShareRecord,
onRefresh: () -> Unit,
onRepair: () -> Unit,
onRemove: () -> Unit,
onRename: () -> Unit = {},
) {
var menu by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
supportingContent = {
Column {
Text(
when {
share.fromAccount -> "Folder of your Drive"
share.hasCustomPassword -> "Link and custom password"
else -> "Public link"
},
)
Text(crawlLine(share), style = MaterialTheme.typography.bodySmall)
// Until a crawl succeeds: a link revoked upstream otherwise
// just stops growing.
share.crawlError?.let {
Text(
"Last crawl failed: $it",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
},
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() },
)
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
onClick = { menu = false; onRename() },
)
// 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.
if (!share.fromAccount) {
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") } },
)
}
/**
* Add a link. The form stays up while the link is opened, and with the reason
* under it when the link is refused, so fixing a typo is a retry rather than
* starting over.
*/
@Composable
private fun AddShareDialog(
initialUrl: String,
onDismiss: () -> Unit,
onAdd: suspend (String, String, String?) -> String?,
) {
var name by remember { mutableStateOf("") }
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var showLink by remember { mutableStateOf(false) }
var password by remember { mutableStateOf("") }
var adding by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = { if (!adding) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
url,
{ url = it },
label = { Text("Share link") },
singleLine = true,
// The URL fragment is a secret. Password input disables
// IME learning/suggestions while normal long-press paste
// remains available; the eye shows what was pasted.
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { showLink = !showLink }) {
Icon(
if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showLink) "Hide link" else "Show link",
)
}
},
)
OutlinedTextField(
name,
{ name = it },
label = { Text("Name (optional)") },
supportingText = { Text("Defaults to the shared folder's name") },
singleLine = true,
)
OutlinedTextField(
password,
{ password = it },
label = { Text("Custom password (optional)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
if (adding) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Opening the link…")
}
} else {
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
},
confirmButton = {
AccentButton(
onClick = {
adding = true
refusal = null
scope.launch {
refusal = onAdd(name.trim(), url.trim(), password)
adding = false
if (refusal == null) onDismiss()
}
},
enabled = url.isNotBlank() && !adding,
) { Text(if (adding) "Adding…" else "Add") }
},
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
)
}
@@ -0,0 +1,790 @@
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.combinedClickable
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.RemoveDone
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.produceState
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.DangerButton
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,
/** Several episodes seen or unseen at once, named for the message. */
onMarkEpisodes: (List<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
) {
val context = LocalContext.current
val download = rememberDownload()
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, title.continueIndex) }
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())
// Decoded off the main thread: WorkManager reports progress several times
// a second while a season downloads, and each report re-reads every record.
val downloads by produceState(emptyList<RetainedDownload>(), work) {
value = withContext(Dispatchers.IO) { 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)) {
download(playlist, title.displayName)
}
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)) {
// By position: two seasons can share a label, and a
// repeated key is a crash in a lazy list.
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
}
}
season?.let {
// For someone joining the show partway, who would
// otherwise tick every earlier episode one by one.
val seen = it.episodes.all(EpisodeRecord::watched)
IconButton(onClick = { onMarkEpisodes(it.episodes, !seen, "${title.displayName} ${it.label}") }) {
Icon(
if (seen) Icons.Default.RemoveDone else Icons.Default.DoneAll,
contentDescription = "Mark ${it.label} ${if (seen) "unwatched" else "watched"}",
)
}
IconButton(onClick = { download(it.episodes, "${title.displayName} ${it.label}") }) {
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) },
onWatchedUpTo = {
val upTo = playlist.indexOfFirst { it.linkId == episode.linkId }
onMarkEpisodes(
playlist.take(upTo + 1),
true,
"${title.displayName} up to ${episode.label}",
)
},
)
}
}
}
}
}
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,
onWatchedUpTo: () -> Unit = {},
) {
// A long press, for someone joining a show partway: everything before
// this one ticked at once rather than one by one.
var menu by remember { mutableStateOf(false) }
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED
val heading = episode.heading(position)
Column(
Modifier
.fillMaxWidth()
.combinedClickable(
enabled = playerReady,
onClick = onPlay,
onLongClick = { menu = true },
onLongClickLabel = "More for this episode",
)
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Mark watched up to here") },
leadingIcon = { Icon(Icons.Default.DoneAll, contentDescription = null) },
onClick = {
menu = false
onWatchedUpTo()
},
)
}
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(
downloadStatusLine(active, rememberQueueReason(active)),
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) }
// Choosing fetches the entry's episodes too, which is a few requests.
var choosing by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
var confirmForget by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (confirmForget) {
// Asked, because a match chosen by hand is work the next look-up
// does not redo: it decides again on its own.
AlertDialog(
onDismissRequest = { confirmForget = false },
title = { Text("Forget this match?") },
text = {
Text(
"Its poster, description and episode names go, and the next look-up " +
"matches it again on its own. A match you chose by hand is lost.",
)
},
confirmButton = {
DangerButton(onClick = {
confirmForget = false
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget") }
},
dismissButton = { TonalButton(onClick = { confirmForget = false }) { Text("Cancel") } },
)
return
}
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") }
if (choosing) {
Text(
"Fetching its episodes…",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
}
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
EdgedButton(
onClick = {
choosing = true
scope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError)
choosing = false
}
},
enabled = !choosing,
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 = { confirmForget = true }) { Text("Forget match") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
)
}
@@ -2,6 +2,7 @@ package io.narl.protonstream.ui.theme
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -9,6 +10,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationDrawerItemDefaults
@@ -18,14 +20,18 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
import androidx.compose.material3.SliderState import androidx.compose.material3.SliderState
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
@@ -68,17 +74,30 @@ internal fun AccentButton(
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val shape = MaterialTheme.shapes.small val shape = MaterialTheme.shapes.small
Button( // The button pads itself out to the 48dp minimum touch target, and a
onClick = onClick, // background on its modifier fills that whole target — 8dp taller than
// Only while it is live: a disabled button keeps Material's flat // the 40dp container every other button draws. So the padding is taken
// disabled container, because a greyed control that still wears the // here, outside the ramp, and the button inside is told not to add its
// one strong colour in the window reads as pressable. // own. The box hands its size down, so a `fillMaxWidth` still reaches it.
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier, Box(
enabled = enabled, modifier.minimumInteractiveComponentSize(),
shape = shape, contentAlignment = Alignment.Center,
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), propagateMinConstraints = true,
content = content, ) {
) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Button(
onClick = onClick,
// Only while it is live: a disabled button keeps Material's flat
// disabled container, because a greyed control that still wears the
// one strong colour in the window reads as pressable.
modifier = if (enabled) Modifier.background(accentBrush(), shape) else Modifier,
enabled = enabled,
shape = shape,
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
content = content,
)
}
}
} }
@Composable @Composable
@@ -123,6 +142,28 @@ internal fun QuietButton(
content = content, content = content,
) )
/**
* The yes of a confirmation that deletes or forgets something.
*
* Not [AccentButton]: the filled accent is this app's "go on", and a dialog
* whose most inviting button is the one that loses something reads as the
* safe choice. Error-toned text instead, as Material's own guidance has it.
*/
@Composable
internal fun DangerButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
content = content,
)
/** /**
* How far along something is, drawn in the accent. * How far along something is, drawn in the accent.
* *
@@ -168,11 +209,21 @@ internal fun AccentProgress(
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) { internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier, buffered: Float = 0f) {
val brush = accentBrush() val brush = accentBrush()
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) { Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2) val radius = CornerRadius(size.height / 2)
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius) 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 val done = size.width * state.coercedValueAsFraction
if (done >= size.height) { if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius) drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
@@ -56,6 +56,12 @@ object AppearanceState {
.onSuccess { current.value = it } .onSuccess { current.value = it }
} }
/** Read the stored choice again, after sync rewrote it. */
suspend fun reload() {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */ /** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) { fun apply(palette: PaletteRecord) {
current.value = palette current.value = palette
@@ -2,9 +2,28 @@ package io.narl.protonstream.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle 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 androidx.compose.ui.text.font.FontWeight
import io.narl.protonstream.R
import androidx.compose.ui.unit.sp 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. * 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, fontSize = size.sp,
lineHeight = (size * 1.35f).sp, lineHeight = (size * 1.35f).sp,
fontWeight = weight, 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" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp" android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108"> android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" /> <group
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" /> 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> </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 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 everything that is not opaque, so this is the launcher's sliced play mark as
white silhouette rather than the two-tone drawable. one white silhouette, scaled from the SVG's 128-unit space onto 24dp.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24"> android:viewportWidth="24" android:viewportHeight="24">
<path <group
android:fillColor="#FFFFFFFF" android:pivotX="70" android:pivotY="62"
android:fillType="evenOdd" android:scaleX="0.37" android:scaleY="0.37"
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" /> 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> </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> <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> </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,37 @@
package io.narl.protonstream.download
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TransferRateTest {
@Test
fun `the first sample has no rate to report`() {
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
}
@Test
fun `a steady transfer reports its rate`() {
val rate = TransferRate()
rate.sample(0, 0)
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
}
@Test
fun `one slow block moves the rate without halving it`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
val after = rate.sample(2_000_000, 3_000)
assertTrue(after in 600_000L..999_999L)
}
@Test
fun `a resumed download starting below the last sample starts over`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
assertEquals(1_000_000L, rate.sample(0, 2_000))
}
}
@@ -0,0 +1,38 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AccountUiTest {
@Test
fun `the sync line says how long ago and what came in`() {
val minute = 60_000L
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
assertEquals(
"Synced 2 h ago · 3 from other devices",
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
)
}
@Test
fun `a failed sync is said in place of when it last worked`() {
assertEquals(
"Did not sync: offline",
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
)
}
@Test
fun `the verification page may only navigate within Proton over https`() {
assertTrue(isProtonPage("https", "verify.proton.me"))
assertTrue(isProtonPage("https", "proton.me"))
assertFalse(isProtonPage("http", "verify.proton.me"))
assertFalse(isProtonPage("https", "proton.me.example.com"))
assertFalse(isProtonPage("https", "notproton.me"))
assertFalse(isProtonPage("https", null))
}
}
@@ -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,59 @@
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(),
continueIndex = null,
)
@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,344 @@
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.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
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(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, 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(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, 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 }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
}
@Test
fun `the settings page`() = capture("settings-phone") {
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
}
@Test
fun `the playback settings`() = capture("settings-playback") {
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
}
@Test
fun `the storage settings`() = capture("settings-storage") {
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, 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(),
// What the engine would pick for these fixtures: the part-watched episode.
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
.takeIf { it >= 0 }?.toUInt(),
)
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)
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
val MATCHED get() = LIBRARY.copy(
metadataSettings = MetadataSettingsRecord(
enabled = true,
provider = MetadataProvider.ANI_LIST,
language = "en",
ready = true,
),
)
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"))),
),
// Crawled relative to the clock, so the row reads "2 h ago" on
// whatever day the test runs.
shares = listOf(
ShareRecord(
"anime",
"anime",
true,
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
crawlFiles = 120uL,
),
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
),
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 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: 59 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: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 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: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 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-junit = "1.3.0"
androidx-test-runner = "1.7.0" androidx-test-runner = "1.7.0"
ktlint-plugin = "13.1.0" ktlint-plugin = "13.1.0"
roborazzi = "1.76.0"
[libraries] [libraries]
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" } 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-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-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
+1
View File
@@ -24,6 +24,7 @@ pstr-core.workspace = true
pstr-meta.workspace = true pstr-meta.workspace = true
pstr-stream.workspace = true pstr-stream.workspace = true
tokio.workspace = true tokio.workspace = true
futures.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
thiserror.workspace = true thiserror.workspace = true
uniffi.workspace = true uniffi.workspace = true
File diff suppressed because it is too large. Load diff
+5
View File
@@ -33,6 +33,11 @@ serde_json.workspace = true
directories.workspace = true directories.workspace = true
chrono.workspace = true chrono.workspace = true
souvlaki.workspace = true souvlaki.workspace = true
wry.workspace = true
tao.workspace = true
[build-dependencies]
embed-resource.workspace = true
[dev-dependencies] [dev-dependencies]
skrifa.workspace = true skrifa.workspace = true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+15
View File
@@ -0,0 +1,15 @@
//! Compiles `proton-stream.rc` into the Windows exe; nothing on any other
//! target. `rc.exe` on an MSVC build, mingw's `windres` on the cross build
//! from Linux.
fn main() {
println!("cargo:rerun-if-changed=proton-stream.rc");
println!("cargo:rerun-if-changed=../../packaging/windows/proton-stream.ico");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
// Only the GUI gets the icon; `pstr` is a console tool.
embed_resource::compile_for("proton-stream.rc", ["proton-stream"], embed_resource::NONE)
.manifest_optional()
.expect("compile the icon resource");
}
+4
View File
@@ -0,0 +1,4 @@
// The exe's icon. The window sets one at runtime too, but that only lasts as
// long as the window: Explorer, the Start menu and a taskbar pin read the icon
// out of the file, and without this one they fall back to the generic one.
1 ICON "../../packaging/windows/proton-stream.ico"
+839 -135
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -11,6 +11,9 @@ use serde::{Deserialize, Serialize};
/// What the settings page offers for the streaming cache, in GiB. /// What the settings page offers for the streaming cache, in GiB.
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16]; pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
/// What the settings page offers for the size of everything, in percent.
pub const TEXT_SIZE_CHOICES: [u16; 5] = [90, 100, 115, 130, 150];
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
// A file from an older version, or edited by hand, missing a field: the rest // A file from an older version, or edited by hand, missing a field: the rest
// still counts, and the missing one takes its default. // still counts, and the missing one takes its default.
@@ -21,6 +24,15 @@ pub struct DesktopPrefs {
pub hardware_decoding: bool, pub hardware_decoding: bool,
/// How much decrypted video the disk cache may keep. /// How much decrypted video the disk cache may keep.
pub cache_gib: u32, pub cache_gib: u32,
/// How the library was last arranged, so it opens that way.
pub filter: crate::app::Filter,
pub sort: pstr_core::browse::Sort,
pub grouped: bool,
/// The library's offer of posters and synopses was turned down.
pub metadata_offer_dismissed: bool,
/// How large text and controls are drawn, in percent. Per computer, not
/// synced: what reads well depends on the screen and how far away it is.
pub text_size: u16,
} }
impl Default for DesktopPrefs { impl Default for DesktopPrefs {
@@ -28,11 +40,23 @@ impl Default for DesktopPrefs {
Self { Self {
hardware_decoding: true, hardware_decoding: true,
cache_gib: 4, cache_gib: 4,
filter: crate::app::Filter::default(),
sort: pstr_core::browse::Sort::default(),
// On: a share holding a series and its three films is four tiles
// of the same picture otherwise.
grouped: true,
metadata_offer_dismissed: false,
text_size: 100,
} }
} }
} }
impl DesktopPrefs { impl DesktopPrefs {
/// [`Self::text_size`] as egui's zoom factor, kept to what is offered.
pub fn zoom(&self) -> f32 {
f32::from(self.text_size.clamp(50, 200)) / 100.0
}
pub fn cache_bytes(&self) -> u64 { pub fn cache_bytes(&self) -> u64 {
u64::from(self.cache_gib.clamp(1, 64)) << 30 u64::from(self.cache_gib.clamp(1, 64)) << 30
} }
@@ -93,6 +117,17 @@ mod tests {
assert!(prefs.hardware_decoding); assert!(prefs.hardware_decoding);
} }
#[test]
fn a_text_size_from_an_older_file_is_full_size_and_a_wild_one_is_kept_in_range() {
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
assert_eq!(prefs.zoom(), 1.0);
let wild = DesktopPrefs {
text_size: 900,
..DesktopPrefs::default()
};
assert_eq!(wild.zoom(), 2.0);
}
#[test] #[test]
fn an_unparseable_file_is_never_overwritten() { fn an_unparseable_file_is_never_overwritten() {
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id())); let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
File diff suppressed because it is too large. Load diff
+1
View File
@@ -30,5 +30,6 @@ pub mod pacing;
pub mod playback; pub mod playback;
pub mod theme; pub mod theme;
pub mod ui; pub mod ui;
pub mod verify;
pub mod video; pub mod video;
pub mod window; pub mod window;
+6
View File
@@ -16,6 +16,12 @@ use anyhow::Context;
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
// Before anything else: a verification window is a child process of the
// app, and must not claim the single instance or open a library.
if let Some(code) = pstr_app::verify::run_if_requested() {
std::process::exit(code);
}
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
+17 -9
View File
@@ -338,6 +338,18 @@ impl Playback {
(end > self.position + 0.5).then_some((label, end)) (end > self.position + 0.5).then_some((label, end))
} }
/// Whether the chapters say where the credits start. Most files that are
/// not anime have no chapters at all.
pub fn knows_credits(&self) -> bool {
self.credits_at.is_some()
}
/// Seconds of the file left to play, once its length is known.
pub fn remaining(&self) -> Option<f64> {
self.duration
.map(|duration| (duration - self.position).max(0.0))
}
/// Whether the playhead is inside the run of credits and preview that ends /// Whether the playhead is inside the run of credits and preview that ends
/// the file — what "the episode is over" means when a file states its /// the file — what "the episode is over" means when a file states its
/// chapters. /// chapters.
@@ -504,7 +516,11 @@ fn run(
orders: std::sync::mpsc::Receiver<Command>, orders: std::sync::mpsc::Receiver<Command>,
finished: Arc<AtomicBool>, finished: Arc<AtomicBool>,
) { ) {
let handle = match player.play(stream) { let opened = match target.resume_at {
Some(at) => player.play_from(stream, at),
None => player.play(stream),
};
let handle = match opened {
Ok(handle) => handle, Ok(handle) => handle,
Err(error) => { Err(error) => {
engine.emit(Event::Error(format!("load {}: {error}", target.name))); engine.emit(Event::Error(format!("load {}: {error}", target.name)));
@@ -517,7 +533,6 @@ fn run(
let mut position = target.resume_at.unwrap_or(0.0); let mut position = target.resume_at.unwrap_or(0.0);
let mut duration: Option<f64> = None; let mut duration: Option<f64> = None;
let mut watched = false; let mut watched = false;
let mut resumed = target.resume_at.is_none();
let mut last_save = std::time::Instant::now(); let mut last_save = std::time::Instant::now();
'playback: loop { 'playback: loop {
@@ -551,13 +566,6 @@ fn run(
restore_tracks(&player, show); restore_tracks(&player, show);
} }
emit_tracks(&engine, id, &player); emit_tracks(&engine, id, &player);
// Only now is there a timeline to seek within.
if !resumed && let Some(at) = target.resume_at {
resumed = true;
if let Err(error) = player.seek_to(at) {
tracing::warn!("resume at {at}: {error}");
}
}
} }
PlayerEvent::Duration(value) => duration = Some(*value), PlayerEvent::Duration(value) => duration = Some(*value),
PlayerEvent::Position(value) => { PlayerEvent::Position(value) => {
+6
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. /// 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_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.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. /// The type ramp, named once.
/// ///
+600
View File
@@ -0,0 +1,600 @@
//! The Proton account, on the shares page: signing in, watch-history sync, and
//! picking folders of the viewer's own Drive as library shares.
//!
//! Optional and said so: public links need no account, and the page reads the
//! same without one. What signing in adds is spelled out next to the form,
//! because "sign in" alone does not say why an app that streams public links
//! would want a password.
use pstr_core::account::{DriveEntry, DrivePlace, PlaceKind};
use pstr_core::proton_sdk::ids::NodeUid;
use pstr_core::sync::SyncReport;
use crate::app::Action;
use crate::engine::AccountStatus;
use crate::theme;
use crate::ui;
use crate::ui::widgets::Named;
/// What the account section holds between frames.
#[derive(Default)]
pub struct AccountPanel {
pub status: AccountStatus,
pub username: String,
pub password: String,
pub code: String,
pub mailbox: String,
/// A request is out and not answered yet.
pub busy: bool,
/// Why the last sign-in step was refused.
pub error: Option<String>,
/// When watch history last synced, in Unix seconds, and what it did.
pub synced: Option<(i64, SyncReport)>,
/// Why the last sync failed, until one succeeds.
pub sync_error: Option<String>,
pub browser: DriveBrowser,
}
impl AccountPanel {
/// Apply where the account now stands. The typed secrets go as soon as
/// they are not needed: a password has no business sitting in a struct for
/// the rest of the session.
pub fn set_status(&mut self, status: AccountStatus) {
self.busy = false;
match &status {
AccountStatus::SignedIn(_) | AccountStatus::SignedOut => {
self.password.clear();
self.code.clear();
self.mailbox.clear();
if matches!(status, AccountStatus::SignedIn(_)) {
self.error = None;
} else {
self.browser = DriveBrowser::default();
self.synced = None;
self.sync_error = None;
}
}
AccountStatus::SecondFactor => self.error = None,
AccountStatus::MailboxPassword => {
self.code.clear();
self.error = None;
}
AccountStatus::Unknown => {}
}
self.status = status;
}
pub fn refused(&mut self, error: String) {
self.busy = false;
self.error = Some(error);
}
}
/// Where the Drive browser is.
#[derive(Default)]
pub struct DriveBrowser {
pub open: bool,
/// The folders descended through, each by name. Empty means the places.
pub trail: Vec<(String, NodeUid)>,
pub places: Option<Vec<DrivePlace>>,
/// The listing of the folder at the end of the trail, once it arrives.
pub entries: Option<(NodeUid, Vec<DriveEntry>)>,
pub loading: bool,
pub error: Option<String>,
}
impl DriveBrowser {
pub fn places_arrived(&mut self, places: Vec<DrivePlace>) {
self.loading = false;
self.error = None;
self.places = Some(places);
}
/// Take a listing, unless the viewer has already moved on from it.
pub fn folder_arrived(&mut self, uid: NodeUid, entries: Vec<DriveEntry>) {
if self
.trail
.last()
.is_some_and(|(_, current)| *current == uid)
{
self.loading = false;
self.error = None;
self.entries = Some((uid, entries));
}
}
pub fn failed(&mut self, error: String) {
self.loading = false;
self.error = Some(error);
}
fn enter(&mut self, name: String, uid: NodeUid, actions: &mut Vec<Action>) {
self.trail.push((name, uid.clone()));
self.entries = None;
self.loading = true;
self.error = None;
actions.push(Action::BrowseFolder(uid));
}
fn up(&mut self, actions: &mut Vec<Action>) {
self.trail.pop();
self.entries = None;
self.error = None;
match self.trail.last() {
Some((_, uid)) => {
self.loading = true;
actions.push(Action::BrowseFolder(uid.clone()));
}
None => self.loading = self.places.is_none(),
}
}
}
/// The account card: a sign-in form, or who is signed in and how sync is doing.
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui::section(ui, "Proton account");
ui.label(ui::muted(
"Optional. Signed in, your shares, settings and watch history are kept in your own \
Drive, so every device shows the same library and resumes where another left off, \
and folders of your Drive — your files, other devices, what was shared with you — \
can be added to the library.",
));
ui.add_space(theme::space::L);
card(ui, |ui| match panel.status.clone() {
AccountStatus::Unknown => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("reading the stored session…"));
});
}
AccountStatus::SignedOut => sign_in_form(ui, panel, actions),
AccountStatus::SecondFactor => code_form(ui, panel, actions),
AccountStatus::MailboxPassword => mailbox_form(ui, panel, actions),
AccountStatus::SignedIn(username) => signed_in(ui, panel, &username, actions),
});
}
fn card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
egui::Frame::new()
.fill(theme::card())
.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));
add(ui);
});
}
fn sign_in_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Address"));
ui.add(
egui::TextEdit::singleline(&mut panel.username)
.hint_text("you@proton.me")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::M);
ui.label(ui::muted("Password"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.password)
.password(true)
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.username.trim().is_empty() && !panel.password.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Sign in"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
panel.error = None;
actions.push(Action::SignIn {
username: panel.username.trim().to_owned(),
password: panel.password.clone(),
});
}
busy_line(ui, panel, "signing in…");
});
}
fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Two-factor code from your authenticator app"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.code)
.hint_text("123456")
.desired_width(160.0),
);
field.request_focus();
ui.add_space(theme::space::S);
// Security keys need WebAuthn, which the sign-in here does not speak. An
// account with nothing else is refused before this form (`AccountStore`).
ui.label(ui::muted(
"Security keys are not supported here — use the code from your authenticator app.",
));
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.code.trim().is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Continue"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitSecondFactor(panel.code.trim().to_owned()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "checking…");
});
}
fn mailbox_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted(
"This account has a separate mailbox password. It unlocks your files, and is kept \
in your system keyring.",
));
ui.add_space(theme::space::S);
let field = ui.add(
egui::TextEdit::singleline(&mut panel.mailbox)
.password(true)
.hint_text("Mailbox password")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.mailbox.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Unlock"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitMailboxPassword(panel.mailbox.clone()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "unlocking…");
});
}
fn signed_in(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
username: &str,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new(format!(
"{} {username}",
egui_phosphor::regular::USER_CIRCLE
))
.strong(),
);
let status = match (&panel.sync_error, &panel.synced) {
(Some(error), _) => format!("Did not sync: {error}"),
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
(None, None) => {
"Library, settings and watch history sync through your Drive".to_owned()
}
};
ui.label(ui::muted(status));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(format!("{} Sign out", egui_phosphor::regular::SIGN_OUT))
.fill(theme::card_hover()),
)
.clicked()
{
actions.push(Action::SignOut);
}
if ui
.button(format!(
"{} Sync now",
egui_phosphor::regular::ARROWS_CLOCKWISE
))
.clicked()
{
actions.push(Action::SyncNow);
}
});
});
}
/// "Synced 3 min ago · 2 positions from other devices".
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
let mut line = format!("Synced {}", ui::ago(at, now));
if report.applied > 0 {
line.push_str(&format!(
" · {} from other devices",
ui::library::plural(report.applied, "position")
));
}
line
}
fn error_line(ui: &mut egui::Ui, panel: &AccountPanel) {
if let Some(error) = &panel.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
}
fn busy_line(ui: &mut egui::Ui, panel: &AccountPanel, text: &str) {
if panel.busy {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted(text));
}
}
/// Browse the account's Drive and add a folder of it to the library.
pub fn browser(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
shares: &[pstr_core::Share],
actions: &mut Vec<Action>,
) {
if !matches!(panel.status, AccountStatus::SignedIn(_)) {
return;
}
ui.add_space(theme::space::XXL);
ui::section(ui, "Add from your Drive");
let browser = &mut panel.browser;
if !browser.open {
ui.label(ui::muted(
"A folder of your own Drive becomes a share like any link: crawled, browsable and \
streamed through your account.",
));
ui.add_space(theme::space::L);
if ui
.button(format!(
"{} Browse your Drive",
egui_phosphor::regular::FOLDER
))
.clicked()
{
browser.open = true;
if browser.places.is_none() {
browser.loading = true;
actions.push(Action::BrowsePlaces);
}
}
return;
}
card(ui, |ui| {
ui.horizontal(|ui| {
if !browser.trail.is_empty()
&& ui
.button(egui_phosphor::regular::ARROW_LEFT)
.hint("Up a folder")
.clicked()
{
browser.up(actions);
}
let mut crumbs = vec!["Drive".to_owned()];
crumbs.extend(browser.trail.iter().map(|(name, _)| name.clone()));
ui.label(
egui::RichText::new(
crumbs.join(&format!(" {} ", egui_phosphor::regular::CARET_RIGHT)),
)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
browser.open = false;
}
});
});
ui.add_space(theme::space::M);
if let Some(error) = &browser.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
if browser.loading {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("listing…"));
});
return;
}
let mut entered = None;
egui::ScrollArea::vertical()
.max_height(320.0)
.auto_shrink([false, true])
.show(ui, |ui| match browser.trail.last() {
None => {
for place in browser.places.iter().flatten() {
let icon = match place.kind {
PlaceKind::MyFiles => egui_phosphor::regular::HARD_DRIVES,
PlaceKind::Device => egui_phosphor::regular::DESKTOP,
PlaceKind::SharedWithMe => egui_phosphor::regular::USERS,
};
if row(ui, icon, &place.name, None, true).clicked() {
entered = Some((place.name.clone(), place.uid.clone()));
}
}
}
Some(_) => {
let entries = browser
.entries
.as_ref()
.map(|(_, entries)| entries.as_slice());
if entries.is_some_and(<[DriveEntry]>::is_empty) {
ui.label(ui::muted("This folder is empty."));
}
for entry in entries.unwrap_or_default() {
let (icon, detail) = if entry.is_folder {
(egui_phosphor::regular::FOLDER, None)
} else if entry
.media_type
.as_deref()
.is_some_and(|media| media.starts_with("video/"))
{
(
egui_phosphor::regular::FILE_VIDEO,
entry.size.map(format_size),
)
} else {
(egui_phosphor::regular::FILE, entry.size.map(format_size))
};
if row(ui, icon, &entry.name, detail.as_deref(), entry.is_folder).clicked()
&& entry.is_folder
{
entered = Some((entry.name.clone(), entry.uid.clone()));
}
}
}
});
if let Some((name, uid)) = entered {
browser.enter(name, uid, actions);
}
if let Some((name, uid)) = browser.trail.last() {
ui.add_space(theme::space::L);
let added = shares.iter().any(|share| {
share
.folder
.as_ref()
.is_some_and(|folder| folder.uid() == *uid)
});
ui.horizontal(|ui| {
if added {
ui.label(ui::muted("This folder is already in the library."));
} else if ui::accent_button(ui, "Add this folder").clicked() {
actions.push(Action::AddAccountFolder {
name: name.clone(),
uid: uid.clone(),
});
browser.open = false;
}
});
}
});
}
/// One line of the browser. Only folders and places are clickable.
fn row(
ui: &mut egui::Ui,
icon: &str,
name: &str,
detail: Option<&str>,
clickable: bool,
) -> egui::Response {
let mut text = egui::text::LayoutJob::default();
let body = ui.style().text_styles[&egui::TextStyle::Body].clone();
text.append(
&format!("{icon} {name}"),
0.0,
egui::TextFormat::simple(
body.clone(),
if clickable {
theme::text()
} else {
theme::muted()
},
),
);
if let Some(detail) = detail {
text.append(
&format!(" {detail}"),
0.0,
egui::TextFormat::simple(body, theme::muted()),
);
}
let sense = if clickable {
egui::Sense::click()
} else {
egui::Sense::hover()
};
let response = ui.add(egui::Label::new(text).sense(sense).truncate());
if clickable && response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response
}
fn format_size(bytes: i64) -> String {
let bytes = bytes.max(0) as f64;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
if bytes >= GIB {
format!("{:.1} GiB", bytes / GIB)
} else {
format!("{:.0} MiB", bytes / MIB)
}
}
pub(crate) fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sync_line_says_how_long_ago_and_what_came_in() {
let quiet = SyncReport::default();
assert_eq!(sync_line(100, &quiet, 130), "Synced just now");
assert_eq!(sync_line(100, &quiet, 400), "Synced 5 min ago");
let busy = SyncReport {
applied: 2,
..SyncReport::default()
};
assert_eq!(
sync_line(0, &busy, 7200),
"Synced 2 h ago · 2 positions from other devices"
);
}
#[test]
fn signing_out_forgets_what_was_typed_and_browsed() {
let mut panel = AccountPanel {
password: "secret".into(),
mailbox: "also secret".into(),
..AccountPanel::default()
};
panel.browser.open = true;
panel.set_status(AccountStatus::SignedOut);
assert!(panel.password.is_empty() && panel.mailbox.is_empty());
assert!(!panel.browser.open);
}
#[test]
fn a_listing_for_a_folder_already_left_is_dropped() {
let uid = |link: &str| {
NodeUid::new(
pstr_core::proton_sdk::ids::VolumeId::new("v".to_owned()),
pstr_core::proton_sdk::ids::LinkId::new(link.to_owned()),
)
};
let mut browser = DriveBrowser {
trail: vec![("b".into(), uid("b"))],
loading: true,
..DriveBrowser::default()
};
browser.folder_arrived(uid("a"), Vec::new());
assert!(browser.loading && browser.entries.is_none());
browser.folder_arrived(uid("b"), Vec::new());
assert!(!browser.loading && browser.entries.is_some());
}
}
+10 -2
View File
@@ -1,6 +1,6 @@
//! Inspectable desktop download queue. //! Inspectable desktop download queue.
use crate::app::Action; use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadState}; use crate::engine::{DownloadItem, DownloadState};
use crate::{theme, ui}; use crate::{theme, ui};
@@ -222,12 +222,20 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
} }
} }
DownloadState::Completed => { DownloadState::Completed => {
// The point of a download is to watch it, and the library is a
// detour from here — offline most of all.
if ui::accent_button(ui, "Play").clicked() {
actions.push(Action::PlayResuming(item.target.clone()));
}
if ui.button("Open").on_hover_text("Go to its title").clicked() {
actions.push(Action::Goto(Page::Title(item.target.title_key.clone())));
}
if ui if ui
.button("Delete") .button("Delete")
.on_hover_text("Delete the offline copy; keep the online source") .on_hover_text("Delete the offline copy; keep the online source")
.clicked() .clicked()
{ {
actions.push(Action::RemoveDownload(key(), false)); actions.push(Action::DeleteOffline(vec![key()], item.target.caption()));
} }
} }
} }
+2 -1
View File
@@ -10,6 +10,7 @@ use pstr_core::library::{Episode, Library, Title, TitleKind};
use crate::app::{Action, Page}; use crate::app::{Action, Page};
use crate::theme; use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, Art}; use crate::ui::{self, Art};
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0); const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
@@ -161,7 +162,7 @@ fn row(
) )
.frame(false), .frame(false),
) )
.on_hover_text(if episode.is_watched() { .hint(if episode.is_watched() {
"Remove from history — marks it unwatched" "Remove from history — marks it unwatched"
} else { } else {
"Remove from history — forgets where you stopped" "Remove from history — forgets where you stopped"
+209 -59
View File
@@ -1,5 +1,6 @@
//! The library page: what to watch, as a wall of stills. //! The library page: what to watch, as a wall of stills.
use pstr_core::appearance::Tiles;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Filter, LibraryView, Page, Sort}; use crate::app::{Action, Filter, LibraryView, Page, Sort};
@@ -16,6 +17,13 @@ pub struct Shelves<'a> {
/// Whether the catalog has been read yet. /// Whether the catalog has been read yet.
pub loaded: bool, pub loaded: bool,
pub search: &'a str, pub search: &'a str,
/// The grid's shape.
pub tiles: Tiles,
/// What a crawl in progress has found, if one is.
pub crawl: Option<&'a str>,
/// Whether to offer posters and synopses: they are off, and the offer has
/// not been turned down.
pub offer_metadata: bool,
} }
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) { pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
@@ -24,12 +32,15 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
view, view,
loaded, loaded,
search, search,
tiles,
crawl,
offer_metadata,
} = shelves; } = shelves;
if !loaded { if !loaded {
return placeholder(ui); return placeholder(ui);
} }
if library.is_empty() { if library.is_empty() {
return empty_state(ui, actions); return empty_state(ui, crawl, actions);
} }
let pick = |indices: &[usize]| -> Vec<&Title> { let pick = |indices: &[usize]| -> Vec<&Title> {
@@ -44,6 +55,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
if !searching && offer_metadata {
metadata_offer(ui, actions);
ui.add_space(theme::space::XL);
}
if !searching { if !searching {
if let Some(title) = featured(library, view, art.metadata, today()) { if let Some(title) = featured(library, view, art.metadata, today()) {
hero(ui, art, title, actions); hero(ui, art, title, actions);
@@ -55,6 +70,16 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
continue_row(ui, art, &resumable, actions); continue_row(ui, art, &resumable, actions);
ui.add_space(theme::space::XL); 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.horizontal(|ui| { ui.horizontal(|ui| {
@@ -86,7 +111,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
return; return;
} }
grid(ui, art, &matches, actions); grid(ui, art, &matches, view, tiles, actions);
}); });
} }
@@ -121,7 +146,7 @@ fn featured<'a>(
metadata metadata
.get(&title.key) .get(&title.key)
.and_then(|record| record.metadata.as_ref()) .and_then(|record| record.metadata.as_ref())
.is_some_and(|found| found.backdrop_url.is_some()) .is_some_and(|found| found.wide_art().is_some())
}) })
.collect(); .collect();
if pictured.is_empty() { if pictured.is_empty() {
@@ -144,7 +169,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
return; return;
} }
let radius = egui::CornerRadius::same(theme::radius::LG); let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.of(title); let picture = art.wide(title);
let found = art let found = art
.metadata .metadata
.get(&title.key) .get(&title.key)
@@ -252,7 +277,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
ui.add( ui.add(
egui::Label::new( egui::Label::new(
theme::Role::Display theme::Role::Display
.rich(&title.name) .rich(art.name(title))
.strong() .strong()
.color(theme::text()), .color(theme::text()),
) )
@@ -266,24 +291,20 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
} }
} }
/// The filter pills and the order, at the right of the grid's heading. /// 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>) { fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
// Right to left: the order first, so it ends up last. // Right to left: the order first, so it ends up last.
let sort = egui::ComboBox::from_id_salt("library-sort") let sort = egui::ComboBox::from_id_salt("library-sort")
.selected_text(match view.sort { .selected_text(view.sort.label())
Sort::Name => "A – Z",
Sort::Recent => "Recently watched",
Sort::Year => "Newest",
})
.width(150.0) .width(150.0)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
let mut picked = None; let mut picked = None;
for (sort, label) in [ for sort in Sort::ALL {
(Sort::Name, "A – Z"), if ui
(Sort::Recent, "Recently watched"), .selectable_label(view.sort == sort, sort.label())
(Sort::Year, "Newest"), .clicked()
] { {
if ui.selectable_label(view.sort == sort, label).clicked() {
picked = Some(sort); picked = Some(sort);
} }
} }
@@ -292,23 +313,86 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
if let Some(Some(sort)) = sort.inner { if let Some(Some(sort)) = sort.inner {
actions.push(Action::SetShelf(view.filter, sort)); actions.push(Action::SetShelf(view.filter, sort));
} }
ui.add_space(theme::space::S);
let mut grouped = view.grouped;
let toggle = ui.toggle_value(&mut grouped, egui_phosphor::regular::STACK);
toggle.widget_info(|| {
egui::WidgetInfo::selected(
egui::WidgetType::SelectableLabel,
true,
view.grouped,
"Group franchises",
)
});
if toggle
.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); ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented( let filters = Filter::ALL.map(|filter| (filter, filter.label()));
ui, if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) {
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)); actions.push(Action::SetShelf(filter, view.sort));
} }
} }
/// The top shelf: one card per part-watched title, scrolling sideways. /// 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 title in progress, scrolling sideways.
fn continue_row( fn continue_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
@@ -321,33 +405,32 @@ fn continue_row(
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
for title in titles { for title in titles {
let Some(episode) = title.resume() else { let Some(episode) = title.continue_with() else {
continue; continue;
}; };
let remaining = episode let remaining = match episode.resume_at() {
.watch Some(at) => episode
.and_then(|watch| watch.duration_secs) .watch
.map(|duration| { .and_then(|watch| watch.duration_secs)
format!( .map(|duration| format!("{} left", ui::format_time(duration - at)))
"{} left", .unwrap_or_else(|| episode.label()),
ui::format_time( None => format!("Next: {}", episode.label()),
duration - episode.watch.map_or(0.0, |w| w.position_secs) };
)
)
})
.unwrap_or_else(|| episode.label());
let picture = art.of(title);
let response = ui::card( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: picture,
name: &title.name, name: art.name(title),
subtitle: remaining, subtitle: remaining,
progress: episode.progress().map(|value| value as f32), progress: episode.progress().map(|value| value as f32),
badge: episode.numbering(), badge: episode.numbering(),
// A shelf that scrolls sideways has no right edge // 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, width: theme::CARD_WIDTH,
aspect: theme::CARD_ASPECT,
}, },
); );
tile_menu(&response, title, true, actions); tile_menu(&response, title, true, actions);
@@ -361,10 +444,18 @@ fn continue_row(
} }
/// Every title, wrapped to the window. /// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) { fn grid(
let grid = ui::columns(ui.available_width()); ui: &mut egui::Ui,
let row_height = ui::card_height(grid.width); art: &mut Art<'_>,
for row in titles.chunks(grid.columns) { 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 // 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 // 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. // a film plays under the transport bar a frame is every frame.
@@ -385,18 +476,32 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
// the difference times the column count is a visible drift towards // the difference times the column count is a visible drift towards
// the left edge. // the left edge.
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in row { 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( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: picture,
name: &title.name, name: art.name(title),
subtitle: subtitle(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), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
// What a film is says itself in the subtitle below the // What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise. // card; a second `Film` over the poster is noise. A
badge: None, // folded franchise says how much is under it.
badge: folded.map(|more| format!("+{more}")),
width: grid.width, width: grid.width,
aspect,
}, },
); );
tile_menu(&response, title, false, actions); tile_menu(&response, title, false, actions);
@@ -451,7 +556,7 @@ fn tile_menu(
}); });
} }
if continuing if continuing
&& let Some(episode) = title.resume() && let Some(episode) = title.continue_with()
&& ui && ui
.button("Remove from Continue watching") .button("Remove from Continue watching")
.on_hover_text("Forget where you stopped in it") .on_hover_text("Forget where you stopped in it")
@@ -517,7 +622,7 @@ fn subtitle(title: &Title) -> String {
/// fraction is a flash of something false. /// fraction is a flash of something false.
fn placeholder(ui: &mut egui::Ui) { fn placeholder(ui: &mut egui::Ui) {
let grid = ui::columns(ui.available_width()); let grid = ui::columns(ui.available_width());
let height = ui::card_height(grid.width); let height = ui::card_height(grid.width, theme::CARD_ASPECT);
ui::section(ui, "Library"); ui::section(ui, "Library");
for _ in 0..2 { for _ in 0..2 {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -540,9 +645,54 @@ fn placeholder(ui: &mut egui::Ui) {
} }
} }
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) { /// Posters and synopses, offered once there is a library to put them on and
/// never turned on unasked: switching them on sends title names out.
fn metadata_offer(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::symmetric(14, 12))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new("Add posters and synopses?").strong());
ui.label(ui::muted(
"Turning it on in Settings sends the titles in your library — not your \
files, and nothing about what you have watched — to AniList or TMDB.",
));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Not now").clicked() {
actions.push(Action::DismissMetadataOffer);
}
if ui::accent_button(ui, "Open settings").clicked() {
actions.push(Action::Goto(Page::Settings));
}
});
});
});
}
fn empty_state(ui: &mut egui::Ui, crawl: Option<&str>, actions: &mut Vec<Action>) {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.add_space(120.0); ui.add_space(120.0);
// A share just added: say it is being looked through, rather than
// offer to add the one that is already filling the page.
if let Some(crawl) = crawl {
ui.add(egui::Spinner::new().size(28.0));
ui.add_space(theme::space::L);
ui.label(
theme::Role::Title
.rich("Looking through the share")
.strong(),
);
ui.add_space(theme::space::S);
ui.label(ui::muted(format!(
"Its titles appear here once it has been listed ({crawl})."
)));
return;
}
ui.label( ui.label(
theme::Role::Title theme::Role::Title
.rich("Nothing in the library yet") .rich("Nothing in the library yet")
@@ -554,7 +704,7 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
)); ));
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
if ui::accent_button(ui, "Add a share").clicked() { if ui::accent_button(ui, "Add a share").clicked() {
actions.push(Action::Goto(Page::Shares)); actions.push(Action::OpenShareForm);
} }
}); });
} }
+19 -2
View File
@@ -130,12 +130,29 @@ fn current_row(
))); )));
// Only worth offering when there is something to undo: with no // Only worth offering when there is something to undo: with no
// row stored, "clear" would delete nothing. // row stored, "clear" would delete nothing.
if ui //
// A match chosen by hand is asked about first: it is work the
// next match run does not redo.
let asking = ui.id().with(("forget", &matcher.title_key));
if ui.data(|data| data.get_temp::<bool>(asking).unwrap_or(false)) {
ui.label(ui::muted("Forget the match you chose?"));
if ui::danger_button(ui, "Forget").clicked() {
ui.data_mut(|data| data.remove::<bool>(asking));
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
if ui.button("Keep").clicked() {
ui.data_mut(|data| data.remove::<bool>(asking));
}
} else if ui
.button("Clear") .button("Clear")
.on_hover_text("Forget this match and let the next match run decide again") .on_hover_text("Forget this match and let the next match run decide again")
.clicked() .clicked()
{ {
actions.push(Action::ForgetMatch(matcher.title_key.clone())); if *manual {
ui.data_mut(|data| data.insert_temp(asking, true));
} else {
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
} }
} }
Some((None, _)) => { Some((None, _)) => {
+137 -13
View File
@@ -3,6 +3,7 @@
//! mutating the library it is iterating: pages collect [`Action`]s and the app //! mutating the library it is iterating: pages collect [`Action`]s and the app
//! applies them after the frame is drawn. //! applies them after the frame is drawn.
pub mod account;
pub mod downloads; pub mod downloads;
pub mod history; pub mod history;
pub mod library; pub mod library;
@@ -20,10 +21,11 @@ use std::collections::HashMap;
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title}; 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; use crate::theme;
use widgets::Named;
/// Everything the pages need to draw one title's picture. /// Everything the pages need to draw one title's picture.
/// ///
@@ -40,8 +42,12 @@ pub struct Art<'a> {
pub metadata: &'a HashMap<String, MetadataRecord>, pub metadata: &'a HashMap<String, MetadataRecord>,
/// What they said about the episodes under those titles. /// What they said about the episodes under those titles.
pub episodes: &'a HashMap<String, EpisodeGuide>, 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<'_> { impl Art<'_> {
/// What the provider says about one file of a title, matched on the /// What the provider says about one file of a title, matched on the
/// numbering its name states. /// numbering its name states.
@@ -74,6 +80,58 @@ impl Art<'_> {
.texture(&thumbnail_key(node), || engine.request_thumbnail(node)) .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. /// The picture for a title, and how to fit it.
/// ///
/// Provider artwork first, then Proton's still, then nothing. That order is /// Provider artwork first, then Proton's still, then nothing. That order is
@@ -104,6 +162,30 @@ impl Art<'_> {
} }
} }
/// 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}")
}
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
/// the Unix time `at` was.
pub fn ago(at: i64, now: i64) -> String {
let ago = (now - at).max(0);
match ago {
0..60 => "just now".to_owned(),
60..3600 => format!("{} min ago", ago / 60),
3600..86_400 => format!("{} h ago", ago / 3600),
86_400..172_800 => "1 day ago".to_owned(),
_ => format!("{} days ago", ago / 86_400),
}
}
/// `1:03:47`, or `4:12` for anything under an hour. /// `1:03:47`, or `4:12` for anything under an hour.
pub fn format_time(seconds: f64) -> String { pub fn format_time(seconds: f64) -> String {
if !seconds.is_finite() || seconds < 0.0 { if !seconds.is_finite() || seconds < 0.0 {
@@ -243,7 +325,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
); );
if response if response
.on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Clear (Esc)") .hint("Clear (Esc)")
.clicked() .clicked()
{ {
search.clear(); search.clear();
@@ -306,6 +388,19 @@ pub enum Answer {
/// inside the app — stored secrets, downloaded bytes. Everything else acts on /// inside the app — stored secrets, downloaded bytes. Everything else acts on
/// the click. /// the click.
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer { pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
confirm_with(ctx, id, heading, body, verb, true)
}
/// [`confirm`], for a question whose yes loses nothing — a large download —
/// and so is the accent rather than the danger colour.
pub fn confirm_with(
ctx: &egui::Context,
id: &str,
heading: &str,
body: &str,
verb: &str,
destroys: bool,
) -> Answer {
let mut answer = Answer::Pending; let mut answer = Answer::Pending;
let modal = egui::Modal::new(egui::Id::new(("confirm", id))) let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
.frame( .frame(
@@ -326,7 +421,12 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
ui.label(theme::Role::Body.rich(body).color(theme::muted())); ui.label(theme::Role::Body.rich(body).color(theme::muted()));
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if danger_button(ui, verb).clicked() { let yes = if destroys {
danger_button(ui, verb)
} else {
accent_button(ui, verb)
};
if yes.clicked() {
answer = Answer::Confirmed; answer = Answer::Confirmed;
} }
if ui.button("Cancel").clicked() { if ui.button("Cancel").clicked() {
@@ -430,6 +530,7 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
); );
let (rect, response) = ui.allocate_exact_size(size, Sense::click()); let (rect, response) = ui.allocate_exact_size(size, Sense::click());
let response = response.named(text);
if !ui.is_rect_visible(rect) { if !ui.is_rect_visible(rect) {
return response; return response;
} }
@@ -492,6 +593,9 @@ pub struct Card<'a> {
/// sideways-scrolling shelf has no edge to reach and passes /// sideways-scrolling shelf has no edge to reach and passes
/// [`theme::CARD_WIDTH`]. /// [`theme::CARD_WIDTH`].
pub width: f32, 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 /// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
@@ -499,10 +603,10 @@ pub struct Card<'a> {
/// different height by title length reads as broken. /// different height by title length reads as broken.
const CARD_TEXT_HEIGHT: f32 = 54.0; const CARD_TEXT_HEIGHT: f32 = 54.0;
/// How tall [`card`] draws a tile of this width, so a grid can step over rows /// How tall [`card`] draws a tile of this width and shape, so a grid can step
/// it does not draw. /// over rows it does not draw.
pub fn card_height(width: f32) -> f32 { pub fn card_height(width: f32, aspect: f32) -> f32 {
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT (width * aspect).round() + CARD_TEXT_HEIGHT
} }
/// Draw one tile and report whether it was clicked. /// Draw one tile and report whether it was clicked.
@@ -515,12 +619,20 @@ pub fn card_height(width: f32) -> f32 {
/// reads as deliberate in a way a stretched poster does not. /// reads as deliberate in a way a stretched poster does not.
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = card.width; let width = card.width;
let image_height = (width * theme::CARD_ASPECT).round(); 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 text_height = CARD_TEXT_HEIGHT;
let radius = CornerRadius::same(theme::radius::MD); let radius = CornerRadius::same(theme::radius::MD);
let (rect, response) = let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click()); ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
let response = if card.subtitle.is_empty() {
response.named(card.name)
} else {
response.named(format!("{}, {}", card.name, card.subtitle))
};
// Reached with the arrow keys or Tab: bring it into view, before the // 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. // early return below would skip a tile that is just off screen.
if response.gained_focus() { if response.gained_focus() {
@@ -574,7 +686,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
.with_texture(texture.id(), cover_uv(size, image_rect.size())), .with_texture(texture.id(), cover_uv(size, image_rect.size())),
); );
} }
Some((texture, ArtShape::Portrait)) => { Some((texture, ArtShape::Portrait)) if fit => {
painter.add( painter.add(
egui::epaint::RectShape::filled( egui::epaint::RectShape::filled(
contain_rect(texture.size_vec2(), image_rect), contain_rect(texture.size_vec2(), image_rect),
@@ -587,6 +699,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 => {} None => {}
} }
@@ -777,9 +897,13 @@ pub struct Grid {
/// dead space at the right of every row, and on a wide window that reads as a /// 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. /// layout that failed to line up rather than as a margin.
pub fn columns(available: f32) -> Grid { pub fn columns(available: f32) -> Grid {
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() columns_of(available, theme::CARD_WIDTH)
as usize) }
.max(1);
/// [`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 // 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 // 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 // onto a row of its own. A window too narrow for even one full card gets a
+90 -19
View File
@@ -20,6 +20,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
use crate::app::{Action, Adjacent}; use crate::app::{Action, Adjacent};
use crate::playback::{Command, Playback}; use crate::playback::{Command, Playback};
use crate::theme; use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, transport::Neighbours}; use crate::ui::{self, transport::Neighbours};
/// How long the controls stay up after the pointer stops moving. /// How long the controls stay up after the pointer stops moving.
@@ -139,6 +140,9 @@ pub struct UpNextCard {
pub caption: String, pub caption: String,
/// Its still, once there is one. /// Its still, once there is one.
pub still: Option<egui::TextureHandle>, pub still: Option<egui::TextureHandle>,
/// Counting down the end of a file with no credits chapter, where the
/// second button cancels the next episode rather than deferring it.
pub tail: bool,
} }
impl Overlay { impl Overlay {
@@ -185,16 +189,28 @@ impl Overlay {
} }
} }
/// What the player page shows while a file opens and there is no picture yet.
pub struct Waiting<'a> {
/// The episode being opened.
pub name: &'a str,
/// The title it belongs to, as the library names it.
pub title: Option<String>,
/// The title's widest art, dimmed behind the words.
pub art: Option<egui::TextureHandle>,
/// Whether one episode is following another, rather than a first click.
pub next: bool,
}
/// Draw the picture and its controls. /// Draw the picture and its controls.
/// ///
/// `playback` is `None` in the gap between one episode ending and the next /// `playback` is `None` until the file has opened — on a first click, and in
/// having opened — the page stays, because that is where the viewer means to /// the gap between one episode ending and the next — and `opening` is what the
/// be, and `opening` is what it says while they wait. /// page says meanwhile.
pub fn show( pub fn show(
ui: &mut egui::Ui, ui: &mut egui::Ui,
frame: &mut eframe::Frame, frame: &mut eframe::Frame,
playback: Option<&mut Playback>, playback: Option<&mut Playback>,
opening: Option<&str>, opening: Option<Waiting<'_>>,
chrome_state: Chrome<'_>, chrome_state: Chrome<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
@@ -372,26 +388,60 @@ fn stalled(ui: &mut egui::Ui, rect: Rect) {
); );
} }
/// The screen between two episodes: one has ended and the next is opening. /// The screen while a file opens: after a click, or between one episode and
/// the next.
/// ///
/// It keeps a way out, because opening a file over a public link can take /// It keeps a way out, because opening a file over a public link can take
/// seconds and a black screen with no controls is indistinguishable from a /// seconds and a black screen with no controls is indistinguishable from a
/// hang. /// hang.
fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut Vec<Action>) { fn between(ui: &mut egui::Ui, waiting: Option<Waiting<'_>>, rect: Rect, actions: &mut Vec<Action>) {
let Waiting {
name,
title,
art,
next,
} = waiting.unwrap_or(Waiting {
name: "opening…",
title: None,
art: None,
next: true,
});
if let Some(art) = &art {
// Cover rather than fit: bars around a dimmed backdrop look like a
// mistake, and its edges are not what anyone is looking at.
let size = art.size_vec2();
let scale = (rect.width() / size.x).max(rect.height() / size.y);
let shown = size * scale;
let uv = Rect::from_center_size(
egui::pos2(0.5, 0.5),
Vec2::new(rect.width() / shown.x, rect.height() / shown.y),
);
ui.painter()
.image(art.id(), rect, uv, Color32::from_white_alpha(70));
}
ui.painter().text( ui.painter().text(
rect.center() - Vec2::new(0.0, 16.0), rect.center() - Vec2::new(0.0, 16.0),
Align2::CENTER_CENTER, Align2::CENTER_CENTER,
"Up next", match (&title, next) {
(_, true) => "Up next",
(Some(title), false) => title.as_str(),
(None, false) => "Opening",
},
theme::Role::Body.font(), theme::Role::Body.font(),
theme::muted(), theme::muted(),
); );
ui.painter().text( ui.painter().text(
rect.center() + Vec2::new(0.0, 12.0), rect.center() + Vec2::new(0.0, 12.0),
Align2::CENTER_CENTER, Align2::CENTER_CENTER,
opening.unwrap_or("opening…"), name,
theme::Role::Title.font(), theme::Role::Title.font(),
theme::text(), theme::text(),
); );
let spinner = Rect::from_center_size(rect.center() + Vec2::new(0.0, 56.0), Vec2::splat(24.0));
ui.put(
spinner,
egui::Spinner::new().size(24.0).color(Color32::WHITE),
);
ui.scope_builder( ui.scope_builder(
egui::UiBuilder::new() egui::UiBuilder::new()
@@ -404,8 +454,6 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
if ui.button("Back").clicked() { if ui.button("Back").clicked() {
actions.push(Action::LeavePlayer); actions.push(Action::LeavePlayer);
} }
ui.add_space(theme::space::M);
ui.add(egui::Spinner::new().size(14.0));
}, },
); );
} }
@@ -539,7 +587,7 @@ fn up_next_card(
theme::Role::Body.font(), theme::Role::Body.font(),
Color32::WHITE, Color32::WHITE,
); );
if response.on_hover_text("Play now").clicked() { if response.hint("Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next)); actions.push(Action::PlayAdjacent(Adjacent::Next));
} }
@@ -566,11 +614,15 @@ fn up_next_card(
actions.push(Action::PlayAdjacent(Adjacent::Next)); actions.push(Action::PlayAdjacent(Adjacent::Next));
} }
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
if ui let (label, hint) = if card.tail {
.button("Watch till the end") ("Cancel", "Stay here — the next episode will not start")
.on_hover_text("Play this one out — nothing will be skipped") } else {
.clicked() (
{ "Watch till the end",
"Play this one out — nothing will be skipped",
)
};
if ui.button(label).on_hover_text(hint).clicked() {
actions.push(Action::WatchToEnd); actions.push(Action::WatchToEnd);
} }
}); });
@@ -695,7 +747,7 @@ fn chrome(
.corner_radius(CornerRadius::same(theme::radius::MD)) .corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)), .min_size(Vec2::new(38.0, 32.0)),
) )
.on_hover_text(if fullscreen { .hint(if fullscreen {
"Leave fullscreen (F)" "Leave fullscreen (F)"
} else { } else {
"Fullscreen (F)" "Fullscreen (F)"
@@ -727,12 +779,31 @@ fn chrome(
.corner_radius(CornerRadius::same(theme::radius::MD)) .corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)), .min_size(Vec2::new(38.0, 32.0)),
) )
.on_hover_text("Episodes (E)") .hint("Episodes (E)")
.clicked() .clicked()
{ {
actions.push(Action::ToggleEpisodes); actions.push(Action::ToggleEpisodes);
} }
} }
// Esc leaves the page and keeps playing; this is the one-step
// way to be done with the episode.
ui.add_space(theme::space::S);
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::STOP)
.color(Color32::WHITE),
)
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.hint("Stop")
.clicked()
{
actions.push(Action::Player(Command::Stop));
}
}); });
}, },
); );
@@ -834,7 +905,7 @@ fn episode_drawer(
) )
.frame(false), .frame(false),
) )
.on_hover_text("Close (E)") .hint("Close (E)")
.clicked() .clicked()
{ {
actions.push(Action::ToggleEpisodes); actions.push(Action::ToggleEpisodes);
+157 -14
View File
@@ -5,13 +5,16 @@
//! that manages Proton Drive links, and a page that is half link management and //! that manages Proton Drive links, and a page that is half link management and
//! half preferences is two pages. //! half preferences is two pages.
use pstr_core::appearance::{Accent, Appearance, Flavor}; use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId}; use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
use pstr_core::prefs::PlaybackPrefs;
use pstr_player::LANGUAGES;
use crate::app::Action; use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs}; use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs, TEXT_SIZE_CHOICES};
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
use crate::ui::widgets::Named;
/// The settings that are a single value rather than a form. /// The settings that are a single value rather than a form.
/// ///
@@ -19,9 +22,11 @@ use crate::ui;
/// they are read-only, and passing them one by one is what pushed this /// they are read-only, and passing them one by one is what pushed this
/// function past the argument count anybody can read. /// function past the argument count anybody can read.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct Prefs { pub struct Prefs<'a> {
pub autoplay: bool, pub autoplay: bool,
pub auto_skip: bool, pub auto_skip: bool,
/// The languages, read from here.
pub playback: &'a PlaybackPrefs,
pub appearance: Appearance, pub appearance: Appearance,
pub desktop: DesktopPrefs, pub desktop: DesktopPrefs,
/// What the streaming cache holds, once the shares are open. /// What the streaming cache holds, once the shares are open.
@@ -35,7 +40,7 @@ const COLUMN: f32 = 760.0;
pub fn show( pub fn show(
ui: &mut egui::Ui, ui: &mut egui::Ui,
settings: &mut MetadataConfig, settings: &mut MetadataConfig,
prefs: Prefs, prefs: Prefs<'_>,
api_key: &mut String, api_key: &mut String,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
@@ -51,7 +56,7 @@ pub fn show(
ui.vertical(|ui| { ui.vertical(|ui| {
ui.set_width(width); ui.set_width(width);
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
appearance_form(ui, prefs.appearance, actions); appearance_form(ui, prefs.appearance, prefs.desktop, actions);
playback_form(ui, prefs, actions); playback_form(ui, prefs, actions);
storage_form(ui, prefs, actions); storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions); metadata_form(ui, settings, api_key, actions);
@@ -66,7 +71,12 @@ pub fn show(
/// Every control here repaints the whole window the moment it is clicked — /// Every control here repaints the whole window the moment it is clicked —
/// there is no "apply" — because the preview *is* the app. A swatch row that /// 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. /// 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>) { fn appearance_form(
ui: &mut egui::Ui,
appearance: Appearance,
desktop: DesktopPrefs,
actions: &mut Vec<Action>,
) {
ui::widgets::settings_group(ui, "Appearance", None, |ui| { ui::widgets::settings_group(ui, "Appearance", None, |ui| {
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
ui.label(theme::Role::Body.rich("Palette").color(theme::text())); ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
@@ -75,6 +85,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL { for flavor in Flavor::ALL {
if palette_card(ui, appearance, flavor) if palette_card(ui, appearance, flavor)
.named(flavor.label())
.on_hover_text(flavor.description()) .on_hover_text(flavor.description())
.clicked() .clicked()
&& appearance.flavor != flavor && appearance.flavor != flavor
@@ -102,6 +113,62 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
} }
}); });
ui::widgets::settings_row(
ui,
"Text size",
Some("Everything grows with it. Kept on this computer only."),
|ui| {
let choices: Vec<(u16, String)> = TEXT_SIZE_CHOICES
.iter()
.map(|&percent| (percent, format!("{percent}%")))
.collect();
let labels: Vec<(u16, &str)> = choices
.iter()
.map(|(percent, label)| (*percent, label.as_str()))
.collect();
if let Some(text_size) = ui::widgets::segmented(ui, desktop.text_size, &labels) {
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
text_size,
..desktop
}));
}
},
);
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; let mut gradients = appearance.gradients;
ui::widgets::settings_row( ui::widgets::settings_row(
ui, ui,
@@ -230,22 +297,24 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
} }
} }
response response
.on_hover_text(accent.label()) .hint(accent.label())
.on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_cursor(egui::CursorIcon::PointingHand)
} }
/// The playback settings that are not per-file. /// The playback settings that are not per-file.
/// ///
/// Volume, audio language, subtitle language and speed are all set from the /// Volume and speed are set from the player itself, where a viewer can hear
/// player itself, where a viewer can hear the result. /// the result. The languages are here as well as there: what a first episode
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) { /// opens with is decided before there is a player to choose from.
fn playback_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
ui::widgets::settings_group( ui::widgets::settings_group(
ui, ui,
"Playback", "Playback",
Some( Some(
"Volume, audio, subtitles and speed are set from the player, and remembered from there.", "Volume and speed are set from the player. A language chosen in the player for one show is kept for that show.",
), ),
|ui| { |ui| {
languages(ui, prefs.playback, actions);
let mut autoplay = prefs.autoplay; let mut autoplay = prefs.autoplay;
ui::widgets::settings_row( ui::widgets::settings_row(
ui, ui,
@@ -287,8 +356,80 @@ fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
); );
} }
/// The audio and subtitle languages every file opens with, until a show has a
/// choice of its own.
fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Action>) {
let name = |tag: &Option<String>| match tag {
Some(tag) => pstr_player::language_name(tag),
None => "The file's default".to_owned(),
};
let audio = playback.audio_language.clone();
ui::widgets::settings_row(
ui,
"Audio language",
Some("Played when a file has it."),
|ui| {
egui::ComboBox::from_id_salt("audio-language")
.selected_text(name(&audio))
.show_ui(ui, |ui| {
for choice in
std::iter::once(None).chain(LANGUAGES.map(|tag| Some(tag.to_owned())))
{
if ui
.selectable_label(audio == choice, name(&choice))
.clicked()
&& audio != choice
{
actions.push(Action::SetLanguages {
audio: choice,
subtitles: playback.subtitles,
subtitle: playback.subtitle_language.clone(),
});
}
}
});
},
);
// Off, the file's default, or a language: "off" is a decision of its
// own, which no language expresses.
let subtitle = playback
.subtitles
.then(|| playback.subtitle_language.clone());
let subtitle_name = |choice: &Option<Option<String>>| match choice {
None => "Off".to_owned(),
Some(tag) => name(tag),
};
ui::widgets::settings_row(
ui,
"Subtitles",
Some("Shown when a file has them in this language."),
|ui| {
egui::ComboBox::from_id_salt("subtitle-language")
.selected_text(subtitle_name(&subtitle))
.show_ui(ui, |ui| {
let choices = [None, Some(None)]
.into_iter()
.chain(LANGUAGES.map(|tag| Some(Some(tag.to_owned()))));
for choice in choices {
if ui
.selectable_label(subtitle == choice, subtitle_name(&choice))
.clicked()
&& subtitle != choice
{
actions.push(Action::SetLanguages {
audio: playback.audio_language.clone(),
subtitles: choice.is_some(),
subtitle: choice.flatten(),
});
}
}
});
},
);
}
/// The streaming cache: how big it may get, and a way to empty it. /// 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>) { fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
ui::widgets::settings_group( ui::widgets::settings_group(
ui, ui,
"Storage", "Storage",
@@ -369,7 +510,9 @@ fn metadata_form(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \ "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 \ 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 \ 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.", 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); ui.add_space(theme::space::L);
+189 -29
View File
@@ -6,7 +6,10 @@
//! below is the only place a link is ever visible, and only while it is being //! below is the only place a link is ever visible, and only while it is being
//! typed. //! typed.
use std::collections::HashMap;
use pstr_core::Share; use pstr_core::Share;
use pstr_core::catalog::CrawlRecord;
use pstr_core::library::Library; use pstr_core::library::Library;
use crate::app::{Action, ShareForm}; use crate::app::{Action, ShareForm};
@@ -16,8 +19,10 @@ use crate::ui;
pub fn show( pub fn show(
ui: &mut egui::Ui, ui: &mut egui::Ui,
shares: &[Share], shares: &[Share],
crawls: &HashMap<String, CrawlRecord>,
library: &Library, library: &Library,
form: &mut ShareForm, form: &mut ShareForm,
account: &mut ui::account::AccountPanel,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
@@ -35,15 +40,26 @@ pub fn show(
.iter() .iter()
.filter(|title| title.share_ids.contains(&share.id)) .filter(|title| title.share_ids.contains(&share.id))
.count(); .count();
share_row(ui, share, titles, actions); share_row(ui, share, titles, crawls.get(&share.id), form, actions);
} }
ui.add_space(theme::space::XXL); ui.add_space(theme::space::XXL);
add_form(ui, shares, form, actions); add_form(ui, shares, form, actions);
ui.add_space(theme::space::XXL);
ui::account::section(ui, account, actions);
ui::account::browser(ui, account, shares, actions);
}); });
} }
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) { fn share_row(
ui: &mut egui::Ui,
share: &Share,
titles: usize,
crawl: Option<&CrawlRecord>,
form: &mut ShareForm,
actions: &mut Vec<Action>,
) {
egui::Frame::new() egui::Frame::new()
.fill(theme::card()) .fill(theme::card())
.corner_radius(egui::CornerRadius::same(theme::radius::MD)) .corner_radius(egui::CornerRadius::same(theme::radius::MD))
@@ -56,10 +72,29 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
// What is in it, rather than what it is called inside the // What is in it, rather than what it is called inside the
// app. The id is still a hover away for a bug report. // app. The id is still a hover away for a bug report.
let mut detail = ui::library::plural(titles, "title"); let mut detail = ui::library::plural(titles, "title");
if share.folder.is_some() {
detail.push_str(" · from your Drive");
}
if share.has_custom_password { if share.has_custom_password {
detail.push_str(" · password protected"); detail.push_str(" · password protected");
} }
match crawl {
Some(crawl) if crawl.crawled_at > 0 => {
detail.push_str(" · crawled ");
detail.push_str(&ui::ago(crawl.crawled_at, crate::engine::unix_now()));
}
_ => detail.push_str(" · not crawled yet"),
}
ui.label(ui::muted(detail)).on_hover_text(&share.id); ui.label(ui::muted(detail)).on_hover_text(&share.id);
// Why the last look failed, until one succeeds: a link
// revoked upstream otherwise just stops growing.
if let Some(error) = crawl.and_then(|crawl| crawl.error.as_deref()) {
ui.label(
theme::Role::Caption
.rich(format!("Last crawl failed: {error}"))
.color(theme::danger()),
);
}
}); });
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -73,12 +108,121 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
if ui.button("Crawl").clicked() { if ui.button("Crawl").clicked() {
actions.push(Action::Crawl(Some(share.id.clone()))); actions.push(Action::Crawl(Some(share.id.clone())));
} }
if ui.button("Rename").clicked() {
form.renaming = Some(share.id.clone());
form.rename_to = share.name.clone();
}
// Not for a folder of the account, which has no link.
if share.folder.is_none()
&& ui
.button("Re-enter link")
.on_hover_text(
"For a keyring that lost the link, or a password that changed. \
The library, history and downloads stay.",
)
.clicked()
{
form.repairing = Some(share.id.clone());
form.repair_url.clear();
form.repair_password.clear();
}
}); });
}); });
if form.repairing.as_deref() == Some(share.id.as_str()) {
repair_form(ui, share, form, actions);
}
if form.renaming.as_deref() == Some(share.id.as_str()) {
rename_form(ui, share, form, actions);
}
}); });
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
} }
/// A new name for a share, in its own row. Enter saves, as everywhere else.
fn rename_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
ui.add_space(theme::space::M);
ui.horizontal(|ui| {
let field = ui.add(
egui::TextEdit::singleline(&mut form.rename_to)
.hint_text("Name")
.desired_width(320.0),
);
let ready = !form.rename_to.trim().is_empty();
let entered = field.lost_focus() && ui_enter_pressed(ui.ctx());
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
.inner
.clicked()
|| (ready && entered)
{
actions.push(Action::RenameShare {
id: share.id.clone(),
name: form.rename_to.trim().to_owned(),
});
}
if ui.button("Cancel").clicked() {
form.renaming = None;
}
});
}
/// The same link again, and a password if it has one, for a share already in
/// the library.
fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
ui.add_space(theme::space::M);
ui.add(
egui::TextEdit::singleline(&mut form.repair_url)
.password(true)
.hint_text("The share link, including everything after the #")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::S);
ui.add(
egui::TextEdit::singleline(&mut form.repair_password)
.password(true)
.hint_text("Link password, if it has one")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::S);
let problem = (!form.repair_url.trim().is_empty())
.then(|| pstr_core::shares::share_token(form.repair_url.trim()).err())
.flatten();
if let Some(problem) = &problem {
ui.label(
theme::Role::Caption
.rich(problem.to_string())
.color(theme::danger()),
);
}
ui.horizontal(|ui| {
let ready = !form.repair_url.trim().is_empty() && problem.is_none();
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
.inner
.clicked()
{
actions.push(Action::RepairShare {
id: share.id.clone(),
url: form.repair_url.trim().to_owned(),
password: Some(form.repair_password.clone())
.filter(|password| !password.is_empty()),
});
}
if ui.button("Cancel").clicked() {
form.repairing = None;
}
});
}
/// The add form's link field, which the library's empty state focuses.
pub fn link_field_id() -> egui::Id {
egui::Id::new("share-link")
}
fn ui_enter_pressed(ctx: &egui::Context) -> bool {
ctx.input(|input| input.key_pressed(egui::Key::Enter))
}
fn add_form(ui: &mut egui::Ui, shares: &[Share], 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::section(ui, "Add a share");
ui.label(ui::muted( ui.label(ui::muted(
@@ -94,24 +238,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0)); ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Name")); // Enter in any field of the form submits it, as the sign-in
ui.add( // form's do.
egui::TextEdit::singleline(&mut form.name) let mut entered = false;
.hint_text("Anime") let mut submitted_from = |response: egui::Response| {
.desired_width(f32::INFINITY), entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
); };
ui.add_space(theme::space::M);
ui.label(ui::muted("Link")); ui.label(ui::muted("Link"));
ui.horizontal(|ui| { ui.horizontal(|ui| {
let paste = 60.0; let paste = 60.0;
let field = egui::Id::new("share-link"); let field = link_field_id();
ui.add( if std::mem::take(&mut form.focus) {
egui::TextEdit::singleline(&mut form.url) ui.memory_mut(|memory| memory.request_focus(field));
.id(field) }
.password(true) submitted_from(
.hint_text("https://drive.proton.me/urls/…#…") ui.add(
.desired_width(ui.available_width() - paste), 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 — // The link is masked, so pasting is the only sensible way in —
// and a button says that more plainly than the field does. // and a button says that more plainly than the field does.
@@ -136,20 +284,31 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
} }
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
ui.label(ui::muted("Name (optional)"));
submitted_from(
ui.add(
egui::TextEdit::singleline(&mut form.name)
.hint_text("The shared folder's own name")
.desired_width(f32::INFINITY),
),
);
ui.add_space(theme::space::M);
ui::widgets::toggle(ui, &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 { if form.has_password {
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
ui.add( submitted_from(
egui::TextEdit::singleline(&mut form.password) ui.add(
.password(true) egui::TextEdit::singleline(&mut form.password)
.hint_text("Link password") .password(true)
.desired_width(f32::INFINITY), .hint_text("Link password")
.desired_width(f32::INFINITY),
),
); );
} }
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
let ready = !form.name.trim().is_empty() let ready = !form.url.trim().is_empty()
&& !form.url.trim().is_empty()
&& problem.is_none() && problem.is_none()
&& (!form.has_password || !form.password.is_empty()) && (!form.has_password || !form.password.is_empty())
&& !form.sending; && !form.sending;
@@ -159,11 +318,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
} }
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl")) .add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
.inner .inner
.clicked() .clicked();
{ if clicked || (ready && entered) {
actions.push(Action::AddShare { actions.push(Action::AddShare {
name: form.name.trim().to_string(), name: form.name.trim().to_string(),
url: form.url.trim().to_string(), url: form.url.trim().to_string(),
@@ -176,8 +335,8 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
if form.sending { if form.sending {
ui.add(egui::Spinner::new().size(14.0)); ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("adding…")); ui.label(ui::muted("adding…"));
} else if form.name.trim().is_empty() || form.url.trim().is_empty() { } else if form.url.trim().is_empty() {
ui.label(ui::muted("A name and a link are needed.")); ui.label(ui::muted("Paste a share link to add it."));
} }
}); });
}); });
@@ -211,6 +370,7 @@ mod tests {
name: "anime".into(), name: "anime".into(),
token: token.into(), token: token.into(),
has_custom_password: false, has_custom_password: false,
folder: None,
} }
} }
+184 -30
View File
@@ -8,6 +8,7 @@ use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadKey, DownloadState}; use crate::engine::{DownloadItem, DownloadKey, DownloadState};
use crate::playback::PlaybackTarget; use crate::playback::PlaybackTarget;
use crate::theme; use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, Art, Card}; use crate::ui::{self, Art, Card};
pub struct OfflineView<'a> { pub struct OfflineView<'a> {
@@ -20,6 +21,7 @@ pub fn show(
art: &mut Art<'_>, art: &mut Art<'_>,
library: &Library, library: &Library,
key: &str, key: &str,
franchise: Option<&[String]>,
offline: OfflineView<'_>, offline: OfflineView<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
@@ -41,6 +43,15 @@ pub fn show(
header(ui, art, title, &offline, actions); header(ui, art, title, &offline, actions);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
if let Some(keys) = franchise {
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
if members.len() > 1 {
ui::section(ui, "In this franchise");
franchise_row(ui, art, title, &members, actions);
ui.add_space(theme::space::XL);
}
}
// One season at a time, picked from a row of pills, rather than // One season at a time, picked from a row of pills, rather than
// every season stacked as a collapsing header: a four-season show // every season stacked as a collapsing header: a four-season show
// was a page of headers to scroll past to reach the one being // was a page of headers to scroll past to reach the one being
@@ -86,6 +97,7 @@ pub fn show(
))); )));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
season_download(ui, title, season, &offline, actions); season_download(ui, title, season, &offline, actions);
season_watched(ui, title, season, actions);
}); });
}); });
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
@@ -100,6 +112,60 @@ pub fn show(
}); });
} }
/// Every title of the franchise, in release order, as covers — this one
/// marked rather than left out, so the row says where it sits in the story.
fn franchise_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
members: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt(("franchise", &current.key))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = member.key == current.key;
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
}
});
});
}
/// A title's poster, at the poster's own shape, with how far into it the /// A title's poster, at the poster's own shape, with how far into it the
/// viewer is along its foot. /// viewer is along its foot.
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) { fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
@@ -143,6 +209,29 @@ fn back_link(ui: &mut egui::Ui) -> egui::Response {
} }
/// Download, or delete, a whole season. /// Download, or delete, a whole season.
/// Mark a whole season seen, or unseen once it all is — for someone who
/// watched it elsewhere and joins the show here.
fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &mut Vec<Action>) {
let all = season.episodes.iter().all(|episode| episode.is_watched());
let label = if all {
"Mark season unwatched"
} else {
"Mark season watched"
};
if ui.button(label).clicked() {
actions.push(Action::MarkEpisodes {
key: title.key.clone(),
links: season
.episodes
.iter()
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
.collect(),
watched: !all,
what: format!("{} {}", title.name, season.label()),
});
}
}
fn season_download( fn season_download(
ui: &mut egui::Ui, ui: &mut egui::Ui,
title: &Title, title: &Title,
@@ -158,9 +247,10 @@ fn season_download(
.on_hover_text("Delete the offline copies; keep the online source") .on_hover_text("Delete the offline copies; keep the online source")
.clicked() .clicked()
{ {
for key in season_keys { actions.push(Action::DeleteOffline(
actions.push(Action::RemoveDownload(key, false)); season_keys,
} format!("{} {}", title.name, season.label()),
));
} }
} else if ui } else if ui
.button("Download season") .button("Download season")
@@ -190,16 +280,19 @@ fn header(
let record = art.metadata.get(&title.key); let record = art.metadata.get(&title.key);
let found = record.and_then(|record| record.metadata.clone()); let found = record.and_then(|record| record.metadata.clone());
let by_hand = record.is_some_and(|record| record.manual); let by_hand = record.is_some_and(|record| record.manual);
// Asked for once and used twice: the same picture is the poster beside the // The cover beside the text, and the widest, largest picture there is
// text and the banner behind all of it. // behind all of it — two textures, because the band is drawn across the
let picture = art.of(title); // whole window and a tile-sized one stretched that far is soft.
let picture = art.poster(title);
let wide = art.wide(title);
// Reserved before the header is laid out, because the band is as tall as // Reserved before the header is laid out, because the band is as tall as
// whatever the header comes to — a fixed height would clip a long overview // whatever the header comes to — a fixed height would clip a long overview
// on one title and leave a gap under a short one on the next. // on one title and leave a gap under a short one on the next.
let backdrop = picture let backdrop = wide
.as_ref() .as_ref()
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop))); .map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
let shown = art.name(title).to_owned();
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
ui.horizontal_top(|ui| { ui.horizontal_top(|ui| {
@@ -214,11 +307,12 @@ fn header(
ui, ui,
Card { Card {
art: picture, art: picture,
name: &title.name, name: &shown,
subtitle: String::new(), subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None, badge: None,
width: theme::CARD_WIDTH, width: theme::CARD_WIDTH,
aspect: theme::CARD_ASPECT,
}, },
); );
} }
@@ -229,16 +323,24 @@ fn header(
// Text in a column a person can read, with the picture to the // Text in a column a person can read, with the picture to the
// right of it rather than under it. // right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0)); ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&title.name).strong()); ui.label(theme::Role::Display.rich(&shown).strong());
// The provider's name for it, when it is not the one the files use. // Whichever of the share's name and the provider's is not the one
// Worth showing rather than replacing the filename's: a viewer // shown. Worth showing rather than only replacing: a viewer should
// should be able to tell what the match actually matched. // be able to tell what the match actually matched, and find the
if let Some(found) = &found // folder it came from.
&& found.name != title.name let other = found
{ .as_ref()
.map(|found| {
if shown == found.name {
title.name.clone()
} else {
found.name.clone()
}
})
.filter(|other| *other != shown);
if let Some(other) = other {
ui.label(ui::muted(format!( ui.label(ui::muted(format!(
"also known as {}{}", "also known as {other}{}",
found.name,
if by_hand { " (chosen by hand)" } else { "" } if by_hand { " (chosen by hand)" } else { "" }
))); )));
} }
@@ -289,9 +391,7 @@ fn header(
.on_hover_text("Delete the offline copies; keep the online source") .on_hover_text("Delete the offline copies; keep the online source")
.clicked() .clicked()
{ {
for key in title_keys { actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
actions.push(Action::RemoveDownload(key, false));
}
} }
} else if ui } else if ui
.button("Download show") .button("Download show")
@@ -449,12 +549,22 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
/// What the provider had to say. Only ever drawn under a match. /// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) { fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
if !found.genres.is_empty() { let tags = found
.details
.as_ref()
.map(|details| details.tags.as_slice())
.unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0; ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres { for genre in &found.genres {
ui::widgets::chip(ui, genre); ui::widgets::chip(ui, genre);
} }
// After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as.
for tag in tags.iter().take(5) {
ui.label(ui::muted(tag));
}
}); });
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
} }
@@ -530,6 +640,30 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.push(format!("{watched} watched")); parts.push(format!("{watched} watched"));
} }
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
if let Some(season) = details.season_label() {
parts.push(season);
}
parts.extend(details.studios.iter().take(2).cloned());
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
if let Some((episode, at)) = details.next_airing(now) {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
parts.push(format!("episode {episode} airs {when}"));
} else if details.is_airing() {
parts.push("airing".to_owned());
}
}
if let Some(found) = found { if let Some(found) = found {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
@@ -650,14 +784,33 @@ fn episode_row(
let cluster = egui::vec2(ui.available_width(), STILL.y); let cluster = egui::vec2(ui.available_width(), STILL.y);
let layout = egui::Layout::right_to_left(egui::Align::Center); let layout = egui::Layout::right_to_left(egui::Align::Center);
ui.allocate_ui_with_layout(cluster, layout, |ui| { ui.allocate_ui_with_layout(cluster, layout, |ui| {
if ui::widgets::watched_mark(ui, watched) let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
.on_hover_text(if watched { "Mark unwatched. Right-click for more."
"Mark unwatched" } else {
} else { "Mark watched. Right-click for more."
"Mark watched" });
}) // Joining a show partway is the usual reason to mark
.clicked() // anything, and one tick per earlier episode is a chore.
{ mark.context_menu(|ui| {
if ui.button("Mark watched up to here").clicked() {
let links = title
.episodes()
.take(
title
.position_of(&episode.node.share_id, &episode.node.link_id)
.map_or(0, |at| at + 1),
)
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
.collect();
actions.push(Action::MarkEpisodes {
key: title.key.clone(),
links,
watched: true,
what: format!("{} up to {}", title.name, episode.label()),
});
}
});
if mark.clicked() {
actions.push(Action::SetWatched { actions.push(Action::SetWatched {
share_id: episode.node.share_id.clone(), share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(), link_id: episode.node.link_id.clone(),
@@ -726,6 +879,7 @@ pub(crate) fn still(
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
let response = response.named(format!("Play {}", episode.label()));
if !ui.is_rect_visible(rect) { if !ui.is_rect_visible(rect) {
return; return;
} }
@@ -831,7 +985,7 @@ fn download(
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.") .on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
.clicked() .clicked()
{ {
actions.push(Action::RemoveDownload(key, false)); actions.push(Action::DeleteOffline(vec![key], episode.label()));
} }
return; return;
} }
+13 -1
View File
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::app::Action; use crate::app::Action;
use crate::theme; use crate::theme;
use crate::ui::widgets::Named;
/// How long a message stays up. /// How long a message stays up.
const SECONDS: f64 = 5.0; const SECONDS: f64 = 5.0;
@@ -52,6 +53,17 @@ impl Toasts {
self.push_inner(now, text.into(), false, Some((label, action))); self.push_inner(now, text.into(), false, Some((label, action)));
} }
/// An error with a button on it — "Retry".
pub fn push_error_with(
&mut self,
now: f64,
text: impl Into<String>,
label: &'static str,
action: Action,
) {
self.push_inner(now, text.into(), true, Some((label, action)));
}
fn push_inner( fn push_inner(
&mut self, &mut self,
now: f64, now: f64,
@@ -203,7 +215,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)], [c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink), Stroke::new(1.5, ink),
); );
if response.on_hover_text("Dismiss").clicked() { if response.hint("Dismiss").clicked() {
pressed = Pressed::Close; pressed = Pressed::Close;
} }
if let Some((label, _)) = &toast.action { if let Some((label, _)) = &toast.action {
+14 -5
View File
@@ -17,6 +17,7 @@ use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback}; use crate::playback::{Command, Playback};
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
use crate::ui::widgets::Named;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it /// Height of the clickable seek bar. Comfortably larger than the few pixels it
/// draws, because a seek bar you have to aim at is a seek bar nobody uses. /// draws, because a seek bar you have to aim at is a seek bar nobody uses.
@@ -163,7 +164,7 @@ pub fn mini(
egui::StrokeKind::Inside, egui::StrokeKind::Inside,
); );
} }
if response.on_hover_text("Show the picture again").clicked() { if response.hint("Show the picture again").clicked() {
actions.push(Action::Goto(Page::Player)); actions.push(Action::Goto(Page::Player));
} }
} }
@@ -313,7 +314,7 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
} }
if response if response
.on_hover_text(if playback.paused { .hint(if playback.paused {
"Play (Space)" "Play (Space)"
} else { } else {
"Pause (Space)" "Pause (Space)"
@@ -354,7 +355,7 @@ fn step_button(
enabled, enabled,
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)), egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
); );
if response.on_hover_text(hover).clicked() { if response.hint(hover).clicked() {
push(actions); push(actions);
} }
} }
@@ -436,7 +437,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
}; };
if ui if ui
.button(theme::Role::Subhead.rich(icon)) .button(theme::Role::Subhead.rich(icon))
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume)) .hint(format!("Mute (M) — {:.0}%", playback.volume))
.clicked() .clicked()
{ {
actions.push(Action::ToggleMute); actions.push(Action::ToggleMute);
@@ -509,7 +510,8 @@ fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>)
format!("Speed · {}", ui::format_speed(playback.speed)) format!("Speed · {}", ui::format_speed(playback.speed))
}; };
ui.menu_button(label, |ui| { ui.menu_button(label, |ui| {
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] { // The same steps `[` and `]` walk.
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
if ui if ui
.selectable_label( .selectable_label(
(playback.speed - speed).abs() < 1e-6, (playback.speed - speed).abs() < 1e-6,
@@ -566,6 +568,13 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
let width = ui.available_width(); let width = ui.available_width();
let (rect, response) = let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag()); ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
response.widget_info(|| {
egui::WidgetInfo::slider(
playback.duration.is_some(),
playback.position,
format!("Position, {}", ui::format_time(playback.position)),
)
});
let active = response.hovered() || response.dragged(); let active = response.hovered() || response.dragged();
// Grows rather than jumps when the pointer arrives. // Grows rather than jumps when the pointer arrives.
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more