Compare commits

52 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
133 changed files with 21109 additions and 1346 deletions

No files matched your search

+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
+13 -2
View File
@@ -76,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: |
@@ -150,10 +150,17 @@ jobs:
signed_release: true signed_release: true
secrets: inherit 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 name: Publish
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
needs: [linux, windows, android] needs: [linux, windows, android, ios]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: write contents: write
@@ -172,6 +179,10 @@ jobs:
with: with:
name: android name: android
path: artifacts path: artifacts
- uses: actions/download-artifact@v8
with:
name: ios
path: artifacts
- name: Checksums - name: Checksums
run: | run: |
Generated
+1547 -57
View File
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.1.3" 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",
@@ -105,6 +109,12 @@ windows-sys = "0.61"
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. # The exe's own icon resource, which is what Explorer and a taskbar pin draw.
embed-resource = "3" 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"
+17
View File
@@ -26,6 +26,23 @@ indicator while a crawl runs, but not the crawl's progress.
Not yet verified on a device; the host gate (ktlint, unit and screenshot Not yet verified on a device; the host gate (ktlint, unit and screenshot
tests) passes, and Android lint runs in CI. 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) ## 0. CI (done)
Every `Android` workflow run since August failed in `:app:buildRustHost`: Every `Android` workflow run since August failed in `:app:buildRustHost`:
+15 -5
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
@@ -73,7 +77,7 @@ against the SDK's own range download.
| 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, 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). | | 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.
-1
View File
@@ -35,7 +35,6 @@
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" />
+1 -1
View File
@@ -202,7 +202,7 @@ struct PlaybackState {
/// 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
@@ -1,53 +1,37 @@
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. */ /** A share link handed over by a browser or the share sheet, until the Add form takes it. */
private val incomingShareLink = mutableStateOf<String?>(null) private val incomingShareLink = mutableStateOf<String?>(null)
@@ -68,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
@@ -115,34 +79,16 @@ 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( ProtonStreamApp(
playerHost.value, playerHost.value,
inPictureInPicture.value,
shareLink = incomingShareLink.value, shareLink = incomingShareLink.value,
onShareLinkTaken = { incomingShareLink.value = null }, onShareLinkTaken = { incomingShareLink.value = null },
) )
@@ -177,27 +123,7 @@ class MainActivity : ComponentActivity() {
intent.shareLink()?.let { incomingShareLink.value = it } intent.shareLink()?.let { incomingShareLink.value = it }
} }
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// The backstop for launchers that still send this instead of arming
// auto-enter. Entering twice is not an error; not entering at all is.
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
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
@@ -207,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)
@@ -85,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,
@@ -134,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
@@ -245,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) {
@@ -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,6 +80,7 @@ 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
@@ -106,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. */
@@ -114,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
@@ -126,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(
@@ -150,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,
@@ -189,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 {
@@ -209,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,
@@ -229,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
@@ -266,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)
@@ -294,9 +318,8 @@ fun PlayerScreen(
onIndexChange(target) 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(
@@ -324,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.
@@ -334,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>() }
@@ -352,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(),
@@ -393,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(),
@@ -450,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(),
@@ -468,7 +561,10 @@ fun PlayerScreen(
episodes = episodes, episodes = episodes,
playing = index, playing = index,
onEpisode = { jumpTo(it) }, onEpisode = { jumpTo(it) },
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false }, onRotate = {
rotationChosen = true
landscapeLocked = activity?.toggleLandscape() ?: false
},
landscapeLocked = landscapeLocked, landscapeLocked = landscapeLocked,
) )
} }
@@ -476,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
@@ -486,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)
@@ -512,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),
@@ -536,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,
@@ -588,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
@@ -634,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(
@@ -697,45 +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 (episodes.size > 1) { if (!roomForAll) {
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true } CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
} var more by remember { mutableStateOf(false) }
if (chapters.size > 1) { Box {
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true } CompactIcon(Icons.Default.MoreVert, "More") { more = true }
} DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true } val entries = buildList {
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" } if (episodes.size > 1) add(Triple(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true })
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" } if (chapters.size > 1) add(Triple(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true })
CompactIcon( add(Triple(Icons.Default.Speed, "Playback speed") { showSpeeds = true })
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, add(Triple(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" })
"Mute", add(
) { Triple(
host.setMuted(!state.muted) if (state.muted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) } if (state.muted) "Unmute" else "Mute",
} 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 add(
// has the system volume keys and no width to spare. Triple(
if (roomForVolume) { if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
var dragging by remember { mutableStateOf<Float?>(null) } if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
Slider( onRotate,
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME), ),
onValueChange = { dragging = it; host.setVolume(it.toDouble()) }, )
onValueChangeFinished = { }
val chosen = dragging?.toDouble() entries.forEach { (icon, label, action) ->
dragging = null DropdownMenuItem(
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } } text = { Text(label) },
}, leadingIcon = { Icon(icon, contentDescription = null) },
valueRange = 0f..MAX_VOLUME, onClick = { more = false; action() },
modifier = Modifier.width(96.dp), )
}
}
}
} 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,
)
} }
} }
} }
@@ -996,24 +1210,31 @@ private fun EpisodeChooser(
* *
* "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()
} }
@@ -1076,8 +1297,49 @@ private fun UpNextCard(
) )
} }
} }
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) { if (secondsLeft != null) {
Text("Watch till the end") 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(
if (next == null) "That was the last episode of $show" else "Finished $finished",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
if (next != null) {
AccentButton(onClick = onPlayNext) { Text("Play next") }
} else {
QuietButton(onClick = onReplay) { Text("Watch again") }
}
QuietButton(onClick = onClose, modifier = Modifier.padding(start = 8.dp)) { Text("Close") }
} }
} }
} }
@@ -1119,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),
) )
} }
} }
@@ -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.
@@ -35,6 +36,26 @@ class SettingsStore(context: Context) {
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB) get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() } 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 { companion object {
/** The desktop's range, in the steps a phone's storage is bought in. */ /** The desktop's range, in the steps a phone's storage is bought in. */
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16) val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
@@ -44,10 +65,19 @@ class SettingsStore(context: Context) {
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL 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 NAME = "proton_stream_settings"
private const val KEY_WIFI_ONLY = "wifi_only" private const val KEY_WIFI_ONLY = "wifi_only"
private const val KEY_BACKGROUND_AUDIO = "background_audio" private const val KEY_BACKGROUND_AUDIO = "background_audio"
private const val KEY_HARDWARE_DECODING = "hardware_decoding" private const val KEY_HARDWARE_DECODING = "hardware_decoding"
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib" 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,7 +25,10 @@ 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.ArrangementRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort 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
@@ -36,10 +39,17 @@ 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.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(),
/** /**
@@ -48,6 +58,7 @@ data class AppUiState(
* when the grid falls back to [titles] as they are. * when the grid falls back to [titles] as they are.
*/ */
val arrangement: ArrangementRecord? = null, val arrangement: ArrangementRecord? = null,
val filter: LibraryFilter = LibraryFilter.ALL,
val sort: LibrarySort = LibrarySort.NAME, val sort: LibrarySort = LibrarySort.NAME,
val grouped: Boolean = true, val grouped: Boolean = true,
val shares: List<ShareRecord> = emptyList(), val shares: List<ShareRecord> = emptyList(),
@@ -60,8 +71,33 @@ 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. */ /** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null, 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,
) )
/** /**
@@ -76,6 +112,21 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
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) {
@@ -83,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) }
} }
@@ -96,10 +177,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** Reorder the grid, or fold or unfold its franchises. */ /** Sort and fold the library, and keep both for the next launch. */
fun arrange(sort: LibrarySort, grouped: Boolean) { fun arrange(sort: LibrarySort, grouped: Boolean) {
mutableState.update { it.copy(sort = sort, grouped = grouped) } mutableState.update { it.copy(sort = sort, grouped = grouped) }
viewModelScope.launch { reloadLibrary(mutableState.value.query) } 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) {
@@ -109,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.
* *
@@ -127,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()
} }
} }
@@ -144,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()
} }
} }
@@ -185,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()
} }
} }
@@ -212,16 +455,187 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = 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 * 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 * title's positions are what this throws away, and a mis-tap on a tile's
* menu should not cost them. * menu should not cost them.
*/ */
fun setTitleWatched(title: TitleRecord, watched: Boolean) { fun setTitleWatched(title: TitleRecord, watched: Boolean) =
val episodes = title.seasons.flatMap { it.episodes } 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( changeWatch(
episodes, episodes.filter { it.watched != watched },
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}", "$what marked ${if (watched) "watched" else "unwatched"}",
) { engine, episode, before -> ) { engine, episode, before ->
val duration = before?.durationSecs val duration = before?.durationSecs
engine.saveWatchState( engine.saveWatchState(
@@ -269,14 +683,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() val engine = NativeRuntime.engine()
snapshots.forEach { snapshot -> snapshots.forEach { snapshot ->
val state = snapshot.state engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
engine.saveWatchState(
snapshot.shareId,
snapshot.linkId,
state?.positionSecs ?: 0.0,
state?.durationSecs,
state?.watched ?: false,
)
} }
} }
}.onFailure(::reportError) }.onFailure(::reportError)
@@ -310,22 +717,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
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()
@@ -408,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 {
@@ -452,40 +848,43 @@ 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()
val current = mutableState.value // Here rather than inside `library()`, which is read-only
val query = current.query.takeIf(String::isNotBlank) // and cached: a full reload is the one place that has
Reloaded( // already paid for a walk of the offline files.
engine.shares(), engine.pruneOfflineFiles()
engine.library(query), val current = mutableState.value
engine.arrangement(query, current.sort, current.grouped), val query = current.query.takeIf(String::isNotBlank)
engine.offlineFiles(), Reloaded(
engine.metadataSettings(), engine.shares(),
engine.storageUsage(), engine.library(query),
) engine.arrangement(query, current.filter, current.sort, current.grouped),
} engine.offlineFiles(),
}.onSuccess { result -> engine.metadataSettings(),
mutableState.update { it.copy( engine.storageUsage(),
loading = false, )
shares = result.shares, }
titles = result.titles, }.onSuccess { result ->
arrangement = result.arrangement, mutableState.update { it.copy(
offline = result.offline, loading = false,
metadataSettings = result.metadataSettings, shares = result.shares,
storage = result.storage, titles = result.titles,
) } arrangement = result.arrangement,
} offline = result.offline,
.onFailure { error -> metadataSettings = result.metadataSettings,
mutableState.update { it.copy(loading = false, message = error.message) } 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) {
@@ -494,13 +893,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() val engine = NativeRuntime.engine()
val search = query.takeIf(String::isNotBlank) val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.sort, current.grouped) engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
} }
}.onSuccess { (titles, arrangement) -> }.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) } mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } } }.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 {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
@@ -1,5 +1,6 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import android.net.ConnectivityManager
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -15,10 +18,22 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable 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.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.platform.LocalContext
import androidx.compose.ui.input.nestedscroll.nestedScroll 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 import uniffi.pstr_android.EpisodeRecord
/** /**
@@ -92,21 +107,33 @@ internal fun Modifier.sharedArt(key: String): Modifier {
} }
@Composable @Composable
internal fun EmptyState(title: String, body: String) { internal fun EmptyState(title: String, body: String, action: Pair<String, () -> Unit>? = null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium) Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
Text(body, style = MaterialTheme.typography.bodyMedium) 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: the most recently played part-watched episode, * Where Play on a title lands: what Continue watching offers
* else the first unwatched one, else the first — the desktop's `next_up`. * ([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>): Int = internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index 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 } ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0 ?: 0
@@ -120,6 +147,80 @@ internal fun formatBytes(bytes: ULong): String {
} }
} }
/**
* 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". * What an episode row is headed with: "1. Mother and Children".
* *
@@ -3,6 +3,7 @@ package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -40,7 +42,7 @@ import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.AccentProgress import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
onResume: (RetainedDownload) -> Unit, onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit, onDeletePartial: (RetainedDownload) -> Unit,
padding: PaddingValues, padding: PaddingValues,
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
onOpen: (TitleRecord) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG) val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
) )
} }
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file -> items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
SavedRow(file, group.title, onDelete = { deleting = file }) SavedRow(file, group.title, onDelete = { deleting = file }, onPlay = onPlay, onOpen = onOpen)
} }
} }
} }
@@ -138,7 +142,7 @@ internal fun DownloadsScreen(
) )
}, },
confirmButton = { confirmButton = {
AccentButton(onClick = { DangerButton(onClick = {
deleting = null deleting = null
onRemove(file) onRemove(file)
}) { Text("Delete") } }) { Text("Delete") }
@@ -160,9 +164,23 @@ private fun SectionHeading(title: String, caption: String? = null) {
/** A saved episode: its still, what it is, its size, and a way to remove it. */ /** A saved episode: its still, what it is, its size, and a way to remove it. */
@Composable @Composable
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) { private fun SavedRow(
file: OfflineRecord,
title: TitleRecord?,
onDelete: () -> Unit,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
) {
val episode = file.episode 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( ListItem(
modifier = if (title != null && index >= 0) {
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
} else {
Modifier
},
leadingContent = { leadingContent = {
RemoteArtwork( RemoteArtwork(
episode?.stillUrl ?: title?.backdropUrl, episode?.stillUrl ?: title?.backdropUrl,
@@ -179,8 +197,15 @@ private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> U
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · ")) Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
}, },
trailingContent = { trailingContent = {
IconButton(onClick = onDelete) { Row {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download") 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), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -204,14 +229,7 @@ private fun PartialRow(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = { supportingContent = {
Column { Column {
Text( Text(downloadStatusLine(download, rememberQueueReason(download)))
listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
).joinToString(" · "),
)
if (download.total > 0L) { if (download.total > 0L) {
AccentProgress( AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) }, progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
@@ -19,6 +19,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton 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 java.time.LocalDate
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
@@ -35,8 +39,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.download.DownloadCoordinator
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.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
@@ -61,6 +63,7 @@ import androidx.compose.material.icons.filled.Search
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
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
@@ -70,6 +73,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -82,6 +88,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
@@ -96,11 +103,50 @@ import uniffi.pstr_android.TitleType
private const val CONTINUE_WATCHING_MAX = 12 private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */ /** 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) 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. */ /** 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) 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. */ /** 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>) private data class Shelf(val label: String, val titles: List<TitleRecord>)
@@ -118,22 +164,31 @@ internal fun LibraryScreen(
onMatchChanged: () -> Unit = {}, onMatchChanged: () -> Unit = {},
onError: (Throwable) -> Unit = {}, onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {},
) { ) {
val download = rememberDownload()
val context = LocalContext.current 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. // What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) } var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) } var menuResume by remember { mutableStateOf<Resumable?>(null) }
var matching by remember { mutableStateOf<TitleRecord?>(null) } var matching by remember { mutableStateOf<TitleRecord?>(null) }
// Most recently played first, one episode per show: a shelf that lists four // Most recently played first, one episode per show: a shelf that lists four
// episodes of the same series is a shelf with room for nothing else. // episodes of the same series is a shelf with room for nothing else.
// 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) { val resumable = remember(state.titles) {
state.titles.mapNotNull { title -> state.titles.mapNotNull { title ->
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
val playlist = title.seasons.flatMap(SeasonRecord::episodes) val playlist = title.seasons.flatMap(SeasonRecord::episodes)
playlist.withIndex() playlist.getOrNull(index)?.let { episode ->
.filter { it.value.resumeAt != null && !it.value.watched } Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
.maxByOrNull { it.value.lastPlayed } }
?.let { Resumable(title, it.value, it.index) } }.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
} }
// Search is an icon until it is wanted: a permanent field above the // 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. // library spent a band of every visit on something used in few of them.
@@ -179,9 +234,16 @@ internal fun LibraryScreen(
"Nothing matches", "Nothing matches",
"No title in the library is called “${state.query.trim()}”.", "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( state.titles.isEmpty() -> EmptyState(
"Your library is empty", "Your library is empty",
"Add a Proton Drive public link under Shares, then refresh.", "Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
) )
else -> LibraryGrid( else -> LibraryGrid(
state, state,
@@ -190,6 +252,15 @@ internal fun LibraryScreen(
onTitle, onTitle,
onTitleMenu = { menuFor = it }, onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it }, onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
) )
} }
} }
@@ -199,10 +270,10 @@ internal fun LibraryScreen(
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) { TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") { MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
onResume(title, nextUpIndex(playlist)) onResume(title, nextUpIndex(playlist, title.continueIndex))
} }
MenuRow(Icons.Default.Info, "Open") { onTitle(title) } MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) } MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
MenuRow( MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle, if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
if (allWatched) "Mark unwatched" else "Mark watched", if (allWatched) "Mark unwatched" else "Mark watched",
@@ -384,6 +455,8 @@ private fun LibraryGrid(
onTitle: (TitleRecord) -> Unit, onTitle: (TitleRecord) -> Unit,
onTitleMenu: (TitleRecord) -> Unit, onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> Unit, onResumeMenu: (Resumable) -> Unit,
onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null,
) { ) {
// The arrangement names titles by key; before it has arrived, the titles // The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid. // as they came are the grid.
@@ -410,6 +483,9 @@ private fun LibraryGrid(
// opening the app wants is almost always the thing they were part way // 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 // through. Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest. // 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 val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title -> featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) { item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
@@ -441,7 +517,10 @@ private fun LibraryGrid(
} }
} }
if (state.query.isBlank()) { if (state.query.isBlank()) {
shelves.forEach { shelf -> // 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) }) { item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
Column { Column {
Text(shelf.label, style = MaterialTheme.typography.titleMedium) Text(shelf.label, style = MaterialTheme.typography.titleMedium)
@@ -462,9 +541,29 @@ private fun LibraryGrid(
} }
} }
} }
if (resumable.isNotEmpty() || shelves.isNotEmpty()) { item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) { Column {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) 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,
)
} }
} }
} }
@@ -567,7 +666,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) { AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null) Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play") Text(if (resume != null) "Resume" else "Play")
@@ -585,14 +684,16 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
Modifier Modifier
.width(220.dp) .width(220.dp)
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this episode"),
) { ) {
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) { Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
// Named by the text under it; read as well, the name came twice.
RemoteArtwork( RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl, entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.displayName, entry.title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize().clearAndSetSemantics {},
fallback = entry.episode.thumbnailSource, fallback = entry.episode.thumbnailSource,
maxEdge = 220.dp,
) )
entry.episode.progress?.let { progress -> entry.episode.progress?.let { progress ->
AccentProgress( AccentProgress(
@@ -609,7 +710,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
) )
Text( Text(
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "), if (entry.episode.resumeAt == null) {
"Next: ${entry.episode.label}"
} else {
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · ")
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -638,7 +743,7 @@ private fun PosterTile(
Column( Column(
modifier modifier
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
) { ) {
Box { Box {
RemoteArtwork( RemoteArtwork(
@@ -647,8 +752,11 @@ private fun PosterTile(
(if (shared) Modifier.sharedArt(title.key) else Modifier) (if (shared) Modifier.sharedArt(title.key) else Modifier)
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.clearAndSetSemantics {},
fallback = title.thumbnailSource, fallback = title.thumbnailSource,
// The tile's height, at the widest a grid column gets.
maxEdge = 280.dp,
) )
if (folded > 0) { if (folded > 0) {
Text( Text(
@@ -660,7 +768,8 @@ private fun PosterTile(
.padding(6.dp) .padding(6.dp)
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.background(Color.Black.copy(alpha = 0.7f)) .background(Color.Black.copy(alpha = 0.7f))
.padding(horizontal = 6.dp, vertical = 2.dp), .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = "and $folded more" },
) )
} }
} }
@@ -1,5 +1,16 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.fadeOut
import androidx.compose.animation.fadeIn
import androidx.compose.animation.AnimatedVisibility
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -65,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
@@ -98,7 +110,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
@Composable @Composable
fun ProtonStreamApp( fun ProtonStreamApp(
playerHost: LibmpvHost? = null, playerHost: LibmpvHost? = null,
inPictureInPicture: Boolean = false,
shareLink: String? = null, shareLink: String? = null,
onShareLinkTaken: () -> Unit = {}, onShareLinkTaken: () -> Unit = {},
model: AppViewModel = viewModel( model: AppViewModel = viewModel(
@@ -123,6 +134,7 @@ fun ProtonStreamApp(
// is what says so. Saved, because a rotation must not throw the viewer back // is what says so. Saved, because a rotation must not throw the viewer back
// into the video they had just left. // into the video they had just left.
var playerMinimized by rememberSaveable { mutableStateOf(false) } var playerMinimized by rememberSaveable { mutableStateOf(false) }
var addingShare by remember { mutableStateOf(false) }
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey } val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
val playing = playingTitleKey val playing = playingTitleKey
@@ -216,6 +228,12 @@ fun ProtonStreamApp(
onMatchChanged = model::reloadAfterMetadataChange, onMatchChanged = model::reloadAfterMetadataChange,
onError = model::reportError, onError = model::reportError,
onArrange = model::arrange, onArrange = model::arrange,
onAddShare = {
destination = Destination.Shares
addingShare = true
},
onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter,
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
@@ -232,6 +250,7 @@ fun ProtonStreamApp(
model::reloadAfterMetadataChange, model::reloadAfterMetadataChange,
model::setWatched, model::setWatched,
body, body,
onMarkEpisodes = model::markEpisodes,
franchise = selected.franchise.mapNotNull { key -> franchise = selected.franchise.mapNotNull { key ->
state.titles.firstOrNull { it.key == key } state.titles.firstOrNull { it.key == key }
}, },
@@ -263,13 +282,35 @@ fun ProtonStreamApp(
} }
Destination.Shares -> SharesScreen( Destination.Shares -> SharesScreen(
state.shares, state.shares,
model::addShare, { name, url, password ->
// The first share is the library: watch it fill
// rather than stay on a form that has done its job.
val first = state.titles.isEmpty()
model.addShare(name, url, password).also { refusal ->
if (refusal == null && first) destination = Destination.Library
}
},
model::repairShare, model::repairShare,
model::refreshShare, model::refreshShare,
model::removeShare, model::removeShare,
shareLink, // The empty library's button opens the form as a
onShareLinkTaken, // pasted link would, only with nothing in it.
shareLink ?: "".takeIf { addingShare },
{ addingShare = false; onShareLinkTaken() },
body, body,
account = state.account,
accountActions = AccountActions(
signIn = model::signIn,
submitSecondFactor = model::submitSecondFactor,
submitMailboxPassword = model::submitMailboxPassword,
cancelSignIn = model::cancelSignIn,
dismissVerification = model::dismissVerification,
signOut = model::signOut,
syncNow = model::syncNow,
addFolder = model::addAccountFolder,
onError = model::reportError,
),
onRename = model::renameShare,
) )
Destination.History -> HistoryScreen( Destination.History -> HistoryScreen(
state, state,
@@ -289,6 +330,15 @@ fun ProtonStreamApp(
model::resumeDownload, model::resumeDownload,
model::deletePartial, model::deletePartial,
body, body,
onPlay = { title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
onOpen = { title ->
destination = Destination.Library
selectedTitleKey = title.key
},
) )
Destination.Settings -> SettingsScreen( Destination.Settings -> SettingsScreen(
state, state,
@@ -308,29 +358,35 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = padding.calculateBottomPadding() + 8.dp), .padding(bottom = padding.calculateBottomPadding() + 8.dp),
) )
} }
ActivityBanner(
state.activity,
onStop = model::stopCrawl.takeIf { state.crawling },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = body.calculateBottomPadding() + 8.dp),
)
} }
} }
} }
// Drawn over the scaffold rather than instead of it. The player owns the // Drawn over the scaffold rather than instead of it. The player owns the
// whole window while it is up — it is a screen, not a dialog over one, which // whole window while it is up — it is a screen, not a dialog over one, which
// is what lets it draw into the cutout and hand the right window to // is what lets it draw into the cutout — but the page behind it stays
// Picture-in-Picture — but the page behind it stays composed, so leaving the // composed, so leaving the player finds the title page where it was rather
// player finds the title page where it was rather than rebuilt from the top. // than rebuilt from the top.
playing?.let { request -> playing?.let { request ->
PlayerScreen( PlayerScreen(
title = request.title, title = request.title,
episodes = request.episodes, episodes = request.episodes,
index = request.index, index = request.index,
host = playerHost, host = playerHost,
inPictureInPicture = inPictureInPicture,
minimized = playerMinimized, minimized = playerMinimized,
onIndexChange = { playingIndex = it }, onIndexChange = { playingIndex = it },
onSaveProgress = model::saveProgress, onSaveProgress = model::saveProgress,
@@ -338,7 +394,7 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
) )
} }
@@ -388,3 +444,37 @@ private fun MiniTransport(
} }
} }
} }
/**
* What the app is busy with, for as long as it is: a spinner and one line.
* Not dismissible and not timed — it goes when the work does.
*/
@Composable
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = activity != null,
modifier = modifier,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
) {
// Held through the exit, so the line does not blank before it fades.
var shown by remember { mutableStateOf(activity.orEmpty()) }
if (activity != null) shown = activity
Row(
Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics { liveRegion = LiveRegionMode.Polite },
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
// A crawl of a large share can take minutes, and is the viewer's
// to call off; what it has read so far stays.
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
}
}
}
@@ -33,6 +33,7 @@ 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.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight 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
@@ -87,16 +88,20 @@ internal fun RemoteArtwork(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null, fallback: ThumbnailSource? = null,
labelled: Boolean = true, labelled: Boolean = true,
maxEdge: Dp? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val enabled = LocalArtworkLoading.current val enabled = LocalArtworkLoading.current
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
// 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) } var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
LaunchedEffect(url, fallback, enabled) { LaunchedEffect(url, fallback, enabled) {
if (!enabled) { if (!enabled) {
image = ArtworkLoad.Missing image = ArtworkLoad.Missing
return@LaunchedEffect return@LaunchedEffect
} }
image = (loadArtwork(context, url) ?: loadThumbnail(fallback)) image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing ?: ArtworkLoad.Missing
} }
@@ -186,8 +191,8 @@ internal fun initials(name: String): String {
* 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" }
@@ -197,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
@@ -210,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) }
} }
} }
@@ -224,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
} }
@@ -257,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.
* *
@@ -315,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
} }
@@ -65,6 +65,7 @@ import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.download.DownloadCoordinator
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.DangerButton
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.EdgedButton import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
@@ -86,7 +87,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
@Composable @Composable
internal fun SettingsScreen( internal fun SettingsScreen(
state: AppUiState, state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit, /** Resolves to why the settings were not stored, or null once they are. */
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
onMatchAgain: () -> Unit, onMatchAgain: () -> Unit,
onClearCache: () -> Unit, onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit, onRemoveAllOffline: () -> Unit,
@@ -315,7 +317,7 @@ internal fun SettingsScreen(
) )
}, },
confirmButton = { confirmButton = {
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") } DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
}, },
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
) )
@@ -327,10 +329,7 @@ internal fun SettingsScreen(
MetadataSettingsDialog( MetadataSettingsDialog(
current = state.metadataSettings, current = state.metadataSettings,
onDismiss = { showMetadata = false }, onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key -> onSave = onSaveMetadata,
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
) )
} }
} }
@@ -411,14 +410,19 @@ private fun SettingAction(
private fun MetadataSettingsDialog( private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord, current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit, onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
) { ) {
var enabled by remember { mutableStateOf(current.enabled) } var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) } var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) } var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") } 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = { if (!saving) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") }, title = { Text("Metadata enrichment") },
text = { text = {
@@ -447,15 +451,35 @@ private fun MetadataSettingsDialog(
singleLine = true, 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 = { confirmButton = {
AccentButton( AccentButton(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(), enabled = !needsKey && !saving,
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) }, onClick = {
) { Text("Save") } 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) { Text("Cancel") } }, dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
) )
} }
@@ -1,5 +1,10 @@
package io.narl.protonstream.ui 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -25,13 +30,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation 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.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.TonalButton 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 uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -44,34 +54,64 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FolderShared 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 import androidx.compose.ui.graphics.Color
@Composable @Composable
internal fun SharesScreen( internal fun SharesScreen(
shares: List<ShareRecord>, shares: List<ShareRecord>,
onAdd: (String, String, String?) -> Unit, /** Resolves to why the link was refused, or null once it is added. */
onAdd: suspend (String, String, String?) -> String?,
onRepair: (String, String, String?) -> Unit, onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit, onRefresh: (String) -> Unit,
onRemove: (String) -> Unit, onRemove: (String) -> Unit,
incomingLink: String?, incomingLink: String?,
onIncomingLinkTaken: () -> Unit, onIncomingLinkTaken: () -> Unit,
padding: PaddingValues, padding: PaddingValues,
account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null,
onRename: (String, String) -> Unit = { _, _ -> },
) { ) {
var showAdd by remember { mutableStateOf(false) } 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 repairing by remember { mutableStateOf<ShareRecord?>(null) }
var removing by remember { mutableStateOf<ShareRecord?>(null) } var removing by remember { mutableStateOf<ShareRecord?>(null) }
TabPage("Shares", padding) { body -> TabPage("Shares", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) { Box(Modifier.fillMaxSize().padding(body)) {
if (shares.isEmpty()) { if (shares.isEmpty() && account.state == null) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.") EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else { } else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
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 -> items(shares, key = { it.id }) { share ->
ShareRow( ShareRow(
share = share, share = share,
onRefresh = { onRefresh(share.id) }, onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share }, onRepair = { repairing = share },
onRemove = { removing = share }, onRemove = { removing = share },
onRename = { renaming = share },
) )
} }
} }
@@ -95,6 +135,14 @@ internal fun SharesScreen(
onAdd = onAdd, 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 -> repairing?.let { share ->
RepairShareDialog( RepairShareDialog(
share = share, share = share,
@@ -102,20 +150,35 @@ internal fun SharesScreen(
onRepair = { url, password -> onRepair(share.id, url, password) }, 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 -> removing?.let { share ->
// Asked, because it cannot be undone: the link's secret, its offline // Asked, because it cannot be undone: the link's secret and its
// files and its watch positions all go (`Catalog::remove_share`). // offline files go. Watch positions stay (`Catalog::remove_share`).
AlertDialog( AlertDialog(
onDismissRequest = { removing = null }, onDismissRequest = { removing = null },
title = { Text("Remove ${share.name}?") }, title = { Text("Remove ${share.name}?") },
text = { text = {
Text( Text(
"Its titles leave the library, and its offline episodes and watch " + "Its titles leave the library and its offline episodes are deleted. " +
"progress are deleted. Adding the link again starts it fresh.", "Watch progress is kept, so adding the link again picks up where you left off.",
) )
}, },
confirmButton = { confirmButton = {
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") } DangerButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
}, },
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } }, dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
) )
@@ -128,18 +191,58 @@ internal fun SharesScreen(
* Three differently styled controls beside the name squeezed it into a column * Three differently styled controls beside the name squeezed it into a column
* a word wide; one icon leaves the row its width. * 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 @Composable
private fun ShareRow( private fun ShareRow(
share: ShareRecord, share: ShareRecord,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onRepair: () -> Unit, onRepair: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onRename: () -> Unit = {},
) { ) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
ListItem( ListItem(
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) }, headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
supportingContent = { supportingContent = {
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link") 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 = { leadingContent = {
Icon( Icon(
@@ -161,14 +264,21 @@ private fun ShareRow(
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
onClick = { menu = false; onRefresh() }, 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 // Not a remove: a share whose secret has become unreadable
// cannot be removed either, since removal deletes a secret // cannot be removed either, since removal deletes a secret
// the store can no longer touch. // the store can no longer touch.
DropdownMenuItem( if (!share.fromAccount) {
text = { Text("Re-enter link") }, DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) }, text = { Text("Re-enter link") },
onClick = { menu = false; onRepair() }, leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
) onClick = { menu = false; onRepair() },
)
}
DropdownMenuItem( DropdownMenuItem(
text = { Text("Remove") }, text = { Text("Remove") },
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
@@ -236,32 +346,55 @@ private fun RepairShareDialog(
) )
} }
/**
* 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 @Composable
private fun AddShareDialog( private fun AddShareDialog(
initialUrl: String, initialUrl: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAdd: (String, String, String?) -> Unit, onAdd: suspend (String, String, String?) -> String?,
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var url by remember(initialUrl) { mutableStateOf(initialUrl) } var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var showLink by remember { mutableStateOf(false) }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var adding by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = { if (!adding) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") }, title = { Text("Add Proton Drive share") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
OutlinedTextField( OutlinedTextField(
url, url,
{ url = it }, { url = it },
label = { Text("Public share URL") }, label = { Text("Share link") },
singleLine = true, singleLine = true,
// The URL fragment is a secret. Password input disables // The URL fragment is a secret. Password input disables
// IME learning/suggestions while normal long-press paste // IME learning/suggestions while normal long-press paste
// remains available. // remains available; the eye shows what was pasted.
visualTransformation = PasswordVisualTransformation(), visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), 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( OutlinedTextField(
password, password,
@@ -271,14 +404,30 @@ private fun AddShareDialog(
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), 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 = { confirmButton = {
AccentButton( AccentButton(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() }, onClick = {
enabled = name.isNotBlank() && url.isNotBlank(), adding = true
) { Text("Add") } 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) { Text("Cancel") } }, dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
) )
} }
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -30,6 +31,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -38,14 +41,17 @@ import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.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.Download
import androidx.compose.material.icons.filled.Edit 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -68,6 +74,7 @@ import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.download.RetainedDownload
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.DangerButton
import io.narl.protonstream.ui.theme.AccentProgress import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.EdgedButton import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
@@ -97,11 +104,14 @@ internal fun TitleScreen(
onMetadataChanged: () -> Unit, onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit, onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues, 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. */ /** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(), franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {}, onOpenTitle: (TitleRecord) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val download = rememberDownload()
var showMatch by remember(title.key) { mutableStateOf(false) } var showMatch by remember(title.key) { mutableStateOf(false) }
var overviewOpen by remember(title.key) { mutableStateOf(false) } var overviewOpen by remember(title.key) { mutableStateOf(false) }
@@ -122,7 +132,7 @@ internal fun TitleScreen(
// Display order across seasons: what previous/next and autoplay walk, and // Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses. // the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
val nextUp = remember(playlist) { nextUpIndex(playlist) } val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) }
val upNext = playlist.getOrNull(nextUp) val upNext = playlist.getOrNull(nextUp)
// One season on screen at a time, opened on the one Play would land in: a // 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 // forty-episode show as stacked sections is one long scroll to the part
@@ -138,7 +148,11 @@ internal fun TitleScreen(
// without a trip to the Downloads tab. // without a trip to the Downloads tab.
val work by WorkManager.getInstance(context) val work by WorkManager.getInstance(context)
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList()) .getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
val downloads = remember(work) { DownloadStateStore(context).records() } // 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 // 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 // button and synopsis lines two thousand pixels wide are read by turning
@@ -266,7 +280,7 @@ internal fun TitleScreen(
} }
} }
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) { TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
DownloadCoordinator.enqueue(context, playlist) download(playlist, title.displayName)
} }
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true } TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes // The provider's own page for this title: where a viewer goes
@@ -343,7 +357,9 @@ internal fun TitleScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry -> // 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( FilterChip(
selected = index == seasonIndex, selected = index == seasonIndex,
onClick = { seasonIndex = index }, onClick = { seasonIndex = index },
@@ -352,7 +368,16 @@ internal fun TitleScreen(
} }
} }
season?.let { season?.let {
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) { // 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}") Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
} }
} }
@@ -374,6 +399,14 @@ internal fun TitleScreen(
onPause = { DownloadCoordinator.pause(context, it) }, onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) }, onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, 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}",
)
},
) )
} }
} }
@@ -521,16 +554,35 @@ private fun EpisodeRow(
onPause: (RetainedDownload) -> Unit, onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit, onResume: (RetainedDownload) -> Unit,
onSetWatched: (Boolean) -> 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 || val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED download?.status == RetainedDownload.STATUS_QUEUED
val heading = episode.heading(position) val heading = episode.heading(position)
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = playerReady, onClick = onPlay) .combinedClickable(
enabled = playerReady,
onClick = onPlay,
onLongClick = { menu = true },
onLongClickLabel = "More for this episode",
)
.padding(horizontal = 16.dp, vertical = 6.dp), .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) { Row(verticalAlignment = Alignment.CenterVertically) {
// With metadata off the provider has no still, and Proton's own // 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 // thumbnail is a frame of this very episode. Where it was left sits
@@ -636,8 +688,7 @@ private fun EpisodeRow(
fill = solid(MaterialTheme.colorScheme.tertiary), fill = solid(MaterialTheme.colorScheme.tertiary),
) )
Text( Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " + downloadStatusLine(active, rememberQueueReason(active)),
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -654,8 +705,36 @@ internal fun ChangeMatchDialog(
) { ) {
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) } var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
var searching by remember { mutableStateOf(false) } 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 options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
var confirmForget by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Change match") }, title = { Text("Change match") },
@@ -672,16 +751,26 @@ internal fun ChangeMatchDialog(
searching = false searching = false
} }
}) { Text(if (searching) "Searching…" else "Search") } }) { 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)) { LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option -> items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
EdgedButton( EdgedButton(
onClick = { onClick = {
choosing = true
scope.launch { scope.launch {
runCatching { runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) } withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError) }.onSuccess { onChanged() }.onFailure(onError)
choosing = false
} }
}, },
enabled = !choosing,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) { ) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -694,12 +783,7 @@ internal fun ChangeMatchDialog(
} }
}, },
confirmButton = { confirmButton = {
QuietButton(onClick = { QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget match") }
}, },
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } }, dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
) )
@@ -142,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.
* *
@@ -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
@@ -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))
}
}
@@ -26,6 +26,7 @@ class HistoryTest {
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(), displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null, directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
franchise = emptyList(), franchise = emptyList(),
continueIndex = null,
) )
@Test @Test
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
@@ -79,18 +81,23 @@ class ScreenshotTest {
@Test @Test
fun `the library on a phone`() = capture("library-phone") { 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()) LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
} }
@Test @Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet) @Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") { fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues()) LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
} }
@Test @Test
fun `the library in the light palette`() = captureLight("library-phone-light") { fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues()) LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
} }
@Test @Test
@@ -116,7 +123,7 @@ class ScreenshotTest {
// reads it from, so the scale is set there. // reads it from, so the scale is set there.
val density = LocalDensity.current val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) { CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues()) LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
} }
} }
@@ -137,22 +144,22 @@ class ScreenshotTest {
@Test @Test
fun `the shares page`() = capture("shares-phone") { fun `the shares page`() = capture("shares-phone") {
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues()) SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
} }
@Test @Test
fun `the settings page`() = capture("settings-phone") { fun `the settings page`() = capture("settings-phone") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0") SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
} }
@Test @Test
fun `the playback settings`() = capture("settings-playback") { fun `the playback settings`() = capture("settings-playback") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback) SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
} }
@Test @Test
fun `the storage settings`() = capture("settings-storage") { fun `the storage settings`() = capture("settings-storage") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage) SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
} }
@Test @Test
@@ -264,6 +271,9 @@ class ScreenshotTest {
nextEpisode = null, nextEpisode = null,
nextAiringAt = null, nextAiringAt = null,
franchise = emptyList(), 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( val SERIES = title(
@@ -290,6 +300,16 @@ class ScreenshotTest {
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u))))) val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 1988u) .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( val LIBRARY = AppUiState(
loading = false, loading = false,
titles = listOf( titles = listOf(
@@ -306,9 +326,17 @@ class ScreenshotTest {
}, },
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))), 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( shares = listOf(
ShareRecord("anime", "anime", true), ShareRecord(
ShareRecord("films", "films", false), "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), storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
) )
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+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
+2
View File
@@ -33,6 +33,8 @@ 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] [build-dependencies]
embed-resource.workspace = true embed-resource.workspace = true
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) => {
+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"
+82 -31
View File
@@ -19,6 +19,11 @@ pub struct Shelves<'a> {
pub search: &'a str, pub search: &'a str,
/// The grid's shape. /// The grid's shape.
pub tiles: Tiles, 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>) {
@@ -28,12 +33,14 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
loaded, loaded,
search, search,
tiles, 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> {
@@ -48,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);
@@ -304,8 +315,16 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
} }
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
let mut grouped = view.grouped; let mut grouped = view.grouped;
if ui let toggle = ui.toggle_value(&mut grouped, egui_phosphor::regular::STACK);
.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 { .on_hover_text(if view.grouped {
"One tile per franchise. Click to show every title" "One tile per franchise. Click to show every title"
} else { } else {
@@ -316,17 +335,8 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
actions.push(Action::SetGrouped(grouped)); 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));
} }
} }
@@ -382,7 +392,7 @@ fn tile_shape(tiles: Tiles) -> (f32, f32) {
} }
} }
/// The top shelf: one card per part-watched title, scrolling sideways. /// 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<'_>,
@@ -395,21 +405,17 @@ 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 picture = art.of(title);
let response = ui::card( let response = ui::card(
@@ -550,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")
@@ -639,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")
@@ -653,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, _)) => {
+41 -2
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;
@@ -24,6 +25,7 @@ use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecor
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, 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.
/// ///
@@ -171,6 +173,19 @@ pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}") 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 {
@@ -310,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();
@@ -373,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(
@@ -393,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() {
@@ -497,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;
} }
@@ -594,6 +628,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
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() {
+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);
+117 -11
View File
@@ -7,11 +7,14 @@
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles}; use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames}; 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,27 @@ 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::widgets::settings_row(
ui, ui,
"Library tiles", "Library tiles",
@@ -265,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,
@@ -322,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",
+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,
} }
} }
+59 -15
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> {
@@ -96,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);
@@ -207,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,
@@ -222,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")
@@ -365,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")
@@ -760,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(),
@@ -836,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;
} }
@@ -941,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.
+34
View File
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::theme; use crate::theme;
/// A name for a screen reader, on a control whose face does not say it.
///
/// A button drawn as an icon glyph is announced as the icon font's private
/// code point, and one painted by hand as nothing at all.
pub trait Named {
/// The control's name to a screen reader.
fn named(self, name: impl Into<String>) -> Self;
/// Hover text that is also the control's name.
fn hint(self, text: impl Into<String>) -> Self;
}
impl Named for egui::Response {
fn named(self, name: impl Into<String>) -> Self {
let name = name.into();
let enabled = self.enabled();
self.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
self
}
fn hint(self, text: impl Into<String>) -> Self {
let text = text.into();
self.named(text.clone()).on_hover_text(text)
}
}
/// An on/off switch with its label beside it, for settings. /// An on/off switch with its label beside it, for settings.
/// ///
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour — /// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
@@ -98,6 +123,9 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response { pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
const SIZE: f32 = 20.0; const SIZE: f32 = 20.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click()); let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, watched, "Watched")
});
if ui.is_rect_visible(rect) { if ui.is_rect_visible(rect) {
let ctx = ui.ctx(); let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE); let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
@@ -278,6 +306,12 @@ pub enum DownloadGlyph {
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response { pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
const SIZE: f32 = 30.0; const SIZE: f32 = 30.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click()); let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
let response = response.named(match glyph {
DownloadGlyph::Download => "Download".to_owned(),
DownloadGlyph::Progress(done) => format!("Downloading, {:.0}%", done * 100.0),
DownloadGlyph::Paused(done) => format!("Download paused, {:.0}%", done * 100.0),
DownloadGlyph::Done => "Downloaded".to_owned(),
});
if !ui.is_rect_visible(rect) { if !ui.is_rect_visible(rect) {
return response; return response;
} }
+190
View File
@@ -0,0 +1,190 @@
//! Proton's CAPTCHA, in a window of its own.
//!
//! Proton puts a CAPTCHA in front of a sign-in it does not recognise — a new
//! network, a VPN exit. The page reports success by posting a message to its
//! host rather than by redirecting, so the system browser has nowhere to send
//! it back to: it needs a webview this app controls, as on Android and in
//! `proton-drive-linux`.
//!
//! The webview runs in a child process — this same executable, started with
//! [`ARG`] — because its windowing library and eframe's cannot both own the
//! event loop of one process. The child prints the solved token on stdout and
//! exits; closing its window exits with nothing printed, which is a cancel.
use std::io::{Read as _, Write as _};
/// The argument that starts this executable as the verification window,
/// followed by the page's URL.
pub const ARG: &str = "--human-verification";
/// Forwards every message the page posts to this process. The page targets
/// `window.parent`, which for a top-level page is the page itself, so a
/// listener on its own window hears it.
const BRIDGE: &str = r#"
window.addEventListener('message', function (event) {
try {
window.ipc.postMessage(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
"#;
/// Run the verification window if this process was started for it, returning
/// the process's exit code; `None` when it was started as the app.
pub fn run_if_requested() -> Option<i32> {
let mut args = std::env::args().skip(1);
if args.next().as_deref() != Some(ARG) {
return None;
}
let Some(url) = args.next().filter(|url| is_proton_page(url)) else {
eprintln!("{ARG} needs a verification page on proton.me");
return Some(2);
};
Some(match window(&url) {
Ok(()) => 0,
Err(error) => {
eprintln!("the verification window failed: {error}");
1
}
})
}
/// Present `url` in the verification window and wait for the token it yields.
///
/// `None` when the viewer closed the window, or it could not be shown.
/// Blocking: call it off the UI thread and outside the async runtime.
pub fn solve(url: &str) -> Option<String> {
let exe = std::env::current_exe()
.inspect_err(|error| tracing::warn!("locate this executable: {error}"))
.ok()?;
let mut child = std::process::Command::new(exe)
.arg(ARG)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.spawn()
.inspect_err(|error| tracing::warn!("open the verification window: {error}"))
.ok()?;
let mut out = String::new();
if let Some(mut stdout) = child.stdout.take() {
let _ = stdout.read_to_string(&mut out);
}
let status = child.wait().ok()?;
if !status.success() {
tracing::warn!("the verification window exited with {status}");
return None;
}
out.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())
.map(str::to_owned)
}
fn window(url: &str) -> anyhow::Result<()> {
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tao::window::WindowBuilder;
use wry::{NewWindowResponse, WebViewBuilder};
let event_loop = EventLoopBuilder::<String>::with_user_event().build();
let window = WindowBuilder::new()
.with_title("Confirm you are human — Proton Stream")
.with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0))
.build(&event_loop)?;
let proxy = event_loop.create_proxy();
let builder = WebViewBuilder::new()
.with_url(url)
.with_initialization_script(BRIDGE)
.with_ipc_handler(move |request| {
// Every message the page posts lands here, most of them its own
// chatter; only a completion carries a token.
if let Some(token) = pstr_core::account::verification_token(request.body()) {
let _ = proxy.send_event(token);
}
})
// The bridge above is reachable by whatever the page shows, so the
// page stays on Proton.
.with_navigation_handler(|url| is_allowed_navigation(&url))
.with_new_window_req_handler(|_, _| NewWindowResponse::Deny);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let _webview = {
use tao::platform::unix::WindowExtUnix as _;
use wry::WebViewBuilderExtUnix as _;
let vbox = window
.default_vbox()
.ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?;
builder.build_gtk(vbox)?
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
let _webview = builder.build(&window)?;
let mut answered = false;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::UserEvent(token) if !answered => {
answered = true;
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{token}");
let _ = stdout.flush();
*control_flow = ControlFlow::Exit;
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => *control_flow = ControlFlow::Exit,
_ => {}
}
})
}
/// Whether `url` is a page of Proton's own, over HTTPS.
fn is_proton_page(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
// A user-info part would let `proton.me@evil.example` pass.
if authority.contains('@') {
return false;
}
let host = authority.split(':').next().unwrap_or_default();
host == "proton.me" || host.ends_with(".proton.me")
}
/// Whether the webview may go to `url`: Proton's pages, and the documents a
/// page makes for itself, which have no origin of their own to leave to.
fn is_allowed_navigation(url: &str) -> bool {
is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_proton_pages_over_https_are_proton_pages() {
assert!(is_proton_page(
"https://verify.proton.me/?embed=1&methods=captcha&token=x"
));
assert!(is_proton_page("https://proton.me"));
assert!(!is_proton_page("http://verify.proton.me/"));
assert!(!is_proton_page("https://proton.me.evil.example/"));
assert!(!is_proton_page("https://evilproton.me/"));
assert!(!is_proton_page("https://proton.me@evil.example/"));
assert!(!is_proton_page(
"https://evil.example/?next=https://proton.me"
));
}
#[test]
fn the_window_stays_on_proton() {
assert!(is_allowed_navigation(
"https://verify-api.proton.me/core/v4/captcha"
));
assert!(is_allowed_navigation("about:blank"));
assert!(!is_allowed_navigation("https://example.com/"));
assert!(!is_allowed_navigation("file:///etc/passwd"));
}
}
+1
View File
@@ -23,5 +23,6 @@ tokio.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
rpassword.workspace = true rpassword.workspace = true
parking_lot.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+228 -7
View File
@@ -8,9 +8,12 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::account::{PlaceKind, SignIn};
use pstr_core::catalog::{Catalog, build_rows}; use pstr_core::catalog::{Catalog, build_rows};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::{ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync;
use pstr_core::{Account, AccountStore, KeyringSecretStore, ShareStore, SharedLibrary};
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent}; use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource}; use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
@@ -69,8 +72,38 @@ enum Command {
#[arg(long)] #[arg(long)]
custom_password: bool, custom_password: bool,
}, },
/// Add a folder of the signed-in account as a share.
///
/// The folder is named by the `VOLUME:LINK` id `browse` prints.
AddFolder {
/// What to call it in the UI.
#[arg(long)]
name: String,
/// The folder, as `VOLUME:LINK`.
folder: String,
},
/// List configured shares. /// List configured shares.
Shares, Shares,
/// Sign in to a Proton account, for watch history across devices and for
/// folders of your own Drive.
///
/// The password, a second-factor code and a mailbox password are prompted
/// for as the account needs them.
Login {
/// The account's address.
username: String,
},
/// Sign out and forget the stored session.
Logout,
/// Show which account is signed in.
Account,
/// List the account's Drive: its top-level places, or one folder.
Browse {
/// The folder to list, as `VOLUME:LINK`; the places when omitted.
folder: Option<String>,
},
/// Sync watch history with the signed-in account's Drive.
Sync,
/// Forget a share and its stored credentials. /// Forget a share and its stored credentials.
Remove { Remove {
/// The share id, as printed by `shares`. /// The share id, as printed by `shares`.
@@ -210,6 +243,7 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let dirs = AppDirs::ensure().context("resolve app directories")?; let dirs = AppDirs::ensure().context("resolve app directories")?;
let store = ShareStore::new(dirs.clone()); let store = ShareStore::new(dirs.clone());
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
match cli.command { match cli.command {
Command::Add { Command::Add {
@@ -222,12 +256,105 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else { } else {
None None
}; };
let share = store let (share, _) = store
.add(&name, &url, password.as_deref()) .add_verified(&name, &url, password.as_deref())
.await
.context("add share")?; .context("add share")?;
println!("added {} ({})", share.name, share.id); println!("added {} ({})", share.name, share.id);
} }
Command::AddFolder { name, folder } => {
let uid = parse_uid(&folder)?;
let share = store
.add_folder(
&name,
AccountFolder {
volume_id: uid.volume_id.as_str().to_owned(),
link_id: uid.link_id.as_str().to_owned(),
},
)
.context("add folder")?;
println!("added {} ({})", share.name, share.id);
}
Command::Login { username } => login(&accounts, &username).await?,
Command::Logout => {
let account = accounts.resume().ok().flatten();
accounts
.sign_out(account.as_ref())
.await
.context("sign out")?;
println!("signed out");
}
Command::Account => match accounts.username().context("read the account")? {
Some(username) => println!("signed in as {username}"),
None => println!("not signed in — sign in with `pstr login <address>`"),
},
Command::Browse { folder } => {
let account = signed_in(&accounts)?;
match folder {
None => {
for place in account.places().await.context("list places")? {
let kind = match place.kind {
PlaceKind::MyFiles => "files",
PlaceKind::Device => "device",
PlaceKind::SharedWithMe => "shared",
};
println!("{}\t{kind}\t{}", format_uid(&place.uid), place.name);
}
}
Some(folder) => {
let entries = account
.folder_children(&parse_uid(&folder)?)
.await
.context("list folder")?;
for entry in entries {
let kind = if entry.is_folder { "dir" } else { "file" };
println!("{}\t{kind}\t{}", format_uid(&entry.uid), entry.name);
}
}
}
}
Command::Sync => {
let account = signed_in(&accounts)?;
let sync = WatchSync::open(&dirs).context("open sync state")?;
let catalog =
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
let report = sync
.sync(&account, &catalog, &store)
.await
.context("sync")?;
let profile = &report.profile;
for id in &profile.shares_removed {
catalog
.lock()
.remove_share(id)
.context("drop a removed share")?;
}
println!(
"read {} other device(s), took {} newer position(s), \
{} share(s) added, {} removed, settings {}, {}",
report.devices,
report.applied,
profile.shares_added.len() + profile.shares_changed.len(),
profile.shares_removed.len(),
if profile.settings {
"updated"
} else {
"unchanged"
},
if report.uploaded {
"uploaded this device's history"
} else {
"this device's history was already up to date"
}
);
}
Command::Shares => { Command::Shares => {
let shares = store.list().context("list shares")?; let shares = store.list().context("list shares")?;
if shares.is_empty() { if shares.is_empty() {
@@ -239,17 +366,41 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else { } else {
"" ""
}; };
println!("{}\t{}{}", share.id, share.name, custom); let kind = if share.folder.is_some() {
" [account folder]"
} else {
""
};
println!("{}\t{}{}{}", share.id, share.name, custom, kind);
} }
} }
Command::Remove { id } => { Command::Remove { id } => {
store.remove(&id).context("remove share")?; store.remove(&id).context("remove share")?;
// Its catalog rows go too, or the library keeps listing its files —
// beside the new share's, when the same folder was added back
// another way.
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let offline = catalog
.offline_files_for_share(&id)
.context("read the share's downloads")?;
for (link_id, file) in &offline {
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
return Err(error).context("delete a downloaded file");
}
_ => {}
}
}
catalog
.remove_share(&id)
.context("drop the share's catalog rows")?;
println!("removed {id}"); println!("removed {id}");
} }
Command::Crawl { share } => { Command::Crawl { share } => {
let (library, failures) = SharedLibrary::open_all(&store) let account = accounts.resume().context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(&store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -643,7 +794,10 @@ async fn open_target(
println!("file: {} ({})", target.name, target.share_id); println!("file: {} ({})", target.name, target.share_id);
let (library, failures) = SharedLibrary::open_all(store) let account = AccountStore::new(std::sync::Arc::new(KeyringSecretStore))
.resume()
.context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -967,7 +1121,74 @@ async fn play(
Ok(()) Ok(())
} }
/// Unix seconds, for the "when was this asked" columns. /// Walk an interactive sign-in through whatever the account asks for.
async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
let password = read_password("Password: ")?;
let mut step = match accounts.sign_in(username, &password, None).await {
Ok(step) => step,
Err(pstr_core::Error::HumanVerification(challenge)) => {
// Solving a CAPTCHA needs a browser engine that can hand the
// token back, which a terminal does not have. The desktop app has
// one, and keeps its session in the same keyring entry.
anyhow::bail!(
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \
desktop app on this computer, which shows it and shares its session with \
pstr, or try again later. ({})",
challenge.verification_url()
);
}
Err(error) => return Err(error).context("sign in"),
};
loop {
step = match step {
SignIn::Done(account) => {
println!("signed in as {}", account.username());
return Ok(());
}
SignIn::SecondFactor(pending) => {
// Security keys need WebAuthn, which this sign-in does not speak.
let code = read_password("Code from your authenticator app: ")?;
pending
.second_factor(&code)
.await
.context("second factor")?
}
SignIn::MailboxPassword(pending) => {
let mailbox = read_password("Mailbox password: ")?;
SignIn::Done(
pending
.mailbox_password(&mailbox)
.await
.context("mailbox password")?,
)
}
};
}
}
fn signed_in(accounts: &AccountStore) -> Result<Account> {
accounts
.resume()
.context("resume the account")?
.ok_or_else(|| anyhow::anyhow!("not signed in — sign in with `pstr login <address>`"))
}
/// A node as `browse` prints it and `add-folder` takes it.
fn format_uid(uid: &NodeUid) -> String {
format!("{}:{}", uid.volume_id.as_str(), uid.link_id.as_str())
}
fn parse_uid(text: &str) -> Result<NodeUid> {
let (volume, link) = text
.split_once(':')
.filter(|(volume, link)| !volume.is_empty() && !link.is_empty())
.ok_or_else(|| anyhow::anyhow!("expected VOLUME:LINK, as `pstr browse` prints"))?;
Ok(NodeUid::new(
VolumeId::new(volume.to_owned()),
LinkId::new(link.to_owned()),
))
}
fn unix_now() -> i64 { fn unix_now() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
+2
View File
@@ -20,6 +20,8 @@ tracing.workspace = true
directories.workspace = true directories.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true
getrandom.workspace = true
# The credential store, per platform. Without the target's feature `keyring` # The credential store, per platform. Without the target's feature `keyring`
# compiles fine and silently resolves to its in-memory `mock` store, which # compiles fine and silently resolves to its in-memory `mock` store, which
+673
View File
@@ -0,0 +1,673 @@
//! Signing in to a Proton account, and keeping the session.
//!
//! The rest of the app works without one — public links need no account. A
//! signed-in account adds two things: folders of the viewer's own Drive (My
//! Files, other devices, what was shared with them) as library shares, and
//! watch history kept in their Drive so every device resumes where another
//! left off ([`crate::sync`]).
//!
//! ## What is kept, and where
//!
//! One JSON blob in the [`SecretStore`] holding the session tokens and, per
//! account key, the passphrase that unlocks it. Those passphrases are derived
//! at sign-in from the mailbox password and the key salts, and kept *instead*
//! of the password: they unlock the key chain just the same, but a store that
//! leaks gives away this app's access to the account rather than the password
//! — which, on a single-password account, is the login password itself.
//! Deriving them later is not an option either way: `core/v4/keys/salts`
//! needs a scope only a password sign-in grants, and answers 403 after the
//! first token refresh.
//!
//! Proton refresh tokens are single-use, so every refresh is written back
//! (see [`persist_refreshed_tokens`]). A refresh that is not leaves the store
//! holding a dead token and the next launch signed out.
//!
//! ## Signing in is several steps
//!
//! Password, then maybe a second-factor code, then — on an account with a
//! separate mailbox password — that. Each front end asks for them in its own
//! way, so [`AccountStore::sign_in`] returns a [`SignIn`] saying what it needs
//! next and holding the half-made session until it is given.
use std::collections::HashMap;
use std::sync::Arc;
use proton_drive_rs::{NodeKind, ProtonDriveClient};
use proton_sdk::api::HumanVerificationCredential;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::shares::{SecretStore, client_configuration};
/// The secret-store key the session is kept under. Share secrets are keyed
/// `share-…`, so the two cannot collide.
const SESSION_KEY: &str = "proton-account";
/// Why an account whose only second factor is a security key cannot sign in.
const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security key, which this \
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
with its code.";
/// Blocks in flight for streaming from the account, as for a public link —
/// see `ShareStore::open`.
const MAX_INFLIGHT_BLOCKS: usize = 48;
/// Everything needed to resume the session unattended.
#[derive(Serialize, Deserialize, Clone)]
struct StoredSession {
session_id: String,
username: String,
user_id: String,
access_token: String,
refresh_token: String,
scopes: Vec<String>,
/// `1` single password, `2` separate mailbox password — Proton's values.
password_mode: u8,
/// Key id → the passphrase that unlocks it. ASCII: they are bcrypt output.
key_passphrases: HashMap<String, String>,
}
impl StoredSession {
fn capture(
session: &ProtonApiSession,
tokens: proton_sdk::http::Tokens,
key_passphrases: HashMap<String, String>,
) -> Self {
Self {
session_id: session.session_id().as_str().to_owned(),
username: session.username().to_owned(),
user_id: session.user_id().as_str().to_owned(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scopes: session.scopes().to_vec(),
password_mode: match session.password_mode() {
PasswordMode::Single => 1,
PasswordMode::Dual => 2,
},
key_passphrases,
}
}
fn unlock(&self) -> HashMap<String, Vec<u8>> {
self.key_passphrases
.iter()
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
.collect()
}
fn resume_parameters(&self) -> ResumeParameters {
ResumeParameters {
session_id: self.session_id.clone().into(),
username: self.username.clone(),
user_id: self.user_id.clone().into(),
access_token: self.access_token.clone(),
refresh_token: self.refresh_token.clone(),
scopes: self.scopes.clone(),
is_waiting_for_second_factor_code: false,
password_mode: match self.password_mode {
1 => PasswordMode::Single,
_ => PasswordMode::Dual,
},
}
}
}
/// A signed-in account, ready for Drive work.
///
/// Cheap to clone; clones share one session and one set of caches.
#[derive(Clone)]
pub struct Account {
session: ProtonApiSession,
drive: ProtonDriveClient,
}
impl Account {
/// The address the viewer signed in with.
pub fn username(&self) -> &str {
self.session.username()
}
/// The Drive client the account's folders and watch history go through.
pub fn drive(&self) -> &ProtonDriveClient {
&self.drive
}
/// Where a library folder can come from: My Files, each device, and each
/// folder someone shared with the viewer.
///
/// The device this app keeps watch history in is left out — it holds
/// nothing to watch. A device or share whose name will not decrypt is
/// still listed, under a placeholder, rather than hidden: it is the
/// viewer's, and they can still open it.
pub async fn places(&self) -> Result<Vec<DrivePlace>> {
let mut places = Vec::new();
let my_files = self.drive.get_my_files_folder().await?;
places.push(DrivePlace {
kind: PlaceKind::MyFiles,
name: "My files".to_owned(),
uid: my_files.uid,
});
let mut devices = self.drive.enumerate_devices().await?;
devices.sort_by_key(|device| device.creation_time);
for device in devices {
let name = device.name.unwrap_or_else(|_| "Unnamed device".to_owned());
if name == crate::sync::DEVICE_NAME {
continue;
}
places.push(DrivePlace {
kind: PlaceKind::Device,
name,
uid: device.root_folder_uid,
});
}
let shared: Vec<NodeUid> = self
.drive
.enumerate_shared_with_me()
.await?
.into_iter()
.map(|item| item.uid)
.collect();
if !shared.is_empty() {
let mut nodes = self.drive.enumerate_nodes(&shared).await?;
nodes.retain(|node| node.is_folder() && !node.trashed);
nodes.sort_by_key(|node| node.name.to_lowercase());
places.extend(nodes.into_iter().map(|node| DrivePlace {
kind: PlaceKind::SharedWithMe,
name: node.name,
uid: node.uid,
}));
}
Ok(places)
}
/// What one folder of the account holds: folders first, then files, each
/// by name. Trashed nodes are left out.
pub async fn folder_children(&self, folder: &NodeUid) -> Result<Vec<DriveEntry>> {
let uids = self
.drive
.enumerate_folder_children_node_uids(folder)
.await?;
if uids.is_empty() {
return Ok(Vec::new());
}
let mut entries: Vec<DriveEntry> = self
.drive
.enumerate_nodes(&uids)
.await?
.into_iter()
.filter(|node| !node.trashed)
.map(|node| {
let (size, media_type) = match &node.kind {
NodeKind::File {
media_type,
claimed_size,
..
} => (*claimed_size, Some(media_type.clone())),
_ => (None, None),
};
DriveEntry {
is_folder: node.is_folder(),
uid: node.uid,
name: node.name,
size,
media_type,
}
})
.collect();
entries.sort_by(|a, b| {
b.is_folder
.cmp(&a.is_folder)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// End the session on Proton's side. Best-effort: a session that is
/// already dead has nothing to end.
async fn end(&self) {
if let Err(error) = self.session.end().await {
tracing::debug!(%error, "ending the Proton session failed");
}
}
}
/// Which kind of place a [`DrivePlace`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaceKind {
MyFiles,
Device,
SharedWithMe,
}
/// A top-level place in the account's Drive.
#[derive(Debug, Clone)]
pub struct DrivePlace {
pub kind: PlaceKind,
pub name: String,
pub uid: NodeUid,
}
/// One node of a folder listing.
#[derive(Debug, Clone)]
pub struct DriveEntry {
pub uid: NodeUid,
pub name: String,
pub is_folder: bool,
/// Plaintext size, when the uploader recorded one.
pub size: Option<i64>,
pub media_type: Option<String>,
}
/// Where a sign-in stands.
pub enum SignIn {
/// Signed in; the session is stored.
Done(Account),
/// The account has two-factor authentication: ask for a code and pass it to
/// [`PendingSignIn::second_factor`].
SecondFactor(PendingSignIn),
/// The account has a separate mailbox password: ask for it and pass it to
/// [`PendingSignIn::mailbox_password`].
MailboxPassword(PendingSignIn),
}
/// A sign-in waiting on the viewer.
pub struct PendingSignIn {
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
password: String,
}
impl PendingSignIn {
/// Submit a second-factor code.
///
/// A wrong code fails here and leaves nothing behind; the front end starts
/// the sign-in over, because Proton does not accept a second try on the
/// same half-made session.
pub async fn second_factor(mut self, code: &str) -> Result<SignIn> {
self.session
.apply_second_factor_code(code.trim())
.await
.map_err(|error| match error {
ProtonError::Api(api) if (400..500).contains(&api.http_status) => {
Error::SignIn("that code was not accepted".to_owned())
}
other => other.into(),
})?;
self.after_second_factor().await
}
/// Submit the mailbox password, on an account that has one.
///
/// By reference, unlike the code: a mistyped mailbox password is checked
/// on this side, the session survives it, and the viewer can try again
/// without starting over.
pub async fn mailbox_password(&self, mailbox_password: &str) -> Result<Account> {
finish(
Arc::clone(&self.secrets),
self.session.clone(),
mailbox_password,
)
.await
}
async fn after_second_factor(self) -> Result<SignIn> {
if self.session.password_mode() == PasswordMode::Dual {
return Ok(SignIn::MailboxPassword(self));
}
let password = self.password.clone();
finish(self.secrets, self.session, &password)
.await
.map(SignIn::Done)
}
}
/// The signed-in account, if any, and signing in and out.
#[derive(Clone)]
pub struct AccountStore {
secrets: Arc<dyn SecretStore>,
}
impl AccountStore {
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
Self { secrets }
}
/// The address of the stored account, without touching the network.
pub fn username(&self) -> Result<Option<String>> {
Ok(self.load()?.map(|stored| stored.username))
}
/// Start signing in.
///
/// `verification` is the token from a solved CAPTCHA, after a first try
/// failed with [`Error::HumanVerification`]; the sign-in starts over with
/// it, because the gated attempt's SRP handshake is spent.
pub async fn sign_in(
&self,
username: &str,
password: &str,
verification: Option<HumanVerificationCredential>,
) -> Result<SignIn> {
let session = ProtonApiSession::begin_verified(
client_configuration(),
username.trim(),
password.as_bytes(),
verification.as_ref(),
)
.await
.map_err(classify_sign_in_failure)?;
let pending = PendingSignIn {
secrets: Arc::clone(&self.secrets),
session,
password: password.to_owned(),
};
if pending.session.is_waiting_for_second_factor() {
let methods = pending.session.second_factor_methods();
if methods.fido2 && !methods.totp {
// A security key needs WebAuthn, which this sign-in does not
// speak; asking for a code would only fail as a wrong one.
pending.session.end().await.ok();
return Err(Error::SignIn(SECURITY_KEY_ONLY.to_owned()));
}
return Ok(SignIn::SecondFactor(pending));
}
pending.after_second_factor().await
}
/// The stored account, resumed, or `None` when nobody is signed in.
///
/// Makes no request: a session whose refresh token has died is found out
/// by the first call that uses it, as [`Error::is_signed_out`].
pub fn resume(&self) -> Result<Option<Account>> {
let Some(stored) = self.load()? else {
return Ok(None);
};
let session = ProtonApiSession::resume(client_configuration(), stored.resume_parameters())?;
persist_refreshed_tokens(&self.secrets, &session, stored.key_passphrases.clone());
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
Ok(Some(account(session, drive)))
}
/// Forget the account: end its session and delete what was stored.
///
/// The stored session goes even when ending it fails — signing out on a
/// plane must still sign out.
pub async fn sign_out(&self, account: Option<&Account>) -> Result<()> {
if let Some(account) = account {
account.end().await;
}
self.secrets.delete(SESSION_KEY)
}
/// The stored session, or `None` for none — or for one this build cannot
/// read, such as the shape development builds before 1.2.0 stored, which
/// kept the mailbox password. Signing in again is the only fix for either,
/// and it replaces what is there.
fn load(&self) -> Result<Option<StoredSession>> {
let Some(encoded) = self.secrets.get(SESSION_KEY)? else {
return Ok(None);
};
Ok(serde_json::from_str(&encoded)
.inspect_err(|error| {
tracing::warn!(%error, "the stored Proton session is unreadable; sign in again");
})
.ok())
}
}
/// Unlock the key chain with `mailbox_password`, store the session, and hand
/// back the account.
///
/// The key passphrases are derived here, while the token still has the scope
/// the key salts need. Loading the addresses is what proves the mailbox
/// password: it unlocks the user keys, and a wrong password unlocks none of
/// them. Only the passphrases are stored; the password goes no further.
async fn finish(
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
mailbox_password: &str,
) -> Result<Account> {
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
probe
.account()
.addresses()
.await
.map_err(|error| match error {
ProtonError::InvalidOperation(_) => {
Error::SignIn("that mailbox password did not unlock the account".to_owned())
}
other => other.into(),
})?;
let key_passphrases = probe
.account()
.key_passphrases()
.await?
.into_iter()
.map(|(id, passphrase)| {
String::from_utf8(passphrase)
.map(|passphrase| (id, passphrase))
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
})
.collect::<Result<HashMap<_, _>>>()?;
let stored = StoredSession::capture(
&session,
session.current_tokens().await,
key_passphrases.clone(),
);
let encoded = serde_json::to_string(&stored)
.map_err(|e| Error::Config(format!("serialize the Proton session: {e}")))?;
secrets.set(SESSION_KEY, &encoded)?;
persist_refreshed_tokens(&secrets, &session, key_passphrases);
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
Ok(account(session, drive))
}
fn account(session: ProtonApiSession, drive: ProtonDriveClient) -> Account {
let drive = drive
.with_max_inflight_blocks(MAX_INFLIGHT_BLOCKS)
// A watch-history file is one small block; the single-request upload
// is one failure point where the draft dance is three.
.with_small_file_upload(true);
Account { session, drive }
}
/// Write every refreshed token pair back to the secret store.
///
/// Refresh tokens are single-use: once the HTTP client has spent the stored
/// one, only the pair it got back can resume the session.
fn persist_refreshed_tokens(
secrets: &Arc<dyn SecretStore>,
session: &ProtonApiSession,
key_passphrases: HashMap<String, String>,
) {
let secrets = Arc::clone(secrets);
let template = session.clone();
session.http().set_on_tokens_refreshed(move |tokens| {
let stored = StoredSession::capture(&template, tokens, key_passphrases.clone());
let written = serde_json::to_string(&stored)
.map_err(|e| Error::Config(e.to_string()))
.and_then(|encoded| secrets.set(SESSION_KEY, &encoded));
if let Err(error) = written {
tracing::warn!(%error, "the refreshed Proton session could not be stored");
}
});
}
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
///
/// Only a gate that can be solved in an app becomes
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
/// failure, which reads better than an empty verification page.
fn classify_sign_in_failure(error: ProtonError) -> Error {
if let ProtonError::Api(api) = &error {
if let Some(challenge) = api.human_verification()
&& challenge.supports_captcha()
{
return Error::HumanVerification(Box::new(challenge));
}
if api.http_status == 422 && !api.is_human_verification_required() {
return Error::SignIn(if api.message.is_empty() {
"Proton did not accept that address and password".to_owned()
} else {
api.message.clone()
});
}
}
error.into()
}
/// The token out of a message posted by Proton's verification page, or `None`
/// for anything that is not a completion.
///
/// The page posts plenty besides — resize requests, readiness pings — and
/// taking an unrecognised message for success would retry the sign-in with a
/// garbage token. Both spellings of the success type have shipped, and some
/// hosts hand the message over stringified twice.
pub fn verification_token(raw: &str) -> Option<String> {
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
if let Some(inner) = value.as_str()
&& let Ok(parsed) = serde_json::from_str(inner)
{
value = parsed;
}
let kind = value.get("type")?.as_str()?;
if !matches!(
kind,
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
) {
return None;
}
let token = value.get("payload")?.get("token")?.as_str()?;
(!token.is_empty()).then(|| token.to_owned())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use parking_lot::Mutex;
use super::*;
#[derive(Default)]
struct MemorySecrets(Mutex<HashMap<String, String>>);
impl SecretStore for MemorySecrets {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
fn stored() -> StoredSession {
StoredSession {
session_id: "session".to_owned(),
username: "viewer@proton.me".to_owned(),
user_id: "user".to_owned(),
access_token: "access".to_owned(),
refresh_token: "refresh".to_owned(),
scopes: vec!["drive".to_owned()],
password_mode: 2,
key_passphrases: HashMap::from([("key-1".to_owned(), "derived".to_owned())]),
}
}
#[test]
fn nobody_is_signed_in_until_a_session_is_stored() {
let store = AccountStore::new(Arc::new(MemorySecrets::default()));
assert_eq!(store.username().expect("read"), None);
assert!(store.resume().expect("resume").is_none());
}
#[test]
fn a_stored_session_resumes_as_its_account_without_a_request() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
.unwrap();
let store = AccountStore::new(secrets);
assert_eq!(
store.username().expect("read").as_deref(),
Some("viewer@proton.me")
);
let account = store.resume().expect("resume").expect("signed in");
assert_eq!(account.username(), "viewer@proton.me");
}
#[tokio::test]
async fn signing_out_forgets_the_stored_session() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
.unwrap();
let store = AccountStore::new(secrets);
store.sign_out(None).await.expect("sign out");
assert_eq!(store.username().expect("read"), None);
}
#[test]
fn the_stored_session_holds_no_password() {
let encoded = serde_json::to_string(&stored()).unwrap();
assert!(!encoded.contains("password\""), "{encoded}");
assert!(!encoded.contains("key_salts"), "{encoded}");
}
#[test]
fn a_session_stored_by_an_older_build_reads_as_signed_out() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(
SESSION_KEY,
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
"refresh_token":"r","scopes":[],"password_mode":1,
"mailbox_password":"hunter2","key_salts":[]}"#,
)
.unwrap();
let store = AccountStore::new(secrets);
assert_eq!(store.username().expect("read"), None);
assert!(store.resume().expect("resume").is_none());
}
#[test]
fn a_stored_session_keeps_its_password_mode() {
let params = stored().resume_parameters();
assert_eq!(params.password_mode, PasswordMode::Dual);
assert!(!params.is_waiting_for_second_factor_code);
}
#[test]
fn a_verification_completion_yields_its_token() {
let raw =
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
assert_eq!(verification_token(raw).as_deref(), Some("tok-1"));
let twice = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
assert_eq!(verification_token(twice).as_deref(), Some("tok-1"));
}
#[test]
fn messages_that_are_not_a_verification_completion_are_ignored() {
for raw in [
r#"{"type":"resize","payload":{"height":400}}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
"not json",
"",
] {
assert!(verification_token(raw).is_none(), "accepted: {raw}");
}
}
}
+98 -2
View File
@@ -7,11 +7,13 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::library::{Library, Title, sort_name}; use crate::catalog::CatalogNode;
use crate::library::{Library, Title, TitleKind, sort_name};
use crate::metadata::{MetadataRecord, TitleNames}; use crate::metadata::{MetadataRecord, TitleNames};
/// What order a grid is in. /// What order a grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sort { pub enum Sort {
/// By the name shown, ignoring a leading article. /// By the name shown, ignoring a leading article.
#[default] #[default]
@@ -50,6 +52,60 @@ impl Sort {
} }
} }
/// Which titles a grid shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Filter {
#[default]
All,
Series,
Films,
/// Started and not finished.
Watching,
/// Not started.
Unwatched,
/// With at least one episode kept on this device.
Downloaded,
}
impl Filter {
pub const ALL: [Self; 6] = [
Self::All,
Self::Series,
Self::Films,
Self::Watching,
Self::Unwatched,
Self::Downloaded,
];
pub fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Series => "Series",
Self::Films => "Films",
Self::Watching => "Watching",
Self::Unwatched => "Not started",
Self::Downloaded => "Downloaded",
}
}
/// Whether the grid shows `title`. `offline` says whether a file is kept
/// on this device, which only the front end knows.
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool {
let watched = title.watched_count();
match self {
Self::All => true,
Self::Series => title.kind == TitleKind::Series,
Self::Films => title.kind == TitleKind::Film,
Self::Watching => {
title.resume().is_some() || (watched > 0 && watched < title.episode_count())
}
Self::Unwatched => watched == 0 && title.resume().is_none(),
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
}
}
}
/// The name to show for `title`: the provider's, in the form the viewer /// The name to show for `title`: the provider's, in the form the viewer
/// picked, else the share's. /// picked, else the share's.
pub fn display_name<'a>( pub fn display_name<'a>(
@@ -392,4 +448,44 @@ mod tests {
assert_eq!(found[0].key, "sousou no frieren"); assert_eq!(found[0].key, "sousou no frieren");
assert_eq!(search(&library, &metadata, "").len(), 3); assert_eq!(search(&library, &metadata, "").len(), 3);
} }
#[test]
fn the_filters_split_the_library_by_kind_and_progress() {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let none = |_: &CatalogNode| false;
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none));
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none));
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none));
// Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film, none));
assert!(!Filter::Watching.admits(&film, none));
}
#[test]
fn the_downloaded_filter_admits_a_title_with_any_episode_kept_offline() {
let node = |link: &str, episode| CatalogNode {
share_id: "s".into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: format!("Show - S01E0{episode}.mkv"),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::ParsedName {
title: "Show".into(),
season: Some(1),
episode: Some(episode),
year: None,
episode_title: None,
},
};
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
let show = &library.titles[0];
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b"));
assert!(!Filter::Downloaded.admits(show, |_| false));
}
} }
+609 -15
View File
@@ -24,7 +24,7 @@ use crate::metadata::{
use crate::naming::{self, ParsedName}; use crate::naming::{self, ParsedName};
/// Bump when adding a migration. /// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 10; pub const SCHEMA_VERSION: i64 = 11;
const MIGRATION_V1: &str = r#" const MIGRATION_V1: &str = r#"
CREATE TABLE nodes ( CREATE TABLE nodes (
@@ -207,6 +207,51 @@ INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
UPDATE title_metadata SET enriched_at = NULL; UPDATE title_metadata SET enriched_at = NULL;
"#; "#;
/// What the last crawl of each share came to.
///
/// Per installation, like the rest of the catalog: when this device last
/// looked at a share is nothing another device needs. `crawled_at` is the last
/// crawl that finished, `attempted_at` the last that was tried, so a share that
/// keeps failing is not retried on every launch. `added` is how many files the
/// last finished crawl found that had never been seen before.
const MIGRATION_V11: &str = r#"
CREATE TABLE share_crawls (
share_id TEXT PRIMARY KEY,
crawled_at INTEGER NOT NULL,
attempted_at INTEGER NOT NULL,
files INTEGER NOT NULL,
added INTEGER NOT NULL,
error TEXT
);
"#;
/// How old a share's last crawl may be before an app looks again on its own.
/// Long enough that a launch rarely costs a crawl; short enough that a new
/// episode turns up the same day.
pub const CRAWL_STALE_AFTER_SECS: i64 = 6 * 60 * 60;
/// One share's row of `share_crawls`. See [`MIGRATION_V11`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrawlRecord {
/// Unix seconds; `0` for a share no crawl of has finished.
pub crawled_at: i64,
pub attempted_at: i64,
/// Playable files the last finished crawl stored.
pub files: u64,
/// Of those, how many no earlier crawl had seen.
pub added: u64,
/// Why the last attempt failed, when it did.
pub error: Option<String>,
}
impl CrawlRecord {
/// Whether the share is due another look: nothing has been tried within
/// `age_secs` of `now`.
pub fn is_stale(&self, now: i64, age_secs: i64) -> bool {
now - self.crawled_at.max(self.attempted_at) >= age_secs
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineFile { pub struct OfflineFile {
pub revision_id: String, pub revision_id: String,
@@ -254,6 +299,16 @@ fn title_track_prefs_row(
/// What `season = -1` means in `episode_metadata`: no season at all. /// What `season = -1` means in `episode_metadata`: no season at all.
const ABSOLUTE_SEASON: i64 = -1; const ABSOLUTE_SEASON: i64 = -1;
/// How long after an episode airs its guide keeps being asked about daily
/// while the provider has no name for it.
///
/// A guide is otherwise trusted as long as a match, a month, and one fetched
/// the week before an episode aired lists it unnamed — "Episode 2" with a
/// date — until the month is up. A name usually follows the air date by a day
/// or so; one that has not come in two weeks is not coming, and asking every
/// day for the rest of time would not change that.
const GUIDE_WAIT_DAYS: i64 = 14;
/// One catalog row: a file or folder inside a share. /// One catalog row: a file or folder inside a share.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogNode { pub struct CatalogNode {
@@ -398,6 +453,12 @@ impl Catalog {
tx.pragma_update(None, "user_version", 10)?; tx.pragma_update(None, "user_version", 10)?;
tx.commit()?; tx.commit()?;
} }
if version < 11 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V11)?;
tx.pragma_update(None, "user_version", 11)?;
tx.commit()?;
}
Ok(()) Ok(())
} }
@@ -459,10 +520,23 @@ impl Catalog {
} }
// Only files: a folder appearing is not something anyone watches. // Only files: a folder appearing is not something anyone watches.
tx.execute( let now = unix_now();
let added = tx.execute(
"INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at) "INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0", SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0",
params![share_id, unix_now()], params![share_id, now],
)?;
let files = nodes.iter().filter(|node| !node.is_folder).count();
tx.execute(
"INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error)
VALUES (?1, ?2, ?2, ?3, ?4, NULL)
ON CONFLICT (share_id) DO UPDATE SET
crawled_at = excluded.crawled_at,
attempted_at = excluded.attempted_at,
files = excluded.files,
added = excluded.added,
error = NULL",
params![share_id, now, files as i64, added as i64],
)?; )?;
if !retain_offline { if !retain_offline {
@@ -473,6 +547,41 @@ impl Catalog {
Ok(()) Ok(())
} }
/// Record that a crawl of `share_id` failed, keeping what the last
/// finished one stored.
pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> {
self.conn.execute(
"INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error)
VALUES (?1, 0, ?2, 0, 0, ?3)
ON CONFLICT (share_id) DO UPDATE SET
attempted_at = excluded.attempted_at,
error = excluded.error",
params![share_id, unix_now(), error],
)?;
Ok(())
}
/// What the last crawl of each share came to, by share id. A share never
/// crawled has no entry.
pub fn crawls(&self) -> Result<HashMap<String, CrawlRecord>> {
let mut statement = self.conn.prepare(
"SELECT share_id, crawled_at, attempted_at, files, added, error FROM share_crawls",
)?;
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
CrawlRecord {
crawled_at: row.get(1)?,
attempted_at: row.get(2)?,
files: row.get::<_, i64>(3)?.max(0) as u64,
added: row.get::<_, i64>(4)?.max(0) as u64,
error: row.get(5)?,
},
))
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Every file in a share, folders excluded, ordered for display. /// Every file in a share, folders excluded, ordered for display.
pub fn files(&self, share_id: &str) -> Result<Vec<CatalogNode>> { pub fn files(&self, share_id: &str) -> Result<Vec<CatalogNode>> {
let mut statement = self.conn.prepare( let mut statement = self.conn.prepare(
@@ -513,19 +622,54 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<_, _>>()?) Ok(rows.collect::<std::result::Result<_, _>>()?)
} }
/// Drop everything a share contributed, watch positions included. /// Every share the catalog holds files for. One no longer configured was
/// forgotten without [`Catalog::remove_share`] — by an older `pstr remove`
/// — and its rows still show up in the library beside whatever replaced it.
pub fn share_ids(&self) -> Result<Vec<String>> {
let mut statement = self
.conn
.prepare("SELECT DISTINCT share_id FROM nodes ORDER BY share_id")?;
let rows = statement.query_map([], |row| row.get(0))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Drop everything a share contributed except where it was watched.
/// ///
/// Unlike a recrawl — which replaces rows and deliberately leaves watch /// Watch state stays, as through a recrawl. It is history, and not only
/// state alone — this is the viewer saying they are done with the share. /// this device's: with an account signed in, the next sync would bring it
/// Keeping positions for files they can no longer reach would only leave /// straight back from the other devices' files, so deleting it here would
/// the "continue watching" row pointing at nothing. /// only make it flicker. Nothing shows it — the library pairs positions
/// with files, and the files are gone — until the share is added again,
/// when it picks up where it left off.
///
/// It is also copied, newer wins as in a merge, onto every other share that
/// holds the same file. A public link swapped for the account's own folder
/// reaches the very same links under a new share id, and without the copy
/// the swap reads as a library nobody has watched.
pub fn remove_share(&mut self, share_id: &str) -> Result<()> { pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?; let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute( tx.execute(
"DELETE FROM watch_state WHERE share_id = ?1", "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
SELECT other.share_id, w.link_id, w.position_secs, w.duration_secs, w.watched, w.updated_at
FROM watch_state w
JOIN nodes own ON own.share_id = w.share_id AND own.link_id = w.link_id
JOIN nodes other ON other.volume_id = own.volume_id
AND other.link_id = own.link_id
AND other.share_id <> own.share_id
WHERE w.share_id = ?1
ON CONFLICT (share_id, link_id) DO UPDATE SET
position_secs = excluded.position_secs,
duration_secs = excluded.duration_secs,
watched = excluded.watched,
updated_at = excluded.updated_at
WHERE excluded.updated_at > watch_state.updated_at
OR (excluded.updated_at = watch_state.updated_at
AND (excluded.watched > watch_state.watched
OR (excluded.watched = watch_state.watched
AND excluded.position_secs > watch_state.position_secs)))",
params![share_id], params![share_id],
)?; )?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute( tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1", "DELETE FROM offline_files WHERE share_id = ?1",
params![share_id], params![share_id],
@@ -534,6 +678,10 @@ impl Catalog {
"DELETE FROM node_first_seen WHERE share_id = ?1", "DELETE FROM node_first_seen WHERE share_id = ?1",
params![share_id], params![share_id],
)?; )?;
tx.execute(
"DELETE FROM share_crawls WHERE share_id = ?1",
params![share_id],
)?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
@@ -721,6 +869,46 @@ impl Catalog {
Ok(()) Ok(())
} }
/// Take each of `states` that is newer than what is recorded, in one
/// transaction, returning how many were taken.
///
/// Newer is a later `updated_at`. A tie is broken by the state itself —
/// watched over not, then the further position — rather than by who is
/// asking, so two devices holding the same tie settle on the same answer
/// instead of each keeping its own forever. See [`crate::sync`].
pub fn merge_watch_states(&mut self, states: &[(String, String, WatchState)]) -> Result<usize> {
let tx = self.conn.transaction()?;
let mut taken = 0;
{
let mut upsert = tx.prepare_cached(
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (share_id, link_id) DO UPDATE SET
position_secs = excluded.position_secs,
duration_secs = excluded.duration_secs,
watched = excluded.watched,
updated_at = excluded.updated_at
WHERE excluded.updated_at > watch_state.updated_at
OR (excluded.updated_at = watch_state.updated_at
AND (excluded.watched > watch_state.watched
OR (excluded.watched = watch_state.watched
AND excluded.position_secs > watch_state.position_secs)))",
)?;
for (share_id, link_id, state) in states {
taken += upsert.execute(params![
share_id,
link_id,
state.position_secs,
state.duration_secs,
state.watched as i64,
state.updated_at,
])?;
}
}
tx.commit()?;
Ok(taken)
}
/// Where playback left off, if it ever started. /// Where playback left off, if it ever started.
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> { pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
let state = self let state = self
@@ -992,14 +1180,27 @@ impl Catalog {
} }
/// When each matched title was last enriched, for the provider it is /// When each matched title was last enriched, for the provider it is
/// matched against. A title missing here has never been, or its match has /// matched against. A title missing here has never been, its match has
/// changed since. /// changed since, or its guide is waiting on the name of an episode that
/// has aired since — see `GUIDE_WAIT_DAYS`.
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> { pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
self.enrichment_ages_at(unix_now())
}
fn enrichment_ages_at(&self, now: i64) -> Result<HashMap<String, (ProviderId, i64)>> {
let mut statement = self.conn.prepare( let mut statement = self.conn.prepare(
"SELECT title_key, provider, enriched_at FROM title_metadata "SELECT title_key, provider, enriched_at FROM title_metadata t
WHERE enriched_at IS NOT NULL", WHERE enriched_at IS NOT NULL
AND NOT (enriched_at < ?1 - 86400
AND EXISTS (
SELECT 1 FROM episode_metadata e
WHERE e.title_key = t.title_key
AND e.name IS NULL
AND e.air_date <= date(?1, 'unixepoch')
AND e.air_date >= date(t.enriched_at, 'unixepoch',
'-' || ?2 || ' days')))",
)?; )?;
let rows = statement.query_map([], |row| { let rows = statement.query_map(params![now, GUIDE_WAIT_DAYS], |row| {
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
@@ -1014,6 +1215,36 @@ impl Catalog {
ages.insert(title_key, (provider, enriched_at)); ages.insert(title_key, (provider, enriched_at));
} }
} }
drop(statement);
// A file that arrived after the guide was fetched, and that the guide
// has no name for, is an episode the guide predates: the next one of a
// running show. Asked again once — the refetch is newer than the file,
// so a name the provider never gives does not make it ask forever.
if !ages.is_empty() {
let first_seen = self.first_seen()?;
let guides = self.all_episode_metadata()?;
for node in self.all_files()? {
let Some(number) = node.parsed.episode else {
continue;
};
let key = crate::library::title_key(&node.parsed.title);
let Some((_, enriched_at)) = ages.get(&key) else {
continue;
};
let seen = first_seen
.get(&(node.share_id, node.link_id))
.copied()
.unwrap_or(0);
let named = guides
.get(&key)
.and_then(|guide| guide.get(node.parsed.season, number))
.is_some_and(|episode| episode.name.is_some());
if seen > *enriched_at && !named {
ages.remove(&key);
}
}
}
Ok(ages) Ok(ages)
} }
} }
@@ -1806,6 +2037,129 @@ mod tests {
assert!(catalog.enrichment_ages().expect("ages").is_empty()); assert!(catalog.enrichment_ages().expect("ages").is_empty());
} }
#[test]
fn a_file_newer_than_its_guide_that_the_guide_cannot_name_asks_for_the_guide_again() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "kusuriya".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1_000,
manual: false,
})
.expect("store");
let enrichment = Enrichment {
episodes: vec![EpisodeMetadata {
season: Some(3),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Locusts".into()),
overview: None,
still_url: None,
air_date: None,
}],
backdrop_url: None,
chain: Vec::new(),
};
catalog
.set_enrichment("kusuriya", ProviderId::AniList, 1_000, &enrichment)
.expect("enrich");
let trusted = |catalog: &Catalog| {
catalog
.enrichment_ages()
.expect("ages")
.contains_key("kusuriya")
};
// Newer than the guide, but the guide names it.
let first = file("s", "e1", "Kusuriya S03E01.mkv");
catalog
.replace_share("s", std::slice::from_ref(&first))
.expect("crawl");
assert!(trusted(&catalog));
// The next episode, which the guide does not know yet.
catalog
.replace_share("s", &[first, file("s", "e2", "Kusuriya S03E02.mkv")])
.expect("crawl again");
assert!(!trusted(&catalog));
// Fetched again since, still without it: not asked about again.
catalog
.set_enrichment("kusuriya", ProviderId::AniList, unix_now() + 1, &enrichment)
.expect("enrich again");
assert!(trusted(&catalog));
}
#[test]
fn a_guide_fetched_before_an_episode_had_a_name_is_asked_again_once_it_airs() {
const DAY: i64 = 86_400;
// 2026-10-04, when the guide was fetched; episode 2 airs on the 9th.
let fetched = 1_791_072_000;
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "kusuriya".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: fetched,
manual: false,
})
.expect("store");
let episode = |number: u32, name: Option<&str>, air_date: &str| EpisodeMetadata {
season: Some(3),
number,
absolute: None,
entry_season: Some(1),
entry_number: Some(number),
name: name.map(str::to_owned),
overview: None,
still_url: None,
air_date: Some(air_date.into()),
};
let enrichment = Enrichment {
episodes: vec![
episode(1, Some("Locusts"), "2026-10-02"),
episode(2, None, "2026-10-09"),
],
backdrop_url: None,
chain: Vec::new(),
};
catalog
.set_enrichment("kusuriya", ProviderId::AniList, fetched, &enrichment)
.expect("enrich");
let trusted = |catalog: &Catalog, now: i64| {
catalog
.enrichment_ages_at(now)
.expect("ages")
.contains_key("kusuriya")
};
// Before episode 2 airs, a nameless future episode is no reason.
assert!(trusted(&catalog, fetched + 4 * DAY));
// Once it has aired, the guide is asked again.
assert!(!trusted(&catalog, fetched + 8 * DAY));
// Asked on the 12th and still nameless: not again the same day…
let refetched = fetched + 8 * DAY;
catalog
.set_enrichment("kusuriya", ProviderId::AniList, refetched, &enrichment)
.expect("enrich again");
assert!(trusted(&catalog, refetched + DAY / 2));
// …but the next.
assert!(!trusted(&catalog, refetched + DAY + 1));
// Two weeks past the air date it is given up on.
let given_up = fetched + 21 * DAY;
catalog
.set_enrichment("kusuriya", ProviderId::AniList, given_up, &enrichment)
.expect("enrich late");
assert!(trusted(&catalog, given_up + 2 * DAY));
}
/// Details round-trip, and refreshing a match's details — the same entry /// Details round-trip, and refreshing a match's details — the same entry
/// stored again — keeps what enrichment found for it. /// stored again — keeps what enrichment found for it.
#[test] #[test]
@@ -1882,6 +2236,91 @@ mod tests {
assert!(catalog.enrichment_ages().expect("ages").is_empty()); assert!(catalog.enrichment_ages().expect("ages").is_empty());
} }
#[test]
fn a_crawl_records_when_it_finished_and_what_it_added() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
.expect("crawl");
catalog
.replace_share(
"s",
&[
file("s", "a", "Show S01E01.mkv"),
file("s", "b", "Show S01E02.mkv"),
],
)
.expect("recrawl");
let record = &catalog.crawls().expect("read")["s"];
assert!(record.crawled_at > 0);
assert_eq!(record.attempted_at, record.crawled_at);
assert_eq!((record.files, record.added), (2, 1));
assert_eq!(record.error, None);
}
/// A failure says why without forgetting what the last good crawl found,
/// and the next good crawl clears it.
#[test]
fn a_failed_crawl_keeps_the_last_good_one_until_the_next() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
.expect("crawl");
catalog
.conn
.execute(
"UPDATE share_crawls SET crawled_at = 5, attempted_at = 5",
[],
)
.expect("age it");
catalog
.record_crawl_failure("s", "link expired")
.expect("fail");
let record = catalog.crawls().expect("read")["s"].clone();
assert_eq!((record.crawled_at, record.files), (5, 1));
assert!(record.attempted_at > 5);
assert_eq!(record.error.as_deref(), Some("link expired"));
assert!(!record.is_stale(record.attempted_at + 10, 60));
catalog
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
.expect("recrawl");
assert_eq!(catalog.crawls().expect("read")["s"].error, None);
}
#[test]
fn a_removed_share_forgets_its_crawls() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
.expect("crawl");
catalog.remove_share("s").expect("remove");
assert!(catalog.crawls().expect("read").is_empty());
}
#[test]
fn migration_v11_opens_a_v10_catalog() {
let conn = Connection::open_in_memory().expect("open");
for migration in [
MIGRATION_V1,
MIGRATION_V2,
MIGRATION_V3,
MIGRATION_V4,
MIGRATION_V5,
MIGRATION_V6,
MIGRATION_V7,
MIGRATION_V8,
MIGRATION_V9,
MIGRATION_V10,
] {
conn.execute_batch(migration).expect("migrate");
}
conn.pragma_update(None, "user_version", 10)
.expect("set version");
let catalog = Catalog::from_connection(conn).expect("upgrade");
assert!(catalog.crawls().expect("read").is_empty());
}
/// A file keeps the moment it first appeared across recrawls, and one /// A file keeps the moment it first appeared across recrawls, and one
/// that was there before this was recorded reads as 0. /// that was there before this was recorded reads as 0.
#[test] #[test]
@@ -2390,6 +2829,161 @@ mod tests {
assert_eq!(rows[0].link_id, "film"); assert_eq!(rows[0].link_id, "film");
} }
#[test]
fn removing_a_share_keeps_its_watch_state_out_of_sight() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("crawl");
let state = WatchState {
position_secs: 120.0,
duration_secs: Some(1440.0),
watched: false,
updated_at: 10,
};
catalog.set_watch_state("s1", "a", &state).expect("set");
catalog.remove_share("s1").expect("remove");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert!(library.titles.is_empty());
assert_eq!(catalog.watch_state("s1", "a").expect("read"), Some(state));
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("re-add");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
}
#[test]
fn removing_a_share_hands_its_watch_state_to_another_share_holding_the_same_file() {
let mut catalog = Catalog::in_memory().expect("open");
let at = |position_secs: f64, updated_at: i64| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
};
catalog
.replace_share(
"link",
&[
file("link", "a", "Show S01E01.mkv"),
file("link", "b", "Show S01E02.mkv"),
file("link", "gone", "Show S01E03.mkv"),
],
)
.expect("crawl the link");
catalog
.replace_share(
"folder",
&[
file("folder", "a", "Show S01E01.mkv"),
file("folder", "b", "Show S01E02.mkv"),
],
)
.expect("crawl the folder");
catalog
.set_watch_state("link", "a", &at(100.0, 10))
.unwrap();
catalog
.set_watch_state("link", "b", &at(100.0, 10))
.unwrap();
catalog
.set_watch_state("link", "gone", &at(100.0, 10))
.unwrap();
// Watched through the folder since: the link's older position must
// not overwrite it.
catalog
.set_watch_state("folder", "b", &at(900.0, 20))
.unwrap();
catalog.remove_share("link").expect("remove");
assert_eq!(
catalog.watch_state("folder", "a").unwrap(),
Some(at(100.0, 10))
);
assert_eq!(
catalog.watch_state("folder", "b").unwrap(),
Some(at(900.0, 20))
);
assert_eq!(catalog.watch_state("folder", "gone").unwrap(), None);
assert_eq!(
catalog.watch_state("link", "a").unwrap(),
Some(at(100.0, 10))
);
}
#[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open");
let at = |position_secs: f64, updated_at: i64| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
};
catalog
.set_watch_state("s1", "kept", &at(100.0, 50))
.unwrap();
catalog
.set_watch_state("s1", "taken", &at(100.0, 50))
.unwrap();
let taken = catalog
.merge_watch_states(&[
("s1".to_owned(), "kept".to_owned(), at(900.0, 40)),
("s1".to_owned(), "taken".to_owned(), at(900.0, 60)),
("s1".to_owned(), "new".to_owned(), at(5.0, 10)),
])
.expect("merge");
assert_eq!(taken, 2);
assert_eq!(
catalog.watch_state("s1", "kept").unwrap(),
Some(at(100.0, 50))
);
assert_eq!(
catalog.watch_state("s1", "taken").unwrap(),
Some(at(900.0, 60))
);
assert_eq!(catalog.watch_state("s1", "new").unwrap(), Some(at(5.0, 10)));
}
#[test]
fn a_merge_tie_settles_the_same_way_whichever_side_holds_it() {
let state = |position_secs: f64, watched: bool| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched,
updated_at: 70,
};
for (local, remote) in [
(state(300.0, false), state(1440.0, true)),
(state(1440.0, true), state(300.0, false)),
(state(300.0, false), state(600.0, false)),
(state(600.0, false), state(300.0, false)),
] {
let mut catalog = Catalog::in_memory().expect("open");
catalog.set_watch_state("s1", "a", &local).unwrap();
catalog
.merge_watch_states(&[("s1".to_owned(), "a".to_owned(), remote)])
.unwrap();
let winner =
if (local.watched, local.position_secs) > (remote.watched, remote.position_secs) {
local
} else {
remote
};
assert_eq!(catalog.watch_state("s1", "a").unwrap(), Some(winner));
}
}
#[test] #[test]
fn an_unwatched_file_has_no_watch_state() { fn an_unwatched_file_has_no_watch_state() {
let catalog = Catalog::in_memory().expect("open"); let catalog = Catalog::in_memory().expect("open");
+6
View File
@@ -88,6 +88,12 @@ impl AppDirs {
self.config.join("shares.json") self.config.join("shares.json")
} }
/// This installation's identity for watch-history sync. See
/// [`crate::sync`].
pub fn sync_file(&self) -> PathBuf {
self.config.join("sync.json")
}
/// The catalog database. /// The catalog database.
pub fn catalog_db(&self) -> PathBuf { pub fn catalog_db(&self) -> PathBuf {
self.data.join("catalog.db") self.data.join("catalog.db")
+36
View File
@@ -30,6 +30,42 @@ pub enum Error {
/// The caller asked for something that does not exist or does not apply. /// The caller asked for something that does not exist or does not apply.
#[error("{0}")] #[error("{0}")]
NotFound(String), NotFound(String),
/// Proton gated a sign-in behind a CAPTCHA. The front end presents
/// [`HumanVerification::verification_url`](proton_sdk::api::HumanVerification::verification_url)
/// and signs in again with the token it yields.
#[error("Proton wants to confirm you are human before signing in")]
HumanVerification(Box<proton_sdk::api::HumanVerification>),
/// No account is signed in, or its session can no longer be refreshed and
/// the viewer has to sign in again.
#[error("{0}")]
SignedOut(String),
/// Signing in failed for a reason the viewer can act on, such as a wrong
/// mailbox password.
#[error("{0}")]
SignIn(String),
/// A public link with a password of its own was opened without one. The
/// handshake refusal says nothing a viewer can act on; this does.
#[error("This link has a password of its own. Enter it to add the link.")]
PasswordNeeded,
}
impl Error {
/// Whether this is the account's session having ended: the refresh token
/// was revoked or expired, or the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it.
pub fn is_signed_out(&self) -> bool {
match self {
Self::SignedOut(_) => true,
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
api.is_invalid_refresh_token() || api.is_insufficient_scope()
}
_ => false,
}
}
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
+5 -1
View File
@@ -5,6 +5,7 @@
//! how a share is opened and how its contents are modelled. //! how a share is opened and how its contents are modelled.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod account;
pub mod appearance; pub mod appearance;
pub mod browse; pub mod browse;
pub mod catalog; pub mod catalog;
@@ -16,13 +17,16 @@ pub mod library;
pub mod metadata; pub mod metadata;
pub mod naming; pub mod naming;
pub mod prefs; pub mod prefs;
pub mod profile;
pub mod shares; pub mod shares;
pub mod shelves; pub mod shelves;
pub mod sync;
pub use account::{Account, AccountStore};
pub use error::{Error, Result}; pub use error::{Error, Result};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub use shares::KeyringSecretStore; pub use shares::KeyringSecretStore;
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary}; pub use shares::{SecretStore, Share, ShareClient, ShareStore, SharedLibrary};
pub use proton_drive_rs; pub use proton_drive_rs;
pub use proton_sdk; pub use proton_sdk;
+98 -6
View File
@@ -189,10 +189,44 @@ impl Title {
.max_by_key(|episode| episode.last_played()) .max_by_key(|episode| episode.last_played())
} }
/// What a click on the poster should play: whatever is part-watched, else /// What Continue watching offers for this title: the part-watched episode,
/// the first unwatched episode, else the first episode. /// or the one after the episode finished last.
///
/// Read off whichever episode was touched last. Finishing an episode is
/// the most ordinary moment to want the next, and a shelf that dropped the
/// show right then was missing its point. An episode reset to the start —
/// which is what leaving Continue watching does — says the viewer is done
/// with it, so the title is left out. One opened for a few seconds says
/// nothing either way and is passed over.
pub fn continue_with(&self) -> Option<&Episode> {
let latest = self
.episodes()
.filter(|episode| {
episode.watch.is_some_and(|watch| {
watch.position_secs == 0.0
|| watch.position_secs >= RESUME_FLOOR_SECS
|| episode.is_watched()
})
})
.max_by_key(|episode| episode.last_played())?;
if latest.resume_at().is_some() {
return Some(latest);
}
if !latest.is_watched() {
return None;
}
let at = self.position_of(&latest.node.share_id, &latest.node.link_id)?;
self.episodes()
.skip(at + 1)
.find(|episode| !episode.is_watched())
}
/// What a click on the poster should play: what Continue watching offers,
/// else whatever is part-watched, else the first unwatched episode, else
/// the first episode.
pub fn next_up(&self) -> Option<&Episode> { pub fn next_up(&self) -> Option<&Episode> {
self.resume() self.continue_with()
.or_else(|| self.resume())
.or_else(|| self.episodes().find(|episode| !episode.is_watched())) .or_else(|| self.episodes().find(|episode| !episode.is_watched()))
.or_else(|| self.episodes().next()) .or_else(|| self.episodes().next())
} }
@@ -357,13 +391,13 @@ impl Library {
self.titles.iter().find(|title| title.key == key) self.titles.iter().find(|title| title.key == key)
} }
/// Titles with something part-watched, most recent first. The top row of /// Titles with something to go on with — see [`Title::continue_with`] —
/// the library. /// most recent first. The top row of the library.
pub fn continue_watching(&self) -> Vec<&Title> { pub fn continue_watching(&self) -> Vec<&Title> {
let mut titles: Vec<&Title> = self let mut titles: Vec<&Title> = self
.titles .titles
.iter() .iter()
.filter(|title| title.resume().is_some()) .filter(|title| title.continue_with().is_some())
.collect(); .collect();
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played())); titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
titles titles
@@ -688,6 +722,64 @@ mod tests {
assert_eq!(library.continue_watching().len(), 1); assert_eq!(library.continue_watching().len(), 1);
} }
fn three_episodes() -> Vec<CatalogNode> {
vec![
node("s", "1", "Show - S01E01", Some(1), Some(1)),
node("s", "2", "Show - S01E02", Some(1), Some(2)),
node("s", "3", "Show - S01E03", Some(1), Some(3)),
]
}
#[test]
fn a_finished_episode_puts_the_next_one_in_continue_watching() {
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
let library = Library::build(three_episodes(), &states);
let next = library.titles[0].continue_with().unwrap();
assert_eq!(next.node.link_id, "2");
assert_eq!(next.resume_at(), None);
assert_eq!(library.continue_watching().len(), 1);
}
#[test]
fn a_finished_last_episode_leaves_continue_watching() {
let mut states = HashMap::new();
for link in ["1", "2", "3"] {
states.insert(("s".into(), link.into()), watch(1440.0, 1440.0, true, 10));
}
let library = Library::build(three_episodes(), &states);
assert!(library.continue_watching().is_empty());
}
/// Leaving Continue watching resets the episode to the start. Offering the
/// one after the last finished episode instead would bring the title
/// straight back.
#[test]
fn an_episode_reset_to_the_start_keeps_its_title_out_of_continue_watching() {
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
states.insert(("s".into(), "2".into()), watch(0.0, 1440.0, false, 20));
let library = Library::build(three_episodes(), &states);
assert!(library.continue_watching().is_empty());
}
#[test]
fn a_few_seconds_of_the_next_episode_do_not_hide_the_one_part_watched() {
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(700.0, 1440.0, false, 10));
states.insert(("s".into(), "2".into()), watch(4.0, 1440.0, false, 20));
let library = Library::build(three_episodes(), &states);
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "1");
}
#[test]
fn the_episode_after_the_finished_one_is_offered_past_an_older_unwatched_one() {
let mut states = HashMap::new();
states.insert(("s".into(), "2".into()), watch(1440.0, 1440.0, true, 10));
let library = Library::build(three_episodes(), &states);
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "3");
}
#[test] #[test]
fn a_watch_state_set_in_place_matches_a_rebuild() { fn a_watch_state_set_in_place_matches_a_rebuild() {
let nodes = vec![ let nodes = vec![
+808
View File
@@ -0,0 +1,808 @@
//! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches and the per-show track
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
//!
//! ## Registers
//!
//! Each synced thing is one *register*: a key (`share/<id>`,
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer
//! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share is a register with no value, so a
//! removal travels like any other change.
//!
//! ## Noticing changes
//!
//! Nothing that writes a setting or a share knows about sync. Instead
//! `sync-state.json` remembers every register as last synced, and each sync
//! compares it with what is on this device: whatever differs changed since,
//! and is stamped with the time of the sync. That costs a change made between
//! syncs its exact time, which matters only when two devices change the same
//! setting within one sync interval of each other.
//!
//! The first sync of an installation stamps what it finds at zero rather than
//! now. A fresh install holds only defaults, and those must not outrank the
//! library every other device already agreed on; what it has that nobody else
//! does is still added, since nothing older competes with it.
//!
//! ## What does not travel
//!
//! The volume and mute, which belong to the speakers in front of the viewer,
//! and every setting the front ends keep themselves: the cache budget, Wi-Fi
//! only, background audio, hardware decoding, window geometry.
//!
//! ## Secrets
//!
//! A share's register carries its link, fragment included, and its custom
//! password, and the TMDB API key travels too: without them another device
//! could list the share but never open it. The file holding them is in the
//! viewer's own Drive, end-to-end encrypted with their account's keys, which is
//! the same trust the credential store gives them locally; they are never
//! written anywhere else in plain text.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::catalog::{Catalog, TitleTrackPrefs};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
use crate::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata};
use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/";
const TRACKS: &str = "tracks/";
const MATCH: &str = "match/";
/// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them.
const SETTINGS: [(&str, &str, &[&str]); 3] = [
("settings/playback", "playback.json", &["volume", "muted"]),
("settings/appearance", "appearance.json", &[]),
("settings/metadata", "metadata.json", &[]),
];
const METADATA_SETTINGS: &str = "settings/metadata";
/// One synced value and when it last changed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Register {
/// `None` is a removal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
pub updated_at: i64,
}
pub(crate) type Registers = BTreeMap<String, Register>;
/// What this installation last synced, kept beside its sync identity.
#[derive(Debug, Default, Serialize, Deserialize)]
struct State {
registers: Registers,
}
/// A share as it travels: everything another device needs to open it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedShare {
name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
custom_password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
folder: Option<AccountFolder>,
}
/// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedMatch {
provider: ProviderId,
metadata: Option<TitleMetadata>,
fetched_at: i64,
}
/// What applying other devices' registers changed here, for the front end to
/// act on: reopen and crawl shares, repaint, reload the library.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProfileChanges {
/// Shares this device did not have, now configured. Not yet crawled.
pub shares_added: Vec<String>,
/// Shares whose name or secrets changed.
pub shares_changed: Vec<String>,
/// Shares removed elsewhere, now gone from the configuration here. Their
/// catalog rows and offline files are the front end's to drop, the same
/// way it does for a share removed on this device.
pub shares_removed: Vec<String>,
/// Whether any settings file or the TMDB key changed.
pub settings: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: usize,
}
impl ProfileChanges {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn shares(&self) -> bool {
!(self.shares_added.is_empty()
&& self.shares_changed.is_empty()
&& self.shares_removed.is_empty())
}
}
/// The local side of profile sync: where the state file lives, and the stores
/// registers are read from and applied to.
pub(crate) struct Profile<'a> {
pub store: &'a ShareStore,
pub catalog: &'a Mutex<Catalog>,
}
/// What is on this device, register by register.
#[derive(Debug, Default)]
struct Observed {
values: BTreeMap<String, Value>,
/// Keys that exist but could not be read — a share whose secret will not
/// decrypt. Neither a change nor a removal.
unknown: BTreeSet<String>,
/// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed.
shares_listed: bool,
}
fn state_file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("sync-state.json")
}
fn tmdb_secret() -> String {
format!("metadata-{}", ProviderId::Tmdb.as_str())
}
const TMDB_KEY: &str = "secret/metadata-tmdb";
impl Profile<'_> {
fn dirs(&self) -> &AppDirs {
&self.store.dirs
}
/// Read the state, notice what changed here since, take what other
/// devices have newer, and write the state back. Returns the registers to
/// upload and what changed on this device.
pub fn sync<'r>(
&self,
remote: impl IntoIterator<Item = &'r Registers>,
now: i64,
) -> Result<(Registers, ProfileChanges)> {
let path = state_file(self.dirs());
let stored = read_json::<State>(&path)?;
let first = stored.is_none();
let mut state = stored.unwrap_or_default();
let observed = self.observe();
stamp(&mut state.registers, observed, if first { 0 } else { now });
let winners = merge(&state.registers, remote);
let changes = self.apply(&mut state.registers, winners);
write_json(&path, &state)?;
Ok((state.registers, changes))
}
fn observe(&self) -> Observed {
let mut observed = Observed::default();
for (key, file, local) in SETTINGS {
match read_json::<Value>(&self.dirs().config.join(file)) {
Ok(Some(value)) => {
observed
.values
.insert(key.to_owned(), without(value, local));
}
Ok(None) => {}
Err(error) => {
tracing::warn!(%error, file, "a settings file was not synced");
observed.unknown.insert(key.to_owned());
}
}
}
match self.store.secrets.get(&tmdb_secret()) {
Ok(Some(key)) => {
observed
.values
.insert(TMDB_KEY.to_owned(), Value::String(key));
}
Ok(None) => {}
Err(_) => {
observed.unknown.insert(TMDB_KEY.to_owned());
}
}
if let Ok(shares) = self.store.list() {
observed.shares_listed = true;
for share in shares {
let key = format!("{SHARE}{}", share.id);
let secrets = if share.folder.is_some() {
None
} else {
match self.store.secrets_of(&share.id) {
Ok(secrets) => Some(secrets),
Err(_) => {
observed.unknown.insert(key);
continue;
}
}
};
let synced = SyncedShare {
name: share.name,
token: share.token,
url: secrets.as_ref().map(|secrets| secrets.url.clone()),
custom_password: secrets.and_then(|secrets| secrets.custom_password),
folder: share.folder,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(key, value);
}
}
}
let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks {
if let Ok(value) = serde_json::to_value(prefs) {
observed.values.insert(format!("{TRACKS}{title}"), value);
}
}
}
if let Ok(records) = catalog.all_metadata() {
for (title, record) in records {
if !record.manual {
continue;
}
let synced = SyncedMatch {
provider: record.provider,
metadata: record.metadata,
fetched_at: record.fetched_at,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(format!("{MATCH}{title}"), value);
}
}
}
observed
}
/// Apply each winning register here, recording it in `registers` once it
/// took. One that fails is left out, so the next sync tries it again.
fn apply(&self, registers: &mut Registers, winners: Registers) -> ProfileChanges {
let mut changes = ProfileChanges::default();
for (key, register) in winners {
match self.apply_one(&key, register.value.as_ref(), &mut changes) {
Ok(()) => {
registers.insert(key, register);
}
Err(error) => tracing::warn!(%error, key, "a synced change was not applied"),
}
}
changes
}
fn apply_one(
&self,
key: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
if let Some(id) = key.strip_prefix(SHARE) {
return self.apply_share(id, value, changes);
}
if let Some(title) = key.strip_prefix(TRACKS) {
if let Some(value) = value {
let prefs: TitleTrackPrefs = parse(value)?;
self.catalog.lock().set_title_track_prefs(title, &prefs)?;
changes.titles += 1;
}
return Ok(());
}
if let Some(title) = key.strip_prefix(MATCH) {
if let Some(value) = value {
let synced: SyncedMatch = parse(value)?;
self.catalog.lock().set_metadata(&MetadataRecord {
title_key: title.to_owned(),
provider: synced.provider,
metadata: synced.metadata,
fetched_at: synced.fetched_at,
manual: true,
})?;
changes.titles += 1;
}
return Ok(());
}
if key == TMDB_KEY {
match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
None => self.store.secrets.delete(&tmdb_secret())?,
}
changes.settings = true;
return Ok(());
}
if let Some((_, file, local)) = SETTINGS.iter().find(|(name, ..)| *name == key) {
let Some(Value::Object(incoming)) = value else {
return Ok(());
};
let path = self.dirs().config.join(file);
let before = read_json::<Value>(&path)?;
let mut merged = match &before {
Some(Value::Object(existing)) => existing.clone(),
_ => serde_json::Map::new(),
};
for (field, field_value) in incoming {
if !local.contains(&field.as_str()) {
merged.insert(field.clone(), field_value.clone());
}
}
let merged = Value::Object(merged);
if key == METADATA_SETTINGS {
self.metadata_settings_changing(before.as_ref(), &merged)?;
}
write_json(&path, &merged)?;
changes.settings = true;
return Ok(());
}
// A register a newer build writes. Kept, so it travels on.
Ok(())
}
fn apply_share(
&self,
id: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
let existing = self.store.list()?.into_iter().find(|share| share.id == id);
let Some(value) = value else {
if existing.is_some() {
self.store.remove(id)?;
changes.shares_removed.push(id.to_owned());
}
return Ok(());
};
let synced: SyncedShare = parse(value)?;
let expected = match (&synced.folder, &synced.url) {
(Some(folder), _) => folder.share_id(),
(None, Some(_)) => format!("share-{}", synced.token),
(None, None) => return Err(invalid("a synced link has no URL")),
};
if expected != id {
return Err(invalid("a synced share's id does not match what it opens"));
}
let secrets = synced.url.map(|url| ShareSecrets {
url,
custom_password: synced.custom_password,
});
let share = Share {
id: id.to_owned(),
name: synced.name,
token: synced.token,
has_custom_password: secrets
.as_ref()
.is_some_and(|secrets| secrets.custom_password.is_some()),
folder: synced.folder,
};
if existing.as_ref() == Some(&share)
&& secrets.as_ref().is_none_or(|secrets| {
self.store
.secrets_of(id)
.is_ok_and(|stored| &stored == secrets)
})
{
return Ok(());
}
self.store.put_synced(share, secrets.as_ref())?;
if existing.is_some() {
changes.shares_changed.push(id.to_owned());
} else {
changes.shares_added.push(id.to_owned());
}
Ok(())
}
/// Turning enrichment off or switching provider drops every stored answer
/// on the device that did it, which the settings page also does here.
fn metadata_settings_changing(&self, before: Option<&Value>, after: &Value) -> Result<()> {
let config = |value: Option<&Value>| -> MetadataConfig {
value
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_default()
};
let (before, after) = (config(before), config(Some(after)));
if !after.enabled || before.provider != after.provider {
self.catalog.lock().clear_metadata()?;
}
Ok(())
}
}
/// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() {
if register.value.is_some()
&& key.starts_with(SHARE)
&& observed.shares_listed
&& !observed.values.contains_key(key)
&& !observed.unknown.contains(key)
{
*register = Register {
value: None,
updated_at: now,
};
}
}
for (key, value) in observed.values {
let unchanged = registers
.get(&key)
.is_some_and(|register| same(&key, register.value.as_ref(), Some(&value)));
if !unchanged {
registers.insert(
key,
Register {
value: Some(value),
updated_at: now,
},
);
}
}
}
/// Whether two values of one register are the same choice.
///
/// A match is compared by what it points at: its stored details move every
/// time the provider is asked again, and a register re-stamped by every
/// refresh would bounce between devices for ever.
fn same(key: &str, a: Option<&Value>, b: Option<&Value>) -> bool {
if key.starts_with(MATCH) {
let pointer = |value: Option<&Value>| {
value.map(|value| {
(
value.get("provider").cloned(),
value
.get("metadata")
.and_then(|metadata| metadata.get("remote_id"))
.cloned(),
)
})
};
return pointer(a) == pointer(b);
}
a == b
}
/// The registers from other devices that are newer than this one's.
fn merge<'r>(local: &Registers, remote: impl IntoIterator<Item = &'r Registers>) -> Registers {
let mut winners = Registers::new();
for registers in remote {
for (key, register) in registers {
let current = winners.get(key).or_else(|| local.get(key));
if current.is_none_or(|current| newer(register, current)) {
winners.insert(key.clone(), register.clone());
}
}
}
winners
}
/// Newer wins; a tie between two different values goes the same way on every
/// device, or two of them would each keep their own.
fn newer(candidate: &Register, current: &Register) -> bool {
if candidate.updated_at != current.updated_at {
return candidate.updated_at > current.updated_at;
}
let order = |register: &Register| {
register
.value
.as_ref()
.map(Value::to_string)
.unwrap_or_default()
};
order(candidate) > order(current)
}
/// `value` without the fields that stay on this device.
fn without(value: Value, local: &[&str]) -> Value {
match value {
Value::Object(mut fields) => {
for field in local {
fields.remove(*field);
}
Value::Object(fields)
}
other => other,
}
}
fn parse<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
serde_json::from_value(value.clone()).map_err(|e| invalid(&e.to_string()))
}
fn invalid(reason: &str) -> crate::Error {
crate::Error::Config(format!("synced setting: {reason}"))
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use super::*;
use crate::shares::SecretStore;
#[derive(Default)]
struct MemorySecrets(Mutex<HashMap<String, String>>);
impl SecretStore for MemorySecrets {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
/// One installation's configuration and catalog.
pub(crate) struct Device {
root: PathBuf,
pub store: ShareStore,
pub catalog: Mutex<Catalog>,
}
impl Device {
pub(crate) fn new(tag: &str) -> Self {
let mut bytes = [0_u8; 8];
getrandom::fill(&mut bytes).unwrap();
let unique: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
let root = std::env::temp_dir().join(format!("pstr-profile-{tag}-{unique}"));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
Self {
root,
store: ShareStore::with_secret_store(dirs, Arc::new(MemorySecrets::default())),
catalog: Mutex::new(Catalog::in_memory().unwrap()),
}
}
pub(crate) fn lock(&self) -> parking_lot::MutexGuard<'_, Catalog> {
self.catalog.lock()
}
pub(crate) fn profile(&self) -> Profile<'_> {
Profile {
store: &self.store,
catalog: &self.catalog,
}
}
fn sync(&self, remote: &[&Registers], now: i64) -> (Registers, ProfileChanges) {
self.profile().sync(remote.iter().copied(), now).unwrap()
}
fn share_names(&self) -> Vec<String> {
self.store
.list()
.unwrap()
.into_iter()
.map(|share| share.name)
.collect()
}
}
impl Drop for Device {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#secretpart";
#[test]
fn a_share_added_on_one_device_arrives_on_another_with_its_secrets() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
laptop.store.add("Anime", LINK, Some("hunter2")).unwrap();
let (sent, _) = laptop.sync(&[], 20);
let (_, changes) = phone.sync(&[&sent], 30);
assert_eq!(changes.shares_added, ["share-ABCDEFGHIJ"]);
assert_eq!(phone.share_names(), ["Anime"]);
let secrets = phone.store.secrets_of("share-ABCDEFGHIJ").unwrap();
assert_eq!(secrets.url, LINK);
assert_eq!(secrets.custom_password.as_deref(), Some("hunter2"));
}
#[test]
fn a_share_removed_on_one_device_is_removed_on_the_others() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.store.add("Anime", LINK, None).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
laptop.store.remove("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[&phone_sent], 30);
let (_, changes) = phone.sync(&[&sent], 40);
assert_eq!(changes.shares_removed, ["share-ABCDEFGHIJ"]);
assert!(phone.share_names().is_empty());
}
#[test]
fn a_fresh_installation_takes_the_library_rather_than_its_defaults() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::load(&laptop.store.dirs).unwrap();
prefs.autoplay_next = false;
prefs.speed = 1.5;
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
// The new phone has written its defaults before it ever synced.
crate::prefs::save(&phone.store.dirs, &crate::prefs::PlaybackPrefs::default()).unwrap();
let (_, changes) = phone.sync(&[&sent], 30);
assert!(changes.settings);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert!(!taken.autoplay_next);
assert_eq!(taken.speed, 1.5);
}
#[test]
fn volume_and_mute_stay_on_the_device_that_set_them() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::PlaybackPrefs {
volume: 20.0,
muted: true,
..Default::default()
};
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
prefs.volume = 80.0;
prefs.muted = false;
prefs.subtitles = false;
crate::prefs::save(&phone.store.dirs, &prefs).unwrap();
phone.sync(&[], 5);
phone.sync(&[&sent], 30);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert_eq!(taken.volume, 80.0);
assert!(!taken.muted);
assert!(taken.subtitles, "the laptop's later choice wins");
}
#[test]
fn the_newer_change_wins_whichever_device_syncs_first() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
phone.sync(&[], 1);
let mut appearance = crate::appearance::load(&laptop.store.dirs).unwrap();
appearance.gradients = false;
crate::appearance::save(&laptop.store.dirs, &appearance).unwrap();
let (from_laptop, _) = laptop.sync(&[], 10);
appearance.gradients = true;
appearance.accent = crate::appearance::Accent::Peach;
crate::appearance::save(&phone.store.dirs, &appearance).unwrap();
let (from_phone, _) = phone.sync(&[&from_laptop], 20);
laptop.sync(&[&from_phone], 30);
for device in [&laptop, &phone] {
let seen = crate::appearance::load(&device.store.dirs).unwrap();
assert_eq!(seen.accent, crate::appearance::Accent::Peach);
assert!(seen.gradients);
}
}
#[test]
fn an_unchanged_device_sends_the_same_registers_again() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
let (first, _) = laptop.sync(&[], 10);
let (second, changes) = laptop.sync(&[&first], 20);
assert_eq!(first, second);
assert!(changes.is_empty());
}
#[test]
fn a_share_whose_secret_cannot_be_read_is_not_removed_elsewhere() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
laptop.sync(&[], 10);
laptop.store.secrets.delete("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[], 20);
assert!(sent["share/share-ABCDEFGHIJ"].value.is_some());
}
#[test]
fn a_hand_picked_match_and_a_shows_track_choice_travel() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let metadata = TitleMetadata {
provider: ProviderId::AniList,
remote_id: "21".to_owned(),
name: "One Piece".to_owned(),
original_name: None,
overview: None,
year: Some(1999),
kind: crate::library::TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: None,
};
laptop
.catalog
.lock()
.set_metadata(&MetadataRecord {
title_key: "one piece".to_owned(),
provider: ProviderId::AniList,
metadata: Some(metadata),
fetched_at: 5,
manual: true,
})
.unwrap();
let tracks = TitleTrackPrefs {
audio_language: Some("jpn".to_owned()),
..Default::default()
};
laptop
.catalog
.lock()
.set_title_track_prefs("one piece", &tracks)
.unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert_eq!(changes.titles, 2);
let catalog = phone.catalog.lock();
let record = catalog.metadata("one piece").unwrap().unwrap();
assert!(record.manual);
assert_eq!(record.metadata.unwrap().remote_id, "21");
assert_eq!(
catalog.title_track_prefs("one piece").unwrap(),
Some(tracks)
);
}
#[test]
fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone");
let mut forged = Registers::new();
forged.insert(
"share/share-OTHER".to_owned(),
Register {
value: Some(serde_json::json!({
"name": "x", "token": "ABCDEFGHIJ", "url": LINK
})),
updated_at: 10,
},
);
let (registers, changes) = phone.sync(&[&forged], 20);
assert!(changes.shares_added.is_empty());
assert!(!registers.contains_key("share/share-OTHER"));
}
}
+732 -40
View File
@@ -14,15 +14,28 @@
//! A library is not one folder. [`SharedLibrary`] opens every configured share //! A library is not one folder. [`SharedLibrary`] opens every configured share
//! and merges them into one catalog, so the app has a single browsable view //! and merges them into one catalog, so the app has a single browsable view
//! regardless of how many links it was given. //! regardless of how many links it was given.
//!
//! ## Folders from a signed-in account
//!
//! A share can also be a folder of the viewer's own Drive — My Files, another
//! device, or something shared with them — opened through the account signed
//! in by [`crate::account`] instead of a link. It has no secrets of its own:
//! the account session is the credential, and without it such a share simply
//! fails to open, the same as a revoked link. [`ShareClient`] puts both kinds
//! behind one surface so the crawl, the stream layer and the thumbnail path
//! do not care which one they hold.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient}; use proton_drive_rs::{
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
};
use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::account::Account;
use crate::config::{AppDirs, read_json, write_json}; use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -42,19 +55,26 @@ pub trait SecretStore: Send + Sync {
} }
/// The desktop OS credential store. /// The desktop OS credential store.
///
/// A value too long for one entry is split across several; see
/// [`ENTRY_UNITS`].
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct KeyringSecretStore; pub struct KeyringSecretStore;
/// One credential-store entry per key, with no splitting.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl KeyringSecretStore { struct KeyringEntries;
#[cfg(not(target_os = "android"))]
impl KeyringEntries {
fn entry(key: &str) -> Result<keyring::Entry> { fn entry(key: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?) Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
} }
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore { impl SecretStore for KeyringEntries {
fn set(&self, key: &str, value: &str) -> Result<()> { fn set(&self, key: &str, value: &str) -> Result<()> {
Self::entry(key)?.set_password(value)?; Self::entry(key)?.set_password(value)?;
Ok(()) Ok(())
@@ -76,6 +96,205 @@ impl SecretStore for KeyringSecretStore {
} }
} }
#[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
chunked::set(&KeyringEntries, key, value)
}
fn get(&self, key: &str) -> Result<Option<String>> {
chunked::get(&KeyringEntries, key)
}
fn delete(&self, key: &str) -> Result<()> {
chunked::delete(&KeyringEntries, key)
}
}
/// The most UTF-16 code units one credential-store entry is given.
///
/// Windows caps a credential's blob at 2560 bytes, and `keyring` stores it as
/// UTF-16: 1280 units. A share secret is far below that, but the account
/// session — tokens, scopes, the mailbox password and a salt per key — can
/// pass it, and failing to store a refreshed token signs the viewer out at
/// the next launch. The margin is for the header.
#[cfg(any(not(target_os = "android"), test))]
const ENTRY_UNITS: usize = 1200;
/// Splitting a long secret across several entries of an underlying store.
///
/// A short value is one entry, exactly as before splitting existed, so every
/// secret already stored still reads. A long one is written as `key#0`,
/// `key#1`, … with `key` itself holding [`HEADER`] and the count. The parts go
/// first and the header last, so a reader never finds a header naming parts
/// that are not there yet.
#[cfg(any(not(target_os = "android"), test))]
mod chunked {
use super::{ENTRY_UNITS, SecretStore};
use crate::error::Result;
/// Marks a split value. A short value that happens to start with it is
/// split too, so it cannot be mistaken for a header.
const HEADER: &str = "pstr-chunked:";
fn part(key: &str, index: usize) -> String {
format!("{key}#{index}")
}
fn parts_of(header: &str) -> Option<usize> {
header.strip_prefix(HEADER)?.parse().ok()
}
/// `value` cut into pieces of at most [`ENTRY_UNITS`] UTF-16 units, never
/// inside a character.
fn split(value: &str) -> Vec<&str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut units = 0;
for (at, ch) in value.char_indices() {
if units + ch.len_utf16() > ENTRY_UNITS {
pieces.push(&value[start..at]);
start = at;
units = 0;
}
units += ch.len_utf16();
}
pieces.push(&value[start..]);
pieces
}
pub(super) fn set(store: &dyn SecretStore, key: &str, value: &str) -> Result<()> {
let before = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
let fits = value.encode_utf16().count() <= ENTRY_UNITS && !value.starts_with(HEADER);
let written = if fits {
store.set(key, value)?;
0
} else {
let pieces = split(value);
for (index, piece) in pieces.iter().enumerate() {
store.set(&part(key, index), piece)?;
}
store.set(key, &format!("{HEADER}{}", pieces.len()))?;
pieces.len()
};
for index in written..before {
store.delete(&part(key, index))?;
}
Ok(())
}
pub(super) fn get(store: &dyn SecretStore, key: &str) -> Result<Option<String>> {
let Some(value) = store.get(key)? else {
return Ok(None);
};
let Some(count) = parts_of(&value) else {
return Ok(Some(value));
};
let mut joined = String::new();
for index in 0..count {
// A part gone missing is a value that no longer exists whole;
// half of a token is worse than none.
let Some(piece) = store.get(&part(key, index))? else {
return Ok(None);
};
joined.push_str(&piece);
}
Ok(Some(joined))
}
pub(super) fn delete(store: &dyn SecretStore, key: &str) -> Result<()> {
let count = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
store.delete(key)?;
for index in 0..count {
store.delete(&part(key, index))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use parking_lot::Mutex;
use super::*;
/// An underlying store that refuses what Windows would.
#[derive(Default)]
struct Limited(Mutex<BTreeMap<String, String>>);
impl SecretStore for Limited {
fn set(&self, key: &str, value: &str) -> Result<()> {
assert!(
value.encode_utf16().count() <= 1280,
"{key} is too long for one entry"
);
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
#[test]
fn a_short_secret_is_one_entry_as_it_always_was() {
let store = Limited::default();
set(&store, "share-a", "fragment").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "share-a").unwrap().as_deref(), Some("fragment"));
}
#[test]
fn a_secret_longer_than_an_entry_round_trips() {
let store = Limited::default();
let long: String = "aé😀".repeat(1500);
set(&store, "proton-account", &long).unwrap();
assert!(store.0.lock().len() > 2);
assert_eq!(get(&store, "proton-account").unwrap(), Some(long));
}
#[test]
fn a_shorter_value_leaves_no_parts_of_the_longer_one_behind() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
set(&store, "k", &"y".repeat(1300)).unwrap();
assert_eq!(store.0.lock().len(), 3);
set(&store, "k", "short").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("short"));
}
#[test]
fn a_value_that_looks_like_a_header_is_not_taken_for_one() {
let store = Limited::default();
set(&store, "k", "pstr-chunked:3").unwrap();
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("pstr-chunked:3"));
}
#[test]
fn deleting_a_split_secret_deletes_every_part() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
delete(&store, "k").unwrap();
assert!(store.0.lock().is_empty());
assert_eq!(get(&store, "k").unwrap(), None);
}
#[test]
fn a_split_secret_missing_a_part_reads_as_absent() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
store.delete("k#1").unwrap();
assert_eq!(get(&store, "k").unwrap(), None);
}
}
}
/// What the app records about a share, minus its secrets. /// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share { pub struct Share {
@@ -92,22 +311,63 @@ pub struct Share {
/// rather than after it fails. /// rather than after it fails.
#[serde(default)] #[serde(default)]
pub has_custom_password: bool, pub has_custom_password: bool,
/// Set when this share is a folder of the signed-in account rather than a
/// public link; `token` is then empty and there are no secrets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<AccountFolder>,
}
/// A folder of the signed-in account, kept as a library share.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountFolder {
pub volume_id: String,
pub link_id: String,
}
impl AccountFolder {
pub fn uid(&self) -> NodeUid {
NodeUid::new(
VolumeId::from(self.volume_id.clone()),
LinkId::from(self.link_id.clone()),
)
}
/// The share id this folder is kept under.
///
/// Derived, like a link's `share-{token}`, so the same folder added on two
/// devices is the same share — which is what lets watch history recorded
/// against it on one device land on the other. Hashed rather than spliced
/// in because Proton ids are base64 and may carry characters a catalog key
/// is better off without.
pub fn share_id(&self) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
hash.update(self.volume_id.as_bytes());
hash.update([0]);
hash.update(self.link_id.as_bytes());
let digest = hash.finalize();
let hex: String = digest[..12]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("drive-{hex}")
}
} }
/// The secrets for one share, as held in the credential store. /// The secrets for one share, as held in the credential store.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ShareSecrets { pub(crate) struct ShareSecrets {
/// The full share URL, fragment included. /// The full share URL, fragment included.
url: String, pub(crate) url: String,
/// The custom password, when the link has one. /// The custom password, when the link has one.
#[serde(default)] #[serde(default)]
custom_password: Option<String>, pub(crate) custom_password: Option<String>,
} }
/// The configured shares, and their secrets. /// The configured shares, and their secrets.
pub struct ShareStore { pub struct ShareStore {
dirs: AppDirs, pub(crate) dirs: AppDirs,
secrets: Arc<dyn SecretStore>, pub(crate) secrets: Arc<dyn SecretStore>,
} }
impl ShareStore { impl ShareStore {
@@ -136,9 +396,9 @@ impl ShareStore {
let mut shares = self.list()?; let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) { if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(format!( return Err(Error::Config(
"a share for token {token} is already configured" "that link is already in the library".to_owned(),
))); ));
} }
let secrets = ShareSecrets { let secrets = ShareSecrets {
@@ -154,6 +414,105 @@ impl ShareStore {
name: name.to_string(), name: name.to_string(),
token, token,
has_custom_password: secrets.custom_password.is_some(), has_custom_password: secrets.custom_password.is_some(),
folder: None,
};
shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?;
Ok(share)
}
/// [`Self::add`], but only once the link has been opened with the
/// password given. An empty `name` takes the shared folder's own.
///
/// A link that is mistyped, expired or behind the wrong password used to
/// be stored anyway, so the first try failed on the crawl and every retry
/// failed on "already configured" — the only way out was to remove a share
/// that had never worked. Opening first means a refused link leaves
/// nothing behind, and the client it opened is handed back for the crawl.
pub async fn add_verified(
&self,
name: &str,
url: &str,
custom_password: Option<&str>,
) -> Result<(Share, ShareClient)> {
let token = token_from_url(url)?;
let id = format!("share-{token}");
if self.list()?.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that link is already in the library".to_owned(),
));
}
let custom_password = custom_password.filter(|password| !password.is_empty());
let client = match open_link(url, custom_password).await {
Ok(client) => client,
// Asked only after a refusal: the link's public flags cost a round
// trip, and most links open without one.
Err(error) if custom_password.is_none() => {
let protected = ProtonDrivePublicLinkClient::info(client_configuration(), url)
.await
.is_ok_and(|info| info.is_custom_password_protected);
return Err(if protected {
Error::PasswordNeeded
} else {
error
});
}
Err(error) => return Err(error),
};
// A name is the viewer's to give, not theirs to invent before they
// can see anything: without one, the shared folder's own name.
let name = match name.trim() {
"" => client
.root_node()
.await
.ok()
.map(|root| root.name)
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Shared folder".to_owned()),
given => given.to_owned(),
};
let share = self.add(&name, url, custom_password)?;
Ok((share, client))
}
/// Record a share another installation has, with the secrets it opens
/// with, replacing what this one holds under the same id. See
/// [`crate::profile`].
pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> {
if let Some(secrets) = secrets {
self.store_secrets(&share.id, secrets)?;
}
let mut shares = self.list()?;
match shares.iter_mut().find(|existing| existing.id == share.id) {
Some(existing) => *existing = share,
None => shares.push(share),
}
write_json(&self.dirs.shares_file(), &shares)
}
/// A share's secrets, for syncing them to another installation.
pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> {
self.load_secrets(id)
}
/// Record a folder of the signed-in account as a share.
///
/// Nothing goes in the credential store: the folder opens through the
/// account's session, which [`crate::account::AccountStore`] keeps.
pub fn add_folder(&self, name: &str, folder: AccountFolder) -> Result<Share> {
let id = folder.share_id();
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that folder is already in the library".to_owned(),
));
}
let share = Share {
id,
name: name.to_string(),
token: String::new(),
has_custom_password: false,
folder: Some(folder),
}; };
shares.push(share.clone()); shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?; write_json(&self.dirs.shares_file(), &shares)?;
@@ -184,6 +543,11 @@ impl ShareStore {
.iter_mut() .iter_mut()
.find(|share| share.id == id) .find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?; .ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
if share.folder.is_some() {
return Err(Error::Config(
"that share is a folder of your account and has no link".to_owned(),
));
}
if share.token != token { if share.token != token {
return Err(Error::Config( return Err(Error::Config(
"that link is for a different share; add it as a new one".to_owned(), "that link is for a different share; add it as a new one".to_owned(),
@@ -203,6 +567,25 @@ impl ShareStore {
Ok(updated) Ok(updated)
} }
/// Call a share something else. Only the name changes: the id, which the
/// catalog, watch state and offline files are keyed by, stays. Sync sees
/// the new name as a change to the share's register, like any other.
pub fn rename(&self, id: &str, name: &str) -> Result<Share> {
let name = name.trim();
if name.is_empty() {
return Err(Error::Config("a share needs a name".to_owned()));
}
let mut shares = self.list()?;
let share = shares
.iter_mut()
.find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
share.name = name.to_owned();
let renamed = share.clone();
write_json(&self.dirs.shares_file(), &shares)?;
Ok(renamed)
}
/// Forget a share and its secrets. /// Forget a share and its secrets.
/// ///
/// The config entry goes first here, for the mirror-image reason: if the /// The config entry goes first here, for the mirror-image reason: if the
@@ -221,20 +604,22 @@ impl ShareStore {
self.secrets.delete(id) self.secrets.delete(id)
} }
/// Open a share as a visitor. /// Open a share: a link as a visitor, a folder through `account`.
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> { pub async fn open(&self, share: &Share, account: Option<&Account>) -> Result<ShareClient> {
if let Some(folder) = &share.folder {
let account = account.ok_or_else(|| {
Error::SignedOut(format!(
"sign in to Proton to open {}, a folder of your account",
share.name
))
})?;
return Ok(ShareClient::Account {
client: account.drive().clone(),
root: folder.uid(),
});
}
let secrets = self.load_secrets(&share.id)?; let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open( open_link(&secrets.url, secrets.custom_password.as_deref()).await
client_configuration(),
&secrets.url,
secrets.custom_password.as_deref(),
)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(client)
} }
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> { fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
@@ -254,9 +639,178 @@ impl ShareStore {
} }
} }
/// Open a public link as a visitor.
async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClient> {
let client = ProtonDrivePublicLinkClient::open(client_configuration(), url, custom_password)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(ShareClient::PublicLink(Box::new(client)))
}
/// Several opened shares, presented as one library. /// Several opened shares, presented as one library.
pub struct SharedLibrary { pub struct SharedLibrary {
clients: BTreeMap<String, ProtonDrivePublicLinkClient>, clients: BTreeMap<String, ShareClient>,
sessions: SessionAges,
}
/// How long a visitor session is assumed good for without a refresh.
///
/// A refresh costs one round trip. Not refreshing costs an authentication
/// failure that the viewer can only clear by restarting or recrawling.
pub const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
/// When each share's visitor session was last known good.
///
/// Sessions are short-lived and an app routinely sits open, or in the
/// background, for hours between one episode and the next, so age is a reason
/// to reauthenticate before opening anything. A share with no entry is stale.
#[derive(Debug, Default)]
pub struct SessionAges {
refreshed: parking_lot::Mutex<std::collections::HashMap<String, std::time::Instant>>,
}
impl SessionAges {
/// Record that `share_id`'s session was good just now.
pub fn mark(&self, share_id: &str) {
self.refreshed
.lock()
.insert(share_id.to_owned(), std::time::Instant::now());
}
fn is_stale(&self, share_id: &str) -> bool {
self.refreshed
.lock()
.get(share_id)
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR)
}
/// Run `operation`, reauthenticating around it.
///
/// Two guards, because there are two ways to lose a visitor session. Age is
/// the common one, and a timestamp catches it before the request. Anything
/// else — a session dropped early, a machine that slept through a clock
/// change — only shows up as a failure, so a fresh-looking session gets one
/// retry behind a refresh. `refresh` is best-effort: when it fails the
/// operation reports the real problem, which is more useful than a refresh
/// error standing in for it.
pub async fn run<T, E, R, RF, O, OF>(
&self,
share_id: &str,
refresh: R,
operation: O,
) -> std::result::Result<T, E>
where
R: Fn() -> RF,
RF: std::future::Future<Output = bool>,
O: Fn() -> OF,
OF: std::future::Future<Output = std::result::Result<T, E>>,
{
let stale = self.is_stale(share_id);
if stale && refresh().await {
self.mark(share_id);
}
match operation().await {
Err(_) if !stale => {
if refresh().await {
self.mark(share_id);
}
operation().await
}
result => result,
}
}
}
/// One opened share, whichever kind it is.
///
/// Cheap to clone: both clients share their caches and sessions between
/// clones.
#[derive(Clone)]
pub enum ShareClient {
/// A public link, opened as a visitor. Boxed: the visitor client is
/// several times the size of the account variant, and libraries are
/// mostly links, so every map entry would otherwise pay for it.
PublicLink(Box<ProtonDrivePublicLinkClient>),
/// A folder of the signed-in account.
Account {
client: ProtonDriveClient,
root: NodeUid,
},
}
impl ShareClient {
/// The share's top folder.
pub async fn root_node(&self) -> proton_sdk::error::Result<Node> {
match self {
Self::PublicLink(client) => client.get_root_node().await,
Self::Account { client, root } => client.get_node(root).await?.ok_or_else(|| {
proton_sdk::error::ProtonError::invalid_operation(
"the folder is no longer in your Drive",
)
}),
}
}
pub async fn enumerate_folder_children_node_uids(
&self,
folder: &NodeUid,
) -> proton_sdk::error::Result<Vec<NodeUid>> {
match self {
Self::PublicLink(client) => client.enumerate_folder_children_node_uids(folder).await,
Self::Account { client, .. } => {
client.enumerate_folder_children_node_uids(folder).await
}
}
}
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> proton_sdk::error::Result<Vec<Node>> {
match self {
Self::PublicLink(client) => client.enumerate_nodes(uids).await,
Self::Account { client, .. } => client.enumerate_nodes(uids).await,
}
}
pub async fn open_revision(&self, uid: &NodeUid) -> proton_sdk::error::Result<RevisionReader> {
match self {
Self::PublicLink(client) => client.open_revision(uid).await,
Self::Account { client, .. } => client.open_revision(uid).await,
}
}
pub async fn download_range(
&self,
uid: &NodeUid,
offset: u64,
length: u64,
) -> proton_sdk::error::Result<Vec<u8>> {
match self {
Self::PublicLink(client) => client.download_range(uid, offset, length).await,
Self::Account { client, .. } => client.download_range(uid, offset, length).await,
}
}
pub async fn download_thumbnail(
&self,
uid: &NodeUid,
kind: ThumbnailType,
) -> proton_sdk::error::Result<Option<Vec<u8>>> {
match self {
Self::PublicLink(client) => client.download_thumbnail(uid, kind).await,
Self::Account { client, .. } => client.download_thumbnail(uid, kind).await,
}
}
/// Replay a visitor handshake. An account session refreshes its own
/// tokens when one expires, so there is nothing to do for a folder.
pub async fn refresh_session(&self) -> proton_sdk::error::Result<()> {
match self {
Self::PublicLink(client) => client.refresh_session().await,
Self::Account { .. } => Ok(()),
}
}
} }
impl SharedLibrary { impl SharedLibrary {
@@ -265,8 +819,14 @@ impl SharedLibrary {
/// A share that fails to open is reported and skipped rather than failing /// A share that fails to open is reported and skipped rather than failing
/// the whole library — one revoked link should not make the other three /// the whole library — one revoked link should not make the other three
/// unwatchable. /// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> { ///
Self::open_all_reusing(store, BTreeMap::new()).await /// Folders of the account open through `account`; without one they are
/// reported as failures like any other share that will not open.
pub async fn open_all(
store: &ShareStore,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> {
Self::open_all_reusing(store, BTreeMap::new(), account).await
} }
/// Open every configured share, reusing the clients in `reusable`. /// Open every configured share, reusing the clients in `reusable`.
@@ -278,25 +838,30 @@ impl SharedLibrary {
/// link. /// link.
pub async fn open_all_reusing( pub async fn open_all_reusing(
store: &ShareStore, store: &ShareStore,
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>, mut reusable: BTreeMap<String, ShareClient>,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> { ) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new(); let mut clients = BTreeMap::new();
let mut failures = Vec::new(); let mut failures = Vec::new();
// A reused client's session is of unknown age, so it is left stale; one
// opened here has just completed its handshake.
let sessions = SessionAges::default();
for share in store.list()? { for share in store.list()? {
if let Some(client) = reusable.remove(&share.id) { if let Some(client) = reusable.remove(&share.id) {
clients.insert(share.id, client); clients.insert(share.id, client);
continue; continue;
} }
match store.open(&share).await { match store.open(&share, account).await {
Ok(client) => { Ok(client) => {
sessions.mark(&share.id);
clients.insert(share.id.clone(), client); clients.insert(share.id.clone(), client);
} }
Err(e) => failures.push((share, e)), Err(e) => failures.push((share, e)),
} }
} }
Ok((Self { clients }, failures)) Ok((Self { clients, sessions }, failures))
} }
/// The opened clients, minus `dropping`, ready to hand to /// The opened clients, minus `dropping`, ready to hand to
@@ -305,19 +870,22 @@ impl SharedLibrary {
/// The excluded share is the one whose credentials or membership just /// The excluded share is the one whose credentials or membership just
/// changed; leaving it out is what makes a removed share's live client /// changed; leaving it out is what makes a removed share's live client
/// unreachable rather than merely stale. /// unreachable rather than merely stale.
pub fn reusable_clients( ///
&self, /// Folders of the account are never handed back: they carry the session
dropping: &str, /// they were opened with, and the account may have signed out or changed
) -> BTreeMap<String, ProtonDrivePublicLinkClient> { /// since.
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
self.clients self.clients
.iter() .iter()
.filter(|(id, _)| id.as_str() != dropping) .filter(|(id, client)| {
id.as_str() != dropping && matches!(client, ShareClient::PublicLink(_))
})
.map(|(id, client)| (id.clone(), client.clone())) .map(|(id, client)| (id.clone(), client.clone()))
.collect() .collect()
} }
/// The client for one share. /// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> { pub fn client(&self, share_id: &str) -> Option<&ShareClient> {
self.clients.get(share_id) self.clients.get(share_id)
} }
@@ -334,9 +902,37 @@ impl SharedLibrary {
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?; .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
client.refresh_session().await?; client.refresh_session().await?;
self.sessions.mark(share_id);
Ok(()) Ok(())
} }
/// Run `operation` against one share, refreshing its visitor session first
/// when it is old and once more if the operation fails. See
/// [`SessionAges::run`]. Every open of something on a share goes through
/// here, so an app left running for hours does not need restarting to play.
pub async fn reauthenticating<T, E, O, OF>(
&self,
share_id: &str,
operation: O,
) -> std::result::Result<T, E>
where
O: Fn() -> OF,
OF: std::future::Future<Output = std::result::Result<T, E>>,
{
self.sessions
.run(
share_id,
|| async {
match self.client(share_id) {
Some(client) => client.refresh_session().await.is_ok(),
None => false,
}
},
operation,
)
.await
}
/// Walk one share's whole subtree, level by level, yielding every node. /// Walk one share's whole subtree, level by level, yielding every node.
/// ///
/// There is no recursion helper on the visitor path — the authenticated /// There is no recursion helper on the visitor path — the authenticated
@@ -377,7 +973,7 @@ impl SharedLibrary {
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?; .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?; let root = client.root_node().await?;
let mut level = vec![root.uid.clone()]; let mut level = vec![root.uid.clone()];
let mut found = vec![root]; let mut found = vec![root];
@@ -433,7 +1029,7 @@ const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERS
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The API configuration every visitor client is built from. /// The API configuration every visitor client is built from.
fn client_configuration() -> ProtonClientConfiguration { pub(crate) fn client_configuration() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT) ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
} }
@@ -478,6 +1074,83 @@ fn token_from_url(url: &str) -> Result<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// An operation that fails its first `failures` calls, counting every call.
fn flaky(
failures: usize,
calls: &AtomicUsize,
) -> impl Fn() -> std::future::Ready<std::result::Result<(), ()>> {
move || {
let call = calls.fetch_add(1, Ordering::SeqCst);
std::future::ready(if call < failures { Err(()) } else { Ok(()) })
}
}
#[tokio::test]
async fn a_share_never_refreshed_is_refreshed_before_the_operation() {
let ages = SessionAges::default();
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
let refresh = || {
refreshes.fetch_add(1, Ordering::SeqCst);
std::future::ready(true)
};
ages.run("s", refresh, flaky(0, &calls)).await.unwrap();
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_fresh_session_is_not_refreshed_when_the_operation_succeeds() {
let ages = SessionAges::default();
ages.mark("s");
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
let refresh = || {
refreshes.fetch_add(1, Ordering::SeqCst);
std::future::ready(true)
};
ages.run("s", refresh, flaky(0, &calls)).await.unwrap();
assert_eq!(refreshes.load(Ordering::SeqCst), 0);
}
/// A session Proton dropped early looks fresh by age, and only the failure
/// gives it away.
#[tokio::test]
async fn a_fresh_session_that_fails_is_refreshed_once_and_retried() {
let ages = SessionAges::default();
ages.mark("s");
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
let refresh = || {
refreshes.fetch_add(1, Ordering::SeqCst);
std::future::ready(true)
};
ages.run("s", refresh, flaky(1, &calls)).await.unwrap();
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// A refresh just happened, so a second one would learn nothing: the
/// failure is the operation's own and is reported as it is.
#[tokio::test]
async fn a_stale_session_that_still_fails_after_its_refresh_is_not_retried() {
let ages = SessionAges::default();
let calls = AtomicUsize::new(0);
let result = ages
.run("s", || std::future::ready(true), flaky(5, &calls))
.await;
assert!(result.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_failed_refresh_leaves_the_session_stale() {
let ages = SessionAges::default();
let calls = AtomicUsize::new(0);
ages.run("s", || std::future::ready(false), flaky(0, &calls))
.await
.unwrap();
assert!(ages.is_stale("s"));
}
#[test] #[test]
fn a_share_url_yields_its_token() { fn a_share_url_yields_its_token() {
@@ -603,6 +1276,25 @@ mod tests {
); );
} }
#[test]
fn a_renamed_share_keeps_its_id_and_refuses_an_empty_name() {
let (store, _) = store_in("rename");
let added = store
.add(
"Library",
"https://drive.proton.me/urls/ABC123#s3cr3t",
None,
)
.expect("add");
let renamed = store.rename(&added.id, " Anime ").expect("rename");
assert_eq!(renamed.id, added.id);
assert_eq!(renamed.name, "Anime");
assert_eq!(store.list().expect("list")[0].name, "Anime");
assert!(store.rename(&added.id, " ").is_err());
assert!(store.rename("share-missing", "Anime").is_err());
}
/// A different link is a different share. Repointing this one at it would /// A different link is a different share. Repointing this one at it would
/// leave every catalog row and offline file describing files that are not /// leave every catalog row and offline file describing files that are not
/// there. /// there.
File diff suppressed because it is too large. Load diff
+16
View File
@@ -421,6 +421,22 @@ impl MetadataService {
} }
} }
/// Pin `title` to the entry the viewer picked, and enrich it.
///
/// Both halves, always: the point of choosing an entry by hand is usually
/// that the episode names were wrong too, so a choice that stored only the
/// title record left the episodes, the backdrop and the franchise chain of
/// the *old* match — or none — until some later match run got round to it.
/// A failed enrichment still keeps the choice, as in [`Self::work`].
pub async fn choose(&self, title: &Title, found: TitleMetadata) -> Outcome {
let enrichment = self.enrich_or_log(title, &found).await;
Outcome {
title_key: title.key.clone(),
record: Some(self.chosen(title.key.clone(), found)),
enrichment,
}
}
async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> { async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> {
self.enrich(title, found) self.enrich(title, found)
.await .await
+1 -1
View File
@@ -52,4 +52,4 @@ pub use player::{
}; };
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry}; pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
pub use render::VideoRenderer; pub use render::VideoRenderer;
pub use tracks::{Track, TrackKind, language_name, pick_track}; pub use tracks::{LANGUAGES, Track, TrackKind, language_name, pick_track};
+48 -2
View File
@@ -217,6 +217,9 @@ pub struct Player {
/// no stream callback can be running. /// no stream callback can be running.
mpv: Mpv, mpv: Mpv,
registry: Arc<StreamRegistry>, registry: Arc<StreamRegistry>,
/// [`Self::play_from`] set mpv's `start`, which holds for every file
/// after until it is put back.
start_set: std::sync::atomic::AtomicBool,
} }
impl Player { impl Player {
@@ -298,7 +301,11 @@ impl Player {
protocol::register(&mpv, &registry)?; protocol::register(&mpv, &registry)?;
let player = Self { mpv, registry }; let player = Self {
mpv,
registry,
start_set: std::sync::atomic::AtomicBool::new(false),
};
player.observe_playback_properties()?; player.observe_playback_properties()?;
Ok(player) Ok(player)
} }
@@ -325,11 +332,42 @@ impl Player {
/// The returned handle keeps the URL resolvable; drop it once the file is /// The returned handle keeps the URL resolvable; drop it once the file is
/// done with. Dropping it does not interrupt playback already underway. /// done with. Dropping it does not interrupt playback already underway.
pub fn play(&self, stream: VideoStream) -> Result<StreamHandle> { pub fn play(&self, stream: VideoStream) -> Result<StreamHandle> {
self.clear_start();
self.load(stream)
}
fn load(&self, stream: VideoStream) -> Result<StreamHandle> {
let handle = self.registry.publish(stream); let handle = self.registry.publish(stream);
self.mpv.command("loadfile", &[&handle.url()])?; self.mpv.command("loadfile", &[&handle.url()])?;
Ok(handle) Ok(handle)
} }
/// Start playing a stream `seconds` in.
///
/// Through mpv's `start` option rather than a seek once the file has
/// loaded: by then the demuxer has begun reading ahead from the
/// beginning, which on a stream is blocks fetched only to be thrown away,
/// queued in front of the ones the seek wants.
pub fn play_from(&self, stream: VideoStream, seconds: f64) -> Result<StreamHandle> {
self.mpv.set_property("start", format!("{seconds}"))?;
self.start_set
.store(true, std::sync::atomic::Ordering::Release);
self.load(stream)
}
/// Put `start` back once the file it was for has loaded, so the next
/// one starts at its beginning. [`Self::play`] puts it back too, for a
/// file that never loaded.
fn clear_start(&self) {
if self
.start_set
.swap(false, std::sync::atomic::Ordering::AcqRel)
&& let Err(error) = self.mpv.set_property("start", "none")
{
tracing::warn!(%error, "could not clear mpv's start");
}
}
/// Play anything else mpv can open: a local path, or one of its own /// Play anything else mpv can open: a local path, or one of its own
/// pseudo-protocols. /// pseudo-protocols.
/// ///
@@ -351,7 +389,15 @@ impl Player {
/// property that briefly has no value is normal, not fatal. /// property that briefly has no value is normal, not fatal.
pub fn poll_event(&self, timeout: f64) -> Option<PlayerEvent> { pub fn poll_event(&self, timeout: f64) -> Option<PlayerEvent> {
match self.mpv.wait_event(timeout)? { match self.mpv.wait_event(timeout)? {
Ok(event) => Some(translate(event)), Ok(event) => {
let event = translate(event);
// Not on an end of file: loading one file ends the one it
// replaces, before the new one has read `start`.
if matches!(event, PlayerEvent::FileLoaded) {
self.clear_start();
}
Some(event)
}
Err(error) => { Err(error) => {
tracing::debug!(%error, "mpv event carried an error"); tracing::debug!(%error, "mpv event carried an error");
Some(PlayerEvent::Other) Some(PlayerEvent::Other)
+10
View File
@@ -190,6 +190,16 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// The languages offered by name where a preference is chosen ahead of any
/// file, as the three-letter tag a Matroska file carries. The same list
/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two-
/// and three-letter forms as one.
pub const LANGUAGES: [&str; 29] = [
"jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor",
"fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum",
"gre", "ind", "may",
];
/// A readable name for a language tag, falling back to the tag itself. /// A readable name for a language tag, falling back to the tag itself.
/// ///
/// Deliberately a short list rather than a full ISO 639 table: it covers what /// Deliberately a short list rather than a full ISO 639 table: it covers what
+1 -1
View File
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks}; pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
pub use ring::{DEFAULT_RING_BYTES, RingStats}; pub use ring::{DEFAULT_RING_BYTES, RingStats};
pub use source::{RevisionOpener, StreamConfig, StreamSource}; pub use source::{RevisionOpener, StreamConfig, StreamSource};
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream}; pub use stream::{COPY_BLOCKS_IN_FLIGHT, DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
+1 -2
View File
@@ -13,7 +13,6 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::error::ProtonError; use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
@@ -159,7 +158,7 @@ impl LibraryOpener {
Self { library } Self { library }
} }
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> { fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
self.library self.library
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open"))) .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
+90 -5
View File
@@ -63,6 +63,12 @@ const MAX_CONCURRENT_BLOCKS_PER_READ: usize = 4;
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way. /// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12; pub const DEFAULT_READAHEAD_BLOCKS: usize = 12;
/// How many blocks of one offline copy are fetched at once, for
/// [`VideoStream::blocks_in_order`]. The same as one read is allowed, for the
/// same reason: enough to hide a round trip, few enough that a couple of
/// copies running together do not swamp a player.
pub const COPY_BLOCKS_IN_FLIGHT: usize = 4;
/// How far a read may land from where the previous one ended and still count as /// How far a read may land from where the previous one ended and still count as
/// a continuation rather than a seek. /// a continuation rather than a seek.
/// ///
@@ -217,20 +223,48 @@ impl VideoStream {
/// seek, and a seek cancels read-ahead — which here would cancel the very /// seek, and a seek cancels read-ahead — which here would cancel the very
/// fetch it was meant to have started. /// fetch it was meant to have started.
pub fn warm_edges(&self) { pub fn warm_edges(&self) {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
let count = self.inner.map.len(); let count = self.inner.map.len();
let edges: &[usize] = match count { let edges: &[usize] = match count {
0 => &[], 0 => &[],
1 => &[0], 1 => &[0],
_ => &[0, count - 1], _ => &[0, count - 1],
}; };
for &index in edges { self.warm(edges);
}
/// [`Self::warm_edges`], and the block `fraction` of the way through the
/// file: where a resumed episode seeks once its index is read.
///
/// An estimate, from the bytes being spread evenly over the running time.
/// A video's bitrate is not even, so it can land a block off the one the
/// seek wants. Then it is one block fetched for nothing, against a round
/// trip saved, after the two the edges cost, when it lands.
pub fn warm_resume(&self, fraction: f64) {
self.warm_edges();
if !(0.0..1.0).contains(&fraction) {
return;
}
let offset = (self.inner.map.size() as f64 * fraction) as u64;
let count = self.inner.map.len();
if let Some(index) = self.inner.map.block_at(offset)
&& index != 0
&& index + 1 != count
{
self.warm(&[index]);
}
}
/// Fetch these blocks in the background. Detached: see
/// [`Self::warm_edges`] for why not read-ahead.
fn warm(&self, indices: &[usize]) {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
for &index in indices {
let inner = Arc::clone(&self.inner); let inner = Arc::clone(&self.inner);
runtime.spawn(async move { runtime.spawn(async move {
if let Err(error) = block_at(inner, index).await { if let Err(error) = block_at(inner, index).await {
tracing::debug!(index, %error, "warming an edge block failed"); tracing::debug!(index, %error, "warming a block failed");
} }
}); });
} }
@@ -299,6 +333,42 @@ impl VideoStream {
Ok(buf) Ok(buf)
} }
/// Every block from `first` on, fetched `in_flight` at a time and yielded
/// in file order.
///
/// What an offline copy reads through. File order is what a partial file's
/// "its length is a block boundary" resume rule needs, and a few blocks in
/// flight is what keeps a copy from running one round trip per block. Use
/// it on a stream from [`crate::StreamSource::open_for_copy`]: a playback
/// stream would add its own read-ahead on top.
pub fn blocks_in_order(
&self,
first: usize,
in_flight: usize,
) -> futures::stream::BoxStream<'static, Result<Vec<u8>>> {
let start: u64 = self.block_sizes()[..first.min(self.block_sizes().len())]
.iter()
.sum();
let ranges: Vec<(u64, u64)> = self
.block_sizes()
.iter()
.skip(first)
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = self.clone();
stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(in_flight.max(1))
.boxed()
}
pub fn stats(&self) -> StreamStats { pub fn stats(&self) -> StreamStats {
StreamStats { StreamStats {
ring: self.inner.ring.stats(), ring: self.inner.ring.stats(),
@@ -995,6 +1065,21 @@ mod tests {
assert_eq!(fake.fetches(), 2); assert_eq!(fake.fetches(), 2);
} }
#[tokio::test]
async fn warming_a_resume_fetches_the_block_that_far_in_besides_the_edges() {
let fake = Fake::new(&[1000; 10]);
let stream = stream_over(Arc::clone(&fake), 0);
stream.warm_resume(0.45);
assert!(
eventually(|| {
fake.fetches_of(0) == 1 && fake.fetches_of(4) == 1 && fake.fetches_of(9) == 1
})
.await
);
assert_eq!(fake.fetches(), 3);
}
#[tokio::test] #[tokio::test]
async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() { async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() {
let disk = disk_in("fill").await; let disk = disk_in("fill").await;
+5 -5
View File
@@ -55,15 +55,15 @@ installs the signed minified APK and runs instrumentation against it, which is
the only path that exercises R8 — the step that has broken this app five distinct the only path that exercises R8 — the step that has broken this app five distinct
ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*` ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*`
variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation, variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation,
process recreation, playback and background controls, Picture-in-Picture, process recreation, playback and background controls,
download cancellation/resume, and an offline launch with networking disabled — download cancellation/resume, and an offline launch with networking disabled —
is `scripts/android-acceptance.sh`; `--list` prints every case and what it is `scripts/android-acceptance.sh`; `--list` prints every case and what it
needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share, needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share,
given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
reach the device as instrumentation arguments and are stored nowhere. Without reach the device as instrumentation arguments and are stored nowhere. Without
them those cases report `skip`. `picture-in-picture` is still `pending`, so the them those cases report `skip`. There is no Picture-in-Picture: it was removed
run tells you the matrix is incomplete — but no longer because of B40, which is after a PiP window that never sized its video right proved less use than
fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer background audio and the lock-screen transport. `docs/TESTING.md` has the layer-by-layer
detail, including why lint runs against a baseline. detail, including why lint runs against a baseline.
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
@@ -110,6 +110,6 @@ ABIs, plus a source archive and revision record, under
packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It
feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
memory. `PlaybackService` owns the mpv core for background audio and media memory. `PlaybackService` owns the mpv core for background audio and media
controls, while the activity supplies the current `Surface` and enters PiP. controls, while the activity supplies the current `Surface`.
Do not publish an Android binary until this exact pipeline is verified on real Do not publish an Android binary until this exact pipeline is verified on real
arm64 hardware and an x86_64 emulator. arm64 hardware and an x86_64 emulator.
+35 -1
View File
@@ -104,6 +104,36 @@ an edge case.
holds only the id, display name and token. holds only the id, display name and token.
- Nothing this app holds can write to the share: a viewer link grants - Nothing this app holds can write to the share: a viewer link grants
`MemberRole::Viewer`, and the SDK's public-link write side is unported anyway. `MemberRole::Viewer`, and the SDK's public-link write side is unported anyway.
- Signing in to a Proton account is optional (`pstr-core::account`). The
session — tokens, and per account key the passphrase that unlocks it,
derived at sign-in — is one blob in the same credential store, under
`proton-account`. The mailbox password itself is never stored. Only TOTP
second factors are spoken; an account whose only one is a security key is
refused with a reason before the code prompt. Refresh tokens are single-use, so
every refresh is written back. On the desktop the blob can outgrow one
credential entry — Windows caps one at 1280 UTF-16 units — so
`KeyringSecretStore` splits a long value across `key#0`, `key#1`, … under a
header in `key`. The account writes exactly one kind of thing: the
watch-history files on its own `proton-stream` device (`pstr-core::sync`),
including trashing the file of an installation quiet for 90 days once its
history is merged into this one's. Folders of the account added as shares
are only ever read.
- The same file carries the rest of what makes every device look alike
(`pstr-core::profile`): the shares, with each link and custom password so
another device can open them; the playback, appearance and metadata settings
except volume and mute; the TMDB key; hand-picked matches; per-show track
choices. Each is a register merged per key, newer wins, a removed share being
a register with no value. `sync-state.json` holds the registers as last
synced, and a sync stamps whatever differs from it, so nothing that writes a
setting has to know about sync. A first sync stamps what it finds at zero, so
a fresh install's defaults never outrank the library everyone else agreed on.
Automatic matches are not synced: every device with enrichment on finds them
again. The front end crawls what arrived (`uncrawled_shares` covers a sync
that ran in the background) and drops what was removed with its files.
- A CAPTCHA in front of a sign-in is shown in a webview, because the page
reports success by posting a message to its host. On the desktop that is a
child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux,
WebView2 on Windows. It may only navigate to `https://*.proton.me`.
- Signature verification is absent on the visitor path by design — an anonymous - Signature verification is absent on the visitor path by design — an anonymous
visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still
proves the bytes were encrypted to the node key. Nodes come back with nothing proves the bytes were encrypted to the node key. Nodes come back with nothing
@@ -118,7 +148,11 @@ an edge case.
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable | | Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
| Share secrets | OS credential store | **No** | | Share secrets | OS credential store | **No** |
| Catalog | data dir, `catalog.db` | Yes — recrawl | | Catalog | data dir, `catalog.db` | Yes — recrawl |
| Watch state | same DB, own table | **No** — deliberately survives a recrawl | | Watch state | same DB, own table | **No** — deliberately survives a recrawl and removing the share; with an account, mirrored to its Drive |
| Proton session | OS credential store, `proton-account` | No — sign in again |
| Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read |
| Sync state | config dir, `sync-state.json` | Yes — a missing one is a first sync, which takes everything other devices have |
| Watch history and profile in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode and per register, newer wins. Holds share links and passwords, end-to-end encrypted by Drive. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again |
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion | | Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px | | Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px |
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px | | Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
+43 -13
View File
@@ -20,8 +20,7 @@ and B41 states which of its items are done.
Individually small, grouped so they are not lost. The items marked **Landed** Individually small, grouped so they are not lost. The items marked **Landed**
came with Phases 4 and 5; the rest are still open, which is why this entry is. came with Phases 4 and 5; the rest are still open, which is why this entry is.
What remains is one item — the asynchronous track-list read-back — plus the What remains is one item: the asynchronous track-list read-back.
`SettingsStore` construction on the main thread.
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an - `NativeMpvHost.kt:131-135` reads the track list back immediately after an
asynchronous property set, so the selection tick lags by up to a second; asynchronous property set, so the selection tick lags by up to a second;
@@ -39,8 +38,10 @@ What remains is one item — the asynchronous track-list read-back — plus the
unreachable. The constructor is private behind `NativeMpvHost.createOrNull()`, unreachable. The constructor is private behind `NativeMpvHost.createOrNull()`,
the service holds a nullable host, and the binder hands back a nullable one — the service holds a nullable host, and the binder hands back a nullable one —
which is what `PlayerScreen` was already written for. which is what `PlayerScreen` was already written for.
- `PlaybackService.kt:43` constructs `SettingsStore` on the main thread inside a - **Landed.** `PlaybackService.kt:43` constructs `SettingsStore` on the main
hot callback; the first SharedPreferences access is disk I/O. thread inside a hot callback; the first SharedPreferences access is disk I/O.
`ProtonStreamApplication` now loads the file on a background thread at
startup, so every later read is from memory.
- **Landed.** `MainActivity` set `bound` only in `onServiceConnected`, so an - **Landed.** `MainActivity` set `bound` only in `onServiceConnected`, so an
activity destroyed with a bind still pending never unbound. It is now set from activity destroyed with a bind still pending never unbound. It is now set from
the `bindService` return value. the `bindService` return value.
@@ -117,15 +118,13 @@ position advances, which can only happen if real decrypted bytes reached the
decoder. One live run serves all five cases, because each re-crawl of a real decoder. One live run serves all five cases, because each re-crawl of a real
share costs about six minutes and the crawl is not what any of them tests. share costs about six minutes and the crawl is not what any of them tests.
**Still open, and the reason this entry stays open.** `picture-in-picture` is **Still open, and the reason this entry stays open.** The matrix has not been
`pending`. It is no longer blocked on the app — run on hardware since the 2026-10 redesign of every screen, and
[B40](#b40--picture-in-picture-was-entered-but-never-adapted-to) landed, so `process-recreation` has not been re-run since
there is correct behaviour to assert — but the live suite drives the engine and [B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
libmpv directly, and PiP is a property of the window, so the case needs an landed. The `picture-in-picture` case that was `pending` below went with
activity-level driver that does not exist yet. Every run still prints that the Picture-in-Picture itself, which was later removed (`docs/ANDROID.md`), so
matrix is incomplete. `process-recreation` has been promoted off its `xfail` nothing is `pending` any more.
now that [B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
has landed, and has not been re-run on hardware since.
**Verified on hardware**, 2026-08-08, Pixel 6 (`arm64-v8a`, API 37) against the **Verified on hardware**, 2026-08-08, Pixel 6 (`arm64-v8a`, API 37) against the
signed minified release APK: signed minified release APK:
@@ -146,6 +145,32 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B64 — Android offline downloads looked stuck
**Symptom.** A queued episode, and more so a queued season, sat at a progress
bar that did not move for long stretches, then jumped.
**Cause.** Three, compounding. WorkManager starts every queued worker whose
constraints are met, so "Download season" was a dozen transfers at once, each
with a share of the link. Each read through the *playback* stream, so behind
every block it read sat twelve blocks of read-ahead competing for the same
bandwidth — and filling the shared memory ring and the disk block cache, a
second copy of every byte. And progress was reported once per completed
in-order block, so the first report waited on all of that. The desktop had none
of these: three downloads at most, a copy stream with no read-ahead, four
blocks in flight.
**Fix.** The Android engine allows two downloads to transfer at once; the rest
wait for a slot, reported as queued (the worker no longer marks itself running
before its first progress report, and its notification says "Waiting to
download"). Downloads open through `StreamSource::open_for_copy` and read with
`VideoStream::blocks_in_order` at `COPY_BLOCKS_IN_FLIGHT`, now shared with the
desktop engine. The rows show a smoothed transfer rate beside the bytes, so a
slow download reads as slow rather than stuck.
**Verified.** Workspace gate and the Android host check pass, with
`TransferRateTest` covering the rate. Not yet observed on a device.
### B63 — a taskbar pin on Windows lost its icon ### B63 — a taskbar pin on Windows lost its icon
**Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at **Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at
@@ -1263,6 +1288,11 @@ out rather than leaving them mid-timeout.
suppressed. The `picture-in-picture` acceptance case is now implementable — it suppressed. The `picture-in-picture` acceptance case is now implementable — it
was `pending` precisely because the assertion would have had to assert this bug. was `pending` precisely because the assertion would have had to assert this bug.
**Since removed.** Picture-in-Picture itself was taken out later: a PiP window
that never sized its video right proved less use than background audio and the
lock-screen transport (`docs/ANDROID.md`). This entry records a fix to code
that no longer exists.
### B45 — the play/pause button did nothing, everywhere ### B45 — the play/pause button did nothing, everywhere
**Symptom.** Once an episode was open, every transport that can pause it was **Symptom.** Once an episode was open, every transport that can pause it was
+4 -1
View File
@@ -161,7 +161,10 @@ reads an external subtitle through the same protocol); the work is in
## Design decisions already taken ## Design decisions already taken
- **Fat client, no server.** Each viewer's app talks to Proton directly. - **Fat client, no server.** Each viewer's app talks to Proton directly.
- **Public links only**, but several, merged into one catalog. No account login. - **Public links first**, several, merged into one catalog. A Proton account
is optional: it adds folders of the viewer's own Drive as shares, and watch
history synced through a `proton-stream` device in that Drive — one file per
installation, so no two devices ever write the same file.
- **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS - **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS
subtitles with hardware decode, for free. subtitles with hardware decode, for free.
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or - **No transcoding.** mpv demuxes and decodes natively; there is no remux or
+90
View File
@@ -0,0 +1,90 @@
# iOS Development
The iOS client lives in `ios/`. It is a SwiftUI port of the Android client, screen
for screen and string for string, over the same Rust engine: `crates/pstr-android`
builds unchanged for `aarch64-apple-ios` as a static library, and UniFFI writes its
Swift bindings (module `PstrBridge`). It targets iOS 17 and newer on iPhone and
iPad. Keep reusable behaviour in the Rust crates; when Android changes, the iOS
file of the same name is the one to change with it.
| Android | iOS |
|---|---|
| `ui/AppViewModel.kt` | `App/AppModel.swift` |
| `ui/ProtonStreamApp.kt` | `App/ProtonStreamApp.swift` (`RootView`) |
| `native/NativeRuntime.kt`, `native/KeystoreSecretStore.kt` | `App/NativeRuntime.swift`, `App/KeychainSecretStore.swift` |
| `settings/SettingsStore.kt` | `App/SettingsStore.swift` (same keys, in `UserDefaults`) |
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
| `download/*` | `Download/*` |
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
Playback is libmpv from [MPVKit](https://github.com/mpvkit/MPVKit)'s GPL build,
rendering through MoltenVK into a `CAMetalLayer`, with the `pstr://` stream protocol
reading decrypted blocks straight out of Rust as on Android.
## Toolchain
macOS with Xcode 16 or newer, XcodeGen, and Rust 1.96 with the iOS targets:
```bash
brew install xcodegen
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
```
The Xcode project and `Info.plist` are generated from `ios/project.yml` and are not
committed, nor are the bindings under `ios/build/generated`.
```bash
scripts/build-ios.sh test # simulator static library, bindings, unit tests
scripts/build-ios.sh all # device static library, bindings, dist/proton-stream-<version>-ios.ipa
```
`all` is `rust`, `bindings` and `ipa` in order; each can be run on its own. After
`bindings` and an `xcodegen generate` in `ios/`, the project opens in Xcode as usual,
where a run on a device needs a development team set on the target.
The `.ipa` is unsigned. SideStore and AltStore re-sign what they install with the
viewer's own Apple ID, which is also why the app has no extensions: a free Apple ID
gets few app IDs, and each extension costs one.
`.github/workflows/ios.yml` runs the tests and builds the `.ipa` on `macos-15` for
every push to `main` and every pull request that touches the iOS sources or the Rust
crates under them. `release.yml` calls it too, and publishes the `.ipa` next to the
other artifacts.
`scripts/render-ios-icon.sh` renders `ios/ProtonStream/Resources/AppIcon.svg`, which
is Android's adaptive launcher icon with both layers on one canvas, into the asset
catalog.
## Where iOS differs
Each of these is the platform's doing, not a choice to diverge.
- **No Picture-in-Picture.** libmpv draws into a Metal layer, and AVKit's
Picture-in-Picture only takes frames from `AVPlayerLayer` or
`AVSampleBufferDisplayLayer`. Background audio and the lock-screen transport work
as on Android.
- **No share sheet.** A share extension would cost a second app ID. A link reaches
the app through its URL scheme — `protonstream://add?url=<link>`, or the link with
`https` swapped for `protonstream` — or through **Paste link** in the Add share
form, which finds a link anywhere in the clipboard's text.
- **Downloads run while the app does.** A transfer is a Rust task, not a
`URLSession` task, so iOS has nothing to hand it to in the background. A download
keeps going for the grace iOS gives a backgrounded app, then stops at a block
boundary and is queued again; it resumes from its `.part` file when the app next
comes forward. "Download on Wi-Fi only" stops transfers on a cellular or
otherwise expensive network rather than holding them back.
- **Sync runs when the app leaves the screen**, inside the same grace, in
place of Android's `WatchSyncWorker`, and every five minutes while it is open.
- **Background audio off pauses** the episode when the app leaves the screen, where
Android stops it.
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
stand in for the ripple, and a settings page goes back by an edge strip of its
own, as a title page does, where Android has the system back.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync
identity.
+3 -4
View File
@@ -255,10 +255,9 @@ three dispositions, and the third is the one to read:
| `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone | | `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone |
| `skip` | needs a share that was not configured | | `skip` | needs a share that was not configured |
Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is Today `process-recreation` is `xfail` against B35 — asserting today's behaviour
`pending` against B40 — asserting today's behaviour would encode the bug rather would encode the bug rather than catch it. The `picture-in-picture` case went
than catch it. **The matrix is not complete, and the runner says so on every with Picture-in-Picture itself, so nothing is `pending`.
run.**
The five live cases need a share, given as environment variables that reach the The five live cases need a share, given as environment variables that reach the
device as instrumentation arguments and are written nowhere: device as instrumentation arguments and are written nowhere:
+20
View File
@@ -0,0 +1,20 @@
# proton-stream for iOS license
SPDX-License-Identifier: GPL-3.0-or-later
Copyright (c) proton-stream contributors.
The iOS application under this directory is free software: you may
redistribute it and/or modify it under the terms of the GNU General Public
License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html)
for the complete license text.
This licensing choice applies to the iOS application and its distribution,
including the combined native player. Rust crates outside `ios/` retain the
licenses declared in their manifests; the repository root `LICENSE` remains the
MIT license for those components.
+821 -93
View File
@@ -1,140 +1,868 @@
import Foundation import Foundation
import Observation import Observation
import UIKit
/// The app's one Rust engine and the screen state read from it. /// The Proton account: where a sign-in stands, and how sync is doing.
struct AccountUiState: Equatable {
/// Nil until the bridge has said.
var state: AccountState?
/// A sign-in step is out and not answered yet.
var busy = false
/// Why the last sign-in step was refused.
var error: String?
/// The CAPTCHA page Proton wants solved before it accepts the sign-in.
var verificationUrl: String?
/// When watch history last synced.
var syncedAt: Date?
/// Positions the last sync took from other devices.
var applied = 0
/// Why the last sync failed, until one succeeds.
var syncError: String?
}
/// One episode's watch state as it was before a change the viewer can undo.
/// Nil `state` means it had never been played.
struct WatchSnapshot: Equatable {
let shareId: String
let linkId: String
let state: WatchStateRecord?
}
/// The app's screen state and every action on it: Android's `AppViewModel`,
/// field for field and message for message.
/// ///
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only /// Rust owns the Proton sessions, SQLite and every decrypted byte; this asks it
/// asks it questions off the main thread and keeps the answers for SwiftUI. /// questions off the main thread and keeps the answers for SwiftUI.
@MainActor @MainActor
@Observable @Observable
final class AppModel { final class AppModel {
let engine: AndroidEngine? var loading = true
private(set) var startupError: String? var refreshing = false
/// A crawl is running, which the banner offers to stop.
private(set) var crawling = false
private(set) var query = ""
var titles: [TitleRecord] = [] var titles: [TitleRecord] = []
/// The grid: `titles` ordered, each franchise folded into one tile when
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
/// when the grid falls back to `titles` as they are.
var arrangement: ArrangementRecord?
/// As the viewer last left them.
var filter: LibraryFilter = SettingsStore().libraryFilter
var sort: LibrarySort = SettingsStore().librarySort
var grouped = SettingsStore().libraryGrouped
var shares: [ShareRecord] = [] var shares: [ShareRecord] = []
var search = "" var offline: [OfflineRecord] = []
var crawling = false var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
var error: String? var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
/// What the snackbar says.
var message: String?
/// 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.
private(set) var activity: String?
/// What Undo on `message` puts back, when the message offers one.
var undo: [WatchSnapshot]?
var account = AccountUiState()
init() { @ObservationIgnored private var searchTask: Task<Void, Never>?
do { @ObservationIgnored private var started = false
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore()) /// Launch and the first `.active` both ask; one crawl answers.
} catch { @ObservationIgnored private var catchingUp = false
engine = nil /// Every banner line whose work is still running, oldest first.
startupError = String(describing: error) @ObservationIgnored private var activities: [(token: Int, line: String)] = []
@ObservationIgnored private var nextActivity = 0
/// Crawls running now.
@ObservationIgnored private var crawlsRunning = 0
/// Set by `stopCrawl`, so a queue of shares stops rather than going on to the next.
@ObservationIgnored private var crawlStopped = false
/// Five minutes, as on Android: cheap when nothing changed — one folder
/// listing, and no upload.
private static let syncInterval: Duration = .seconds(5 * 60)
func start() {
if started { return }
started = true
reload()
enteredForeground()
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
DownloadCoordinator.shared.resumeQueued()
Task {
let state = (try? await run { $0.accountState() }) ?? .signedOut
account.state = state
// While the app is alive, on top of after sign-in and whenever the
// player closes.
while !Task.isCancelled {
await syncWatchHistory()
try? await Task.sleep(for: Self.syncInterval)
}
} }
} }
/// Application Support survives updates and is backed up, which is what
/// the watch history wants. The block cache goes under Caches, which iOS
/// may purge and never backs up.
private static func paths() throws -> AndroidPaths {
let files = FileManager.default
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let config = support.appendingPathComponent("config", isDirectory: true)
let data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
struct NoEngine: Error, CustomStringConvertible {
var description: String { "the Rust engine did not start" }
}
/// Run a blocking bridge call off the main thread. The synchronous exports /// Run a blocking bridge call off the main thread. The synchronous exports
/// take the catalog lock and touch SQLite, which a scroll must not wait on. /// take the catalog lock and touch SQLite, which a scroll must not wait on.
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T { nonisolated func run<T: Sendable>(_ work: @escaping @Sendable (AndroidEngine) throws -> T) async throws -> T {
guard let engine else { throw NoEngine() } try await Task.detached(priority: .userInitiated) {
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value try work(try NativeRuntime.blockingEngine())
}.value
} }
func report(_ error: Error) { // MARK: - Library
self.error = String(describing: error)
/// Reorder the grid, or fold or unfold its franchises.
func arrange(_ sort: LibrarySort, _ grouped: Bool) {
self.sort = sort
self.grouped = grouped
let settings = SettingsStore()
settings.librarySort = sort
settings.libraryGrouped = grouped
Task { await reloadLibrary(query) }
} }
func reload() async { /// Narrow the grid to some titles, and keep the choice for the next launch.
let needle = search.trimmingCharacters(in: .whitespaces) func setFilter(_ filter: LibraryFilter) {
do { self.filter = filter
let (titles, shares) = try await run { engine in SettingsStore().libraryFilter = filter
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares()) Task { await reloadLibrary(query) }
} }
self.titles = titles
self.shares = shares func search(_ query: String) {
} catch { self.query = query
report(error) searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
if Task.isCancelled { return }
await reloadLibrary(query)
} }
} }
func crawl(shareId: String? = nil) async { func refresh() {
guard let engine, !crawling else { return } crawl(nil)
}
/// Recrawl one share rather than the whole library: a share whose link has
/// expired makes a whole-library refresh fail, and must not leave no way to
/// refresh the shares that still work.
func refreshShare(_ id: String) {
crawl(id)
}
private func crawl(_ shareId: String?) {
Task { await crawlNow(shareId) }
}
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
private func crawlShares(_ id: String?) async throws {
crawlsRunning += 1
crawling = true crawling = true
defer { crawling = false } defer {
do { crawlsRunning -= 1
try await engine.crawl(shareId: shareId) crawling = crawlsRunning > 0
} catch {
report(error)
} }
await reload() try await NativeRuntime.engine().crawl(shareId: id)
} }
func addShare(name: String, url: String, password: String?) async { /// Stop the crawls running now. Shares already read stay in the library;
do { /// the one being read keeps what it had.
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) } func stopCrawl() {
await crawl(shareId: share.id) crawlStopped = true
} catch { Task { try? await NativeRuntime.engine().stopCrawl() }
report(error) }
/// The crawl itself, for pull to refresh, which holds its spinner until
/// this returns.
func crawlNow(_ shareId: String?) async {
let name = shareId.flatMap { id in shares.first { $0.id == id }?.name }
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
message = nil
do {
try await crawlShares(shareId)
} catch {
message = errorMessage(error)
}
}
await reloadNow()
// Not awaited: pull to refresh lets go once the crawl is in, and the
// banner carries the lookup.
Task { await 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 func matchAfterCrawl() async {
if metadataSettings.enabled, metadataSettings.ready {
await match(force: false, quiet: true)
} }
} }
func repairShare(id: String, url: String, password: String?) async { /// Run enrichment again. With `force` every title is looked up afresh,
/// including ones that already matched.
func matchTitles(force: Bool) {
Task { await match(force: force) }
}
/// With `quiet`, a run nobody asked for says nothing when it found
/// nothing to do.
private func match(force: Bool, quiet: Bool = false) async {
await busy("Looking up titles…") {
do {
let summary = try await NativeRuntime.engine().matchTitles(force: force)
let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
if !quiet || found {
message = Self.describe(summary)
}
} catch {
message = errorMessage(error)
}
}
reload()
}
/// 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 func busy(_ what: String, _ work: () async -> Void) async {
let token = nextActivity
nextActivity += 1
activities.append((token, what))
showActivity()
await work()
activities.removeAll { $0.token == token }
showActivity()
}
private func showActivity() {
activity = activities.last?.line
refreshing = activity != nil
}
// MARK: - Shares
/// 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.
func addShare(name: String, url: String, password: String?) async -> String? {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
let share: ShareRecord
do { do {
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) } share = try await NativeRuntime.engine().addShare(name: name, url: url, customPassword: password)
await crawl(shareId: id)
} catch { } catch {
report(error) return errorMessage(error)
}
reload()
Task { await crawlAdded([share.id], announce: true) }
return nil
}
/// Crawl shares that are new here — added on this device or by sync —
/// then match them when enrichment is on.
private func crawlAdded(_ ids: [String], announce: Bool) async {
guard !ids.isEmpty else { return }
let before = Set(titles.map(\.key))
var failed: [String] = []
crawlStopped = false
for id in ids {
if crawlStopped { break }
let name = shares.first { $0.id == id }?.name ?? "the new share"
await busy("Reading \(name)…") {
do {
try await crawlShares(id)
} catch {
failed.append("\(name): \(errorMessage(error))")
}
}
}
await 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.isEmpty, announce {
message = failed.joined(separator: "\n")
return
}
await matchAfterCrawl()
if announce {
let added = Set(titles.map(\.key)).subtracting(before).count
message = added == 1 ? "1 title added" : "\(added) titles added"
} else {
let files = shares.filter { ids.contains($0.id) }.reduce(UInt64(0)) { $0 + ($1.crawlAdded ?? 0) }
if files > 0 { message = files == 1 ? "1 new file" : "\(files) new files" }
} }
} }
func removeShare(id: String) async { /// Re-supply the link behind a share whose stored secret cannot be read.
do { /// Re-entering the link rewrites the secret and leaves the catalog and the
try await run { try $0.removeShare(shareId: id) } /// offline files alone.
} catch { func repairShare(id: String, url: String, password: String?) {
report(error) Task {
let name = shares.first { $0.id == id }?.name ?? "the share"
await busy("Reading \(name) again…") {
do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
try await crawlShares(id)
} catch {
reportError(error)
}
await reloadNow()
}
await matchAfterCrawl()
} }
await reload()
} }
/// Marking seen puts the position at the duration where one is known, and /// Call a share something else. The library, history and downloads stay.
/// unwatching rewinds, as on desktop and Android. func renameShare(id: String, name: String) {
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async { Task {
do {
_ = try await run { try $0.renameShare(shareId: id, name: name) }
} catch {
reportError(error)
}
reload()
}
}
func removeShare(_ id: String) {
Task {
let name = shares.first { $0.id == id }?.name ?? "the share"
await busy("Removing \(name)…") {
do {
// Wait for the transfers to stop before Rust removes catalog
// and files. Rust also rejects any late publication.
await DownloadCoordinator.shared.cancelShare(id)
let store = DownloadStateStore.shared
let engine = try await NativeRuntime.engine()
for download in store.records() where download.shareId == id {
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
}
store.removeShare(id)
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
}
await reloadNow()
}
}
}
func dismissMessage() {
message = nil
undo = nil
}
// MARK: - Account
/// Start signing in, or start again with `verificationToken` once the
/// CAPTCHA from a first try is solved.
func signIn(_ username: String, _ password: String, verificationToken: String? = nil) {
signInStep { try await $0.signIn(username: username, password: password, verificationToken: verificationToken) }
}
func submitSecondFactor(_ code: String) {
signInStep { try await $0.submitSecondFactor(code: code) }
}
func submitMailboxPassword(_ password: String) {
signInStep(keepStepOnFailure: true) { try await $0.submitMailboxPassword(password: password) }
}
func cancelSignIn() {
account = AccountUiState(state: .signedOut)
Task { try? await run { $0.cancelSignIn() } }
}
/// The CAPTCHA page closed without a token.
func dismissVerification() {
account.verificationUrl = nil
account.busy = false
}
private func signInStep(keepStepOnFailure: Bool = false, _ step: @escaping (AndroidEngine) async throws -> SignInOutcome) {
account.busy = true
account.error = nil
account.verificationUrl = nil
Task {
do {
let outcome = try await step(NativeRuntime.engine())
switch outcome {
case let .done(username):
account = AccountUiState(state: .signedIn(username: username))
message = "Signed in as \(username)"
await syncWatchHistory()
reload()
case .secondFactor:
account.state = .secondFactor
account.busy = false
case .mailboxPassword:
account.state = .mailboxPassword
account.busy = false
case let .humanVerification(url):
account.verificationUrl = url
}
} catch {
account.busy = false
account.error = errorMessage(error)
// A refused code spends the half-made session.
if !keepStepOnFailure { account.state = .signedOut }
}
}
}
func signOut() {
Task {
await busy("Signing out…") {
do {
try await NativeRuntime.engine().signOut()
} catch {
reportError(error)
}
account = AccountUiState(state: .signedOut)
await reloadNow()
}
}
}
/// Sync now, from a tap: says so when it is done.
func syncNow() {
Task {
var synced = false
await busy("Syncing with your other devices…") { synced = await syncWatchHistory() }
guard synced else { return }
let applied = account.applied
message = applied > 0 ? "Synced, \(applied) positions from other devices" : "Everything is up to date"
}
}
/// Pull other devices' positions, shares and settings 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.
@discardableResult
func syncWatchHistory() async -> Bool {
guard case .signedIn = account.state else { return false }
do { do {
try await run { engine in guard let report = try await NativeRuntime.engine().syncWatchHistory() else { return false }
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs account.syncedAt = Date()
try engine.saveWatchState( account.applied = Int(report.applied)
shareId: episode.shareId, linkId: episode.linkId, account.syncError = nil
positionSecs: watched ? duration ?? 0 : 0, await takeSynced(report)
durationSecs: duration, watched: watched return true
} catch {
let state = try? await run { $0.accountState() }
if let state { account.state = state }
account.syncError = errorMessage(error)
// Signed out by the engine: the session ended, and that is worth
// interrupting for, unlike a dropped connection.
if state == .signedOut { reportError(error) }
return false
}
}
/// What another device changed: its shares crawled or dropped here, its
/// settings repainted.
private func takeSynced(_ report: SyncRecord) async {
for id in report.sharesRemoved {
await DownloadCoordinator.shared.cancelShare(id)
DownloadStateStore.shared.removeShare(id)
}
if report.settingsChanged {
await AppearanceState.shared.reload()
}
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty {
await reloadNow()
}
// A crawl takes longer than the grace iOS gives a backgrounded app.
// What is left uncrawled is picked up on the way back.
if !report.sharesAdded.isEmpty, UIApplication.shared.applicationState != .background {
await crawlAdded(report.sharesAdded, announce: false)
message = report.sharesAdded.count == 1 ? "A share arrived from another device" : "\(report.sharesAdded.count) shares arrived from other devices"
}
}
/// On launch and back on screen: crawl shares due a look — ones the
/// catalog has nothing of, brought by a sync that ran in the background or
/// an add whose crawl never finished, and ones not crawled for six hours,
/// so new episodes turn up without a pull to refresh.
func enteredForeground() {
guard !catchingUp else { return }
catchingUp = true
Task {
defer { catchingUp = false }
guard let ids = try? await run({ try $0.uncrawledShares() }), !ids.isEmpty else { return }
await crawlAdded(ids, announce: false)
}
}
/// Leaving the app: push where playback stopped, inside the grace iOS
/// gives a backgrounded app — what `WatchSyncWorker` does on Android.
func syncInBackground() {
guard case .signedIn = account.state else { return }
let task = BackgroundTask(name: "watch-history-sync")
Task {
await syncWatchHistory()
task.end()
}
}
/// After the player closes: where it stopped is what another device wants next.
func playerClosed() {
reload()
Task { await syncWatchHistory() }
}
func addAccountFolder(name: String, volumeId: String, linkId: String) {
Task {
do {
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
reload()
await crawlAdded([share.id], announce: true)
} catch {
reportError(error)
}
}
}
// MARK: - Watch state
/// 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.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
markEpisodes(title.playlist, watched, title.displayName)
}
/// Some episodes seen or unseen — a season, or a whole title — with an
/// Undo; `what` names them in the message.
func markEpisodes(_ episodes: [EpisodeRecord], _ watched: Bool, _ what: String) {
let episodes = episodes.filter { $0.watched != watched }
showWatched(episodes, watched)
changeWatch(episodes, "\(what) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
let duration = before?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration,
watched: watched
)
}
}
/// Off Continue watching by forgetting where the episode stopped — what the
/// desktop does — with an Undo that puts the position back.
func forgetPosition(_ title: TitleRecord, _ episode: EpisodeRecord) {
changeWatch([episode], "\(title.displayName) removed from Continue watching") { engine, target, before in
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
}
}
/// Off the history page: the position and the watched mark both go, which
/// is "never played", with an Undo.
func removeFromHistory(_ title: TitleRecord, _ episode: EpisodeRecord) {
changeWatch([episode], "\(episode.label) of \(title.displayName) removed from history") { engine, target, before in
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
}
}
/// Put back what the last undoable change replaced.
func performUndo() {
guard let snapshots = undo else { return }
message = nil
undo = nil
Task {
do {
try await run { engine in
for snapshot in snapshots {
try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state)
}
}
} catch {
reportError(error)
}
reload()
}
}
private func changeWatch(
_ episodes: [EpisodeRecord],
_ text: String,
_ change: @escaping @Sendable (AndroidEngine, EpisodeRecord, WatchStateRecord?) throws -> Void
) {
Task {
do {
let snapshots = try await run { engine in
try episodes.map { episode in
let before = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
try change(engine, episode, before)
return WatchSnapshot(shareId: episode.shareId, linkId: episode.linkId, state: before)
}
}
message = text
undo = snapshots
} catch {
reportError(error)
}
reload()
}
}
func reportError(_ error: Error) {
message = errorMessage(error)
}
func report(_ text: String) {
message = text
}
// MARK: - Metadata
/// Store the enrichment settings, and return why not, if they were not.
///
/// The page shows the new settings as soon as they are stored; the match
/// run that follows can take minutes and says so in the activity banner.
/// Before, the page only changed once that run had finished, so a save
/// looked like it had done nothing.
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
do {
metadataSettings = try await run { engine in
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
try engine.setMetadataApiKey(provider: provider, key: apiKey)
}
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
return try engine.metadataSettings()
}
} catch {
return errorMessage(error)
}
await reloadNow()
if enabled {
Task { await match(force: false) }
}
return nil
}
func reloadAfterMetadataChange() { reload() }
/// What an enrichment pass did, in one line. Failures are reported even
/// when most titles matched.
static func describe(_ summary: MatchSummary) -> String {
if summary.matched == 0, summary.unmatched == 0, summary.failed == 0 {
return "Everything is already matched"
}
var parts = ["\(summary.matched) matched"]
if summary.unmatched > 0 { parts.append("\(summary.unmatched) not found") }
if summary.episodes > 0 { parts.append("\(summary.episodes) episodes named") }
if summary.failed > 0 { parts.append("\(summary.failed) failed") }
return parts.joined(separator: ", ")
}
// MARK: - Offline
func removeOffline(_ file: OfflineRecord) {
Task {
await busy("Deleting the download…") {
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
} catch {
message = errorMessage(error)
}
await reloadNow()
}
}
}
func pauseDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.pause(download) }
func resumeDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.resume(download) }
func deletePartial(_ download: RetainedDownload) {
Task {
await DownloadCoordinator.shared.cancelAndWait(download)
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
DownloadStateStore.shared.remove(download.shareId, download.linkId)
} catch {
reportError(error)
}
reload()
}
}
func removeAllOffline() {
Task {
await busy("Deleting every download…") {
await DownloadCoordinator.shared.cancelAll()
do {
try await NativeRuntime.engine().removeAllOffline()
DownloadStateStore.shared.clear()
} catch {
reportError(error)
}
await reloadNow()
}
}
}
func clearBlockCache() {
Task {
do {
let reclaimed = try await run { try $0.clearBlockCache() }
message = "Reclaimed \(formatBytes(reclaimed)) of cache"
} catch {
reportError(error)
}
reload()
}
}
// MARK: - Progress
/// Persist where an episode was left. Runs on its own task rather than the
/// player's, so a save issued on the way out is not cancelled by the
/// player going away.
func saveProgress(_ episode: EpisodeRecord, position: Double, duration: Double, watched: Bool) {
Task {
do {
try await run { engine in
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: min(max(position, 0), duration),
durationSecs: duration,
watched: watched
)
}
} catch {
reportError(error)
}
}
}
/// Mark an episode seen, or unseen, without playing it.
///
/// Unwatching rewinds, matching the desktop client. Marking one seen puts
/// the position at its duration where one is known, so the progress bar
/// agrees with the tick.
/// One episode's tick, with the same Undo a season's has.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
markEpisodes([episode], watched, episode.label)
}
/// Draw a watched mark the moment it is tapped, before the bridge has
/// stored it. The reload that shows the stored state is a second away on
/// an old iPad, and a tick that does not appear gets tapped again. That
/// reload still follows, and puts back whatever was really stored.
private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
titles = titles.map { title in
guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title }
var title = title
title.seasons = title.seasons.map { season in
var season = season
season.episodes = season.episodes.map { episode in
guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode }
var episode = episode
episode.watched = watched
if !watched {
episode.progress = nil
episode.resumeAt = nil
}
return episode
}
return season
}
title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
return title
}
}
// MARK: - Reload
func reload() {
Task { await reloadNow() }
}
/// `reload`, for a caller that goes on to read what it loaded.
func reloadNow() async {
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
let filter = filter
let sort = sort
let grouped = grouped
do {
let result = try await run { engine in
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
_ = try engine.pruneOfflineFiles()
return Reloaded(
shares: try engine.shares(),
titles: try engine.library(search: query),
arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
offline: try engine.offlineFiles(),
metadataSettings: try engine.metadataSettings(),
storage: try engine.storageUsage()
) )
} }
loading = false
shares = result.shares
titles = result.titles
arrangement = result.arrangement
offline = result.offline
metadataSettings = result.metadataSettings
storage = result.storage
} catch { } catch {
report(error) loading = false
message = errorMessage(error)
} }
await reload()
} }
/// Episodes started but not finished, most recent first. private func reloadLibrary(_ query: String) async {
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] { let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
titles let filter = filter
.flatMap { title in let sort = sort
title.seasons.flatMap(\.episodes) let grouped = grouped
.filter { $0.resumeAt != nil && !$0.watched } do {
.map { (title: title, episode: $0) } let (titles, arrangement) = try await run { engine in
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
} }
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed } self.titles = titles
self.arrangement = arrangement
} catch {
message = errorMessage(error)
}
} }
} }
/// The grace iOS gives an app leaving the screen, ended exactly once —
/// whether by the work finishing or by the time running out.
@MainActor
final class BackgroundTask {
private var identifier = UIBackgroundTaskIdentifier.invalid
init(name: String) {
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
MainActor.assumeIsolated { self?.end() }
}
}
func end() {
guard identifier != .invalid else { return }
UIApplication.shared.endBackgroundTask(identifier)
identifier = .invalid
}
}
private struct Reloaded: @unchecked Sendable {
let shares: [ShareRecord]
let titles: [TitleRecord]
let arrangement: ArrangementRecord
let offline: [OfflineRecord]
let metadataSettings: MetadataSettingsRecord
let storage: StorageUsageRecord
}
@@ -0,0 +1,40 @@
import Foundation
/// 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 let shareLinkPattern = #"https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+"#
/// The first share link in `text`, if any — for the clipboard.
func shareLink(in text: String) -> String? {
text.range(of: shareLinkPattern, options: .regularExpression).map { String(text[$0]) }
}
/// The share link a URL opened in this app carries, if any.
///
/// iOS hands an app no `https://drive.proton.me` link — Universal Links belong
/// to the domain's owner — so a link arrives through the app's own scheme:
/// `protonstream://add?url=<link>`, or the link itself with its scheme swapped,
/// `protonstream://drive.proton.me/urls/…#…`.
func shareLink(from url: URL) -> String? {
guard url.scheme?.lowercased() == "protonstream" else { return nil }
if url.host == "add" {
let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "url" }?.value
return value.flatMap(wholeShareLink)
}
let text = url.absoluteString
let rest = text[text.index(text.startIndex, offsetBy: "protonstream://".count)...]
return wholeShareLink("https://\(rest)")
}
/// `value` when the whole of it is a share link — what `ACTION_VIEW` accepts on
/// Android — rather than a link somewhere inside it.
private func wholeShareLink(_ value: String) -> String? {
guard let range = value.range(of: shareLinkPattern, options: .regularExpression),
range == value.startIndex ..< value.endIndex
else { return nil }
return value
}
+73
View File
@@ -0,0 +1,73 @@
import Foundation
/// Process-wide owner of the Rust engine; records and decrypted media remain
/// app-private. The counterpart of Android's `NativeRuntime`.
enum NativeRuntime {
private static let lock = NSLock()
private static var instance: AndroidEngine?
/// The engine, built on first use off the main thread: construction opens
/// SQLite, spins a Tokio runtime and reads the Keychain.
static func engine() async throws -> AndroidEngine {
try await Task.detached(priority: .userInitiated) { try blockingEngine() }.value
}
/// The engine from a thread that may block — a bridge callback, a
/// background task. Never call this on the main thread.
static func blockingEngine() throws -> AndroidEngine {
try lock.withLock {
if let instance { return instance }
let engine = try AndroidEngine(paths: try paths(), secrets: KeychainSecretStore())
// The budget is this app's setting, so the engine starts on the
// default and is told the chosen one before anything streams.
engine.setStreamCacheBudget(bytes: SettingsStore.gibToBytes(SettingsStore().cacheBudgetGib))
instance = engine
return engine
}
}
/// The stored palette, without building the engine.
///
/// Callable from the main thread, and meant to be: it is the only palette
/// read early enough to decide what the first frame is painted in, and
/// `engine()` is not. This reads one small JSON file and resolves it.
static func initialPalette() -> PaletteRecord? {
guard let paths = try? paths() else { return nil }
return storedPalette(paths: paths)
}
/// Application Support for the catalog and the configuration, Caches for
/// the block cache, which iOS may purge.
///
/// Neither the catalog nor the configuration is backed up, as on Android
/// (`allowBackup="false"`): the share secrets live in the Keychain as
/// this-device-only items, so a restored share list would arrive without
/// them, and a restored `sync.json` would give two phones one sync identity
/// writing one file — the case `pstr-core::sync` exists to prevent.
private static func paths() throws -> AndroidPaths {
let files = FileManager.default
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
var config = support.appendingPathComponent("config", isDirectory: true)
var data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
var excluded = URLResourceValues()
excluded.isExcludedFromBackup = true
try config.setResourceValues(excluded)
try data.setResourceValues(excluded)
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
}
/// What a bridge failure says, without Swift's type name around it.
///
/// `BridgeError`'s own description is `String(reflecting:)`, which reads
/// `PstrBridge.BridgeError.Failure(reason: "…")` in a snackbar.
func errorMessage(_ error: Error) -> String {
if case let BridgeError.Failure(reason) = error { return reason }
let text = error.localizedDescription
return text.isEmpty ? "Unexpected error" : text
}
+363 -19
View File
@@ -1,48 +1,392 @@
import AVFoundation import AVFoundation
import SwiftUI import SwiftUI
import UIKit
@main @main
struct ProtonStreamApp: App { struct ProtonStreamApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var model = AppModel() @State private var model = AppModel()
@State private var appearance = AppearanceState.shared
init() { init() {
// `.playback` keeps the audio going with the screen locked or the app // `.playback` keeps the audio going with the screen locked or the app
// in the background, which `UIBackgroundModes: audio` then permits. // in the background, which `UIBackgroundModes: audio` then permits.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
// The palette read before the first frame, so the first frame is
// already painted in it.
AppearanceState.shared.seed(NativeRuntime.initialPalette())
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
let scheme = appearance.scheme
RootView() RootView()
.environment(model) .environment(model)
.preferredColorScheme(.dark) .environment(\.scheme, scheme)
.preferredColorScheme(scheme.colorScheme)
.tint(scheme.primary)
.font(.inter(.bodyLarge))
.background(scheme.background.ignoresSafeArea())
.task { await appearance.load() }
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
MainActor.assumeIsolated { OrientationLock.mask }
}
}
/// From this width the library and an open title share the window.
private let twoPaneWidth: CGFloat = 840
/// From this width the destinations are a rail down the side, not a bar.
private let railWidth: CGFloat = 600
/// How much room the floating mini transport needs at the foot of a page.
private let miniTransportInset: CGFloat = 88
enum Destination: String, CaseIterable {
case library, history, shares, downloads, settings
var label: String {
switch self {
case .library: "Library"
case .history: "History"
case .shares: "Shares"
case .downloads: "Downloads"
case .settings: "Settings"
}
}
var icon: String {
switch self {
case .library: "play.rectangle.on.rectangle.fill"
case .history: "clock.arrow.circlepath"
case .shares: "square.and.arrow.up"
case .downloads: "arrow.down.circle"
case .settings: "gearshape"
} }
} }
} }
struct RootView: View { struct RootView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@Environment(\.scenePhase) private var phase
// Keys, not records: what has to survive is *which* title, and a record is
// no longer current after the library reloads. Everything else is derived,
// so a reload that renames a season updates the open screen.
@SceneStorage("destination") private var destination: Destination = .library
@SceneStorage("selectedTitle") private var selectedTitleKey: String?
@State private var incomingLink: String?
@State private var host = PlayerHost.shared
var body: some View { var body: some View {
if let failure = model.startupError { GeometryReader { geometry in
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure)) let wide = geometry.size.width >= railWidth
} else { ZStack(alignment: .bottom) {
TabView { HStack(spacing: 0) {
NavigationStack { LibraryView() } if wide { NavigationRail(destination: $destination, onSelect: select) }
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") } page(width: geometry.size.width)
NavigationStack { SharesView() } .safeAreaInset(edge: .bottom, spacing: 0) {
.tabItem { Label("Shares", systemImage: "link") } VStack(spacing: 8) {
NavigationStack { SettingsView() } if host.isOpen && host.minimized {
.tabItem { Label("Settings", systemImage: "gearshape") } MiniTransport(host: host)
}
if !wide { NavigationBar(destination: $destination, onSelect: select) }
}
}
}
VStack(spacing: 8) {
ActivityBanner()
Snackbar()
}
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
.animation(.easeInOut(duration: 0.2), value: model.activity)
} }
.task { await model.reload() } .background(scheme.background.ignoresSafeArea())
.alert("Something went wrong", isPresented: .init( // Covered by the player, but still there to VoiceOver, which
get: { model.error != nil }, // would otherwise walk the library behind the picture.
set: { if !$0 { model.error = nil } } .accessibilityHidden(host.isOpen && !host.minimized)
)) { }
Button("OK", role: .cancel) {} .overlay {
} message: { // Over the shell rather than instead of it: the page behind stays
Text(model.error ?? "") // where it was, so leaving the player finds the title page as it was.
if host.isOpen && !host.minimized {
Group {
if let player = host.player {
PlayerView(host: host, player: player)
} else {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
Text(host.playbackError ?? "Playback is unavailable: libmpv did not start.")
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
IconButton("xmark", "Close", tint: .white, action: host.close)
}
}
}
.transition(.move(edge: .bottom))
}
}
.animation(.easeInOut(duration: 0.25), value: host.isOpen && !host.minimized)
.onAppear {
model.start()
host.onSaveProgress = { [model] episode, position, duration, watched in
model.saveProgress(episode, position: position, duration: duration, watched: watched)
}
host.onClosed = { [model] in model.playerClosed() }
}
.onChange(of: model.titles) { _, titles in host.update(titles: titles) }
.onChange(of: phase) { _, phase in
switch phase {
case .background:
host.enteredBackground()
model.syncInBackground()
case .active:
host.enteredForeground()
model.enteredForeground()
DownloadCoordinator.shared.resumeQueued()
default:
break
}
}
// A link opened in this app: show the Add form with it filled in. A
// playing episode keeps going in the mini transport rather than
// covering the form.
.onOpenURL { url in
guard let link = shareLink(from: url) else { return }
incomingLink = link
destination = .shares
selectedTitleKey = nil
if host.isOpen { host.minimized = true }
}
}
private func select(_ target: Destination) {
destination = target
selectedTitleKey = nil
}
private func play(_ title: TitleRecord, _ index: Int) {
host.play(title, at: index)
}
@ViewBuilder private func page(width: CGFloat) -> some View {
switch destination {
case .library:
libraryPage(width: width)
case .history:
HistoryView(onPlay: play, onTitle: { title in
destination = .library
selectedTitleKey = title.key
})
case .shares:
// The first share is the library: watch it fill rather than stay
// on a form that has done its job.
SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) })
case .downloads:
DownloadsView(onPlay: play, onOpen: { title in
destination = .library
selectedTitleKey = title.key
})
case .settings:
SettingsView()
}
}
/// Side by side where there is room for both: on a tablet the library
/// stays in view while a title is open, so moving between titles is one
/// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
let library = LibraryView(
onResume: play,
onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } },
// The form as a pasted link opens it, only with nothing in it.
onAddShare: {
incomingLink = ""
destination = .shares
},
onOpenSettings: { select(.settings) }
)
if width >= twoPaneWidth {
HStack(spacing: 0) {
library.frame(width: width * 0.45)
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
Group {
if let selected { titlePane(selected) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
}
.frame(maxWidth: .infinity)
}
} else {
// The library stays underneath the title page rather than being
// rebuilt, so going back finds the grid scrolled where it was.
ZStack {
library
.opacity(selected == nil ? 1 : 0)
.allowsHitTesting(selected == nil)
.accessibilityHidden(selected != nil)
if let selected {
titlePane(selected)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
.zIndex(1)
}
}
}
}
private func titlePane(_ selected: TitleRecord) -> some View {
TitleView(
title: selected,
onPlay: { _, index in host.play(selected, at: index) },
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
onOpenTitle: { selectedTitleKey = $0.key }
)
.id(selected.key)
}
}
/// The destinations along the foot of a phone, the selected one in an accent
/// pill — the desktop's rule is that the accent marks the thing you are on.
private struct NavigationBar: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
HStack(spacing: 0) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 30)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.top, 10)
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
.background(scheme.surfaceContainer.ignoresSafeArea(edges: .bottom))
}
}
/// The same destinations down the side of a wide window.
private struct NavigationRail: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
VStack(spacing: 12) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 32)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
}
.frame(width: 80)
.contentShape(Rectangle())
}
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
Spacer()
}
.padding(.top, 24)
.background(scheme.surface.ignoresSafeArea())
}
}
/// What the app is busy with, for as long as it is: a spinner and one line.
/// Not dismissible and not timed — it goes when the work does.
private struct ActivityBanner: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let activity = model.activity {
HStack(spacing: 12) {
ProgressView().controlSize(.small).tint(scheme.primary)
Text(activity)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurface)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
// A crawl of a large share can take minutes, and is the
// viewer's to call off; what it has read so far stays.
if model.crawling {
Button("Stop", action: model.stopCrawl).buttonStyle(.quiet)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityElement(children: model.crawling ? .contain : .combine)
.accessibilityAddTraits(.updatesFrequently)
}
}
}
/// The app's one line of feedback: what just happened, with Undo where it can
/// be taken back.
private struct Snackbar: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let message = model.message {
HStack(spacing: 8) {
Text(message)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.inverseOnSurface)
.frame(maxWidth: .infinity, alignment: .leading)
if model.undo != nil {
Button("Undo", action: model.performUndo)
.textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary)
.buttonStyle(.pressable)
.padding(.horizontal, 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(scheme.inverseSurface, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onTapGesture(perform: model.dismissMessage)
// Long with an Undo to reach for, short without — Material's two
// snackbar durations.
.task(id: message) {
try? await Task.sleep(for: .seconds(model.undo != nil ? 10 : 4))
if !Task.isCancelled, model.message == message { model.dismissMessage() }
} }
} }
} }
+92
View File
@@ -0,0 +1,92 @@
import Foundation
/// The settings that are this front end's alone.
///
/// Playback preferences — volume, languages, autoplay, auto-skip — deliberately
/// do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
/// there is one definition of them rather than one per front end. Keys and
/// defaults are Android's `SettingsStore`.
struct SettingsStore {
private let defaults = UserDefaults.standard
var wifiOnly: Bool {
get { bool(Key.wifiOnly, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.wifiOnly) }
}
var backgroundAudio: Bool {
get { bool(Key.backgroundAudio, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.backgroundAudio) }
}
/// Whether mpv may decode in hardware. Off is the way out for a file
/// VideoToolbox shows green or torn frames for; it costs battery, so it is
/// on unless the viewer turns it off. Read at each load, so a change applies
/// from the next file.
var hardwareDecoding: Bool {
get { bool(Key.hardwareDecoding, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.hardwareDecoding) }
}
/// The streaming cache's budget, in GiB. See `cacheBudgetChoices`.
var cacheBudgetGib: Int {
get { defaults.object(forKey: Key.cacheBudgetGib) as? Int ?? Self.defaultCacheBudgetGib }
nonmutating set { defaults.set(newValue, forKey: Key.cacheBudgetGib) }
}
/// The desktop's range, in the steps a phone's storage is bought in.
static let cacheBudgetChoices = [1, 2, 4, 8, 16]
/// `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing.
static let defaultCacheBudgetGib = 4
static func gibToBytes(_ gib: Int) -> UInt64 { UInt64(gib) * 1024 * 1024 * 1024 }
/// How the library was last sorted; `.name` until the viewer chooses.
var librarySort: LibrarySort {
get {
let stored = defaults.string(forKey: Key.librarySort)
return Self.sorts.first { String(describing: $0) == stored } ?? .name
}
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.librarySort) }
}
/// Which titles the library last showed.
var libraryFilter: LibraryFilter {
get {
let stored = defaults.string(forKey: Key.libraryFilter)
return Self.filters.first { String(describing: $0) == stored } ?? .all
}
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.libraryFilter) }
}
/// The library's offer of posters and synopses was turned down.
var metadataOfferDismissed: Bool {
get { bool(Key.metadataOfferDismissed, default: false) }
nonmutating set { defaults.set(newValue, forKey: Key.metadataOfferDismissed) }
}
/// Whether the library folds each franchise into one tile.
var libraryGrouped: Bool {
get { bool(Key.libraryGrouped, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) }
}
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
private func bool(_ key: String, default value: Bool) -> Bool {
defaults.object(forKey: key) as? Bool ?? value
}
private enum Key {
static let wifiOnly = "wifi_only"
static let backgroundAudio = "background_audio"
static let hardwareDecoding = "hardware_decoding"
static let cacheBudgetGib = "cache_budget_gib"
static let librarySort = "library_sort"
static let libraryFilter = "library_filter"
static let libraryGrouped = "library_grouped"
static let metadataOfferDismissed = "metadata_offer_dismissed"
}
}
@@ -0,0 +1,352 @@
import Foundation
import Network
import Observation
import UIKit
/// Offline downloads: the queue, the retries and the network policy.
///
/// Rust owns the block-aligned transfer and the catalog mutation, as on
/// Android; this owns what WorkManager owns there. iOS has no equivalent of a
/// foreground worker for a transfer that is not a `URLSession` task, so a
/// download runs while the app does, plus the grace `beginBackgroundTask`
/// grants once it leaves the screen. One cut short is put back in the queue,
/// and the `.part` file resumes at its last whole block on the next launch.
@MainActor
@Observable
final class DownloadCoordinator {
static let shared = DownloadCoordinator()
/// Every retained download, sorted by label. Re-read after each write.
private(set) var records: [RetainedDownload] = []
/// Why a queued download is not running, when it is not merely a free
/// slot: a Wi-Fi-only download on mobile data waits without a word, and
/// "Queued" for an hour on the bus reads as stuck.
private(set) var queueReason: String?
/// A download finished or was removed: the catalog has changed.
@ObservationIgnored var onFinished: (() -> Void)?
@ObservationIgnored private let store = DownloadStateStore.shared
@ObservationIgnored private var running: [String: Transfer] = [:]
@ObservationIgnored private let monitor = NWPathMonitor()
@ObservationIgnored private var path: NWPath?
@ObservationIgnored private var refreshQueued = false
private init() {
store.onChange = { [weak self] in
Task { @MainActor in self?.scheduleRefresh() }
}
records = store.records()
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in self?.pathChanged(path) }
}
monitor.start(queue: DispatchQueue(label: "io.narl.protonstream.network"))
}
/// Free space to leave behind a download, for the catalog and the caches.
private static let storageHeadroom: Int64 = 256 * 1024 * 1024
private static let maxAttempts = 3
func enqueue(_ episode: EpisodeRecord) { enqueue([episode]) }
func enqueue(_ episodes: [EpisodeRecord]) {
let wanted = episodes.filter { !$0.offline }
if wanted.isEmpty { return }
// Space is checked once for the whole request, against its total.
// Queueing a season that cannot fit and discovering it episode by
// episode at ENOSPC is how the app fills its own database's disk.
let room = Self.roomFor(wanted.reduce(Int64(0)) { $0 + Int64($1.size ?? 0) })
let downloads = wanted.map { episode in
RetainedDownload(
shareId: episode.shareId,
volumeId: episode.volumeId,
linkId: episode.linkId,
label: episode.label,
total: Int64(episode.size ?? 0),
status: room ? RetainedDownload.statusQueued : RetainedDownload.statusFailed,
error: room ? nil : "Not enough free space"
)
}
// An episode already in flight keeps its record and its transfer.
store.putAll(downloads.filter { running[$0.key] == nil })
if room { downloads.forEach(start) }
}
func resume(_ download: RetainedDownload) {
var queued = download
queued.status = RetainedDownload.statusQueued
queued.error = nil
guard let transfer = running[download.key] else {
store.put(queued)
start(queued)
return
}
// Still running: nothing to resume. Still winding down from a pause:
// let it record that first, or its last write lands over this one.
guard transfer.observer.stopped else { return }
Task {
await transfer.task.value
store.put(queued)
start(queued)
}
}
func pause(_ download: RetainedDownload) { setStatus(download, RetainedDownload.statusPaused) }
func cancel(_ download: RetainedDownload) { setStatus(download, RetainedDownload.statusCancelled) }
/// Stop one download and wait for its transfer to let go of the `.part`
/// file, so the caller can remove it.
func cancelAndWait(_ download: RetainedDownload) async {
cancel(download)
await running[download.key]?.task.value
}
/// Stop every download of one share, and wait for them.
func cancelShare(_ shareId: String) async {
let transfers = running.values.filter { $0.download.shareId == shareId }
for transfer in transfers { stop(transfer.download.key, as: RetainedDownload.statusCancelled) }
for transfer in transfers { await transfer.task.value }
}
/// Stop everything, and wait.
func cancelAll() async {
let transfers = Array(running.values)
for transfer in transfers { stop(transfer.download.key, as: RetainedDownload.statusCancelled) }
for transfer in transfers { await transfer.task.value }
}
/// Start whatever is queued — on launch, back in the foreground, and when
/// the network comes back. A download the system cut short is queued, not
/// failed, so it is picked up here.
func resumeQueued() {
for download in store.records() where download.status == RetainedDownload.statusQueued
|| download.status == RetainedDownload.statusRunning {
start(download)
}
}
/// Re-apply "Wi-Fi only" to what is queued and running: a season queued
/// before the setting was turned on must not then download over cellular.
func applyNetworkPolicy() {
queueReason = switch path?.status {
case .satisfied?: allowed ? nil : "Waiting for Wi-Fi"
default: "Waiting for a connection"
}
if allowed {
resumeQueued()
} else {
for key in Array(running.keys) { stop(key, as: RetainedDownload.statusQueued) }
}
}
// MARK: - Running
private final class Transfer {
let download: RetainedDownload
let observer: Observer
var task: Task<Void, Never>!
init(download: RetainedDownload, observer: Observer) {
self.download = download
self.observer = observer
}
}
/// Whether the network policy lets a transfer run now.
private var allowed: Bool {
guard let path, path.status == .satisfied else { return false }
return !SettingsStore().wifiOnly || !(path.isExpensive || path.usesInterfaceType(.cellular))
}
private func pathChanged(_ path: NWPath) {
self.path = path
applyNetworkPolicy()
}
private func start(_ download: RetainedDownload) {
guard running[download.key] == nil, allowed else { return }
let observer = Observer(store: store, download: download)
let transfer = Transfer(download: download, observer: observer)
running[download.key] = transfer
transfer.task = Task { [weak self] in
await self?.run(transfer)
}
}
/// Ask a transfer to stop at its next block boundary. `status` is what its
/// record says afterwards; nil leaves that to whoever called.
private func stop(_ key: String, as status: String?) {
guard let transfer = running[key] else { return }
transfer.observer.stop(as: status)
}
private func setStatus(_ download: RetainedDownload, _ status: String) {
store.update(download.shareId, download.linkId) { stored in
var changed = stored ?? download
changed.status = status
changed.error = nil
return changed
}
stop(download.key, as: status)
}
private func run(_ transfer: Transfer) async {
let download = transfer.download
let observer = transfer.observer
let background = UIApplication.shared.beginBackgroundTask(withName: "offline-\(download.linkId)") {
// Out of background time: stop at the next block and queue it, so
// the next launch carries on from the `.part` file.
observer.stop(as: RetainedDownload.statusQueued)
}
defer {
running[download.key] = nil
UIApplication.shared.endBackgroundTask(background)
}
for attempt in 1 ... Self.maxAttempts {
// A pause or a cancel that landed before this got going.
if let stored = store.get(download.shareId, download.linkId),
stored.status == RetainedDownload.statusPaused || stored.status == RetainedDownload.statusCancelled {
return
}
// 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.
store.update(download.shareId, download.linkId) { stored in
var current = stored ?? download
current.status = RetainedDownload.statusQueued
current.error = nil
current.bytesPerSecond = 0
return current
}
do {
let engine = try await NativeRuntime.engine()
_ = try await engine.downloadEpisode(
shareId: download.shareId,
volumeId: download.volumeId,
linkId: download.linkId,
observer: observer
)
store.remove(download.shareId, download.linkId)
onFinished?()
return
} catch {
if let status = observer.stoppedAs {
// A pause is also a stop. Under the lock, so the record
// read here is the one the write lands on.
store.update(download.shareId, download.linkId) { stored in
guard var current = stored, current.status != RetainedDownload.statusPaused else { return nil }
current.status = status
current.bytesPerSecond = 0
return current
}
return
}
if observer.stopped { return }
let message = errorMessage(error)
if attempt < Self.maxAttempts {
store.update(download.shareId, download.linkId) { stored in
var current = stored ?? download
current.status = RetainedDownload.statusQueued
current.error = message
current.bytesPerSecond = 0
return current
}
// WorkManager's exponential backoff, from 30 s.
try? await Task.sleep(for: .seconds(30 * (1 << (attempt - 1))))
if observer.stopped { return }
} else {
store.update(download.shareId, download.linkId) { stored in
var current = stored ?? download
current.status = RetainedDownload.statusFailed
current.error = message
current.bytesPerSecond = 0
return current
}
}
}
}
}
private func scheduleRefresh() {
// Progress lands a dozen times a second on a fast link; one re-read
// per frame is plenty.
if refreshQueued { return }
refreshQueued = true
Task { @MainActor in
refreshQueued = false
records = store.records()
}
}
/// Whether `bytes` plus working room will fit where offline files are kept.
/// An unknown size — zero — is allowed through.
private static func roomFor(_ bytes: Int64) -> Bool {
if bytes <= 0 { return true }
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
guard let values = try? support.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
let available = values.volumeAvailableCapacityForImportantUsage
else { return true }
return available - bytes >= storageHeadroom
}
}
/// The bridge's view of one transfer: where it is, and whether to stop.
private final class Observer: DownloadObserver, @unchecked Sendable {
private let store: DownloadStateStore
private let download: RetainedDownload
private let lock = NSLock()
private var rate = TransferRate()
private var reportedAt: Int64 = 0
private var reportedPercent = -1
private var stopRequested = false
private var stopStatus: String?
/// Shortest gap between two identical-looking progress reports.
private static let progressIntervalMs: Int64 = 1000
init(store: DownloadStateStore, download: RetainedDownload) {
self.store = store
self.download = download
}
func stop(as status: String?) {
lock.withLock {
stopRequested = true
if let status { stopStatus = status }
}
}
var stopped: Bool { lock.withLock { stopRequested } }
var stoppedAs: String? { lock.withLock { stopStatus } }
/// Called once per 4 MiB block. Nothing the viewer can see changes that
/// often, so a report is only made when the percentage moves or a second
/// has passed. The final one is never suppressed.
func onProgress(downloaded: UInt64, total: UInt64) throws {
let percent = total == 0 ? 0 : Int(downloaded * 100 / total)
let now = Int64(ProcessInfo.processInfo.systemUptime * 1000)
let complete = total > 0 && downloaded >= total
let report: Int64? = lock.withLock {
let bytesPerSecond = rate.sample(Int64(downloaded), now: now)
if !complete, percent == reportedPercent, now - reportedAt < Self.progressIntervalMs { return nil }
reportedAt = now
reportedPercent = percent
return bytesPerSecond
}
guard let bytesPerSecond = report else { return }
store.update(download.shareId, download.linkId) { [download] stored in
var current = stored ?? download
current.downloaded = Int64(downloaded)
current.total = Int64(total)
current.bytesPerSecond = bytesPerSecond
if current.status != RetainedDownload.statusPaused, current.status != RetainedDownload.statusCancelled {
current.status = RetainedDownload.statusRunning
}
return current
}
}
func isCancelled() throws -> Bool { stopped }
}
@@ -0,0 +1,196 @@
import Foundation
/// Durable user-facing state for one offline download, kept until it finishes
/// or the viewer deletes it. The fields and their JSON names are Android's
/// `RetainedDownload`.
struct RetainedDownload: Equatable, Hashable, Identifiable, Sendable {
var shareId: String
var volumeId: String
var linkId: String
var label: String
var downloaded: Int64 = 0
var total: Int64 = 0
var status: String = RetainedDownload.statusQueued
var error: String?
/// Recent transfer rate while running; 0 when unknown or not running.
var bytesPerSecond: Int64 = 0
var key: String { downloadKey(shareId, linkId) }
var id: String { key }
static let statusQueued = "queued"
static let statusRunning = "running"
static let statusPaused = "paused"
static let statusFailed = "failed"
static let statusCancelled = "cancelled"
}
func downloadKey(_ shareId: String, _ linkId: String) -> String { "\(shareId)\u{1f}\(linkId)" }
/// The retained downloads, as one JSON file of `key → record` beside the
/// catalog — Android keeps the same records in SharedPreferences.
///
/// Every write goes through one lock: the transfer's progress reports and the
/// viewer's pause are concurrent writers to the same record, and a whole-record
/// `put` from a screen's stale copy would both lose that progress and race the
/// transfer's own read-modify-write.
final class DownloadStateStore: @unchecked Sendable {
static let shared = DownloadStateStore(file: DownloadStateStore.defaultFile)
/// Called after every write, on whichever thread wrote.
var onChange: (() -> Void)?
private let file: URL
private let lock = NSLock()
private var entries: [String: String]?
init(file: URL) {
self.file = file
}
func records() -> [RetainedDownload] {
lock.withLock { loaded().values.compactMap(Self.decode) }
.sorted { $0.label.lowercased() < $1.label.lowercased() }
}
func get(_ shareId: String, _ linkId: String) -> RetainedDownload? {
lock.withLock { loaded()[downloadKey(shareId, linkId)].flatMap(Self.decode) }
}
func put(_ record: RetainedDownload) {
edit { $0[record.key] = Self.encode(record) }
}
/// Read-modify-write one record, excluding every other writer. Returning
/// nil from `change` leaves the record alone.
func update(_ shareId: String, _ linkId: String, _ change: (RetainedDownload?) -> RetainedDownload?) {
edit { entries in
let key = downloadKey(shareId, linkId)
if let changed = change(entries[key].flatMap(Self.decode)) {
entries[changed.key] = Self.encode(changed)
}
}
}
/// Write a whole queue in one go: queueing a season is one write, not one
/// per episode.
func putAll(_ records: [RetainedDownload]) {
edit { entries in
for record in records { entries[record.key] = Self.encode(record) }
}
}
func remove(_ shareId: String, _ linkId: String) {
edit { $0[downloadKey(shareId, linkId)] = nil }
}
/// Forget every retained download. Callers stop the transfers first.
func clear() {
edit { $0.removeAll() }
}
func removeShare(_ shareId: String) {
edit { entries in
entries = entries.filter { Self.decode($0.value)?.shareId != shareId }
}
}
private func edit(_ change: (inout [String: String]) -> Void) {
lock.withLock {
var current = loaded()
change(&current)
entries = current
// Atomic, so a kill mid-write leaves the last good file.
if let data = try? JSONEncoder().encode(current) {
try? data.write(to: file, options: [.atomic])
}
}
onChange?()
}
/// Under `lock`.
private func loaded() -> [String: String] {
if let entries { return entries }
let read = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:]
entries = read
return read
}
static func encode(_ record: RetainedDownload) -> String {
var json: [String: Any] = [
"share": record.shareId,
"volume": record.volumeId,
"link": record.linkId,
"label": record.label,
"downloaded": record.downloaded,
"total": record.total,
"status": record.status,
"rate": record.bytesPerSecond,
]
if let error = record.error { json["error"] = error }
let data = (try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])) ?? Data()
return String(decoding: data, as: UTF8.self)
}
/// A row that does not decode is skipped rather than failing the list.
static func decode(_ encoded: String) -> RetainedDownload? {
guard let object = try? JSONSerialization.jsonObject(with: Data(encoded.utf8)),
let json = object as? [String: Any],
let share = json["share"] as? String,
let volume = json["volume"] as? String,
let link = json["link"] as? String
else { return nil }
let error = (json["error"] as? String).flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
return RetainedDownload(
shareId: share,
volumeId: volume,
linkId: link,
label: json["label"] as? String ?? link,
downloaded: (json["downloaded"] as? NSNumber)?.int64Value ?? 0,
total: (json["total"] as? NSNumber)?.int64Value ?? 0,
status: json["status"] as? String ?? RetainedDownload.statusQueued,
error: error,
bytesPerSecond: (json["rate"] as? NSNumber)?.int64Value ?? 0
)
}
/// In a directory of its own, excluded from backup like the catalog: an
/// atomic write replaces the file, so the flag has to sit on its parent.
private static var defaultFile: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
var directory = support.appendingPathComponent("state", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var excluded = URLResourceValues()
excluded.isExcludedFromBackup = true
try? directory.setResourceValues(excluded)
return directory.appendingPathComponent("offline-downloads.json")
}
}
/// 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.
struct TransferRate {
private var lastBytes: Int64 = -1
private var lastAt: Int64 = 0
private var smoothed = 0.0
/// Record `bytes` transferred at `now` (milliseconds) and return the rate.
mutating func sample(_ bytes: Int64, now: Int64) -> Int64 {
if lastBytes < 0 || bytes < lastBytes {
lastBytes = bytes
lastAt = now
return Int64(smoothed)
}
let elapsed = now - lastAt
if elapsed <= 0 { return Int64(smoothed) }
let instant = Double(bytes - lastBytes) * 1000 / Double(elapsed)
smoothed = smoothed > 0 ? 0.7 * smoothed + 0.3 * instant : instant
lastBytes = bytes
lastAt = now
return Int64(smoothed)
}
}
+235 -67
View File
@@ -17,6 +17,16 @@ final class MpvMetalLayer: CAMetalLayer {
} }
} }
/// Why the open file ended, mirroring `pstr_player::EndReason` so every client
/// agrees on what "finished" means.
///
/// Only `eof` is an episode watched to the end. Treating the others as one — a
/// stop, a shutdown, a failed demux — is what marks an episode that never
/// played as watched and autoplays past it.
enum EndReason {
case eof, stopped, quit, failed, other
}
struct MpvTrack: Identifiable, Hashable { struct MpvTrack: Identifiable, Hashable {
let id: Int64 let id: Int64
let type: String let type: String
@@ -25,51 +35,93 @@ struct MpvTrack: Identifiable, Hashable {
let selected: Bool let selected: Bool
var label: String { var label: String {
let parts = [title, language.uppercased()].filter { !$0.isEmpty } let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ") return parts.isEmpty ? "\(type.prefix(1).uppercased() + type.dropFirst()) \(id)" : parts.joined(separator: " — ")
} }
} }
/// One libmpv core rendering into a Metal layer. Every public method runs on /// One libmpv core rendering into a Metal layer, for the life of the app.
/// the main thread; mpv's events are read on a thread of their own and applied ///
/// back on the main thread. /// Every public method runs on the main thread; mpv's events are read on a
/// thread of their own and applied back on the main thread. The state mirrors
/// what `pstr_mpv.cpp` publishes on Android, field for field.
@MainActor @MainActor
@Observable @Observable
final class MpvPlayer { final class MpvPlayer {
enum EndReason { case eof, stopped, failed }
private(set) var position: Double = 0 private(set) var position: Double = 0
private(set) var duration: Double = 0 private(set) var duration: Double = 0
private(set) var volume: Double = 100
private(set) var paused = false private(set) var paused = false
private(set) var muted = false
/// The file ended, and why. Nil while one is open.
private(set) var endReason: EndReason?
/// mpv has run out of buffered data and stopped to refill — not the same
/// as `paused`, which is the viewer's doing.
private(set) var buffering = false private(set) var buffering = false
/// How full the demuxer cache is while `buffering`, 0–100.
private(set) var cachePercent: Double = 0
/// A seek has been issued but playback has not resumed at the new position.
private(set) var seeking = false
/// How far the demuxer has read ahead, in seconds into the file.
private(set) var cachedUntil: Double = 0
/// The picture's display size, or zero until mpv has decoded enough.
private(set) var videoWidth: Double = 0
private(set) var videoHeight: Double = 0
private(set) var tracks: [MpvTrack] = [] private(set) var tracks: [MpvTrack] = []
private(set) var chapters: [ChapterRecord] = [] private(set) var chapters: [ChapterRecord] = []
private(set) var failure: String? /// Which episode the readings above describe, or nil between `loadfile`
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in /// and mpv's `START_FILE`. Everything that *writes* per-episode state —
/// that window still describes the *outgoing* file, including the /// the resume position above all — must check it: one core serves every
/// `END_FILE(stop)` stopping it emits. /// episode, and a reading attributed to the wrong one resumes the next
private(set) var loading = false /// episode where the last one stopped.
private(set) var media: String?
/// What mpv last complained about, until the viewer dismisses it.
var problem: String?
/// Bumped by every load, so a reader can tell "episode two, second zero"
/// from a leftover reading of episode one.
private(set) var generation = 0
/// Whether the picture is stopped for a reason the viewer did not choose.
var stalled: Bool { (buffering || seeking) && !paused && endReason == nil }
/// Called once per file that ends, never for the one a load replaced. /// Called once per file that ends, never for the one a load replaced.
@ObservationIgnored var onEnded: ((EndReason) -> Void)? @ObservationIgnored var onEnded: ((EndReason, String) -> Void)?
/// Called on every position change, for what has to react to the clock.
@ObservationIgnored var onTick: (() -> Void)?
/// Called when the duration or the pause state changes: what the lock
/// screen shows, and whether the screen may sleep.
@ObservationIgnored var onStateChanged: (() -> Void)?
@ObservationIgnored let layer = MpvMetalLayer() @ObservationIgnored let layer = MpvMetalLayer()
@ObservationIgnored private var mpv: OpaquePointer? @ObservationIgnored private var mpv: OpaquePointer?
/// Between `loadfile` and `START_FILE`. Everything mpv reports in that
/// window still describes the *outgoing* file, including the
/// `END_FILE(stop)` that stopping it emits.
@ObservationIgnored private var loading = false
@ObservationIgnored private var pendingMedia: String?
init() { /// A core, or nil when libmpv would not start.
static func create() -> MpvPlayer? {
let player = MpvPlayer()
return player.mpv == nil ? nil : player
}
private init() {
layer.framebufferOnly = true layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor layer.backgroundColor = UIColor.black.cgColor
guard let mpv = mpv_create() else { // For the frames between a resize and mpv's next one: a drawable of
failure = "libmpv did not start" // the old shape is letterboxed rather than stretched.
return layer.contentsGravity = .resizeAspect
} guard let mpv = mpv_create() else { return }
var wid = layer // mpv takes the layer's address as an int64, not a Swift reference.
var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque()))
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid) mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
for (name, value) in [ for (name, value) in [
("config", "no"), ("config", "no"),
("vo", "gpu-next"), ("vo", "gpu-next"),
("gpu-api", "vulkan"), ("gpu-api", "vulkan"),
("gpu-context", "moltenvk"), ("gpu-context", "moltenvk"),
// The default; `load` sets it per file from the viewer's setting.
("hwdec", "videotoolbox"), ("hwdec", "videotoolbox"),
("video-rotate", "no"), ("video-rotate", "no"),
("keep-open", "no"), ("keep-open", "no"),
@@ -78,20 +130,21 @@ final class MpvPlayer {
("demuxer-readahead-secs", "30"), ("demuxer-readahead-secs", "30"),
("demuxer-max-bytes", "50331648"), ("demuxer-max-bytes", "50331648"),
("subs-fallback", "yes"), ("subs-fallback", "yes"),
// AVAudioSession is this app's to manage, as audio focus is
// Android's.
("audio-exclusive", "no"),
] { ] {
mpv_set_option_string(mpv, name, value) mpv_set_option_string(mpv, name, value)
} }
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else { guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
mpv_terminate_destroy(mpv) mpv_terminate_destroy(mpv)
failure = "libmpv did not initialize"
return return
} }
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE) for (id, name, format) in Self.observed {
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE) mpv_observe_property(mpv, id, name, format)
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG) }
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG) // Errors mpv reports about the file itself, rather than about the
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64) // request that opened it. Without these a failed demux is silent.
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
mpv_request_log_messages(mpv, "error") mpv_request_log_messages(mpv, "error")
self.mpv = mpv self.mpv = mpv
@@ -100,52 +153,106 @@ final class MpvPlayer {
thread.start() thread.start()
} }
private static let observed: [(UInt64, String, mpv_format)] = [
(1, "time-pos", MPV_FORMAT_DOUBLE),
(2, "duration", MPV_FORMAT_DOUBLE),
(3, "pause", MPV_FORMAT_FLAG),
(4, "volume", MPV_FORMAT_DOUBLE),
(5, "mute", MPV_FORMAT_FLAG),
(6, "video-params/dw", MPV_FORMAT_INT64),
(7, "video-params/dh", MPV_FORMAT_INT64),
(8, "paused-for-cache", MPV_FORMAT_FLAG),
(9, "cache-buffering-state", MPV_FORMAT_INT64),
(10, "demuxer-cache-time", MPV_FORMAT_DOUBLE),
(11, "track-list/count", MPV_FORMAT_INT64),
(12, "chapter-list/count", MPV_FORMAT_INT64),
(13, "seeking", MPV_FORMAT_FLAG),
]
// MARK: Commands // MARK: Commands
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller /// Load `pstr://<handle>` as `media`. On `false` nothing was issued, and
/// still owns the handle's release. /// the caller still owns the handle's release.
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool { func load(
media: String,
handle: UInt64,
start: Double,
audio: String?,
subtitle: String?,
subtitles: Bool,
hardwareDecoding: Bool
) -> Bool {
guard mpv != nil else { return false } guard mpv != nil else { return false }
// Per file, before `loadfile`: a file VideoToolbox decodes wrongly is
// fixed from the next episode without restarting the core.
setString("hwdec", hardwareDecoding ? "videotoolbox" : "no")
setString("start", start > 0 ? String(format: "%.3f", start) : "none") setString("start", start > 0 ? String(format: "%.3f", start) : "none")
setString("alang", audio ?? "") setString("alang", audio ?? "")
setString("slang", subtitle ?? "") setString("slang", subtitle ?? "")
setString("sid", subtitles ? "auto" : "no") setString("sid", subtitles ? "auto" : "no")
position = 0 // Retire the outgoing file's readings *before* the new one loads: mpv
// only republishes them once it has demuxed enough of the stream, and
// until then every reader would see the last episode's clock under the
// new episode's name. Volume, mute and pause are the core's, not the
// file's, and carry across.
position = start
duration = 0 duration = 0
endReason = nil
buffering = false
cachePercent = 0
seeking = false
cachedUntil = 0
videoWidth = 0
videoHeight = 0
tracks = [] tracks = []
chapters = [] chapters = []
failure = nil problem = nil
self.media = nil
generation += 1
pendingMedia = media
loading = true loading = true
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 { if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
// Nothing was issued, so no START_FILE will arrive to clear this.
loading = false loading = false
return false return false
} }
return true return true
} }
func setPaused(_ value: Bool) { setFlag("pause", value) } /// Written through rather than left to the observer: a tap that only shows
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) } /// its effect on mpv's next property event reads as a button that missed.
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) } func setPaused(_ value: Bool) {
setFlag("pause", value)
paused = value
}
func seek(to seconds: Double) {
setDouble("time-pos", max(0, seconds))
seeking = true
}
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) } func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) } func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
func setMuted(_ value: Bool) { setFlag("mute", value) } func setMuted(_ value: Bool) { setFlag("mute", value) }
func select(audio: Bool, track: MpvTrack?) { /// A track, or none: `nil` turns subtitles off.
let property = audio ? "aid" : "sid" func select(type: String, track: MpvTrack?) {
if let track { setString(property, String(track.id)) } else { setString(property, "no") } setString(type == "audio" ? "aid" : "sid", track.map { String($0.id) } ?? "no")
refreshTracks() refreshTracks()
} }
/// Showing no picture while backgrounded keeps the audio playing and avoids /// Showing no picture while backgrounded keeps the audio playing: iOS
/// the black frame MoltenVK otherwise leaves on return. /// refuses GPU work from a background app, and would end the process for it.
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") } func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a /// Stop the open file. The stream behind it is released by mpv closing it,
/// block fetch, and the event thread destroys the core once it is down. /// which this causes; releasing it from here would race that close.
func shutdown() { func stop() {
guard let mpv else { return } _ = command(["stop"])
self.mpv = nil media = nil
mpv_command_string(mpv, "quit") pendingMedia = nil
chapters = []
tracks = []
} }
// MARK: Events // MARK: Events
@@ -153,10 +260,13 @@ final class MpvPlayer {
private enum Event { private enum Event {
case double(UInt64, Double) case double(UInt64, Double)
case flag(UInt64, Bool) case flag(UInt64, Bool)
case count(UInt64) case int(UInt64, Int64)
case unavailable(UInt64)
case started
case loaded case loaded
case ended(Int32) case ended(Int64)
case log(String) case restarted
case log(prefix: String, text: String)
} }
/// Runs until the core shuts down, then destroys it. Nothing else calls /// Runs until the core shuts down, then destroys it. Nothing else calls
@@ -170,24 +280,34 @@ final class MpvPlayer {
mpv_terminate_destroy(mpv) mpv_terminate_destroy(mpv)
return return
case MPV_EVENT_PROPERTY_CHANGE: case MPV_EVENT_PROPERTY_CHANGE:
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee, guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee else { break }
let data = property.data else { break } guard let data = property.data else {
decoded = .unavailable(event.reply_userdata)
break
}
switch property.format { switch property.format {
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self)) case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0) case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata) case MPV_FORMAT_INT64: decoded = .int(event.reply_userdata, data.load(as: Int64.self))
default: break default: decoded = .unavailable(event.reply_userdata)
} }
case MPV_EVENT_START_FILE:
decoded = .started
case MPV_EVENT_FILE_LOADED: case MPV_EVENT_FILE_LOADED:
decoded = .loaded decoded = .loaded
case MPV_EVENT_PLAYBACK_RESTART:
decoded = .restarted
case MPV_EVENT_END_FILE: case MPV_EVENT_END_FILE:
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee { if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
decoded = .ended(end.reason.rawValue) decoded = .ended(Int64(end.reason.rawValue))
} }
case MPV_EVENT_LOG_MESSAGE: case MPV_EVENT_LOG_MESSAGE:
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee, if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
let text = message.text { let text = message.text {
decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)) decoded = .log(
prefix: message.prefix.map { String(cString: $0) } ?? "",
text: String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)
)
} }
default: default:
break break
@@ -202,28 +322,76 @@ final class MpvPlayer {
switch event { switch event {
case let .double(id, value): case let .double(id, value):
if loading { return } if loading { return }
if id == 1 { position = value } else if id == 2 { duration = value } switch id {
case 1:
position = value
onTick?()
case 2:
duration = value
onStateChanged?()
case 4: volume = value
case 10: cachedUntil = value
default: break
}
case let .flag(id, value): case let .flag(id, value):
if id == 3 { paused = value } else if id == 4 { buffering = value } switch id {
case let .count(id): case 3:
paused = value
onStateChanged?()
case 5: muted = value
case 8:
// Said, so the lock screen's clock stops through a stall.
if !loading, buffering != value {
buffering = value
onStateChanged?()
}
case 13: if !loading { seeking = value }
default: break
}
case let .int(id, value):
if loading { return } if loading { return }
if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() } switch id {
case 6: videoWidth = Double(value)
case 7: videoHeight = Double(value)
case 9: cachePercent = Double(value)
case 11: refreshTracks()
case 12: refreshChapters()
default: break
}
case let .unavailable(id):
if loading { return }
if id == 6 || id == 7 { videoWidth = 0; videoHeight = 0 }
case .started:
// From here on, what mpv reports is the file just loaded.
if loading {
loading = false
media = pendingMedia
}
case .loaded: case .loaded:
loading = false
refreshTracks() refreshTracks()
refreshChapters() refreshChapters()
case .restarted:
seeking = false
case let .ended(reason): case let .ended(reason):
// The outgoing file's END_FILE arrives after the next load began. // The outgoing file's END_FILE arrives after the next load began.
if loading { return } if loading { return }
switch reason { let ended: EndReason = switch reason {
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof) case Int64(MPV_END_FILE_REASON_EOF.rawValue): .eof
case MPV_END_FILE_REASON_ERROR.rawValue: case Int64(MPV_END_FILE_REASON_STOP.rawValue): .stopped
failure = failure ?? "The file could not be played" case Int64(MPV_END_FILE_REASON_QUIT.rawValue): .quit
onEnded?(.failed) case Int64(MPV_END_FILE_REASON_ERROR.rawValue): .failed
default: onEnded?(.stopped) default: .other
} }
case let .log(text): endReason = ended
if !text.isEmpty { failure = text } if ended == .failed { problem = problem ?? "This episode could not be played." }
if let media { onEnded?(ended, media) }
case let .log(prefix, text):
// The outgoing file's complaints are not the new one's.
if loading { return }
// The first complaint per file, and not ffmpeg's: its decoders log
// recoverable glitches at error level all through a healthy file.
if text.isEmpty || prefix.hasPrefix("ffmpeg") || problem != nil { return }
problem = text
} }
} }
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more