Compare commits

65 Commits
Author SHA1 Message Date
nvrl 658c620c82 chore(release): bump version to 1.6.2 2026-10-17 21:05:19 +02:00
nvrl c1487c31d0 fix(packaging): centre the play mark in the desktop icon
The mark's box centred on x=73 of the 64-centred tile, so it sat visibly
right. It now centres x=70, between its box and its centroid, as the
Android and iOS icons already do. icon-256.png and the Windows .ico are
rendered again from the SVG.
2026-10-17 21:00:29 +02:00
nvrl a8a18be248 docs(readme): screenshots, install table and features up to 1.6.1
The README still described an early, public-links-only app. It now
shows the desktop and Android apps, lists what each release ships per
platform, covers sign-in, Drive folders, sync, downloads, Android TV and
dialogue-first subtitles, and points at the wiki for the details.
2026-10-17 20:46:32 +02:00
nvrl 16c2b4c9d3 docs: user guide pages, published to the GitHub wiki
docs/ gains pages for people using the app rather than building it:
getting started, installation, the user guide, account and sync,
Android and iOS, the command line, privacy, an FAQ and troubleshooting.
docs/README.md is the wiki's home page.

scripts/publish-wiki.sh, ported from proton-drive-linux, checks every
link in docs/ and README.md, rewrites links between pages to wiki page
names and copies the images they show. The Wiki workflow runs the check
on pull requests and publishes on every push to main.

images/ holds library, title and episode screenshots from the desktop
and Android apps, and the logo.
2026-10-17 20:46:09 +02:00
nvrl 2871a13ba6 Merge pull request #2 from mathieu-rahab/fix/macos-embedded-video
fix(player): resolve GL entry points from OpenGL.framework on macOS
2026-10-16 02:12:46 +02:00
nvrl 6e0be9e3c8 chore(release): bump version to 1.6.1 2026-10-15 22:50:10 +02:00
rahab.mathieu 0868c2af69 fix(player): resolve GL entry points from OpenGL.framework on macOS 2026-10-15 21:35:23 +02:00
nvrl 4a3a7291b8 chore(deps): proton-sdk and proton-drive-rs 0.9.0
Adapt sign-in to the 0.8 account API and use what it adds:

- Key passphrases are KeyPassphrases now, wiped on drop. The stored
  session keeps its JSON shape and converts on unlock.
- The mailbox password is checked with AccountClient::unlock(), one
  core/v4/users request instead of loading every address, and a wrong
  one is recognised as ProtonError::KeysLocked instead of any
  InvalidOperation.
- KeysLocked from a resumed session counts as signed out: the mailbox
  password changed, and only signing in again derives passphrases that
  unlock the keys.
- A sign-in Proton answers with code 2028 (Blocked) says so, instead of
  showing the server text that claims the app version is outdated.

Also picked up without code changes: a token refresh that outlives its
caller and is never replayed (0.8.2).
2026-10-15 19:56:56 +02:00
nvrl ab7f057afe chore(release): bump version to 1.6.0 2026-10-14 22:12:58 +02:00
nvrl b1ea076ea3 feat(core,app,android,ios): play the dialogue, not the signs and songs
Anime releases often carry two subtitle tracks in one language, the
dialogue and a "Signs & Songs" track, and the second is as often as not
the one marked default or forced. mpv, picking by language, played it.

pstr_core::subtitles reads a track's name and forced flag for whether it
is the dialogue, and every client asks it once per file, after mpv has
made its own pick, whether to switch to a track in the same language
that is. A subtitle track the viewer chose by name stays, and subtitles
that are off stay off.
2026-10-14 18:18:40 +02:00
nvrl 8d615579e2 fix(ios): fit the picture to the window after a rotation
MPVKit's MoltenVK context reads the layer's drawableSize only when mpv
builds its video output and reports no resize after that (mpvkit/MPVKit#3).
A player opened upright and turned on its side went on drawing the upright
frame into a corner of the landscape drawable. The view now tells the
player when its drawable changes, and once the size settles the player
rebuilds mpv's output by turning the picture off and on, as the background
already does. The vid writes run in order off the main thread.
2026-10-14 18:01:22 +02:00
nvrl cafd969567 fix(app): build the fontconfig face helper only where it is used
Windows and macOS find their CJK font by path, so on those targets the
helper was dead code and the Windows cross gate refused it.
2026-10-14 00:07:46 +02:00
nvrl 69981e3b45 chore(release): bump version to 1.5.1 2026-10-13 23:29:57 +02:00
nvrl cbe0f7166b fix(app): never let the system's CJK font stop the app at start
fc-match reports a variable font's named instance in the high 16 bits of
%{index}. Fedora's variable Noto Sans CJK answered 262144, egui read that
as a face index, and panicked on the font it could not parse. Keep only
the face, and parse the fallback with skrifa first so a font egui cannot
read is left out instead.

Fixes #1
2026-10-13 21:47:45 +02:00
nvrl f0402e74f9 feat(android): install on Android TV, and take a remote's keys in the player
The app is listed in the TV launcher with a banner, and neither a touch
screen nor leanback is required. While the controls are down the player
takes D-pad left and right as a ten-second seek, the middle button as
bringing the controls up, and play/pause and space as they say.
2026-10-13 21:37:40 +02:00
nvrl d4e9628199 feat(ios): notify when a show gains episodes
A BGProcessingTask runs the same crawl_for_news no sooner than every six
hours, when iOS chooses. A processing task rather than an app refresh,
since a crawl of a large share does not fit in thirty seconds.
2026-10-13 21:26:15 +02:00
nvrl 4b9cbd26bc feat(android): notify when a show gains episodes
NewEpisodesWorker runs crawl_for_news every six hours on a network with
the battery not low, and posts one notification per show, grouped. A
setting under Playback turns it off and cancels the schedule.
2026-10-13 21:26:15 +02:00
nvrl 588a7fcb09 feat(android): crawl for news, and say which titles gained episodes
crawl_for_news crawls the shares due a look that were crawled here
before, and returns per title how many files first appeared during it and
the first of them. A share crawled for the first time is left out, since
every file in it is new.
2026-10-13 21:26:15 +02:00
nvrl e3e855a02d docs: sidecar subtitles are no longer a gap 2026-10-13 21:11:20 +02:00
nvrl 7790ae9cbb feat(android,ios): play the subtitle files beside an episode
The bridge's open_subtitles opens and publishes them and names the one to
turn on. The native player holds them until mpv has the episode loaded,
adds them with sub-add, and releases them instead when another episode
was loaded in the meantime.
2026-10-13 21:11:20 +02:00
nvrl f0fe90f0fe feat(core,app,cli): pair subtitle files with their video and play them
A crawl now keeps .srt/.ass/.ssa/.vtt files and pairs each with a video by
name: the video's name followed by a dot or nothing, the same inside a
Subs folder, the Subs/<video>/ layout, and failing those the only video in
the folder. The words past the video's name give the language and the
picker's title. They go in a new sidecars table (schema v12), rebuilt by
every crawl.

The desktop opens them while mpv opens the video and adds them with
sub-add through the stream registry once the file has loaded. One in the
preferred language is turned on when subtitles are on and the file has
none of its own playing; the rest wait in the subtitle menu.
2026-10-13 21:11:20 +02:00
nvrl 51454b5a6e refactor(core,player): move the language table into pstr-core
The crawl needs it to read a subtitle file's language from its name
(en, eng, English), and pstr-player re-exports it unchanged.
2026-10-13 21:11:20 +02:00
nvrl c44b04cf4a fix(ios): keep a crawl running when the app leaves the screen
Without a background task iOS suspends the app at once and drops its
connections, so a glance at another app failed a minutes-long crawl.
2026-10-13 20:45:14 +02:00
nvrl 85bc975f5f fix(core,android,app): say why a crawl failed, not only which URL
A transport failure's text is reqwest's "error sending request for url
(…)", and the cause sits further down its source chain. Error::describe
appends the causes and drops the URL, so the reason fits on a share row.
2026-10-13 20:45:14 +02:00
nvrl 4f130d1d6e fix(core): list a folder again when its connection drops mid-crawl
A crawl is hundreds of listings and the first failure ended it. The SDK
replays a timeout or a refused connection, not a request whose connection
dropped, which is what a phone sees after the OS tears down its sockets or
hands over between networks. A listing is read-only, so the crawl retries
it after 1, 3 and 9 seconds.
2026-10-13 20:45:14 +02:00
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
120 changed files with 9221 additions and 1122 deletions

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
name: Wiki
# docs/ is the source of the GitHub wiki. Every change to the documentation
# checks its links; a push to main publishes the result, so the wiki follows
# main. Pull requests only check: nothing unmerged reaches the wiki. Run it from
# the Actions tab to publish another ref, such as a release tag.
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'images/**'
- 'README.md'
- 'scripts/publish-wiki.sh'
- '.github/workflows/wiki.yml'
pull_request:
paths:
- 'docs/**'
- 'images/**'
- 'README.md'
- 'scripts/publish-wiki.sh'
- '.github/workflows/wiki.yml'
workflow_dispatch:
inputs:
ref:
description: 'Tag or branch to publish'
required: false
default: 'main'
type: string
permissions:
contents: read
jobs:
check:
name: Check documentation links
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.sha }}
- name: Check links
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
publish:
name: Publish to the wiki
needs: check
# Pull requests never publish.
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
concurrency:
group: wiki-publish
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.sha }}
- name: Publish
env:
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
Generated
+330 -21
View File
@@ -11,6 +11,110 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "accesskit_atspi_common"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"atspi-common",
"phf",
"serde",
"zvariant 5.15.0",
]
[[package]]
name = "accesskit_consumer"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_macos"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
"hashbrown 0.16.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
[[package]]
name = "accesskit_unix"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
dependencies = [
"accesskit",
"accesskit_atspi_common",
"async-channel",
"async-executor",
"async-task",
"atspi",
"futures-lite",
"futures-util",
"serde",
"zbus 5.19.0",
]
[[package]]
name = "accesskit_windows"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"hashbrown 0.16.1",
"static_assertions",
"windows 0.62.2",
"windows-core",
]
[[package]]
name = "accesskit_winit"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
dependencies = [
"accesskit",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
"raw-window-handle",
"winit",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -360,6 +464,20 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]] [[package]]
name = "async-io" name = "async-io"
version = "2.6.0" version = "2.6.0"
@@ -482,6 +600,43 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atspi"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
dependencies = [
"atspi-common",
"atspi-proxies",
]
[[package]]
name = "atspi-common"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
dependencies = [
"enumflags2",
"serde",
"static_assertions",
"zbus 5.19.0",
"zbus-lockstep",
"zbus-lockstep-macros",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
name = "atspi-proxies"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
dependencies = [
"atspi-common",
"serde",
"zbus 5.19.0",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -2017,6 +2172,7 @@ version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db"
dependencies = [ dependencies = [
"accesskit_winit",
"arboard", "arboard",
"bytemuck", "bytemuck",
"egui", "egui",
@@ -4688,9 +4844,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pgp" name = "pgp"
version = "0.20.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
dependencies = [ dependencies = [
"aead 0.5.2", "aead 0.5.2",
"aes 0.8.4", "aes 0.8.4",
@@ -5062,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.7.3" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f" checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.0", "base64 0.23.0",
@@ -5089,9 +5245,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.7.3" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0" checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
@@ -5118,11 +5274,12 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"zeroize",
] ]
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "1.4.0" version = "1.6.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"futures", "futures",
@@ -5142,7 +5299,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "1.4.0" version = "1.6.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5176,7 +5333,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "1.4.0" version = "1.6.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5195,7 +5352,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "1.4.0" version = "1.6.2"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -5215,7 +5372,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "1.4.0" version = "1.6.2"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -5228,7 +5385,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "1.4.0" version = "1.6.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -5243,7 +5400,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "1.4.0" version = "1.6.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
@@ -5870,7 +6027,7 @@ dependencies = [
"rand 0.8.7", "rand 0.8.7",
"serde", "serde",
"sha2 0.10.9", "sha2 0.10.9",
"zbus", "zbus 4.4.0",
] ]
[[package]] [[package]]
@@ -7229,6 +7386,11 @@ name = "uuid"
version = "1.24.0" version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
@@ -8544,9 +8706,68 @@ dependencies = [
"uds_windows", "uds_windows",
"windows-sys 0.52.0", "windows-sys 0.52.0",
"xdg-home", "xdg-home",
"zbus_macros", "zbus_macros 4.4.0",
"zbus_names", "zbus_names 3.0.0",
"zvariant", "zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix 1.1.4",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros 5.19.0",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
name = "zbus-lockstep"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
dependencies = [
"zbus_xml",
"zvariant 5.15.0",
]
[[package]]
name = "zbus-lockstep-macros"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"zbus-lockstep",
"zbus_xml",
"zvariant 5.15.0",
] ]
[[package]] [[package]]
@@ -8559,7 +8780,22 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"zvariant_utils", "zvariant_utils 2.1.0",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.3",
"zbus_names 4.3.4",
"zvariant 5.15.0",
"zvariant_utils 4.2.0",
] ]
[[package]] [[package]]
@@ -8570,7 +8806,39 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [ dependencies = [
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant", "zvariant 4.2.0",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant 5.15.0",
]
[[package]]
name = "zbus_xml"
version = "5.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
dependencies = [
"serde",
"winnow 1.0.4",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
] ]
[[package]] [[package]]
@@ -8704,7 +8972,22 @@ dependencies = [
"enumflags2", "enumflags2",
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant_derive", "zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive 5.15.0",
"zvariant_utils 4.2.0",
] ]
[[package]] [[package]]
@@ -8717,7 +9000,20 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"zvariant_utils", "zvariant_utils 2.1.0",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.3",
"zvariant_utils 4.2.0",
] ]
[[package]] [[package]]
@@ -8730,3 +9026,16 @@ dependencies = [
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.3",
"winnow 1.0.4",
]
+6 -4
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.4.0" version = "1.6.2"
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.3" } proton-sdk = { version = "0.9.0" }
proton-drive-rs = { version = "0.7.3" } proton-drive-rs = { version = "0.9.0" }
# Internal crates. # Internal crates.
pstr-core = { path = "crates/pstr-core" } pstr-core = { path = "crates/pstr-core" }
@@ -73,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",
+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`:
+144 -109
View File
@@ -1,83 +1,145 @@
<div align="center">
<img src="images/logo.svg" alt="" width="112" height="112">
# proton-stream # proton-stream
A Netflix-style cross-platform client for **Proton Drive public links**. **Your Proton Drive, streaming.** A Netflix-style library for the videos in your Proton Drive and
the share links people send you, on Linux, Windows, Android, Android TV and iOS.
Paste a share URL and its password; get a browsable, streamable library. No **[proton-stream.narl.io](https://proton-stream.narl.io)**
Proton account, no server to host, no download step — episodes start playing in
seconds and the seek bar works.
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust [![CI](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml/badge.svg)](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
Proton Drive SDK. [![Release](https://img.shields.io/github/v/release/narrrl/proton-stream?sort=semver)](https://github.com/narrrl/proton-stream/releases/latest)
[![License: MIT / GPL-3.0](https://img.shields.io/badge/license-MIT%20%2F%20GPL--3.0-blue.svg)](#license)
[![Rust 1.96+](https://img.shields.io/badge/rust-1.96%2B-orange.svg)](https://www.rust-lang.org)
[![Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/narl)
<img src="images/desktop-home.png" alt="The library in the desktop app" width="820">
</div>
> [!IMPORTANT]
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
> to describe what the software talks to. It provides no content: it plays what you keep in
> Proton Drive or what is shared with you.
## Features
**Library**
- **Public share links or your own Drive.** Paste a share link, with its password if it has one,
or sign in and pick folders of your Drive. Any number of them merge into one library.
- **Seasons and episodes from your folders.** The folder names the show, the file name gives the
season and episode, and a file without numbers is a film.
- **Posters, synopses and shelves**, from AniList or TMDB, **off by default**: shelves such as
*Directed by* and *From MADHOUSE*, a franchise folded into one tile, and a hand-picked match
that is never overwritten.
- **Filters and sorting:** series, films, watching, not started, downloaded; by name, last watched,
last added, air date, rating or popularity.
**Player**
- **Streams, never downloads first.** Episodes start in seconds, and a seek fetches only what it
lands on.
- **libmpv inside the app's own window:** HEVC 10-bit, HDR and ASS subtitles with hardware
decoding, and no transcoding.
- **Skip the opening and the ending** by chapter, an **Up next** countdown, and next and previous
episode.
- **Subtitle files beside the video** (`.srt`, `.ass`, `.ssa`, `.vtt`), and **the dialogue track
over *Signs & Songs***. Audio and subtitle choices are remembered for each show.
**Everywhere**
- **Resume on any device.** Sign in, and your place in every episode, your shares and your
settings sync through your own Drive.
- **Downloads** of an episode, a season or a show, to play offline.
- **Android 12+ and Android TV** with Material 3, background audio and new-episode
notifications; **iOS 17+** as a SwiftUI port for SideStore and AltStore.
- **Themes:** a near-black default, four Catppuccin flavours and Persona 5, each with nine accents.
- **Full keyboard control** on the desktop, and the `pstr` command line.
<table>
<tr>
<td width="50%"><img src="images/desktop-library.png" alt="The whole library, filtered and sorted"></td>
<td width="50%">
<img src="images/android-home.png" alt="Android: home" width="24%">
<img src="images/android-library.png" alt="Android: all titles" width="24%">
<img src="images/android-title.png" alt="Android: a title page" width="24%">
<img src="images/android-episodes.png" alt="Android: episodes and downloads" width="24%">
</td>
</tr>
</table>
## Install
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has:
| Platform | Download |
|---|---|
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` |
| Fedora | `proton-stream-<version>-1.x86_64.rpm` |
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` |
| Windows | `proton-stream-<version>-x64.msi`, or the portable `…-x86_64-windows.zip` |
| Android, Android TV | `proton-stream-<version>-android.apk` |
| iOS, iPadOS | `proton-stream-<version>-ios.ipa`, for SideStore or AltStore |
```bash
sudo apt install ./proton-stream_*_amd64.deb # Debian, Ubuntu
sudo dnf install ./proton-stream-*.x86_64.rpm # Fedora
cd packaging && makepkg -si # Arch, from a checkout
```
Then open **Proton Stream**, paste a share link on the **Shares** page (or press **Ctrl+V**), and
the library fills in. [Getting started](docs/GETTING_STARTED.md) walks through it, and
[Installation](docs/INSTALL.md) covers every platform, updating and uninstalling.
## Documentation
The [wiki](https://github.com/narrrl/proton-stream/wiki) is generated from [`docs/`](docs/) on
every push to `main`:
- [Getting started](docs/GETTING_STARTED.md), [Installation](docs/INSTALL.md) and the
[FAQ](docs/FAQ.md)
- [User guide](docs/USER_GUIDE.md), [Account and sync](docs/ACCOUNT_AND_SYNC.md) and
[Android and iOS](docs/MOBILE.md)
- [Command-line reference](docs/CLI.md), [Privacy](docs/PRIVACY.md) and
[Troubleshooting](docs/TROUBLESHOOTING.md)
- For contributors: [Development](docs/DEVELOPMENT.md), [Architecture](docs/ARCHITECTURE.md),
[Testing](docs/TESTING.md), [Android](docs/ANDROID.md) and [iOS](docs/IOS.md)
## Why it works ## Why it works
A Proton Drive file's content blocks are each a self-contained PGP packet A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
decrypted under one shared session key. There is no sequential streaming session shared session key. There is no sequential streaming session to resume, so seeking costs exactly
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode the blocks the seek lands on: a 2 GB episode plays without downloading 2 GB of it.
is playable without downloading 2 GB of it.
## Status On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link: **2.3 s to first
frame, about 1.5 s to resume after dragging to an arbitrary point, and 61 MiB fetched for the
first 38 seconds of playback.** Watch it again and the disk cache serves it with **no network at
all**: 0.3 s to first frame.
Early, but it is an app now: the picture lands **inside the window**, not in an The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
mpv window of its own. The SDK streaming surface, the catalog, the streaming no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
layer and the player are done and verified against a real share, and the app has whole data flow.
a library wall, a page per title with seasons and episodes,
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
button for openings and endings, next/previous episode with an "up next"
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
A native Android 12+ client is under `android/`, sharing the Rust catalog and Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
streaming implementation through UniFFI. It features a modern Material 3 interface, SDK.
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).
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same ## Privacy
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
[`docs/IOS.md`](docs/IOS.md).
```bash - **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
cargo run --release -p pstr-app # the app posters and descriptions, which sends your title names to AniList or TMDB.
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link - **The part of a share link after `#` is its decryption key.** It, any link password and your
``` Proton session live in the system credential store (Secret Service, Credential Manager, the
Android Keystore, the iOS Keychain), never in a config file.
- **Sync is end-to-end encrypted.** Watch history, shares and settings travel through your own
Drive, one file per device.
- **The block cache and downloads hold decrypted video** in your cache and data folders. Size or
empty the cache in **Settings → Storage**.
One window at a time: a second launch brings the open window forward and [Privacy](docs/PRIVACY.md) lists every connection and every file.
hands it any share link it was given, then exits.
The `pstr` CLI does the same work headlessly, and is what tells you which layer
is broken:
```bash
cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment'
cargo run -p pstr-cli -- crawl
cargo run -p pstr-cli -- list
cargo run -p pstr-cli -- metadata # opt-in; off unless configured
cargo run --release -p pstr-cli -- play --file Frieren --disk-cache
cargo run --release -p pstr-cli -- bench --file Frieren --verify
```
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link:
**2.3 s to first frame, ~1.5 s to resume after dragging to an arbitrary point,
61 MiB fetched for the first 38 seconds of playback.** Watch it again and the
disk cache serves it with **no network at all** — 0.3 s to first frame.
`bench` measures the same file without a player, reporting cold seek latency,
sustained throughput and peak memory, and `--verify` cross-checks a mid-file read
against the SDK's own range download.
## Design
| | |
|---|---|
| Topology | Fat client. No server; each viewer's app talks to Proton directly. |
| Auth | Public links only — but several, merged into one library. |
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
| Transcoding | None. mpv demuxes and decodes natively. |
| UI | egui/eframe on glow; mpv renders into the same GL context. |
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, 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, and an iOS 17+ port of it for sideloading. |
## Building ## Building
@@ -85,53 +147,26 @@ Rust 2024, MSRV 1.96.
```bash ```bash
cargo build --release cargo build --release
cargo run --release -p pstr-app # the app
cargo run -p pstr-cli -- add --name anime 'URL' # the same work, headless
``` ```
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in. WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
On Windows the libmpv ones
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
crates.io; nothing here builds against a sibling checkout.
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`, - **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
and a portable `.zip` plus an `.msi` on Windows — are produced by `scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the [`packaging/README.md`](packaging/README.md).
per-platform detail. - **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and - **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain; [`docs/IOS.md`](docs/IOS.md).
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
- The share URL fragment **is** the decryption password. It and any custom
password live in the OS credential store — Secret Service on Linux, Credential
Manager on Windows — never in a config file.
- Nothing leaves your machine except requests to Proton, unless you turn metadata
enrichment on. The player is fed through an in-process mpv stream callback, not
a local HTTP server: there is no port for another process on the machine to ask
for the plaintext, and mpv is handed an opaque token rather than a URL that
would put a share secret in its log and window title.
- The block cache holds decrypted content. It lives in your cache directory,
can be deleted at any time the app is not running, and can be sized or
emptied from Settings → Storage.
- A share link given as a command-line argument is visible to other users of
the machine in the process list while that launch runs, and may land in
shell history. Pasting it into the window (Ctrl+V) does neither.
- The desktop's media widget is handed the title, the episode and a `file://`
path to artwork already on disk — never a provider URL, so the shell never
contacts a metadata provider on its own.
## License ## License
The shared Rust crates and desktop application are MIT licensed. The Android and The shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
iOS applications and their combined native distributions are GPL-3.0-or-later; see and their combined native distributions are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and [`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and the bundled
the bundled-component notices. 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.
+9
View File
@@ -7,6 +7,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!--
Android TV: listed in its launcher, and installable without a touch
screen. Neither is required, so phones are offered the same build.
-->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application <application
android:name=".ProtonStreamApplication" android:name=".ProtonStreamApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -14,6 +21,7 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
tools:targetApi="33" tools:targetApi="33"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@drawable/tv_banner"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ProtonStream"> android:theme="@style/Theme.ProtonStream">
@@ -39,6 +47,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<!-- <!--
Proton Drive public links. autoVerify="false", said explicitly: Proton Drive public links. autoVerify="false", said explicitly:
+104 -4
View File
@@ -27,6 +27,7 @@ extern "C" {
#include <string_view> #include <string_view>
#include <thread> #include <thread>
#include <utility> #include <utility>
#include <vector>
namespace { namespace {
@@ -202,7 +203,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
@@ -210,6 +211,17 @@ struct PlaybackState {
/// episode one — which is the difference between resuming an episode where /// episode one — which is the difference between resuming an episode where
/// the viewer left it and resuming it where the *previous* one ended. /// the viewer left it and resuming it where the *previous* one ended.
int64_t generation = 0; int64_t generation = 0;
/// mpv has loaded this generation's file and made its own track pick,
/// which is the first moment the pick can be second-guessed.
bool loaded = false;
};
/// A subtitle file published for mpv, and what to call it.
struct Subtitle {
uint64_t handle = 0;
std::string title;
std::string language;
bool select = false;
}; };
class Player { class Player {
@@ -338,7 +350,10 @@ class Player {
state_.generation = generation; state_.generation = generation;
state_.loading = true; state_.loading = true;
error_.clear(); error_.clear();
for (const auto &subtitle : pending_subtitles_) released_.push_back(subtitle.handle);
pending_subtitles_.clear();
} }
flush_released();
const std::string url = "pstr://" + std::to_string(handle); const std::string url = "pstr://" + std::to_string(handle);
const char *command[] = {"loadfile", url.c_str(), nullptr}; const char *command[] = {"loadfile", url.c_str(), nullptr};
if (mpv_command(mpv_, command) < 0) { if (mpv_command(mpv_, command) < 0) {
@@ -372,6 +387,27 @@ class Player {
mpv_command(mpv_, command); mpv_command(mpv_, command);
} }
/// A subtitle file beside the file of load `generation`, added once mpv
/// has that file loaded: an external track belongs to the file open at
/// the time. Handed over for a file already replaced, it is released
/// instead. `select` turns it on, but only when the file has no subtitle
/// of its own playing.
void add_subtitle(int64_t generation, Subtitle subtitle) {
bool now = false;
{
std::lock_guard lock(state_mutex_);
if (generation != state_.generation) {
released_.push_back(subtitle.handle);
} else if (loaded_generation_ == generation) {
now = true;
} else {
pending_subtitles_.push_back(std::move(subtitle));
}
}
flush_released();
if (now) attach_subtitle(subtitle);
}
PlaybackState state() const { PlaybackState state() const {
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
return state_; return state_;
@@ -395,6 +431,8 @@ class Player {
char *lang_raw = nullptr; char *lang_raw = nullptr;
char *title_raw = nullptr; char *title_raw = nullptr;
int selected = 0; int selected = 0;
int is_default = 0;
int forced = 0;
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id); mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw); mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
MpvString type(type_raw); MpvString type(type_raw);
@@ -403,10 +441,14 @@ class Player {
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw); mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
MpvString title(title_raw); MpvString title(title_raw);
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected); mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
mpv_get_property(mpv_, (base + "default").c_str(), MPV_FORMAT_FLAG, &is_default);
mpv_get_property(mpv_, (base + "forced").c_str(), MPV_FORMAT_FLAG, &forced);
if (i) json += ','; if (i) json += ',';
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) + json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" + "\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}"; escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") +
",\"default\":" + (is_default ? "true" : "false") +
",\"forced\":" + (forced ? "true" : "false") + "}";
} }
return json + ']'; return json + ']';
} }
@@ -434,6 +476,35 @@ class Player {
} }
private: private:
void attach_subtitle(const Subtitle &subtitle) {
bool showing = false;
if (char *sid = mpv_get_property_string(mpv_, "sid")) {
showing = std::strcmp(sid, "no") != 0;
mpv_free(sid);
}
const std::string url = "pstr://" + std::to_string(subtitle.handle);
std::vector<const char *> command{
"sub-add", url.c_str(), subtitle.select && !showing ? "select" : "auto",
subtitle.title.c_str()};
if (!subtitle.language.empty()) command.push_back(subtitle.language.c_str());
command.push_back(nullptr);
// On failure mpv may never have opened it, so nothing will close it.
// Releasing one it did open and close is a no-op.
if (mpv_command(mpv_, command.data()) < 0) pstr_android_stream_release(subtitle.handle);
}
/// Release the subtitle streams queued under `state_mutex_`, outside it:
/// a release runs Rust destructors, which must not run under a lock a
/// reader of the state may be waiting on.
void flush_released() {
std::vector<uint64_t> handles;
{
std::lock_guard lock(state_mutex_);
handles.swap(released_);
}
for (const uint64_t handle : handles) pstr_android_stream_release(handle);
}
struct MpvFree { struct MpvFree {
void operator()(char *value) const noexcept { if (value) mpv_free(value); } void operator()(char *value) const noexcept { if (value) mpv_free(value); }
}; };
@@ -534,6 +605,17 @@ class Player {
if (event->event_id == MPV_EVENT_FILE_LOADED) { if (event->event_id == MPV_EVENT_FILE_LOADED) {
const double start = pending_start_.exchange(0.0); const double start = pending_start_.exchange(0.0);
if (start > 0.0) set_double("time-pos", start); if (start > 0.0) set_double("time-pos", start);
std::vector<Subtitle> ready;
{
std::lock_guard lock(state_mutex_);
// Mid-load, this is the outgoing file finishing its load.
if (!state_.loading) {
loaded_generation_ = state_.generation;
state_.loaded = true;
ready.swap(pending_subtitles_);
}
}
for (const auto &subtitle : ready) attach_subtitle(subtitle);
} }
if (event->event_id == MPV_EVENT_END_FILE) { if (event->event_id == MPV_EVENT_END_FILE) {
const auto *end = static_cast<mpv_event_end_file *>(event->data); const auto *end = static_cast<mpv_event_end_file *>(event->data);
@@ -757,6 +839,12 @@ class Player {
bool surface_changed_ = false; bool surface_changed_ = false;
std::atomic<bool> frame_ready_{false}; std::atomic<bool> frame_ready_{false};
std::atomic<double> pending_start_{0.0}; std::atomic<double> pending_start_{0.0};
/// The load generation whose file mpv has loaded, under `state_mutex_`.
int64_t loaded_generation_ = -1;
/// Subtitles handed over before their file loaded, under `state_mutex_`.
std::vector<Subtitle> pending_subtitles_;
/// Streams to release once `state_mutex_` is let go.
std::vector<uint64_t> released_;
}; };
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); } Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
@@ -850,6 +938,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
: JNI_FALSE); : JNI_FALSE);
}); });
} }
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAddSubtitle(JNIEnv *env, jobject, jlong handle, jlong generation, jlong stream, jstring title, jstring language, jboolean select) noexcept {
c_boundary([=] {
Subtitle subtitle{static_cast<uint64_t>(stream), {}, {}, select == JNI_TRUE};
auto *p = from(handle);
if (!p || !utf8(env, title, subtitle.title) || !utf8(env, language, subtitle.language)) {
pstr_android_stream_release(subtitle.handle);
return;
}
p->add_subtitle(generation, std::move(subtitle));
});
}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept { extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); }); c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
} }
@@ -881,8 +980,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
state.video_width, state.video_height, state.video_width, state.video_height,
static_cast<jdouble>(state.end_reason), static_cast<jdouble>(state.end_reason),
state.buffering ? 1.0 : 0.0, state.cache_percent, state.buffering ? 1.0 : 0.0, state.cache_percent,
state.seeking ? 1.0 : 0.0, state.cached_until}; state.seeking ? 1.0 : 0.0, state.cached_until,
constexpr jsize kFields = 14; state.loaded ? 1.0 : 0.0};
constexpr jsize kFields = 15;
jdoubleArray result = env->NewDoubleArray(kFields); jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast<jdoubleArray>(nullptr); if (!result) return static_cast<jdoubleArray>(nullptr);
env->SetDoubleArrayRegion(result, 0, kFields, values); env->SetDoubleArrayRegion(result, 0, kFields, values);
@@ -21,6 +21,7 @@ import io.narl.protonstream.native.NativeRuntime
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.NewEpisodesWorker
import io.narl.protonstream.sync.WatchSyncWorker 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
@@ -79,6 +80,7 @@ class MainActivity : ComponentActivity() {
playbackConnection, playbackConnection,
Context.BIND_AUTO_CREATE, Context.BIND_AUTO_CREATE,
) )
NewEpisodesWorker.schedule(this)
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
@@ -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()
} }
} }
@@ -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,12 +29,27 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel() createNotificationChannel()
setForeground(createForegroundInfo(null))
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 // Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means // and holds the rest back, and only the first progress report means
// this one has a slot. // this one has a slot.
@@ -62,7 +81,7 @@ class OfflineDownloadWorker(
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.
@@ -120,15 +139,20 @@ class OfflineDownloadWorker(
) )
} }
/** [progress] is a percentage, or null while waiting for a download slot. */ /**
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( .setContentTitle(label)
if (progress == null) "Waiting to download" else "Making episode available offline", .setContentText(if (progress == null) "Waiting to download" else "$progress %")
)
.setProgress(100, progress ?: 0, progress == null || progress == 0) .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),
@@ -137,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 {
@@ -149,6 +203,10 @@ 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
@@ -186,4 +244,4 @@ internal class TransferRate {
} }
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)
@@ -19,7 +19,10 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.SubtitleTrackRecord
import uniffi.pstr_android.chapterPlan import uniffi.pstr_android.chapterPlan
import uniffi.pstr_android.fullSubtitlesInstead
/** /**
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients * Why the open file ended, mirroring `pstr_player::EndReason` so both clients
@@ -107,6 +110,10 @@ data class MpvTrack(
val language: String, val language: String,
val title: String, val title: String,
val selected: Boolean, val selected: Boolean,
/** The container marks it as the default choice. */
val isDefault: Boolean = false,
/** The container marks it as forced: on-screen text only, by convention. */
val forced: Boolean = false,
) { ) {
val label: String get() = listOf(language, title).filter(String::isNotBlank) val label: String get() = listOf(language, title).filter(String::isNotBlank)
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" } .joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
@@ -128,6 +135,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
@Volatile private var currentMedia: String? = null @Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0 @Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null @Volatile private var plannedFor: Pair<Double, Long>? = null
/** The load generation whose subtitle pick has been looked at. */
@Volatile private var subtitlesCheckedFor: Double = -1.0
var onPlaybackStarted: (() -> Unit)? = null var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null var onExplicitStop: (() -> Unit)? = null
@@ -245,7 +254,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) {
@@ -292,6 +301,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong()) override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
/**
* Hand mpv the subtitle files beside [media], the episode [play] loaded.
*
* Native code holds them until mpv has the file loaded, since an external
* track belongs to the file open at the time, and releases them instead
* if another episode has been loaded since. [SubtitleFilesRecord.preferred]
* is turned on only when the file has no subtitle of its own playing;
* pass [subtitles] false to turn none on.
*/
fun addSubtitles(media: String, files: SubtitleFilesRecord, subtitles: Boolean) {
val generation = currentGeneration.toLong()
val added = withOpenHandle { handle ->
files.files.forEachIndexed { index, file ->
if (currentMedia == media) {
val select = subtitles && files.preferred == index.toUInt()
nativeAddSubtitle(handle, generation, file.handle.toLong(), file.title, file.language, select)
} else {
pstrAndroidStreamRelease(file.handle.toLong())
}
}
}
if (added == null) files.files.forEach { pstrAndroidStreamRelease(it.handle.toLong()) }
}
private fun startPolling() { private fun startPolling() {
if (poller?.isActive == true) return if (poller?.isActive == true) return
// Off the main thread deliberately: `nativeState` and `nativeTracks` // Off the main thread deliberately: `nativeState` and `nativeTracks`
@@ -322,6 +355,12 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
// both of which are the main thread's. // both of which are the main thread's.
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) } withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
refreshChapters(next) refreshChapters(next)
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
subtitlesCheckedFor != currentGeneration
) {
subtitlesCheckedFor = currentGeneration
preferFullSubtitles()
}
} }
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it } withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
if (tick++ % 4 == 0) refreshTracks() if (tick++ % 4 == 0) refreshTracks()
@@ -359,13 +398,32 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
} }
} }
/**
* Play the dialogue rather than the signs and songs mpv picked by language
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
* have made, not the viewer's.
*/
private fun preferFullSubtitles() {
refreshTracks()
val subtitles = mutableTracks.value.filter { it.type == "sub" }
val id = fullSubtitlesInstead(
subtitles.map {
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
},
) ?: return
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
}
private fun refreshTracks() { private fun refreshTracks() {
runCatching { runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return val encoded = withOpenHandle(::nativeTracks) ?: return
val values = JSONArray(encoded) val values = JSONArray(encoded)
List(values.length()) { index -> List(values.length()) { index ->
values.getJSONObject(index).run { values.getJSONObject(index).run {
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected")) MpvTrack(
getLong("id"), getString("type"), getString("language"), getString("title"),
getBoolean("selected"), optBoolean("default"), optBoolean("forced"),
)
} }
} }
}.onSuccess { mutableTracks.value = it } }.onSuccess { mutableTracks.value = it }
@@ -414,6 +472,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
subtitles: Boolean, subtitles: Boolean,
hardwareDecoding: Boolean, hardwareDecoding: Boolean,
): Boolean ): Boolean
private external fun nativeAddSubtitle(
handle: Long,
generation: Long,
stream: Long,
title: String,
language: String?,
select: Boolean,
)
private external fun nativePause(handle: Long, paused: Boolean) private external fun nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double) private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double) private external fun nativeVolume(handle: Long, volume: Double)
@@ -434,7 +500,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
companion object { companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */ /** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
private const val FIELDS = 14 private const val FIELDS = 15
private const val GENERATION = 6 private const val GENERATION = 6
private const val WIDTH = 7 private const val WIDTH = 7
private const val HEIGHT = 8 private const val HEIGHT = 8
@@ -443,6 +509,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private const val CACHE_PERCENT = 11 private const val CACHE_PERCENT = 11
private const val SEEKING = 12 private const val SEEKING = 12
private const val CACHED_UNTIL = 13 private const val CACHED_UNTIL = 13
private const val LOADED = 14
init { init {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.narl.protonstream.MainActivity import io.narl.protonstream.MainActivity
import io.narl.protonstream.R import io.narl.protonstream.R
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.loadArtwork import io.narl.protonstream.ui.loadArtwork
import io.narl.protonstream.ui.loadThumbnail import io.narl.protonstream.ui.loadThumbnail
@@ -33,6 +35,8 @@ 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 and rotation. */ /** Owns libmpv so audio survives Activity recreation and rotation. */
@@ -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,23 @@ 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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.focusable
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
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 +88,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 +125,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 +134,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,7 +155,10 @@ 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.
@@ -184,8 +216,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 {
@@ -204,7 +244,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,
@@ -218,12 +258,21 @@ fun PlayerScreen(
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles, startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
SettingsStore(context).hardwareDecoding, SettingsStore(context).hardwareDecoding,
) )
// After the load, because mpv keeps none of the three across one. // After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume) nativeHost.setVolume(startup.volume)
nativeHost.setMuted(startup.muted) nativeHost.setMuted(startup.muted)
nativeHost.setSpeed(startup.speed) nativeHost.setSpeed(startup.speed)
session.transferToPlayer() session.transferToPlayer()
// While mpv opens the episode, and off the spinner's path: the host
// holds them until it has the file.
launch(Dispatchers.IO) {
val files = NativeRuntime.engine()
.openSubtitles(episode.shareId, episode.linkId, startup.subtitleLanguage)
nativeHost.addSubtitles(key, files, startup.subtitles)
}
}.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
@@ -261,12 +310,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)
@@ -318,8 +363,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.
@@ -328,14 +375,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>() }
@@ -346,24 +421,105 @@ 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
} }
} }
// Which side was double-tapped, shown for a moment so the seek is seen
// to have happened; the count makes a second tap on one side show again.
var nudge by remember { mutableStateOf<Pair<Int, Int>?>(null) }
LaunchedEffect(nudge) {
if (nudge != null) {
delay(NUDGE_MS)
nudge = null
}
}
val current by rememberUpdatedState(state)
// Where a remote's keys land while the controls are down. Once they are
// up, the D-pad moves between their buttons as it does anywhere else.
val keys = remember { FocusRequester() }
LaunchedEffect(showControls) { if (!showControls) runCatching { keys.requestFocus() } }
if (minimized) return if (minimized) return
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.clickable( // A TV remote, a keyboard: left and right seek as a double tap
interactionSource = remember { MutableInteractionSource() }, // does, the middle button brings the controls up, and play/pause
indication = null, // and space do what they say.
onClick = { showControls = !showControls }, .focusRequester(keys)
), .onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.DirectionLeft, Key.DirectionRight -> {
if (showControls) return@onKeyEvent false
val seconds = if (event.key == Key.DirectionLeft) -DOUBLE_TAP_SECONDS else DOUBLE_TAP_SECONDS
host.seek((current.position + seconds).coerceAtLeast(0.0))
nudge = seconds to (nudge?.second ?: 0) + 1
true
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.DirectionUp, Key.DirectionDown -> {
if (showControls) return@onKeyEvent false
showControls = true
true
}
Key.MediaPlayPause, Key.Spacebar -> {
host.setPaused(!current.paused)
showControls = true
true
}
Key.MediaPlay -> { host.setPaused(false); true }
Key.MediaPause -> { host.setPaused(true); true }
else -> false
}
}
.focusable()
// Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both.
.pointerInput(host) {
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(),
@@ -455,7 +611,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,
) )
} }
@@ -473,21 +632,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 = 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)
@@ -499,10 +696,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),
@@ -523,14 +723,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,
@@ -575,6 +790,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
@@ -621,8 +856,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(
@@ -684,45 +926,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,
)
} }
} }
} }
@@ -983,24 +1260,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()
} }
@@ -1063,8 +1347,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") }
} }
} }
} }
@@ -1106,12 +1431,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,31 @@ 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) } }
/** Whether a background crawl may say that a show gained episodes. */
var newEpisodeNotifications: Boolean
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, 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 +70,20 @@ 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_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
} }
} }
@@ -0,0 +1,136 @@
package io.narl.protonstream.sync
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.narl.protonstream.MainActivity
import io.narl.protonstream.R
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
import uniffi.pstr_android.NewEpisodesRecord
import java.util.concurrent.TimeUnit
/**
* Look at the shares while the app is away, and say when a show gained episodes.
*
* The app crawls a share it has not looked at for six hours whenever it opens,
* which finds a new episode only once someone opens it to look. This runs the
* same crawl on WorkManager's schedule instead. It only reports files the
* crawl itself found, so a share the app crawled since says nothing twice.
*/
class NewEpisodesWorker(
appContext: Context,
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
if (news.isNotEmpty()) post(news)
return Result.success()
}
private fun post(news: List<NewEpisodesRecord>) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
val manager = applicationContext.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "A show in your library gained episodes"
},
)
val open = PendingIntent.getActivity(
applicationContext,
0,
Intent(applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val compat = NotificationManagerCompat.from(applicationContext)
// One per show, keyed by it, so a second crawl's news for the same show
// replaces the first rather than stacking under it.
news.forEach { item ->
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle(item.title)
.setContentText(describe(item))
.setContentIntent(open)
.setGroup(GROUP)
.setAutoCancel(true)
.build()
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
}
if (news.size > 1) {
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle("New episodes")
.setContentText(news.joinToString { it.title })
.setContentIntent(open)
.setGroup(GROUP)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
}
}
companion object {
private const val WORK_NAME = "new-episodes"
private const val CHANNEL_ID = "new-episodes"
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
private const val SUMMARY_TAG = "new-episodes-summary"
private const val NOTIFICATION_ID = 7
private const val INTERVAL_HOURS = 6L
/** What one show's notification says: "S01E04 and 2 more". */
fun describe(news: NewEpisodesRecord): String {
val more = news.count.toInt() - 1
val first = news.first
return when {
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
more == 0 -> "$first is new"
more == 1 -> "$first and 1 more are new"
else -> "$first and $more more are new"
}
}
/**
* Keep the periodic look scheduled, or cancel it when the viewer
* turned it off. Called on every launch; keeping an existing schedule
* means a launch does not push the next look back.
*/
fun schedule(context: Context) {
val work = WorkManager.getInstance(context.applicationContext)
if (!SettingsStore(context).newEpisodeNotifications) {
work.cancelUniqueWork(WORK_NAME)
return
}
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build(),
)
.build()
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}
@@ -25,8 +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 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
@@ -46,6 +48,8 @@ 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(),
/** /**
@@ -54,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(),
@@ -110,13 +115,40 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
/** Launch and coming back both ask; one crawl answers. */ /** Launch and coming back both ask; one crawl answers. */
private var catchingUp = false 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) {
mutableState.update { mutableState.update {
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() catchUp()
viewModelScope.launch { viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } } val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
@@ -145,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) {
@@ -160,27 +209,70 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch { viewModelScope.launch {
busy("Refreshing the library…") { busy("Refreshing the library…") {
mutableState.update { it.copy(message = null) } mutableState.update { it.copy(message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } } runCatching { crawlShares(null) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
reload() reloadNow()
matchAfterCrawl()
} }
} }
/** /**
* Say [what] in the activity banner while [work] runs. Nested work keeps * Look up whatever a crawl brought that has no answer yet: a new show, or
* the outer line once the inner one is done. * 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) { private suspend fun busy(what: String, work: suspend () -> Unit) {
val outer = mutableState.value.activity val token = nextActivity++
mutableState.update { it.copy(activity = what, refreshing = true) } activities += token to what
showActivity()
try { try {
work() work()
} finally { } finally {
mutableState.update { it.copy(activity = outer, refreshing = outer != null) } 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.
* *
@@ -194,10 +286,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share" val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
busy("Reading $name…") { busy("Reading $name…") {
mutableState.update { it.copy(message = null) } mutableState.update { it.copy(message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } runCatching { crawlShares(id) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
reload() reloadNow()
matchAfterCrawl()
} }
} }
@@ -213,15 +306,25 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch { match(force) } viewModelScope.launch { match(force) }
} }
private suspend fun match(force: Boolean) { /**
* 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…") { 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) } }
} }
reloadNow() 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. * Add a link, and return why it was refused, if it was.
* *
@@ -249,29 +352,39 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
if (ids.isEmpty()) return if (ids.isEmpty()) return
val before = mutableState.value.titles.map { it.key }.toSet() val before = mutableState.value.titles.map { it.key }.toSet()
val failed = mutableListOf<String>() val failed = mutableListOf<String>()
crawlStopped = false
for (id in ids) { for (id in ids) {
if (crawlStopped) break
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share" val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
busy("Reading $name…") { busy("Reading $name…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } runCatching { crawlShares(id) }
.onFailure { failed += "$name: ${it.message}" } .onFailure { failed += "$name: ${it.message}" }
} }
} }
reloadNow() reloadNow()
if (failed.isNotEmpty()) { // 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")) } mutableState.update { it.copy(message = failed.joinToString("\n")) }
return return
} }
if (mutableState.value.metadataSettings.enabled) match(force = false) matchAfterCrawl()
if (announce) { if (announce) {
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size 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") } 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") }
}
} }
} }
/** /**
* Crawl shares the catalog has nothing of: brought by a sync that ran in * Crawl shares due a look: ones the catalog has nothing of — brought by a
* [io.narl.protonstream.sync.WatchSyncWorker], or an add whose crawl never * sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
* finished. * whose crawl never finished — and ones not crawled for six hours, so new
* episodes turn up without a pull to refresh.
*/ */
fun catchUp() { fun catchUp() {
if (catchingUp) return if (catchingUp) return
@@ -305,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()
} }
} }
@@ -502,11 +625,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* 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(
@@ -554,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)
@@ -698,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 {
@@ -759,7 +865,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
Reloaded( Reloaded(
engine.shares(), engine.shares(),
engine.library(query), engine.library(query),
engine.arrangement(query, current.sort, current.grouped), engine.arrangement(query, current.filter, current.sort, current.grouped),
engine.offlineFiles(), engine.offlineFiles(),
engine.metadataSettings(), engine.metadataSettings(),
engine.storageUsage(), engine.storageUsage(),
@@ -787,7 +893,7 @@ 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) }
@@ -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,11 +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.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
/** /**
@@ -93,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
@@ -122,11 +148,52 @@ internal fun formatBytes(bytes: ULong): String {
} }
/** /**
* One line under a download that has not finished: "Running · 120.0 MiB of * Download episodes, asking first when it is more than one: a season or a
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot. * 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.
*/ */
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull( @Composable
download.status.replaceFirstChar { it.uppercase() }, 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 { download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}" "${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
}, },
@@ -135,6 +202,25 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
?.let { "${formatBytes(it.toULong())}/s" }, ?.let { "${formatBytes(it.toULong())}/s" },
).joinToString(" · ") ).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,7 +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(downloadStatusLine(download)) Text(downloadStatusLine(download, rememberQueueReason(download)))
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" },
) )
} }
} }
@@ -76,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
@@ -133,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
@@ -226,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 ->
@@ -242,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 }
}, },
@@ -273,12 +282,21 @@ 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, account = state.account,
accountActions = AccountActions( accountActions = AccountActions(
@@ -292,6 +310,7 @@ fun ProtonStreamApp(
addFolder = model::addAccountFolder, addFolder = model::addAccountFolder,
onError = model::reportError, onError = model::reportError,
), ),
onRename = model::renameShare,
) )
Destination.History -> HistoryScreen( Destination.History -> HistoryScreen(
state, state,
@@ -311,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,
@@ -339,7 +367,8 @@ fun ProtonStreamApp(
} }
ActivityBanner( ActivityBanner(
state.activity, state.activity,
Modifier onStop = model::stopCrawl.takeIf { state.crawling },
modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = body.calculateBottomPadding() + 8.dp), .padding(bottom = body.calculateBottomPadding() + 8.dp),
) )
@@ -421,7 +450,7 @@ private fun MiniTransport(
* Not dismissible and not timed — it goes when the work does. * Not dismissible and not timed — it goes when the work does.
*/ */
@Composable @Composable
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) { private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
AnimatedVisibility( AnimatedVisibility(
visible = activity != null, visible = activity != null,
modifier = modifier, modifier = modifier,
@@ -442,7 +471,10 @@ private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2) 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
} }
@@ -59,12 +59,14 @@ 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.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.NewEpisodesWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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
@@ -103,6 +105,7 @@ internal fun SettingsScreen(
val settings = remember { SettingsStore(context) } val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) } var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) } var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) } var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) } var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
// Playback preferences are the shared store's, not this app's: they are the // Playback preferences are the shared store's, not this app's: they are the
@@ -206,6 +209,15 @@ internal fun SettingsScreen(
backgroundAudio = it backgroundAudio = it
settings.backgroundAudio = it settings.backgroundAudio = it
} }
SettingSwitch(
"New episode notifications",
"Looks at your shares every few hours and says when a show gains episodes",
newEpisodes,
) {
newEpisodes = it
settings.newEpisodeNotifications = it
NewEpisodesWorker.schedule(context)
}
} }
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) { SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) { SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
@@ -316,7 +328,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") } },
) )
@@ -419,6 +431,7 @@ private fun MetadataSettingsDialog(
var refusal by remember { mutableStateOf<String?>(null) } var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank() val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
val forgets = current.enabled && (!enabled || provider != current.provider)
AlertDialog( AlertDialog(
onDismissRequest = { if (!saving) onDismiss() }, onDismissRequest = { if (!saving) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
@@ -450,6 +463,16 @@ private fun MetadataSettingsDialog(
) )
} }
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error) 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) } refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
} }
}, },
@@ -30,13 +30,17 @@ 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.AccountState
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -50,6 +54,8 @@ 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
@@ -65,8 +71,10 @@ internal fun SharesScreen(
padding: PaddingValues, padding: PaddingValues,
account: AccountUiState = AccountUiState(), account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null, 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 signingIn by remember { mutableStateOf(false) }
var browsing by remember { mutableStateOf(false) } var browsing by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) } var repairing by remember { mutableStateOf<ShareRecord?>(null) }
@@ -103,6 +111,7 @@ internal fun SharesScreen(
onRefresh = { onRefresh(share.id) }, onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share }, onRepair = { repairing = share },
onRemove = { removing = share }, onRemove = { removing = share },
onRename = { renaming = share },
) )
} }
} }
@@ -141,6 +150,21 @@ 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 and its // Asked, because it cannot be undone: the link's secret and its
// offline files go. Watch positions stay (`Catalog::remove_share`). // offline files go. Watch positions stay (`Catalog::remove_share`).
@@ -154,7 +178,7 @@ internal fun SharesScreen(
) )
}, },
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") } },
) )
@@ -167,24 +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( Column {
when { Text(
share.fromAccount -> "Folder of your Drive" when {
share.hasCustomPassword -> "Link and custom password, stored securely" share.fromAccount -> "Folder of your Drive"
else -> "Public link" 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(
@@ -206,6 +264,11 @@ 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.
@@ -296,6 +359,7 @@ private fun AddShareDialog(
) { ) {
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 adding by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) } var refusal by remember { mutableStateOf<String?>(null) }
@@ -306,17 +370,31 @@ private fun AddShareDialog(
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,
@@ -347,7 +425,7 @@ private fun AddShareDialog(
if (refusal == null) onDismiss() if (refusal == null) onDismiss()
} }
}, },
enabled = name.isNotBlank() && url.isNotBlank() && !adding, enabled = url.isNotBlank() && !adding,
) { Text(if (adding) "Adding…" else "Add") } ) { Text(if (adding) "Adding…" else "Add") }
}, },
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { 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,7 +688,7 @@ private fun EpisodeRow(
fill = solid(MaterialTheme.colorScheme.tertiary), fill = solid(MaterialTheme.colorScheme.tertiary),
) )
Text( Text(
downloadStatusLine(active), downloadStatusLine(active, rememberQueueReason(active)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -656,7 +708,33 @@ internal fun ChangeMatchDialog(
// Choosing fetches the entry's episodes too, which is a few requests. // Choosing fetches the entry's episodes too, which is a few requests.
var choosing by remember { mutableStateOf(false) } 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") },
@@ -705,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.
* *
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -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())
} }
} }
@@ -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: 49 KiB

After

Width:  |  Height:  |  Size: 68 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

+551 -93
View File
@@ -4,7 +4,7 @@
//! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing; //! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing;
//! Rust remains the sole owner of Proton sessions, SQLite and decrypted bytes. //! Rust remains the sole owner of Proton sessions, SQLite and decrypted bytes.
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
@@ -12,8 +12,10 @@ use std::sync::{Arc, OnceLock};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn}; use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
use pstr_core::browse::Sort; use pstr_core::browse::{Filter, Sort};
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows}; use pstr_core::catalog::{
Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
};
use pstr_core::chapters::{Chapter, ChapterRole}; use pstr_core::chapters::{Chapter, ChapterRole};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::{Episode, Library, Title, TitleKind}; use pstr_core::library::{Episode, Library, Title, TitleKind};
@@ -25,6 +27,7 @@ use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential; use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
use pstr_core::shares::AccountFolder; use pstr_core::shares::AccountFolder;
use pstr_core::subtitles::SubtitleTrack;
use pstr_core::sync::WatchSync; use pstr_core::sync::WatchSync;
use pstr_core::{ use pstr_core::{
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary, Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
@@ -102,6 +105,36 @@ pub struct AndroidPaths {
pub cache: String, pub cache: String,
} }
/// A subtitle file beside an episode, opened and published for the native
/// player like the episode itself. The player releases `handle` when mpv
/// closes it; one never handed to mpv goes back through
/// `pstr_android_stream_release`.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SubtitleFileRecord {
pub handle: u64,
/// What else the file name says — "forced", "SDH" — or "External".
pub title: String,
pub language: Option<String>,
}
/// An episode's subtitle files, and which to turn on when the episode has
/// none of its own playing.
#[derive(Debug, Clone, Default, uniffi::Record)]
pub struct SubtitleFilesRecord {
pub files: Vec<SubtitleFileRecord>,
pub preferred: Option<u32>,
}
/// Episodes of one title that a background crawl found, for a notification.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NewEpisodesRecord {
pub title_key: String,
pub title: String,
pub count: u32,
/// The first new episode's label — "S01E04" — when there is one.
pub first: Option<String>,
}
/// Android implements this with an AES-GCM key held by Android Keystore. /// Android implements this with an AES-GCM key held by Android Keystore.
#[uniffi::export(callback_interface)] #[uniffi::export(callback_interface)]
pub trait AndroidSecretStore: Send + Sync { pub trait AndroidSecretStore: Send + Sync {
@@ -140,6 +173,18 @@ pub struct ShareRecord {
/// A folder of the signed-in account rather than a public link. /// A folder of the signed-in account rather than a public link.
#[uniffi(default = false)] #[uniffi(default = false)]
pub from_account: bool, pub from_account: bool,
/// When the last crawl here finished, Unix seconds. `None` before one has.
#[uniffi(default = None)]
pub crawled_at: Option<i64>,
/// Playable files that crawl stored.
#[uniffi(default = None)]
pub crawl_files: Option<u64>,
/// Of those, how many no earlier crawl had seen.
#[uniffi(default = None)]
pub crawl_added: Option<u64>,
/// Why the last attempt failed, until one succeeds.
#[uniffi(default = None)]
pub crawl_error: Option<String>,
} }
fn share_record(share: Share) -> ShareRecord { fn share_record(share: Share) -> ShareRecord {
@@ -148,6 +193,10 @@ fn share_record(share: Share) -> ShareRecord {
id: share.id, id: share.id,
name: share.name, name: share.name,
has_custom_password: share.has_custom_password, has_custom_password: share.has_custom_password,
crawled_at: None,
crawl_files: None,
crawl_added: None,
crawl_error: None,
} }
} }
@@ -301,6 +350,17 @@ pub enum TitleNamesChoice {
Romaji, Romaji,
} }
/// The shared `pstr_core::browse::Filter`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter {
All,
Series,
Films,
Watching,
Unwatched,
Downloaded,
}
/// The shared `pstr_core::browse::Sort`. /// The shared `pstr_core::browse::Sort`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibrarySort { pub enum LibrarySort {
@@ -509,6 +569,11 @@ pub struct TitleRecord {
/// Every title of its franchise, in release order, its own key included; /// Every title of its franchise, in release order, its own key included;
/// empty for a title that stands alone. /// empty for a title that stands alone.
pub franchise: Vec<String>, pub franchise: Vec<String>,
/// Where Continue watching picks this title up, as an index into its
/// episodes in display order: the part-watched one, or the one after the
/// episode finished last. `None` keeps it off the shelf. See
/// `pstr_core::library::Title::continue_with`.
pub continue_index: Option<u32>,
} }
/// Playback preferences that outlive one file, one title and one launch. /// Playback preferences that outlive one file, one title and one launch.
@@ -675,6 +740,38 @@ fn chapter_kind(role: ChapterRole) -> ChapterKind {
} }
} }
/// One subtitle track as the player read it out of mpv's `track-list`.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SubtitleTrackRecord {
pub id: i64,
pub language: Option<String>,
pub title: Option<String>,
pub default: bool,
pub forced: bool,
pub selected: bool,
}
/// The dialogue track to play in place of the subtitle track mpv chose, when
/// mpv chose the signs and songs (`pstr_core::subtitles`). Asked once per file,
/// as soon as mpv has loaded it and made its own pick.
#[uniffi::export]
pub fn full_subtitles_instead(tracks: Vec<SubtitleTrackRecord>) -> Option<i64> {
let tracks: Vec<SubtitleTrack> = tracks
.into_iter()
.map(|track| SubtitleTrack {
id: track.id,
language: track
.language
.filter(|language| !language.trim().is_empty()),
title: track.title.filter(|title| !title.trim().is_empty()),
default: track.default,
forced: track.forced,
selected: track.selected,
})
.collect();
pstr_core::subtitles::full_instead(&tracks)
}
/// What the app is holding on disk. Offline episodes are the viewer's, the /// What the app is holding on disk. Offline episodes are the viewer's, the
/// block cache is not: only the latter may be reclaimed without asking. /// block cache is not: only the latter may be reclaimed without asking.
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
@@ -942,6 +1039,8 @@ pub struct AndroidEngine {
reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>, reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
/// The whole library as bridge records, behind the catalog's write counter. /// The whole library as bridge records, behind the catalog's write counter.
library_cache: Mutex<Option<CachedLibrary>>, library_cache: Mutex<Option<CachedLibrary>>,
/// Advanced by [`AndroidEngine::stop_crawl`]; a crawl watching it stops.
crawl_stop: tokio::sync::watch::Sender<u64>,
/// Serializes offline publication with share removal cleanup. /// Serializes offline publication with share removal cleanup.
share_publication: Mutex<()>, share_publication: Mutex<()>,
/// Advances after every successful share-store mutation. A connection is /// Advances after every successful share-store mutation. A connection is
@@ -949,12 +1048,6 @@ pub struct AndroidEngine {
share_generation: AtomicU64, share_generation: AtomicU64,
/// How many Proton thumbnails may be in flight at once. /// How many Proton thumbnails may be in flight at once.
thumbnails: tokio::sync::Semaphore, thumbnails: tokio::sync::Semaphore,
/// When each share's visitor session was last refreshed.
///
/// Sessions are short-lived and the app is routinely backgrounded for hours
/// between one episode and the next, so age — not just a share-store
/// mutation — is a reason to reauthenticate before opening anything.
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
/// The streaming cache's budget in bytes, for the next connection and, /// The streaming cache's budget in bytes, for the next connection and,
/// through [`AndroidEngine::set_stream_cache_budget`], the open one. /// through [`AndroidEngine::set_stream_cache_budget`], the open one.
cache_budget: AtomicU64, cache_budget: AtomicU64,
@@ -982,13 +1075,6 @@ const OFFLINE_DOWNLOAD_CONCURRENCY: usize = 2;
/// How often a download waiting for a slot asks whether it was cancelled. /// How often a download waiting for a slot asks whether it was cancelled.
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500); const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
/// 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, before this, the viewer could only clear with a manual
/// pull-to-refresh.
const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
#[uniffi::export] #[uniffi::export]
impl AndroidEngine { impl AndroidEngine {
#[uniffi::constructor] #[uniffi::constructor]
@@ -1031,10 +1117,10 @@ impl AndroidEngine {
connecting: tokio::sync::Mutex::new(()), connecting: tokio::sync::Mutex::new(()),
reusable_clients: Mutex::new(None), reusable_clients: Mutex::new(None),
library_cache: Mutex::new(None), library_cache: Mutex::new(None),
crawl_stop: tokio::sync::watch::channel(0).0,
share_publication: Mutex::new(()), share_publication: Mutex::new(()),
share_generation: AtomicU64::new(0), share_generation: AtomicU64::new(0),
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY), thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
session_refreshed: Mutex::new(HashMap::new()),
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES), cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)), download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
accounts, accounts,
@@ -1268,31 +1354,95 @@ impl AndroidEngine {
Ok(share_record(share)) Ok(share_record(share))
} }
/// Configured shares with nothing in the catalog: brought in by sync, /// Configured shares due a crawl: never crawled here — brought in by sync,
/// possibly while the app was in the background, or added here and never /// possibly while the app was in the background, or added and never
/// crawled to the end. Crawl each. An empty share is crawled every time, /// crawled to the end — or not looked at for
/// which costs one listing. /// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`], so a share grows
/// without anyone pressing Refresh. Crawl each.
pub fn uncrawled_shares(&self) -> Result<Vec<String>, BridgeError> { pub fn uncrawled_shares(&self) -> Result<Vec<String>, BridgeError> {
let shares = self.store.list().map_err(BridgeError::from_display)?; let shares = self.store.list().map_err(BridgeError::from_display)?;
let catalog = self.catalog.lock(); let crawls = self
let mut uncrawled = Vec::new(); .catalog
for share in shares { .lock()
if catalog .crawls()
.files(&share.id) .map_err(BridgeError::from_display)?;
.map_err(BridgeError::from_display)? let now = now();
.is_empty() Ok(shares
{ .into_iter()
uncrawled.push(share.id); .filter(|share| {
} crawls.get(&share.id).is_none_or(|record| {
} record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS)
Ok(uncrawled) })
})
.map(|share| share.id)
.collect())
} }
/// Crawl the shares due a look and say which titles gained episodes, for
/// a notification from a background job.
///
/// Only shares crawled here before: the first crawl of one finds every
/// file new, and "312 new episodes" is not news. A share that fails is
/// skipped, so one revoked link does not hide another's episodes.
pub async fn crawl_for_news(self: Arc<Self>) -> Result<Vec<NewEpisodesRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let crawls = self
.catalog
.lock()
.crawls()
.map_err(BridgeError::from_display)?;
let due: Vec<String> = self
.uncrawled_shares()?
.into_iter()
.filter(|id| crawls.get(id).is_some_and(|record| record.crawled_at > 0))
.collect();
if due.is_empty() {
return Ok(Vec::new());
}
let since = now();
for id in due {
if let Err(error) = self.crawl_inner(Some(id)).await {
log::debug!("background crawl failed: {error}");
}
}
self.with_library(|cached| {
let mut news = news_since(&cached.library, &cached.first_seen, since);
// By the name the library shows, which may be the provider's.
for item in &mut news {
if let Some(record) = cached.titles.iter().find(|t| t.key == item.title_key) {
item.title.clone_from(&record.display_name);
}
}
news
})
})
.await
.map_err(BridgeError::from_display)?
}
/// Every configured share, with what its last crawl here came to.
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> { pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
self.store let shares = self.store.list().map_err(BridgeError::from_display)?;
.list() let crawls = self
.map_err(BridgeError::from_display) .catalog
.map(|shares| shares.into_iter().map(share_record).collect()) .lock()
.crawls()
.map_err(BridgeError::from_display)?;
Ok(shares
.into_iter()
.map(|share| {
let crawl = crawls.get(&share.id).cloned();
let mut record = share_record(share);
if let Some(crawl) = crawl {
record.crawled_at = (crawl.crawled_at > 0).then_some(crawl.crawled_at);
record.crawl_files = (crawl.crawled_at > 0).then_some(crawl.files);
record.crawl_added = (crawl.crawled_at > 0).then_some(crawl.added);
record.crawl_error = crawl.error;
}
record
})
.collect())
} }
/// Add a public link, once it has opened with the password given. A link /// Add a public link, once it has opened with the password given. A link
@@ -1344,6 +1494,14 @@ impl AndroidEngine {
Ok(share_record(share)) Ok(share_record(share))
} }
/// Call a share something else; see `ShareStore::rename`.
pub fn rename_share(&self, share_id: String, name: String) -> Result<ShareRecord, BridgeError> {
self.store
.rename(&share_id, &name)
.map(share_record)
.map_err(BridgeError::from_display)
}
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> { pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
let _publication = self.share_publication.lock(); let _publication = self.share_publication.lock();
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
@@ -1429,19 +1587,31 @@ impl AndroidEngine {
}) })
} }
/// The library grid for `search`, in `sort` order, each franchise folded /// The library grid for `search`, narrowed by `filter`, in `sort` order,
/// into one tile when `grouped` — and the shelves above it. Keys into /// each franchise folded into one tile when `grouped` — and the shelves
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge /// above it. Keys into [`AndroidEngine::library`]'s titles, so the records
/// once. /// cross the bridge once.
pub fn arrangement( pub fn arrangement(
&self, &self,
search: Option<String>, search: Option<String>,
filter: LibraryFilter,
sort: LibrarySort, sort: LibrarySort,
grouped: bool, grouped: bool,
) -> Result<ArrangementRecord, BridgeError> { ) -> Result<ArrangementRecord, BridgeError> {
let query = search.unwrap_or_default(); let query = search.unwrap_or_default();
let filter = filter_choice(filter);
self.with_library(|cached| { self.with_library(|cached| {
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query); let titles: Vec<&Title> =
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.into_iter()
.filter(|title| {
filter.admits(title, |node| {
cached
.offline
.contains(&(node.share_id.clone(), node.link_id.clone()))
})
})
.collect();
let tiles = pstr_core::browse::arrange( let tiles = pstr_core::browse::arrange(
&titles, &titles,
pstr_core::browse::Order { pstr_core::browse::Order {
@@ -1452,7 +1622,9 @@ impl AndroidEngine {
added: &cached.added, added: &cached.added,
}, },
); );
let shelves = if query.trim().is_empty() { // Shelves are the whole library's, and only when it is shown
// whole.
let shelves = if query.trim().is_empty() && filter == Filter::All {
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES) pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
.into_iter() .into_iter()
.map(|shelf| ShelfRecord { .map(|shelf| ShelfRecord {
@@ -1613,6 +1785,37 @@ impl AndroidEngine {
.map(|state| state.map(watch_state_record)) .map(|state| state.map(watch_state_record))
} }
/// Put one file's watch state back exactly as [`Self::watch_state`] read
/// it — what Undo does. Unlike [`Self::save_watch_state`] it keeps the
/// original time, so an undone change sorts where it did before. `None`,
/// a file that had never been played, becomes a reset older than anything,
/// which every later state outranks.
pub fn restore_watch_state(
&self,
share_id: String,
link_id: String,
state: Option<WatchStateRecord>,
) -> Result<(), BridgeError> {
let state = state.map_or(
WatchState {
position_secs: 0.0,
duration_secs: None,
watched: false,
updated_at: 0,
},
|state| WatchState {
position_secs: state.position_secs,
duration_secs: state.duration_secs,
watched: state.watched,
updated_at: state.updated_at,
},
);
self.catalog
.lock()
.set_watch_state(&share_id, &link_id, &state)
.map_err(BridgeError::from_display)
}
pub fn save_watch_state( pub fn save_watch_state(
&self, &self,
share_id: String, share_id: String,
@@ -1877,6 +2080,124 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)? .map_err(BridgeError::from_display)?
} }
/// Open the subtitle files paired with an episode, for the native player
/// to add once it has the episode loaded. `language` is the subtitle
/// language in effect for it, which picks [`SubtitleFilesRecord::preferred`].
///
/// Through the session already open: a downloaded episode played without
/// one goes without its subtitles rather than waiting on a handshake. One
/// that fails to open is left out; the episode plays either way.
pub async fn open_subtitles(
self: Arc<Self>,
share_id: String,
link_id: String,
language: Option<String>,
) -> SubtitleFilesRecord {
let runtime = Arc::clone(&self.runtime);
let opened = spawned(&runtime, async move {
let sidecars = self
.catalog
.lock()
.sidecars(&share_id, &link_id)
.unwrap_or_default();
if sidecars.is_empty() {
return SubtitleFilesRecord::default();
}
let Some(connection) = self.cached_connection() else {
return SubtitleFilesRecord::default();
};
let (engine, connection) = (&self, &connection);
let opening = sidecars.into_iter().map(|sidecar| async move {
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
match engine
.open_from_source(connection, &sidecar.share_id, &uid)
.await
{
Ok(stream) => {
stream.warm_edges();
Some((sidecar, stream))
}
Err(error) => {
log::debug!("opening subtitle {} failed: {error}", sidecar.name);
None
}
}
});
let opened: Vec<_> = futures::future::join_all(opening)
.await
.into_iter()
.flatten()
.collect();
let sidecars: Vec<_> = opened.iter().map(|(sidecar, _)| sidecar.clone()).collect();
let preferred = pstr_core::catalog::preferred_sidecar(&sidecars, language.as_deref())
.and_then(|index| u32::try_from(index).ok());
let files = opened
.into_iter()
.map(|(sidecar, stream)| {
let handle = Arc::new(AndroidStream {
runtime: Arc::clone(&self.runtime),
stream,
cancel: tokio::sync::Notify::new(),
native_id: Mutex::new(None),
})
.native_handle();
SubtitleFileRecord {
handle,
title: sidecar.title.unwrap_or_else(|| "External".to_owned()),
language: sidecar.language,
}
})
.collect();
SubtitleFilesRecord { files, preferred }
})
.await;
opened.unwrap_or_else(|error| {
log::debug!("opening subtitles stopped: {error}");
SubtitleFilesRecord::default()
})
}
/// Fetch the blocks an episode opens with into the cache, without playing
/// it: the next episode, near the end of this one, so that autoplay or
/// "Play now" starts from the cache rather than from two round trips.
///
/// Quietly nothing for a downloaded episode, and on any failure: all a
/// failure costs is the head start.
pub async fn warm_episode(
self: Arc<Self>,
share_id: String,
volume_id: String,
link_id: String,
) {
let runtime = Arc::clone(&self.runtime);
let warmed = spawned(&runtime, async move {
let offline = self
.catalog
.lock()
.offline_file(&share_id, &link_id)
.ok()
.flatten()
.is_some();
if offline {
return;
}
let Ok(connection) = self.connection().await else {
return;
};
match self
.open_from_source(&connection, &share_id, &node_uid(&volume_id, &link_id))
.await
{
Ok(stream) => stream.warm_edges(),
Err(error) => log::debug!("warming the next episode failed: {error}"),
}
})
.await;
if let Err(error) = warmed {
log::debug!("warming the next episode stopped: {error}");
}
}
pub async fn download_episode( pub async fn download_episode(
self: Arc<Self>, self: Arc<Self>,
share_id: String, share_id: String,
@@ -1958,6 +2279,13 @@ impl AndroidEngine {
.await .await
.map_err(BridgeError::from_display)? .map_err(BridgeError::from_display)?
} }
/// Stop every crawl running now. Shares already listed stay stored; the
/// one being listed keeps what it had. The stopped `crawl` returns
/// without an error, since whoever stopped it knows why.
pub fn stop_crawl(&self) {
self.crawl_stop.send_modify(|generation| *generation += 1);
}
} }
impl AndroidEngine { impl AndroidEngine {
@@ -2177,6 +2505,8 @@ impl AndroidEngine {
metadata, metadata,
added, added,
titles, titles,
offline: offline.into_keys().collect(),
first_seen,
}; };
let answer = read(&cached); let answer = read(&cached);
*self.library_cache.lock() = Some(cached); *self.library_cache.lock() = Some(cached);
@@ -2281,7 +2611,6 @@ impl AndroidEngine {
} }
} }
drop(reusable); drop(reusable);
self.session_refreshed.lock().remove(share_id);
self.share_generation.fetch_add(1, Ordering::AcqRel); self.share_generation.fetch_add(1, Ordering::AcqRel);
} }
@@ -2445,48 +2774,31 @@ impl AndroidEngine {
uid: &NodeUid, uid: &NodeUid,
copy: bool, copy: bool,
) -> Result<VideoStream, BridgeError> { ) -> Result<VideoStream, BridgeError> {
let open = || async { connection
if copy { .library
connection.source.open_for_copy(share_id, uid).await .reauthenticating(share_id, || async {
} else { if copy {
connection.source.open(share_id, uid).await connection.source.open_for_copy(share_id, uid).await
} } else {
}; connection.source.open(share_id, uid).await
let stale = self }
.session_refreshed })
.lock() .await
.get(share_id) .map_err(BridgeError::from_display)
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR);
if stale {
self.refresh_session(connection, share_id).await;
}
match open().await {
Ok(stream) => Ok(stream),
Err(_) if !stale => {
self.refresh_session(connection, share_id).await;
open().await.map_err(BridgeError::from_display)
}
Err(error) => Err(BridgeError::from_display(error)),
}
}
/// Best-effort: a refresh that fails leaves the open to report the real
/// problem, which is more useful than a refresh error standing in for it.
async fn refresh_session(&self, connection: &Connection, share_id: &str) {
if connection.library.refresh_session(share_id).await.is_ok() {
self.session_refreshed
.lock()
.insert(share_id.to_owned(), std::time::Instant::now());
}
} }
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> { async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
let mut stop = self.crawl_stop.subscribe();
stop.borrow_and_update();
let connection = self.connection().await?; let connection = self.connection().await?;
let targets: Vec<String> = match share_id { let targets: Vec<String> = match share_id {
Some(id) => vec![id], Some(id) => vec![id],
None => connection.library.share_ids().map(str::to_owned).collect(), None => connection.library.share_ids().map(str::to_owned).collect(),
}; };
for id in targets { for id in targets {
if stop.has_changed().unwrap_or(false) {
return Ok(());
}
connection connection
.library .library
.refresh_session(&id) .refresh_session(&id)
@@ -2494,24 +2806,39 @@ impl AndroidEngine {
.map_err(|error| BridgeError::Failure { .map_err(|error| BridgeError::Failure {
reason: format!("refresh session for {id}: {error}"), reason: format!("refresh session for {id}: {error}"),
})?; })?;
self.session_refreshed
.lock()
.insert(id.clone(), std::time::Instant::now());
let before = self let before = self
.catalog .catalog
.lock() .lock()
.all_offline_files() .all_offline_files()
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
let nodes = connection let crawled = tokio::select! {
.library crawled = connection.library.crawl(&id) => crawled,
.crawl(&id) _ = stop.changed() => return Ok(()),
.await };
.map_err(BridgeError::from_display)?; let nodes = match crawled {
Ok(nodes) => nodes,
Err(error) => {
// On the share's row until a crawl succeeds: a link revoked
// upstream otherwise just stops growing.
if let Err(error) = self
.catalog
.lock()
.record_crawl_failure(&id, &error.describe())
{
log::warn!("record a failed crawl: {error}");
}
return Err(BridgeError::from_display(error.describe()));
}
};
let rows = build_rows(&id, &nodes); let rows = build_rows(&id, &nodes);
self.catalog let sidecars = build_sidecars(&id, &nodes);
.lock() {
.replace_share(&id, &rows) let mut catalog = self.catalog.lock();
.map_err(BridgeError::from_display)?; catalog
.replace_share(&id, &rows)
.and_then(|()| catalog.replace_sidecars(&id, &sidecars))
.map_err(BridgeError::from_display)?;
}
let after = self let after = self
.catalog .catalog
.lock() .lock()
@@ -2574,8 +2901,35 @@ impl AndroidEngine {
} }
let connection = self.connection().await?; let connection = self.connection().await?;
self.open_from_source(&connection, share_id, &node_uid(volume_id, link_id)) let stream = self
.await .open_from_source(&connection, share_id, &node_uid(volume_id, link_id))
.await?;
// Before mpv has the handle: building its core and surface takes a
// moment, and the network can spend that moment on the two reads
// every file opens with — and, resuming, on the one it seeks to.
match self.resume_fraction(share_id, link_id) {
Some(fraction) => stream.warm_resume(fraction),
None => stream.warm_edges(),
}
Ok(stream)
}
/// How far into the file an episode resumes, as a fraction of its
/// running time, from what was saved the last time it played. `None` for
/// one that starts from the beginning, as the clients do for a finished
/// episode or one barely begun.
fn resume_fraction(&self, share_id: &str, link_id: &str) -> Option<f64> {
let state = self
.catalog
.lock()
.watch_state(share_id, link_id)
.ok()
.flatten()?;
if state.watched || state.position_secs < pstr_core::library::RESUME_FLOOR_SECS {
return None;
}
let duration = state.duration_secs.filter(|duration| *duration > 0.0)?;
Some(state.position_secs / duration)
} }
async fn download_episode_inner( async fn download_episode_inner(
@@ -3030,6 +3384,42 @@ struct CachedLibrary {
/// When each title last gained a file. /// When each title last gained a file.
added: HashMap<String, i64>, added: HashMap<String, i64>,
titles: Vec<TitleRecord>, titles: Vec<TitleRecord>,
/// The files kept on this device, by share and link id.
offline: HashSet<(String, String)>,
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
first_seen: HashMap<(String, String), i64>,
}
/// The titles whose files first appeared at or after `since`, with how many
/// and the first of them, most new episodes first.
fn news_since(
library: &Library,
first_seen: &HashMap<(String, String), i64>,
since: i64,
) -> Vec<NewEpisodesRecord> {
let mut news: Vec<NewEpisodesRecord> = library
.titles
.iter()
.filter_map(|title| {
let fresh: Vec<&pstr_core::library::Episode> = title
.episodes()
.filter(|episode| {
first_seen
.get(&(episode.node.share_id.clone(), episode.node.link_id.clone()))
.is_some_and(|seen| *seen >= since)
})
.collect();
let first = fresh.first()?;
Some(NewEpisodesRecord {
title_key: title.key.clone(),
title: title.name.clone(),
count: u32::try_from(fresh.len()).unwrap_or(u32::MAX),
first: Some(first.label()),
})
})
.collect();
news.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.title.cmp(&b.title)));
news
} }
/// How many provider lookups may be in flight at once. /// How many provider lookups may be in flight at once.
@@ -3091,6 +3481,17 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
} }
} }
fn filter_choice(filter: LibraryFilter) -> Filter {
match filter {
LibraryFilter::All => Filter::All,
LibraryFilter::Series => Filter::Series,
LibraryFilter::Films => Filter::Films,
LibraryFilter::Watching => Filter::Watching,
LibraryFilter::Unwatched => Filter::Unwatched,
LibraryFilter::Downloaded => Filter::Downloaded,
}
}
fn sort_choice(sort: LibrarySort) -> Sort { fn sort_choice(sort: LibrarySort) -> Sort {
match sort { match sort {
LibrarySort::Name => Sort::Name, LibrarySort::Name => Sort::Name,
@@ -3243,6 +3644,10 @@ fn title_record(
next_episode: None, next_episode: None,
next_airing_at: None, next_airing_at: None,
franchise: Vec::new(), franchise: Vec::new(),
continue_index: title.continue_with().and_then(|episode| {
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
u32::try_from(at).ok()
}),
seasons: title seasons: title
.seasons .seasons
.iter() .iter()
@@ -3380,7 +3785,7 @@ mod tests {
use super::{ use super::{
AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream, AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream,
AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker, AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker,
chapter_plan, match_record, node_uid, normalized_language, palette_record, chapter_plan, match_record, news_since, node_uid, normalized_language, palette_record,
prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release, prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release,
pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata, pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata,
write_partial_marker, write_partial_marker,
@@ -3838,7 +4243,12 @@ mod tests {
} }
let arranged = engine let arranged = engine
.arrangement(None, super::LibrarySort::Name, true) .arrangement(
None,
super::LibraryFilter::All,
super::LibrarySort::Name,
true,
)
.expect("arrange"); .expect("arrange");
assert_eq!(arranged.tiles.len(), 1); assert_eq!(arranged.tiles.len(), 1);
assert_eq!(arranged.tiles[0].key, "sousou no frieren"); assert_eq!(arranged.tiles[0].key, "sousou no frieren");
@@ -3930,6 +4340,54 @@ mod tests {
drop(engine); drop(engine);
std::fs::remove_dir_all(root).expect("remove test directory"); std::fs::remove_dir_all(root).expect("remove test directory");
} }
#[test]
fn news_is_the_titles_that_gained_files_since_the_crawl_began_most_first() {
let file = |link: &str, name: &str| pstr_core::catalog::CatalogNode {
share_id: "s".into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.into(),
is_folder: false,
media_type: None,
size: None,
active_revision_id: None,
parsed: pstr_core::naming::parse(name),
};
let library = pstr_core::library::Library::build(
vec![
file("f1", "Frieren S01E01.mkv"),
file("f2", "Frieren S01E02.mkv"),
file("f3", "Frieren S01E03.mkv"),
file("m1", "Mushishi S01E01.mkv"),
file("m2", "Mushishi S01E02.mkv"),
file("o1", "Monster S01E01.mkv"),
],
&HashMap::new(),
);
let seen = |link: &str, at: i64| (("s".to_string(), link.to_string()), at);
let first_seen: HashMap<_, _> = [
seen("f1", 10),
seen("f2", 100),
seen("f3", 100),
seen("m1", 10),
seen("m2", 100),
seen("o1", 10),
]
.into();
let news: Vec<(String, u32, Option<String>)> = news_since(&library, &first_seen, 100)
.into_iter()
.map(|news| (news.title, news.count, news.first))
.collect();
assert_eq!(
news,
vec![
("Frieren".to_string(), 2, Some("S01E02".to_string())),
("Mushishi".to_string(), 1, Some("S01E02".to_string())),
]
);
}
} }
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
+2 -2
View File
@@ -25,6 +25,8 @@ tracing-subscriber.workspace = true
eframe.workspace = true eframe.workspace = true
egui.workspace = true egui.workspace = true
egui-phosphor.workspace = true egui-phosphor.workspace = true
# The parser egui reads fonts with, to check a system font before handing it over.
skrifa.workspace = true
image.workspace = true image.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
@@ -39,8 +41,6 @@ tao.workspace = true
[build-dependencies] [build-dependencies]
embed-resource.workspace = true embed-resource.workspace = true
[dev-dependencies]
skrifa.workspace = true
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
dbus.workspace = true dbus.workspace = true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

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()));
+308 -29
View File
@@ -28,7 +28,7 @@ use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn}; use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
use pstr_core::appearance::Appearance; use pstr_core::appearance::Appearance;
use pstr_core::catalog::{ use pstr_core::catalog::{
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
}; };
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
@@ -177,7 +177,11 @@ pub enum Event {
/// The share the form asked for is stored; its crawl is starting. /// The share the form asked for is stored; its crawl is starting.
ShareAdded(String), ShareAdded(String),
/// The share the form asked for was refused, with why. Nothing was crawled. /// The share the form asked for was refused, with why. Nothing was crawled.
ShareRejected(String), ShareRejected {
error: String,
/// The link has a password of its own and none was given.
needs_password: bool,
},
/// Shares opened; playback is possible from here. /// Shares opened; playback is possible from here.
Connected { Connected {
/// Shares that did not open, with why. /// Shares that did not open, with why.
@@ -193,8 +197,15 @@ pub enum Event {
share_id: String, share_id: String,
nodes: usize, nodes: usize,
files: usize, files: usize,
/// Files no earlier crawl had seen.
added: u64,
seconds: f64, seconds: f64,
/// Started by the app rather than the viewer, so only news is worth
/// saying.
quiet: bool,
}, },
/// What the last crawl of each share came to, by share id.
Crawls(HashMap<String, pstr_core::catalog::CrawlRecord>),
/// A crawl is under way: which share, and how many nodes so far. /// A crawl is under way: which share, and how many nodes so far.
CrawlProgress { share: String, found: usize }, CrawlProgress { share: String, found: usize },
/// Every requested crawl is done. /// Every requested crawl is done.
@@ -224,6 +235,8 @@ pub enum Event {
unmatched: usize, unmatched: usize,
failed: usize, failed: usize,
}, },
/// A matching run has work to do — one asked for, or one after a crawl.
MatchStarted,
/// Every requested match is done. /// Every requested match is done.
MatchFinished, MatchFinished,
/// What the provider answered a hand search with, unscored and in its own /// What the provider answered a hand search with, unscored and in its own
@@ -296,6 +309,16 @@ pub enum Event {
BrowseFailed(String), BrowseFailed(String),
} }
impl Event {
/// The add form's refusal of `error`.
fn rejected(error: &pstr_core::Error) -> Self {
Self::ShareRejected {
error: error.to_string(),
needs_password: matches!(error, pstr_core::Error::PasswordNeeded),
}
}
}
/// Where the Proton account stands. /// Where the Proton account stands.
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AccountStatus { pub enum AccountStatus {
@@ -400,6 +423,14 @@ const DOWNLOAD_CONCURRENCY: usize = 3;
/// launch and whenever a player stops. /// launch and whenever a player stops.
const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60); const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
/// Unix seconds, now.
pub fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
/// Enrichment as currently configured. /// Enrichment as currently configured.
struct Enrichment { struct Enrichment {
config: MetadataConfig, config: MetadataConfig,
@@ -654,14 +685,52 @@ impl Engine {
.await .await
{ {
Ok((share, _)) => share, Ok((share, _)) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), Err(error) => return engine.emit(Event::rejected(&error)),
}; };
engine.emit(Event::ShareAdded(share.name.clone())); engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares(); engine.load_shares();
// The open connection predates the share, and crawling it through // The open connection predates the share, and crawling it through
// that would find it "not open". // that would find it "not open".
engine.connection.lock().take(); engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await; engine.connect_and_crawl(Some(vec![share.id]), false).await;
});
}
/// Call a share something else. See [`ShareStore::rename`].
pub fn rename_share(&self, id: String, name: String) {
let engine = self.clone();
self.runtime
.spawn_blocking(move || match engine.store.rename(&id, &name) {
Ok(_) => engine.load_shares(),
Err(error) => engine.fail("rename the share", error),
});
}
/// Re-supply the link behind a share whose stored secret is unreadable —
/// a keyring that was reset — or whose custom password changed, keeping
/// its catalog, watch state and downloads. See
/// [`ShareStore::replace_secrets`]. Then crawl it, which is what proves
/// the new secret works.
pub fn repair_share(&self, id: String, url: String, password: Option<String>) {
let engine = self.clone();
self.runtime.spawn(async move {
let share = match engine.store.replace_secrets(&id, &url, password.as_deref()) {
Ok(share) => share,
Err(error) => {
// The add form is not where this was asked, and nothing
// will be crawled to say the crawl is over.
engine.emit(Event::CrawlFinished);
return engine.fail("update the link", error);
}
};
engine.emit(Event::Status(format!(
"updated the link for {}",
share.name
)));
engine.load_shares();
// Its client was opened with the old secret.
engine.connection.lock().take();
engine.connect_and_crawl(Some(vec![share.id]), false).await;
}); });
} }
@@ -675,12 +744,12 @@ impl Engine {
}; };
let share = match engine.store.add_folder(&name, folder) { let share = match engine.store.add_folder(&name, folder) {
Ok(share) => share, Ok(share) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), Err(error) => return engine.emit(Event::rejected(&error)),
}; };
engine.emit(Event::ShareAdded(share.name.clone())); engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares(); engine.load_shares();
engine.connection.lock().take(); engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await; engine.connect_and_crawl(Some(vec![share.id]), false).await;
}); });
} }
@@ -942,7 +1011,7 @@ impl Engine {
self.load_shares(); self.load_shares();
self.load_library(); self.load_library();
for id in profile.shares_added.iter().chain(&profile.shares_changed) { for id in profile.shares_added.iter().chain(&profile.shares_changed) {
self.connect_and_crawl(Some(id.clone())).await; self.connect_and_crawl(Some(vec![id.clone()]), false).await;
} }
} }
@@ -998,6 +1067,35 @@ impl Engine {
}); });
} }
/// Forget every share the catalog holds files for that the share list no
/// longer names — what an older `pstr remove` left behind. Its files would
/// otherwise sit in the library beside whatever replaced it, every episode
/// twice.
pub fn forget_orphaned_shares(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let configured = match engine.store.list() {
Ok(shares) => shares,
Err(error) => return engine.fail("read the share list", error),
};
let orphans: Vec<String> = match engine.catalog.lock().share_ids() {
Ok(ids) => ids
.into_iter()
.filter(|id| !configured.iter().any(|share| &share.id == id))
.collect(),
Err(error) => return engine.fail("read the catalog", error),
};
if orphans.is_empty() {
return;
}
for id in orphans {
tracing::info!(share = %id, "forgetting the catalog rows of a removed share");
engine.forget_share(id, false).await;
}
engine.load_library();
});
}
/// Stop a share's downloads and delete its files and catalog rows, and, /// Stop a share's downloads and delete its files and catalog rows, and,
/// when it is still `configured` — not already removed by sync — its /// when it is still `configured` — not already removed by sync — its
/// entry and secrets. /// entry and secrets.
@@ -1246,18 +1344,65 @@ impl Engine {
pub fn crawl(&self, share: Option<String>) { pub fn crawl(&self, share: Option<String>) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
engine.connect_and_crawl(share).await; engine
.connect_and_crawl(share.map(|id| vec![id]), false)
.await;
}); });
} }
async fn connect_and_crawl(&self, share: Option<String>) { /// Crawl, quietly, every share no crawl has looked at for
/// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`] — or ever.
///
/// Without this a share only grew when someone thought to press Refresh,
/// so the episode that came out yesterday was not in the library until
/// then. Quiet: a failure is logged and recorded on the share's row rather
/// than toasted, and a crawl that found nothing new says nothing.
pub fn crawl_stale(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let stale = {
let crawls = match engine.catalog.lock().crawls() {
Ok(crawls) => crawls,
Err(error) => return tracing::warn!(%error, "read crawl records"),
};
let Ok(shares) = engine.store.list() else {
return;
};
let now = unix_now();
shares
.into_iter()
.filter(|share| {
crawls.get(&share.id).is_none_or(|record| {
record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS)
})
})
.map(|share| share.id)
.collect::<Vec<_>>()
};
if !stale.is_empty() {
engine.connect_and_crawl(Some(stale), true).await;
}
});
}
/// Read what the last crawl of each share came to, for the shares page.
pub fn load_crawls(&self) {
let engine = self.clone();
self.runtime
.spawn_blocking(move || match engine.catalog.lock().crawls() {
Ok(crawls) => engine.emit(Event::Crawls(crawls)),
Err(error) => tracing::warn!(%error, "read crawl records"),
});
}
async fn connect_and_crawl(&self, shares: Option<Vec<String>>, quiet: bool) {
let Some((library, _)) = self.ensure_connected().await else { let Some((library, _)) = self.ensure_connected().await else {
self.emit(Event::CrawlFinished); self.emit(Event::CrawlFinished);
return; return;
}; };
let targets: Vec<String> = match share { let targets: Vec<String> = match shares {
Some(id) => vec![id], Some(ids) => ids,
None => library.share_ids().map(str::to_string).collect(), None => library.share_ids().map(str::to_string).collect(),
}; };
@@ -1267,10 +1412,36 @@ impl Engine {
futures::future::join_all( futures::future::join_all(
targets targets
.into_iter() .into_iter()
.map(|share_id| self.crawl_share(&library, share_id, cancel.clone())), .map(|share_id| self.crawl_share(&library, share_id, cancel.clone(), quiet)),
) )
.await; .await;
self.emit(Event::CrawlFinished); self.emit(Event::CrawlFinished);
self.match_after_crawl().await;
}
/// Look up whatever the crawl brought that has no answer yet — a new show,
/// or a new episode the stored guide cannot name. Silent when there is
/// nothing to ask, which is almost every time: `plan` makes no requests for
/// a library that is already matched.
async fn match_after_crawl(&self) {
if !self.metadata_config().enabled {
return;
}
let titles = {
let catalog = self.catalog.lock();
match catalog
.all_files()
.and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?)))
{
Ok(library) => library.titles,
Err(error) => return self.fail("read the catalog", error),
}
};
if titles.is_empty() {
return;
}
self.run_match(titles, false, true).await;
self.emit(Event::MatchFinished);
} }
/// Stop every crawl still listing. Shares already past their listing /// Stop every crawl still listing. Shares already past their listing
@@ -1285,6 +1456,7 @@ impl Engine {
library: &SharedLibrary, library: &SharedLibrary,
share_id: String, share_id: String,
mut cancel: tokio::sync::watch::Receiver<u64>, mut cancel: tokio::sync::watch::Receiver<u64>,
quiet: bool,
) { ) {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let name = self let name = self
@@ -1314,10 +1486,24 @@ impl Engine {
// share's rows and its stale downloads, and stopping halfway through // share's rows and its stale downloads, and stopping halfway through
// that would leave the two disagreeing. // that would leave the two disagreeing.
let nodes = tokio::select! { let nodes = tokio::select! {
result = library.crawl_reporting(&share_id, report) => match result { result = library.reauthenticating(&share_id, || {
library.crawl_reporting(&share_id, &report)
}) => match result {
Ok(nodes) => nodes, Ok(nodes) => nodes,
Err(error) => { Err(error) => {
self.fail(&format!("crawl {share_id}"), error); if let Err(error) = self
.catalog
.lock()
.record_crawl_failure(&share_id, &error.describe())
{
tracing::warn!(%error, "record a failed crawl");
}
self.load_crawls();
if quiet {
tracing::warn!(%error, "crawl {name}");
} else {
self.fail(&format!("crawl {name}"), error);
}
return; return;
} }
}, },
@@ -1327,6 +1513,7 @@ impl Engine {
} }
}; };
let rows = build_rows(&share_id, &nodes); let rows = build_rows(&share_id, &nodes);
let sidecars = build_sidecars(&share_id, &nodes);
let files = rows.len(); let files = rows.len();
// A writer opened before the crawl may be producing the revision // A writer opened before the crawl may be producing the revision
@@ -1388,7 +1575,7 @@ impl Engine {
for (link_id, _) in &stale { for (link_id, _) in &stale {
catalog.remove_offline_file(&share_id, link_id)?; catalog.remove_offline_file(&share_id, link_id)?;
} }
Ok(()) catalog.replace_sidecars(&share_id, &sidecars)
}) })
}; };
if let Err(error) = stored { if let Err(error) = stored {
@@ -1396,12 +1583,22 @@ impl Engine {
return; return;
} }
let added = self
.catalog
.lock()
.crawls()
.ok()
.and_then(|crawls| crawls.get(&share_id).map(|record| record.added))
.unwrap_or(0);
self.emit(Event::Crawled { self.emit(Event::Crawled {
share_id, share_id,
nodes: nodes.len(), nodes: nodes.len(),
files, files,
added,
seconds: started.elapsed().as_secs_f64(), seconds: started.elapsed().as_secs_f64(),
quiet,
}); });
self.load_crawls();
self.load_library(); self.load_library();
} }
@@ -1492,19 +1689,26 @@ impl Engine {
return; return;
} }
} }
let Some((_, source)) = engine.ensure_connected().await else { let Some((library, source)) = engine.ensure_connected().await else {
return; return;
}; };
let uid = node_uid(&target.volume_id, &target.link_id); let uid = node_uid(&target.volume_id, &target.link_id);
match source.open(&target.share_id, &uid).await { let opened = library
.reauthenticating(&target.share_id, || source.open(&target.share_id, &uid))
.await;
match opened {
Ok(stream) if stream.size() == 0 => { Ok(stream) if stream.size() == 0 => {
engine.emit(Event::Error(format!("{} has no content", target.name))); engine.emit(Event::Error(format!("{} has no content", target.name)));
} }
Ok(stream) => { Ok(stream) => {
// Before mpv is even built: building it takes the UI thread // Before mpv is even built: building it takes the UI thread
// a moment, and the network can spend that moment on the // a moment, and the network can spend that moment on the
// two reads every file opens with. // two reads every file opens with — and, resuming, on the
stream.warm_edges(); // one it is about to seek to.
match engine.resume_fraction(&target) {
Some(fraction) => stream.warm_resume(fraction),
None => stream.warm_edges(),
}
engine.emit(Event::PlaybackReady { engine.emit(Event::PlaybackReady {
target: Box::new(target), target: Box::new(target),
stream, stream,
@@ -1515,6 +1719,69 @@ impl Engine {
}); });
} }
/// Open the subtitle files paired with `target`, for the player to add
/// once mpv has the video.
///
/// Through the session already open: a local copy played without one goes
/// without its subtitles rather than waiting on a handshake. Each is
/// warmed — most are one block — so mpv reads it from the cache. One that
/// fails is left out quietly; the video plays either way.
pub async fn open_sidecars(
&self,
target: &PlaybackTarget,
) -> Vec<(pstr_core::catalog::Sidecar, VideoStream)> {
let sidecars = self
.catalog
.lock()
.sidecars(&target.share_id, &target.link_id)
.unwrap_or_default();
if sidecars.is_empty() {
return Vec::new();
}
let Some((library, source)) = self.opened() else {
return Vec::new();
};
let opening = sidecars.into_iter().map(|sidecar| {
let library = Arc::clone(&library);
let source = source.clone();
async move {
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
match library
.reauthenticating(&sidecar.share_id, || source.open(&sidecar.share_id, &uid))
.await
{
Ok(stream) => {
stream.warm_edges();
Some((sidecar, stream))
}
Err(error) => {
tracing::warn!(%error, "open subtitle {}", sidecar.name);
None
}
}
}
});
futures::future::join_all(opening)
.await
.into_iter()
.flatten()
.collect()
}
/// How far into the file `target` resumes, as a fraction of its running
/// time — known from the last time it played.
fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> {
let at = target.resume_at?;
let state = self
.catalog
.lock()
.watch_state(&target.share_id, &target.link_id)
.ok()
.flatten()?;
let duration = state.duration_secs.filter(|duration| *duration > 0.0)?;
Some(at / duration)
}
/// Fetch the blocks a file opens with into the cache, without playing it. /// Fetch the blocks a file opens with into the cache, without playing it.
/// ///
/// For the episode after the one playing, so that autoplay — or a click on /// For the episode after the one playing, so that autoplay — or a click on
@@ -1537,10 +1804,13 @@ impl Engine {
if offline { if offline {
return; return;
} }
let Some(source) = engine.opened().map(|(_, source)| source) else { let Some((library, source)) = engine.opened() else {
return; return;
}; };
match source.open(&share_id, &uid).await { let opened = library
.reauthenticating(&share_id, || source.open(&share_id, &uid))
.await;
match opened {
Ok(stream) => stream.warm_edges(), Ok(stream) => stream.warm_edges(),
Err(error) => tracing::debug!(%error, "warming the next episode failed"), Err(error) => tracing::debug!(%error, "warming the next episode failed"),
} }
@@ -1873,14 +2143,14 @@ impl Engine {
} }
}; };
self.update_download(&key, |item| item.state = DownloadState::Running); self.update_download(&key, |item| item.state = DownloadState::Running);
let Some((_, source)) = self.ensure_connected().await else { let Some((library, source)) = self.ensure_connected().await else {
anyhow::bail!("share is not connected"); anyhow::bail!("share is not connected");
}; };
let stream = source let uid = node_uid(&target.volume_id, &target.link_id);
.open_for_copy( let stream = library
&target.share_id, .reauthenticating(&target.share_id, || {
&node_uid(&target.volume_id, &target.link_id), source.open_for_copy(&target.share_id, &uid)
) })
.await?; .await?;
let revision_id = stream.revision_id().to_owned(); let revision_id = stream.revision_id().to_owned();
let block_sizes = stream.block_sizes().to_vec(); let block_sizes = stream.block_sizes().to_vec();
@@ -2306,13 +2576,18 @@ impl Engine {
pub fn match_titles(&self, titles: Vec<Title>, force: bool) { pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
engine.run_match(titles, force).await; engine.run_match(titles, force, false).await;
engine.emit(Event::MatchFinished); engine.emit(Event::MatchFinished);
}); });
} }
async fn run_match(&self, titles: Vec<Title>, force: bool) { /// `automatic` is a run nobody asked for, after a crawl: it says nothing
/// when there is nothing to do, or no provider to do it with.
async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) {
let Some(service) = self.metadata_service().await else { let Some(service) = self.metadata_service().await else {
if automatic {
return;
}
return self.emit(Event::Error(self.no_service_reason())); return self.emit(Event::Error(self.no_service_reason()));
}; };
let provider = service.provider(); let provider = service.provider();
@@ -2337,8 +2612,12 @@ impl Engine {
force, force,
); );
if pending.is_empty() { if pending.is_empty() {
if automatic {
return;
}
return self.emit(Event::Status("everything is already matched".into())); return self.emit(Event::Status("everything is already matched".into()));
} }
self.emit(Event::MatchStarted);
self.emit(Event::Status(format!( self.emit(Event::Status(format!(
"matching {} titles against {}…", "matching {} titles against {}…",
pending.len(), pending.len(),
+93 -11
View File
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
use pstr_core::library::{Episode, Title}; use pstr_core::library::{Episode, Title};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_player::{ use pstr_player::{
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind, Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
VideoOutput, TrackKind, VideoOutput,
}; };
use pstr_stream::VideoStream; use pstr_stream::VideoStream;
@@ -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)));
@@ -514,10 +530,18 @@ fn run(
} }
}; };
// Opened while mpv opens the video, and added once it has: an external
// track belongs to the file loaded at the time.
let mut sidecars = Some(engine.runtime().spawn({
let engine = engine.clone();
let target = target.clone();
async move { engine.open_sidecars(&target).await }
}));
let mut subtitles: Vec<StreamHandle> = Vec::new();
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 {
@@ -543,6 +567,10 @@ fn run(
match &event { match &event {
PlayerEvent::FileLoaded => { PlayerEvent::FileLoaded => {
duration = player.duration(); duration = player.duration();
if let Some(opening) = sidecars.take() {
let opened = engine.runtime().block_on(opening).unwrap_or_default();
subtitles = add_sidecars(&engine, &target, &player, opened);
}
// The first moment there is a track list to read: mpv has // The first moment there is a track list to read: mpv has
// demuxed the file and made its own default selection. Put the // demuxed the file and made its own default selection. Put the
// title's own choice over it before the menus are told, so they // title's own choice over it before the menus are told, so they
@@ -550,14 +578,16 @@ fn run(
if let Some(show) = target.track_prefs.as_deref() { if let Some(show) = target.track_prefs.as_deref() {
restore_tracks(&player, show); restore_tracks(&player, show);
} }
emit_tracks(&engine, id, &player); // A subtitle track picked by name stays; one mpv picked by
// Only now is there a timeline to seek within. // language may be the signs and songs, not the dialogue.
if !resumed && let Some(at) = target.resume_at { let picked = target
resumed = true; .track_prefs
if let Err(error) = player.seek_to(at) { .as_deref()
tracing::warn!("resume at {at}: {error}"); .is_some_and(|show| show.subtitle_title.is_some());
} if !picked {
prefer_full_subtitles(&player);
} }
emit_tracks(&engine, id, &player);
} }
PlayerEvent::Duration(value) => duration = Some(*value), PlayerEvent::Duration(value) => duration = Some(*value),
PlayerEvent::Position(value) => { PlayerEvent::Position(value) => {
@@ -599,6 +629,7 @@ fn run(
// the other `Arc`, and mpv must be destroyed there, on the thread whose // the other `Arc`, and mpv must be destroyed there, on the thread whose
// OpenGL context its render context was built against. // OpenGL context its render context was built against.
drop(handle); drop(handle);
drop(subtitles);
drop(player); drop(player);
engine.release( engine.release(
@@ -713,6 +744,57 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
} }
} }
/// Play the dialogue rather than the signs and songs mpv picked by language.
/// Not remembered: it is the choice mpv should have made, not the viewer's.
fn prefer_full_subtitles(player: &Player) {
if let Some(track) = pstr_player::full_subtitles_instead(&player.tracks())
&& let Err(error) = player.select_track(TrackKind::Subtitle, Some(track))
{
tracing::warn!("select dialogue subtitle {track}: {error}");
}
}
/// Hand mpv the subtitle files paired with the video it has just loaded.
///
/// One is turned on only when subtitles are on and the video has none of its
/// own playing — a file's own track was chosen by name and language already,
/// and an external one is the fallback, not an override.
fn add_sidecars(
engine: &Engine,
target: &PlaybackTarget,
player: &Player,
opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>,
) -> Vec<StreamHandle> {
let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref());
let playing = player
.tracks()
.iter()
.any(|track| track.kind == TrackKind::Subtitle && track.selected);
let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
let select = (config.subtitles && !playing)
.then(|| {
pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref())
})
.flatten();
sidecars
.iter()
.zip(streams)
.enumerate()
.filter_map(|(index, (sidecar, stream))| {
let title = sidecar.title.as_deref().unwrap_or("External");
player
.add_subtitle(
stream,
title,
sidecar.language.as_deref(),
select == Some(index),
)
.inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name))
.ok()
})
.collect()
}
/// Tell the UI what the file contains and what of it is playing. /// Tell the UI what the file contains and what of it is playing.
fn emit_tracks(engine: &Engine, id: u64, player: &Player) { fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
engine.emit(Event::Player { engine.emit(Event::Player {
+43 -3
View File
@@ -248,15 +248,40 @@ fn install_cjk_fallback(ctx: &egui::Context) {
let Some((path, index)) = system_cjk_font() else { let Some((path, index)) = system_cjk_font() else {
return; return;
}; };
let Ok(bytes) = std::fs::read(path) else { let Ok(bytes) = std::fs::read(&path) else {
return;
};
let Some(data) = cjk_font_data(bytes, index) else {
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
return; return;
}; };
let mut data = egui::FontData::from_owned(bytes);
data.index = index;
ctx.set_fonts(font_definitions(Some(data))); ctx.set_fonts(font_definitions(Some(data)));
ctx.request_repaint(); ctx.request_repaint();
} }
/// The fallback font, if egui can read it.
///
/// egui panics on a font it cannot parse, and this one comes from whatever the
/// system has installed. So it is parsed here first, the same way egui does,
/// and a font that fails is left out rather than taking the app down.
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
skrifa::FontRef::from_index(&bytes, index).ok()?;
let mut data = egui::FontData::from_owned(bytes);
data.index = index;
Some(data)
}
/// The face within a font collection that fontconfig's `%{index}` names.
///
/// Fontconfig packs a variable font's named instance into the high 16 bits —
/// Fedora's variable Noto Sans CJK answers 262144, instance 4 of face 0. The
/// instance is not a face, and passing it to egui as one stopped the app at
/// start.
#[cfg(any(target_os = "linux", test))]
fn fontconfig_face(index: u32) -> u32 {
index & 0xFFFF
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> { fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let output = std::process::Command::new("fc-match") let output = std::process::Command::new("fc-match")
@@ -269,6 +294,7 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let index = lines let index = lines
.next() .next()
.and_then(|value| value.parse().ok()) .and_then(|value| value.parse().ok())
.map(fontconfig_face)
.unwrap_or(0); .unwrap_or(0);
path.is_file().then_some((path, index)) path.is_file().then_some((path, index))
} }
@@ -855,6 +881,20 @@ const RAMP_STOPS: usize = 64;
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
assert_eq!(fontconfig_face(262_144), 0);
assert_eq!(fontconfig_face(0x0002_0001), 1);
assert_eq!(fontconfig_face(2), 2);
}
#[test]
fn a_fallback_font_egui_cannot_read_is_left_out() {
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
}
#[test] #[test]
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() { fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _; use skrifa::MetadataProvider as _;
+3 -8
View File
@@ -14,6 +14,7 @@ use crate::app::Action;
use crate::engine::AccountStatus; use crate::engine::AccountStatus;
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
use crate::ui::widgets::Named;
/// What the account section holds between frames. /// What the account section holds between frames.
#[derive(Default)] #[derive(Default)]
@@ -315,13 +316,7 @@ fn signed_in(
/// "Synced 3 min ago · 2 positions from other devices". /// "Synced 3 min ago · 2 positions from other devices".
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String { fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
let ago = (now - at).max(0); let mut line = format!("Synced {}", ui::ago(at, now));
let when = match ago {
0..60 => "just now".to_owned(),
60..3600 => format!("{} min ago", ago / 60),
_ => format!("{} h ago", ago / 3600),
};
let mut line = format!("Synced {when}");
if report.applied > 0 { if report.applied > 0 {
line.push_str(&format!( line.push_str(&format!(
" · {} from other devices", " · {} from other devices",
@@ -385,7 +380,7 @@ pub fn browser(
if !browser.trail.is_empty() if !browser.trail.is_empty()
&& ui && ui
.button(egui_phosphor::regular::ARROW_LEFT) .button(egui_phosphor::regular::ARROW_LEFT)
.on_hover_text("Up a folder") .hint("Up a folder")
.clicked() .clicked()
{ {
browser.up(actions); browser.up(actions);
+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, _)) => {
+40 -2
View File
@@ -25,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.
/// ///
@@ -172,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 {
@@ -311,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();
@@ -374,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(
@@ -394,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() {
@@ -498,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;
} }
@@ -595,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",
+180 -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,6 +19,7 @@ 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, account: &mut ui::account::AccountPanel,
@@ -36,7 +40,7 @@ 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);
@@ -48,7 +52,14 @@ pub fn show(
}); });
} }
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))
@@ -67,7 +78,23 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
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| {
@@ -81,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(
@@ -102,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.
@@ -144,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;
@@ -167,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(),
@@ -184,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."));
} }
}); });
}); });
+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;
} }
+22 -1
View File
@@ -9,7 +9,7 @@ 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::account::{PlaceKind, SignIn};
use pstr_core::catalog::{Catalog, build_rows}; use pstr_core::catalog::{Catalog, build_rows, build_sidecars};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::shares::AccountFolder; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync; use pstr_core::sync::WatchSync;
@@ -377,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
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}");
} }
@@ -407,6 +425,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
catalog catalog
.replace_share(&share_id, &rows) .replace_share(&share_id, &rows)
.and_then(|()| {
catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes))
})
.with_context(|| format!("store {share_id}"))?; .with_context(|| format!("store {share_id}"))?;
println!( println!(
+44 -12
View File
@@ -33,7 +33,8 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use proton_drive_rs::{NodeKind, ProtonDriveClient}; use proton_drive_rs::{NodeKind, ProtonDriveClient};
use proton_sdk::api::HumanVerificationCredential; use proton_sdk::account::KeyPassphrases;
use proton_sdk::api::{HumanVerificationCredential, ResponseCode};
use proton_sdk::error::ProtonError; use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
@@ -51,6 +52,12 @@ const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \ app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
with its code."; with its code.";
/// Why a sign-in Proton answered with [`ResponseCode::Blocked`] failed. The
/// server's own text says the app version is no longer supported, which is
/// not true and sends the viewer looking for an update that does not exist.
const SIGN_IN_BLOCKED: &str = "Proton has blocked sign-ins from this app for now. Wait a while \
and try again, or sign in once on proton.me first.";
/// Blocks in flight for streaming from the account, as for a public link — /// Blocks in flight for streaming from the account, as for a public link —
/// see `ShareStore::open`. /// see `ShareStore::open`.
const MAX_INFLIGHT_BLOCKS: usize = 48; const MAX_INFLIGHT_BLOCKS: usize = 48;
@@ -91,10 +98,10 @@ impl StoredSession {
} }
} }
fn unlock(&self) -> HashMap<String, Vec<u8>> { fn unlock(&self) -> KeyPassphrases {
self.key_passphrases self.key_passphrases
.iter() .iter()
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec())) .map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
.collect() .collect()
} }
@@ -424,9 +431,9 @@ impl AccountStore {
/// back the account. /// back the account.
/// ///
/// The key passphrases are derived here, while the token still has the scope /// 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 /// the key salts need. Unlocking the user keys is what proves the mailbox
/// password: it unlocks the user keys, and a wrong password unlocks none of /// password: a wrong one unlocks none of them. Only the passphrases are
/// them. Only the passphrases are stored; the password goes no further. /// stored; the password goes no further.
async fn finish( async fn finish(
secrets: Arc<dyn SecretStore>, secrets: Arc<dyn SecretStore>,
session: ProtonApiSession, session: ProtonApiSession,
@@ -435,10 +442,10 @@ async fn finish(
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec()); let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
probe probe
.account() .account()
.addresses() .unlock()
.await .await
.map_err(|error| match error { .map_err(|error| match error {
ProtonError::InvalidOperation(_) => { ProtonError::KeysLocked => {
Error::SignIn("that mailbox password did not unlock the account".to_owned()) Error::SignIn("that mailbox password did not unlock the account".to_owned())
} }
other => other.into(), other => other.into(),
@@ -447,10 +454,10 @@ async fn finish(
.account() .account()
.key_passphrases() .key_passphrases()
.await? .await?
.into_iter() .iter()
.map(|(id, passphrase)| { .map(|(id, passphrase)| {
String::from_utf8(passphrase) std::str::from_utf8(passphrase)
.map(|passphrase| (id, passphrase)) .map(|passphrase| (id.to_owned(), passphrase.to_owned()))
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned())) .map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
}) })
.collect::<Result<HashMap<_, _>>>()?; .collect::<Result<HashMap<_, _>>>()?;
@@ -500,7 +507,8 @@ fn persist_refreshed_tokens(
}); });
} }
/// Lift a CAPTCHA gate and a wrong password out of the generic API error. /// Lift a CAPTCHA gate, a blocked sign-in and a wrong password out of the
/// generic API error.
/// ///
/// Only a gate that can be solved in an app becomes /// 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 /// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
@@ -512,6 +520,9 @@ fn classify_sign_in_failure(error: ProtonError) -> Error {
{ {
return Error::HumanVerification(Box::new(challenge)); return Error::HumanVerification(Box::new(challenge));
} }
if api.code == ResponseCode::Blocked {
return Error::SignIn(SIGN_IN_BLOCKED.to_owned());
}
if api.http_status == 422 && !api.is_human_verification_required() { if api.http_status == 422 && !api.is_human_verification_required() {
return Error::SignIn(if api.message.is_empty() { return Error::SignIn(if api.message.is_empty() {
"Proton did not accept that address and password".to_owned() "Proton did not accept that address and password".to_owned()
@@ -642,6 +653,27 @@ mod tests {
assert!(store.resume().expect("resume").is_none()); assert!(store.resume().expect("resume").is_none());
} }
#[test]
fn a_stored_session_unlocks_with_its_passphrases() {
let unlock = stored().unlock();
assert_eq!(unlock.len(), 1);
assert_eq!(unlock.get("key-1"), Some(b"derived".as_slice()));
}
#[test]
fn a_blocked_sign_in_does_not_claim_the_app_is_outdated() {
let error = classify_sign_in_failure(ProtonError::Api(proton_sdk::error::ProtonApiError {
code: ResponseCode::Blocked,
http_status: 422,
message: "This version of the app is no longer supported".to_owned(),
details: None,
}));
assert!(
matches!(&error, Error::SignIn(text) if text == SIGN_IN_BLOCKED),
"{error}"
);
}
#[test] #[test]
fn a_stored_session_keeps_its_password_mode() { fn a_stored_session_keeps_its_password_mode() {
let params = stored().resume_parameters(); let params = stored().resume_parameters();
+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));
}
} }
File diff suppressed because it is too large. Load diff
+68 -2
View File
@@ -46,15 +46,53 @@ pub enum Error {
/// mailbox password. /// mailbox password.
#[error("{0}")] #[error("{0}")]
SignIn(String), 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 { impl Error {
/// The message, followed by every cause its own text leaves out.
///
/// A dropped connection reads as reqwest's "error sending request for url
/// (…)" and nothing else: why — a reset, a DNS failure, a certificate — is
/// further down the source chain. That is the part a viewer reporting a
/// failed crawl needs, so the URL, which only says which folder, is left
/// out to keep it on screen.
pub fn describe(&self) -> String {
let mut text = match self {
Self::Drive(proton_sdk::error::ProtonError::Transport(error)) => {
let url = error.url().map(|url| format!(" for url ({url})"));
let own = self.to_string();
url.map_or_else(|| own.clone(), |url| own.replace(&url, ""))
}
_ => self.to_string(),
};
let mut seen = self.to_string();
let mut source = std::error::Error::source(self);
while let Some(cause) = source {
let line = cause.to_string();
if !seen.contains(&line) {
text.push_str(": ");
text.push_str(&line);
seen.push_str(&line);
}
source = cause.source();
}
text
}
/// Whether this is the account's session having ended: the refresh token /// 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 /// was revoked or expired, the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it. /// sign-in grants, or the stored key passphrases no longer unlock the
/// account because its mailbox password changed. Nothing but signing in
/// again fixes it.
pub fn is_signed_out(&self) -> bool { pub fn is_signed_out(&self) -> bool {
match self { match self {
Self::SignedOut(_) => true, Self::SignedOut(_) => true,
Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true,
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => { Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
api.is_invalid_refresh_token() || api.is_insufficient_scope() api.is_invalid_refresh_token() || api.is_insufficient_scope()
} }
@@ -71,3 +109,31 @@ impl From<keyring::Error> for Error {
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, thiserror::Error)]
#[error("connection closed before message completed")]
struct Closed;
#[derive(Debug, thiserror::Error)]
#[error("client error (SendRequest)")]
struct SendRequest(#[source] Closed);
#[test]
fn a_failure_is_described_with_the_causes_its_own_text_leaves_out() {
let error = Error::Io(std::io::Error::other(SendRequest(Closed)));
assert_eq!(
error.describe(),
"io: client error (SendRequest): connection closed before message completed"
);
}
#[test]
fn stored_passphrases_that_no_longer_unlock_mean_signed_out() {
let error = Error::Drive(proton_sdk::error::ProtonError::KeysLocked);
assert!(error.is_signed_out());
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Language tags: what a container or a file name calls a language, and what a
//! viewer calls it.
/// 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",
];
/// Each language's name and the tags it goes by, the [`LANGUAGES`] form first.
///
/// Deliberately a short list rather than a full ISO 639 table: it covers what
/// the shares this app is pointed at actually contain, and an unrecognised tag
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
/// on its own.
const TABLE: [(&str, &[&str]); 29] = [
("English", &["eng", "en"]),
("Japanese", &["jpn", "ja"]),
("German", &["ger", "de", "deu"]),
("French", &["fre", "fr", "fra"]),
("Spanish", &["spa", "es"]),
("Italian", &["ita", "it"]),
("Portuguese", &["por", "pt"]),
("Dutch", &["dut", "nl", "nld"]),
("Russian", &["rus", "ru"]),
("Polish", &["pol", "pl"]),
("Swedish", &["swe", "sv"]),
("Danish", &["dan", "da"]),
("Norwegian", &["nor", "no"]),
("Finnish", &["fin", "fi"]),
("Czech", &["cze", "cs", "ces"]),
("Hungarian", &["hun", "hu"]),
("Turkish", &["tur", "tr"]),
("Arabic", &["ara", "ar"]),
("Hebrew", &["heb", "he"]),
("Hindi", &["hin", "hi"]),
("Korean", &["kor", "ko"]),
("Chinese", &["chi", "zh", "zho"]),
("Thai", &["tha", "th"]),
("Vietnamese", &["vie", "vi"]),
("Ukrainian", &["ukr", "uk"]),
("Romanian", &["rum", "ro", "ron"]),
("Greek", &["gre", "el", "ell"]),
("Indonesian", &["ind", "id"]),
("Malay", &["may", "ms", "msa"]),
];
/// A readable name for a language tag, falling back to the tag itself.
pub fn language_name(tag: &str) -> String {
// Container tags come in both the two- and three-letter forms, and often
// with a region suffix: "en", "eng", "en-US", "pt-BR".
let base = tag.split(['-', '_']).next().unwrap_or(tag);
let Some((name, _)) = TABLE
.iter()
.find(|(_, tags)| tags.iter().any(|known| known.eq_ignore_ascii_case(base)))
else {
return tag.to_string();
};
// The region matters when there is one — Brazilian Portuguese is not
// Portuguese to anyone choosing between the two.
match tag.split_once(['-', '_']) {
Some((_, region)) if !region.is_empty() => {
format!("{name} ({})", region.to_ascii_uppercase())
}
_ => (*name).to_string(),
}
}
/// Whether two container tags name the same language: "en" and "eng" do.
pub fn same_language(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
}
/// The [`LANGUAGES`] tag for one word of a file name, if it names a language:
/// "en", "eng" and "English" are all `"eng"`.
///
/// For the words around a subtitle file's name — `Episode 01.en.srt`,
/// `2_English.srt` — where a two-letter word is far more often a language than
/// anything else.
pub fn language_tag(word: &str) -> Option<&'static str> {
TABLE
.iter()
.find(|(name, tags)| {
name.eq_ignore_ascii_case(word) || tags.iter().any(|tag| tag.eq_ignore_ascii_case(word))
})
.map(|(_, tags)| tags[0])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
assert_eq!(language_name("jpn"), "Japanese");
assert_eq!(language_name("ja"), "Japanese");
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
// An unknown tag is shown as it stands rather than as "Unknown", which
// would make two of them indistinguishable.
assert_eq!(language_name("mis"), "mis");
}
#[test]
fn a_language_named_by_either_tag_or_by_its_name_has_the_tag_a_preference_uses() {
assert_eq!(language_tag("en"), Some("eng"));
assert_eq!(language_tag("ENG"), Some("eng"));
assert_eq!(language_tag("German"), Some("ger"));
assert_eq!(language_tag("deu"), Some("ger"));
assert_eq!(language_tag("forced"), None);
}
#[test]
fn every_language_offered_by_name_is_one_the_table_knows() {
let firsts: Vec<&str> = TABLE.iter().map(|(_, tags)| tags[0]).collect();
let mut offered = LANGUAGES.to_vec();
let mut known = firsts.clone();
offered.sort_unstable();
known.sort_unstable();
assert_eq!(offered, known);
}
}
+2
View File
@@ -13,6 +13,7 @@ pub mod chapters;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod franchise; pub mod franchise;
pub mod language;
pub mod library; pub mod library;
pub mod metadata; pub mod metadata;
pub mod naming; pub mod naming;
@@ -20,6 +21,7 @@ pub mod prefs;
pub mod profile; pub mod profile;
pub mod shares; pub mod shares;
pub mod shelves; pub mod shelves;
pub mod subtitles;
pub mod sync; pub mod sync;
pub use account::{Account, AccountStore}; pub use account::{Account, AccountStore};
+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![
+9
View File
@@ -813,6 +813,15 @@ pub fn is_video_file(name: &str) -> bool {
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) .is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
} }
/// Subtitle formats mpv reads from a file of their own.
const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "ssa", "vtt"];
/// Whether a filename names a subtitle file a video can be paired with.
pub fn is_subtitle_file(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(_, ext)| SUBTITLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+334 -13
View File
@@ -27,11 +27,13 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use proton_drive_rs::{ use proton_drive_rs::{
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType, Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
}; };
use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -422,7 +424,7 @@ impl ShareStore {
} }
/// [`Self::add`], but only once the link has been opened with the /// [`Self::add`], but only once the link has been opened with the
/// password given. /// password given. An empty `name` takes the shared folder's own.
/// ///
/// A link that is mistyped, expired or behind the wrong password used to /// 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 /// be stored anyway, so the first try failed on the crawl and every retry
@@ -443,8 +445,35 @@ impl ShareStore {
)); ));
} }
let custom_password = custom_password.filter(|password| !password.is_empty()); let custom_password = custom_password.filter(|password| !password.is_empty());
let client = open_link(url, custom_password).await?; let client = match open_link(url, custom_password).await {
let share = self.add(name, url, custom_password)?; 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)) Ok((share, client))
} }
@@ -540,6 +569,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
@@ -607,6 +655,75 @@ async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClie
/// Several opened shares, presented as one library. /// Several opened shares, presented as one library.
pub struct SharedLibrary { pub struct SharedLibrary {
clients: BTreeMap<String, ShareClient>, 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. /// One opened share, whichever kind it is.
@@ -728,6 +845,9 @@ impl SharedLibrary {
) -> 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) {
@@ -736,13 +856,14 @@ impl SharedLibrary {
} }
match store.open(&share, account).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
@@ -783,9 +904,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
@@ -826,7 +975,10 @@ 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.root_node().await?; let root = retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
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];
@@ -834,14 +986,10 @@ impl SharedLibrary {
let listings = level.into_iter().map(|folder| { let listings = level.into_iter().map(|folder| {
let client = client.clone(); let client = client.clone();
async move { async move {
let child_uids: Vec<NodeUid> = retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
client.enumerate_folder_children_node_uids(&folder).await?; list_folder(&client, &folder)
if child_uids.is_empty() { })
return Ok(Vec::new()); .await
}
// Chunked and fanned out inside the SDK too, so this is a
// multiplier on that concurrency rather than the only one.
client.enumerate_nodes(&child_uids).await
} }
}); });
@@ -863,6 +1011,59 @@ impl SharedLibrary {
} }
} }
/// One folder's children, with their details.
async fn list_folder(
client: &ShareClient,
folder: &NodeUid,
) -> proton_sdk::error::Result<Vec<Node>> {
let child_uids: Vec<NodeUid> = client.enumerate_folder_children_node_uids(folder).await?;
if child_uids.is_empty() {
return Ok(Vec::new());
}
// Chunked and fanned out inside the SDK too, so this is a multiplier on
// that concurrency rather than the only one.
client.enumerate_nodes(&child_uids).await
}
/// Run `operation`, and again after each delay while it fails in a way
/// `retriable` says another attempt could fix.
///
/// A crawl is hundreds of listings, and one that fails ends the crawl. The SDK
/// replays a timeout or a refused connection itself, but not a connection that
/// dropped mid-request — the request may have been applied, which matters for
/// a write and not at all for a listing. A phone sees exactly that: a pooled
/// connection the OS tore down while the app was off screen, or a hand-over
/// from Wi-Fi to mobile data.
async fn retrying<T, E, O, OF>(
delays: &[Duration],
retriable: impl Fn(&E) -> bool,
operation: O,
) -> std::result::Result<T, E>
where
O: Fn() -> OF,
OF: std::future::Future<Output = std::result::Result<T, E>>,
{
let mut delays = delays.iter();
loop {
match operation().await {
Err(error) if retriable(&error) => match delays.next() {
Some(delay) => tokio::time::sleep(*delay).await,
None => return Err(error),
},
done => return done,
}
}
}
/// How long a crawl waits before listing a folder again. About a quarter of a
/// minute in all: long enough for a network to come back, short enough that a
/// share that is really gone still says so.
const CRAWL_RETRY_DELAYS: [Duration; 3] = [
Duration::from_secs(1),
Duration::from_secs(3),
Duration::from_secs(9),
];
/// How many folders of one level to list at once. /// How many folders of one level to list at once.
/// ///
/// Each of these fans out again inside the SDK — up to four detail batches per /// Each of these fans out again inside the SDK — up to four detail batches per
@@ -927,6 +1128,107 @@ 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_listing_that_drops_is_tried_again_until_it_comes_back() {
let calls = AtomicUsize::new(0);
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(2, &calls)).await;
assert_eq!(result, Ok(()));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn a_listing_that_keeps_failing_reports_its_failure_once_the_delays_run_out() {
let calls = AtomicUsize::new(0);
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(9, &calls)).await;
assert_eq!(result, Err(()));
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn a_failure_another_attempt_cannot_fix_is_reported_at_once() {
let calls = AtomicUsize::new(0);
let result = retrying(&[Duration::ZERO; 3], |_| false, flaky(1, &calls)).await;
assert_eq!(result, Err(()));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[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() {
@@ -1052,6 +1354,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.
+202
View File
@@ -0,0 +1,202 @@
//! Which of a file's subtitle tracks is the one to read.
//!
//! Anime releases often carry two subtitle tracks in one language: the
//! dialogue, and a "Signs & Songs" track that only translates the on-screen
//! text and the lyrics. The second is the one marked default or forced as
//! often as not, and mpv, picking by language, plays it — an episode where
//! nobody seems to speak. A language cannot tell the two apart; the track's
//! name and its forced flag can.
//!
//! Nothing here talks to a player. Every client reads the track list out of
//! mpv once a file is loaded and asks [`full_instead`] whether to switch.
use crate::language::same_language;
/// One subtitle track of the open file, as mpv lists it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubtitleTrack {
/// mpv's id for it, what `sid` is set to.
pub id: i64,
pub language: Option<String>,
/// The name the muxer gave it: "Dialogue", "Signs & Songs".
pub title: Option<String>,
/// The container marks it as the default choice.
pub default: bool,
/// The container marks it as forced: on-screen text only, by convention.
pub forced: bool,
/// It is the one playing.
pub selected: bool,
}
/// How much of what is said a track carries, as far as its name and flags say.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Coverage {
/// Signs, songs, or forced: not the dialogue.
Partial,
/// Nothing says either way.
Unknown,
/// Named as the dialogue: "Dialogue", "Full Subtitles", "SDH".
Full,
}
/// Words in a track's name that make it the whole dialogue. They win over the
/// partial ones: "Full (with Signs)" is still the full track.
const FULL_WORDS: [&str; 6] = ["full", "dialogue", "dialog", "dialogues", "sdh", "cc"];
/// Words in a track's name that make it something other than the dialogue.
const PARTIAL_WORDS: [&str; 13] = [
"sign", "signs", "song", "songs", "s&s", "forced", "karaoke", "lyrics", "op", "ed", "oped",
"opening", "ending",
];
impl SubtitleTrack {
pub fn coverage(&self) -> Coverage {
let title = self.title.as_deref().unwrap_or_default().to_lowercase();
let words: Vec<&str> = title
.split(|c: char| !c.is_alphanumeric() && c != '&')
.filter(|word| !word.is_empty())
.collect();
let has = |list: &[&str]| words.iter().any(|word| list.contains(word));
if has(&FULL_WORDS) {
Coverage::Full
} else if self.forced || has(&PARTIAL_WORDS) {
Coverage::Partial
} else {
Coverage::Unknown
}
}
}
/// The track to play in place of the one mpv chose, if it chose the wrong one.
///
/// Only a track in the same language as the playing one is considered — the
/// language is the viewer's choice, and this only corrects which of its tracks
/// was taken. The playing track is replaced when it is partial and the file has
/// one in its language that is not, or when nothing names it and another is
/// named as the dialogue. Among the rest, a track named as the dialogue comes
/// first, then the container's default, then the file's order. `None` leaves
/// the selection as it is, which includes subtitles being off.
pub fn full_instead(tracks: &[SubtitleTrack]) -> Option<i64> {
let playing = tracks.iter().find(|track| track.selected)?;
let current = playing.coverage();
if current == Coverage::Full {
return None;
}
let same = |track: &SubtitleTrack| match (&playing.language, &track.language) {
(Some(a), Some(b)) => same_language(a, b),
(None, None) => true,
_ => false,
};
tracks
.iter()
.filter(|track| track.id != playing.id && same(track))
.filter(|track| track.coverage() > current)
// `max_by_key` keeps the last of equals; reversed, the first.
.rev()
.max_by_key(|track| (track.coverage(), track.default))
.map(|track| track.id)
}
#[cfg(test)]
mod tests {
use super::*;
fn track(id: i64, language: &str, title: &str) -> SubtitleTrack {
SubtitleTrack {
id,
language: Some(language.into()),
title: (!title.is_empty()).then(|| title.into()),
..SubtitleTrack::default()
}
}
fn playing(mut track: SubtitleTrack) -> SubtitleTrack {
track.selected = true;
track
}
#[test]
fn signs_and_songs_playing_gives_way_to_the_dialogue_in_its_language() {
let tracks = [
playing(track(1, "eng", "Signs & Songs")),
track(2, "eng", "Dialogue"),
];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn an_unnamed_track_gives_way_to_one_named_as_the_dialogue() {
let tracks = [playing(track(1, "en", "")), track(2, "en", "Dialogue")];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn a_forced_track_gives_way_to_an_unnamed_one_and_the_default_comes_first() {
let mut forced = playing(track(1, "eng", "English"));
forced.forced = true;
let mut default = track(3, "eng", "");
default.default = true;
let tracks = [forced, track(2, "eng", ""), default];
assert_eq!(full_instead(&tracks), Some(3));
}
#[test]
fn equal_candidates_resolve_to_the_first_in_the_file() {
let tracks = [
playing(track(1, "eng", "Songs")),
track(2, "eng", ""),
track(3, "eng", ""),
];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn a_track_in_another_language_is_never_switched_to() {
let tracks = [
playing(track(1, "eng", "Signs")),
track(2, "ger", "Dialogue"),
];
assert_eq!(full_instead(&tracks), None);
}
#[test]
fn two_letter_and_three_letter_tags_are_one_language() {
let tracks = [playing(track(1, "en", "OP/ED")), track(2, "eng", "")];
assert_eq!(full_instead(&tracks), Some(2));
}
#[test]
fn the_dialogue_playing_stays_and_subtitles_off_stay_off() {
let tracks = [
track(1, "eng", "Signs & Songs"),
playing(track(2, "eng", "Dialogue")),
];
assert_eq!(full_instead(&tracks), None);
let off = [
track(1, "eng", "Signs & Songs"),
track(2, "eng", "Dialogue"),
];
assert_eq!(full_instead(&off), None);
}
#[test]
fn a_full_word_outweighs_a_partial_one_in_the_same_name() {
assert_eq!(
track(1, "eng", "Full (with Signs)").coverage(),
Coverage::Full
);
assert_eq!(track(1, "eng", "Signs/Songs").coverage(), Coverage::Partial);
assert_eq!(track(1, "eng", "English").coverage(), Coverage::Unknown);
// A word inside another is not that word.
assert_eq!(
track(1, "eng", "Designer cut").coverage(),
Coverage::Unknown
);
}
#[test]
fn a_partial_track_with_only_partial_company_stays() {
let tracks = [playing(track(1, "eng", "Signs")), track(2, "eng", "Songs")];
assert_eq!(full_instead(&tracks), None);
}
}
+10 -1
View File
@@ -42,7 +42,7 @@ use libloading::Library;
/// ///
/// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin /// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin
/// will have picked — though with `libglvnd` in the way, either order works. /// will have picked — though with `libglvnd` in the way, either order works.
#[cfg(unix)] #[cfg(all(unix, not(target_os = "macos")))]
fn candidates() -> Vec<&'static str> { fn candidates() -> Vec<&'static str> {
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some(); let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
if wayland { if wayland {
@@ -52,6 +52,15 @@ fn candidates() -> Vec<&'static str> {
} }
} }
/// eframe's context on macOS is CGL, and the framework exports every GL
/// function it implements as a plain symbol — there is no `GetProcAddress` to
/// go through, so the direct lookup in [`GlLoader::resolve`] answers for all of
/// them.
#[cfg(target_os = "macos")]
fn candidates() -> Vec<&'static str> {
vec!["/System/Library/Frameworks/OpenGL.framework/OpenGL"]
}
#[cfg(windows)] #[cfg(windows)]
fn candidates() -> Vec<&'static str> { fn candidates() -> Vec<&'static str> {
vec!["opengl32.dll"] vec!["opengl32.dll"]
+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, full_subtitles_instead, language_name, pick_track};
+70 -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,64 @@ 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)
} }
/// Add a subtitle file to the one playing, read through the same
/// registry as the video, so mpv never learns where either came from.
///
/// Only once the video has loaded: mpv attaches an external track to the
/// file open now. `select` turns it on; otherwise it waits in the list.
/// The handle keeps it readable, like [`Self::play`]'s.
pub fn add_subtitle(
&self,
stream: VideoStream,
title: &str,
language: Option<&str>,
select: bool,
) -> Result<StreamHandle> {
let handle = self.registry.publish(stream);
let url = handle.url();
let flag = if select { "select" } else { "auto" };
let mut args = vec![url.as_str(), flag, title];
args.extend(language);
self.mpv.command("sub-add", &args)?;
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 +411,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)
+23 -70
View File
@@ -12,6 +12,7 @@
//! an empty string. //! an empty string.
use libmpv2::Mpv; use libmpv2::Mpv;
use pstr_core::subtitles::{SubtitleTrack, full_instead};
/// What a track carries. /// What a track carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -69,6 +70,9 @@ pub struct Track {
pub selected: bool, pub selected: bool,
/// Whether the container marks it as the default choice. /// Whether the container marks it as the default choice.
pub default: bool, pub default: bool,
/// Whether the container marks it as forced: a subtitle track of the
/// on-screen text alone.
pub forced: bool,
/// True for a track added from outside the file — a sidecar subtitle. None /// True for a track added from outside the file — a sidecar subtitle. None
/// of ours are, yet. /// of ours are, yet.
pub external: bool, pub external: bool,
@@ -152,9 +156,22 @@ pub fn pick_track(
) )
} }
/// Whether two container tags name the same language: "en" and "eng" do. /// The dialogue track to play in place of the subtitle track mpv chose, when
fn same_language(a: &str, b: &str) -> bool { /// mpv chose signs and songs; see [`pstr_core::subtitles::full_instead`].
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b) pub fn full_subtitles_instead(tracks: &[Track]) -> Option<i64> {
let subtitles: Vec<_> = tracks
.iter()
.filter(|track| track.kind == TrackKind::Subtitle)
.map(|track| SubtitleTrack {
id: track.id,
language: track.language.clone(),
title: track.title.clone(),
default: track.default,
forced: track.forced,
selected: track.selected,
})
.collect();
full_instead(&subtitles)
} }
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> { fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
@@ -172,6 +189,7 @@ fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
codec: text(mpv, index, "codec"), codec: text(mpv, index, "codec"),
selected: flag(mpv, index, "selected"), selected: flag(mpv, index, "selected"),
default: flag(mpv, index, "default"), default: flag(mpv, index, "default"),
forced: flag(mpv, index, "forced"),
external: flag(mpv, index, "external"), external: flag(mpv, index, "external"),
}) })
} }
@@ -190,63 +208,7 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// A readable name for a language tag, falling back to the tag itself. pub use pstr_core::language::{LANGUAGES, language_name, same_language};
///
/// Deliberately a short list rather than a full ISO 639 table: it covers what
/// the shares this app is pointed at actually contain, and an unrecognised tag
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
/// on its own.
pub fn language_name(tag: &str) -> String {
// Container tags come in both the two- and three-letter forms, and often
// with a region suffix: "en", "eng", "en-US", "pt-BR".
let base = tag
.split(['-', '_'])
.next()
.unwrap_or(tag)
.to_ascii_lowercase();
let name = match base.as_str() {
"en" | "eng" => "English",
"ja" | "jpn" => "Japanese",
"de" | "ger" | "deu" => "German",
"fr" | "fre" | "fra" => "French",
"es" | "spa" => "Spanish",
"it" | "ita" => "Italian",
"pt" | "por" => "Portuguese",
"nl" | "dut" | "nld" => "Dutch",
"ru" | "rus" => "Russian",
"pl" | "pol" => "Polish",
"sv" | "swe" => "Swedish",
"da" | "dan" => "Danish",
"no" | "nor" => "Norwegian",
"fi" | "fin" => "Finnish",
"cs" | "cze" | "ces" => "Czech",
"hu" | "hun" => "Hungarian",
"tr" | "tur" => "Turkish",
"ar" | "ara" => "Arabic",
"he" | "heb" => "Hebrew",
"hi" | "hin" => "Hindi",
"ko" | "kor" => "Korean",
"zh" | "chi" | "zho" => "Chinese",
"th" | "tha" => "Thai",
"vi" | "vie" => "Vietnamese",
"uk" | "ukr" => "Ukrainian",
"ro" | "rum" | "ron" => "Romanian",
"el" | "gre" | "ell" => "Greek",
"id" | "ind" => "Indonesian",
"ms" | "may" | "msa" => "Malay",
_ => return tag.to_string(),
};
// The region matters when there is one — Brazilian Portuguese is not
// Portuguese to anyone choosing between the two.
match tag.split_once(['-', '_']) {
Some((_, region)) if !region.is_empty() => {
format!("{name} ({})", region.to_ascii_uppercase())
}
_ => name.to_string(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -261,6 +223,7 @@ mod tests {
codec: None, codec: None,
selected: false, selected: false,
default: false, default: false,
forced: false,
external: false, external: false,
} }
} }
@@ -282,16 +245,6 @@ mod tests {
assert_eq!(sub.label(), "English — Signs & Songs [ass]"); assert_eq!(sub.label(), "English — Signs & Songs [ass]");
} }
#[test]
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
assert_eq!(language_name("jpn"), "Japanese");
assert_eq!(language_name("ja"), "Japanese");
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
// An unknown tag is shown as it stands rather than as "Unknown", which
// would make two of them indistinguishable.
assert_eq!(language_name("mis"), "mis");
}
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track { fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
Track { Track {
language: language.map(Into::into), language: language.map(Into::into),
+48 -5
View File
@@ -223,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");
} }
}); });
} }
@@ -1037,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;
+69
View File
@@ -0,0 +1,69 @@
# Account and sync
Signing in to Proton is optional. Without it, the app plays public share links and keeps
everything on one device. Signing in adds two things:
- **Folders of your own Drive** as shares, with no link to copy.
- **Sync** of your place in every episode, your shares and your settings, across every device
you sign in on.
## Signing in
The **Proton account** section is at the top of the **Shares** page.
1. Enter your Proton address and password, then **Sign in**.
2. If your account has two-factor authentication, enter the code from your authenticator app.
3. If your account has a separate mailbox password, enter it and choose **Unlock**.
Proton sometimes asks to confirm you are human. The app then opens a small window with Proton's
own CAPTCHA page. Solve it, and sign-in continues.
The app keeps a session, not your password. The session goes into the system keyring. **Sign
out** ends it on Proton's side and deletes it here.
Not supported yet:
- **Security keys** (FIDO2). An account whose only second factor is a security key cannot sign
in. Add an authenticator app as a second method in your Proton settings.
- **Email or SMS verification** instead of a CAPTCHA.
## Adding a folder of your Drive
1. Sign in.
2. Under **Add from your Drive**, choose **Browse your Drive**.
3. Open folders until you reach the one that holds your videos.
4. Choose **Add this folder**.
The folder becomes a share like any link: it is crawled, shown in the library and streamed. It is
read through your account, so there is no link or password to store. The same folder has the
same identity on every device, so watch history follows it.
## Sync
When you are signed in, each device keeps a small file in your Drive. You find them under
**Computers** on the Proton Drive website, in a device called `proton-stream`, in the folder
`watch-history`.
**What syncs:**
- Where you stopped in each episode, and which episodes you watched
- Your shares, including their links and passwords (end-to-end encrypted, like every file in
your Drive)
- Settings: playback, appearance and posters and descriptions, including a TMDB key
- Matches you picked by hand, and your audio and subtitle choice for each show
**What stays on each device:** volume and mute, the streaming cache size, text size, Wi-Fi only,
background audio, hardware decoding, window size, and the library's filter and sort order.
**When it syncs:** when the app starts, every 5 minutes while it is open, when you stop playing,
and when it closes. **Sync now** runs it at once. Android also syncs in the background shortly
after you leave the app.
**How conflicts are settled:** each device writes only its own file, and reads everyone else's.
For each episode and each setting, the newest change wins. A device that signs in for the first
time takes the others' state and does not overwrite it with its own defaults.
A device that has not synced for 90 days has its file moved to the Drive trash.
The status line under your address says how the last sync went, for example *Synced 3 min ago ·
2 positions from other devices*. To stop syncing, sign out.
+12
View File
@@ -7,6 +7,18 @@ screens, while UniFFI exposes the existing catalog, share, streaming, watch-stat
and offline logic. Keep reusable behavior in the Rust crates rather than and offline logic. Keep reusable behavior in the Rust crates rather than
reimplementing it in Kotlin. reimplementing it in Kotlin.
## Android TV
The same build installs on Android TV: it is listed in the TV launcher
(`LEANBACK_LAUNCHER`, with the banner `scripts/render-tv-banner.sh` renders)
and requires neither a touch screen nor leanback. Every screen is Compose
`clickable`s, which the D-pad already moves between. The player takes a
remote's keys while its controls are down: left and right seek ten seconds,
the middle button brings the controls up, and play/pause works as on a
keyboard. There is no TV-specific layout yet, and none of it has been run on a
TV. Signing in to an account, so that shares arrive through sync, is the way
to set one up without typing a share link on a remote.
## Toolchain ## Toolchain
Install JDK 17, Android SDK Platform/Build Tools 36, NDK Install JDK 17, Android SDK Platform/Build Tools 36, NDK
+38 -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,27 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B65 — the desktop app quit at start on a variable CJK font
**Symptom.** On Secureblue (Fedora Atomic) the window showed for a moment and
the process panicked: `Error parsing "system-cjk-fallback" TTF/OTF font file:
Invalid index 262144 for font collection`
([#1](https://github.com/narrrl/proton-stream/issues/1)).
**Cause.** The CJK fallback is whatever `fc-match "Noto Sans CJK JP"` names,
with the face taken from `%{index}`. For a variable font fontconfig packs the
named instance into that number's high 16 bits; Fedora ships Noto Sans CJK as a
variable font, so it answered 262144 — instance 4 of face 0. egui took it as a
face index, found no such face, and panics on a font it cannot parse.
**Fix.** `theme.rs` keeps only the low 16 bits, the face. And the fallback is
parsed with skrifa, as egui parses it, before it is handed over; one that fails
is logged and left out, so no installed font can stop the app again.
**Verified.** Workspace gate, with
`a_variable_fonts_named_instance_is_not_taken_for_a_face` and
`a_fallback_font_egui_cannot_read_is_left_out`. Not yet run on Secureblue.
### B64 — Android offline downloads looked stuck ### B64 — Android offline downloads looked stuck
**Symptom.** A queued episode, and more so a queued season, sat at a progress **Symptom.** A queued episode, and more so a queued season, sat at a progress
@@ -1289,6 +1309,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
+96
View File
@@ -0,0 +1,96 @@
# Command-line reference
The desktop packages install two programs:
- `proton-stream`, the app
- `pstr`, a command-line tool that does the app's work without a window
Both use the same shares, library, settings and keyring, so a share added with `pstr` shows up in
the app.
## proton-stream
```bash
proton-stream # open the app
proton-stream 'https://drive.proton.me/urls/TOKEN#KEY' # open it with the Add form filled in
```
Only one window runs at a time. A second `proton-stream` brings the open window forward, hands it
any share link it was given, and exits.
A link given as an argument is visible to other users of the computer in the process list while
the command runs, and it may land in your shell history. Pasting it into the window with
**Ctrl+V** does neither.
## pstr
`pstr --help` lists every command, and `pstr <command> --help` explains one. `pstr` never takes a
password or key as an argument; it asks for them.
### Shares
| Command | Does |
|---|---|
| `pstr add --name NAME URL` | Add a public share link. Quote the URL, because of the `#`. |
| `pstr add --name NAME URL --custom-password` | The same, and ask for the link's own password |
| `pstr add-folder --name NAME VOLUME:LINK` | Add a folder of your Drive, by the id `pstr browse` prints |
| `pstr shares` | List the shares and their ids |
| `pstr remove ID` | Remove a share and delete its link and password from the keyring |
### Account
| Command | Does |
|---|---|
| `pstr login ADDRESS` | Sign in. Asks for the password, a two-factor code and a mailbox password as the account needs them. |
| `pstr logout` | Sign out and delete the stored session |
| `pstr account` | Show who is signed in |
| `pstr browse [VOLUME:LINK]` | List your Drive's top-level places, or one folder |
| `pstr sync` | Sync watch history now |
`pstr login` cannot show a CAPTCHA. If Proton asks for one, sign in once in the app instead.
### Library
| Command | Does |
|---|---|
| `pstr crawl [--share ID]` | Read every share, or one, into the library |
| `pstr list [--share ID]` | Print what the library holds |
### Posters and descriptions
| Command | Does |
|---|---|
| `pstr metadata status` | Show whether lookups are on, and which provider |
| `pstr metadata set --on --provider anilist` | Turn lookups on with AniList |
| `pstr metadata set --on --provider tmdb --api-key` | Turn lookups on with TMDB, and ask for the key |
| `pstr metadata set --off` | Turn lookups off, and delete every stored answer |
| `pstr metadata match [--force] [--limit N]` | Look up titles now. `--force` asks again about titles that already have an answer. |
### Testing playback
These two are for finding out which layer is slow or broken.
```bash
pstr bench --file Frieren --verify # measure seek time, throughput and memory, no player
pstr play --file Frieren --disk-cache # play in a plain mpv window
```
| Option | Does |
|---|---|
| `--file TEXT` | The first file whose name or title contains `TEXT` |
| `--share ID` | Look in one share only |
| `--disk-cache` | Keep decrypted blocks on disk, as the app does |
| `--readahead N` | Blocks to fetch ahead of the reader |
| `--ring-mib N` | Memory for blocks, in MiB (128 by default) |
| `--verify` | `bench` only: compare a read against the SDK's own download |
| `--throughput-mib N` | `bench` only: how much to read for the speed test (32 by default) |
| `--start SECONDS` | `play` only: start at this position |
| `--headless` | `play` only: decode with no window and no sound |
| `--for-seconds S` | `play` only: stop after this many seconds |
## Environment variables
| Variable | Does |
|---|---|
| `RUST_LOG` | How much both programs log to the terminal, for example `RUST_LOG=debug` |
| `PSTR_FRAME_CAP` | The app's frame rate limit on Wayland. 60 by default; `0` turns it off. |
+15 -10
View File
@@ -15,10 +15,7 @@
## Build order from here ## Build order from here
1. **Sidecar subtitle files** — see the "not done" note at the end of this 1. Packaging is in place (`packaging/README.md`); what is left there is running
section. The work is in `pstr-core`: crawl them, and pair them to a video by
name.
2. Packaging is in place (`packaging/README.md`); what is left there is running
the macOS half on a real host, and a release workflow that drives every the macOS half on a real host, and a release workflow that drives every
platform. platform.
@@ -151,12 +148,20 @@ Note the glyph trap: egui's bundled fonts cover the transport icons
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new (`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
on a button wants checking on screen before it ships. on a button wants checking on screen before it ships.
Not done: **sidecar subtitle files**. A `.srt`/`.ass` sitting beside the video in **Sidecar subtitle files.** A `.srt`, `.ass`, `.ssa` or `.vtt` beside a video
the share is invisible to all of this — the crawl stores video files only, so is paired with it by name at crawl time (`catalog::build_sidecars`): the video's
there is nothing to offer in the menu. The player side is nearly free once the name followed by a dot or nothing (`S01E01.en.forced.ass`), the same inside a
catalog has them (`sub-add pstr://<token>` through the same registry, since mpv `Subs` folder beside the videos, the RARBG layout `Subs/<video>/2_English.srt`,
reads an external subtitle through the same protocol); the work is in and failing those the only video in the folder. The words past the video's
`pstr-core`: crawl them, and pair them to a video by name. name give the language and the picker's title. They are stored in `sidecars`
(schema v12), rebuilt by every crawl like `nodes`.
Each client opens them while mpv opens the video and adds them with `sub-add
pstr://<token>` through the same registry once mpv reports the file loaded. One
is turned on only when subtitles are on and the file has none of its own
playing, and only in the preferred language (`catalog::preferred_sidecar`); the
rest wait in the subtitle menu. A downloaded episode played without a session
goes without them, and they are not downloaded with it.
## Design decisions already taken ## Design decisions already taken
+63
View File
@@ -0,0 +1,63 @@
# FAQ
## Is this an official Proton app?
No. It is an unofficial, open-source client written by one developer. Proton AG has not reviewed,
endorsed or supported it. "Proton" and "Proton Drive" are Proton AG's trademarks.
## Do I need a Proton account?
No, not to watch a public share link. You need one to add folders of your own Drive and to sync
your place across devices. See [Account and sync](ACCOUNT_AND_SYNC.md).
## Does it download the whole file before playing?
No. Proton Drive stores a file as blocks that each decrypt on their own, so the app fetches only
the blocks around the point you are watching. A seek fetches the block it lands on. On a
26-minute 1080p episode the first frame takes about 2.3 seconds.
## Does it transcode?
No. The video plays as it is stored, through libmpv. HEVC 10-bit, HDR and ASS subtitles all work,
with hardware decoding where the device has it.
## Does it provide any videos?
No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue,
no search of other people's shares and no content of its own.
## Why are there no posters?
Posters and descriptions are off by default, because looking them up sends your title names to
AniList or TMDB. Turn them on in **Settings → Posters and descriptions**. See
[Privacy](PRIVACY.md).
## A title is matched to the wrong show. How do I fix it?
Open the title and choose **Match…** (or **Change match**). Search for the right entry and pick
it. A match you pick is never replaced, and it syncs to your other devices.
## The episodes of one show appear as separate titles.
The app takes the show's name from its folder. Put every episode of a show in one folder, or in
season folders below it. [How the library reads your
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has examples.
## Can several people share one library?
Yes. Send them the same share link. Each person's app keeps its own watch history; sync only
connects devices that sign in to the same Proton account.
## Is there a macOS version?
Not as a release. The app builds and runs on macOS from source, and `scripts/build.sh` can make a
`.app` and a `.dmg` on a Mac, but no release ships one yet.
## Can I cast to a TV?
Not yet. On an Android TV, install the APK and run the app on the TV itself.
## Where do I report a bug?
Open an issue on [GitHub](https://github.com/narrrl/proton-stream/issues). Say which platform
and version you use, and what you did.
+66
View File
@@ -0,0 +1,66 @@
# Getting started
This page takes you from nothing to a playing episode. It takes a few minutes.
## 1. Install the app
Download the package for your system from the
[latest release](https://github.com/narrrl/proton-stream/releases/latest) and install it.
[Installation](INSTALL.md) has the commands for each platform.
## 2. Add a share
A *share* is a folder the app reads videos from. There are two kinds:
- **A public share link.** Someone sends you a Proton Drive link such as
`https://drive.proton.me/urls/ABC123#xyz`. You need no Proton account for this.
- **A folder of your own Drive.** Sign in to your Proton account, then pick a folder. See
[Account and sync](ACCOUNT_AND_SYNC.md).
To add a public link on the desktop:
1. Copy the whole link, including the part after `#`. That part is the key that decrypts the
share, so the link does not work without it.
2. Open the **Shares** page, or press **Ctrl+V** anywhere outside a text field.
3. Paste the link into **Link**.
4. If the person who shared it set a password of their own, turn on **The link asks for a
password** and type it.
5. Click **Add share**.
The app opens the link to check it before it saves anything. If the link is wrong, it tells you
why and stores nothing.
On Android and iOS, tap **Add share** on the **Shares** tab. On Android you can also share the
link to the app from your browser. [Android and iOS](MOBILE.md) has the details.
## 3. Wait for the library to fill
The app reads the share's folders. This is called a *crawl*. Titles appear in the **Library** as
they are found. A large share takes a minute or two.
The folder layout decides how titles are grouped. Each show should have its own folder:
```text
Sousou no Frieren/
Season 01/
S01E01.mkv
S01E02.mkv
Paprika (2006).mkv
```
A file without episode numbers is shown as a film. [How the library reads your
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has the full rules.
## 4. Press play
Open a title and click **Play**. The episode starts in a few seconds, and you can drag the seek
bar to any point. The app remembers where you stopped, so **Resume** takes you back.
## Next steps
- **Posters and descriptions.** Turn them on in **Settings → Posters and descriptions**. They are
off by default, because the app then sends your title names to AniList or TMDB. See
[Privacy](PRIVACY.md).
- **Your place on every device.** Sign in to Proton on each device, and your position in every
episode follows you. See [Account and sync](ACCOUNT_AND_SYNC.md).
- **Keyboard.** Press **?** on the desktop for the list of shortcuts.
+123
View File
@@ -0,0 +1,123 @@
# Installation
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has packages for Linux
and Windows, an APK for Android and an IPA for iOS, plus a `SHA256SUMS` file to check them
against. The version is part of each file name.
| Platform | File | Needs |
|---|---|---|
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` | x86_64 |
| Fedora | `proton-stream-<version>-1.x86_64.rpm` | x86_64 |
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` | x86_64, libmpv 2 |
| Arch Linux | Built from the repository with `makepkg` | |
| Windows | `proton-stream-<version>-x64.msi` or `…-x86_64-windows.zip` | x64 |
| Android, Android TV | `proton-stream-<version>-android.apk` | Android 12 or newer, arm64 or x86_64 |
| iOS, iPadOS | `proton-stream-<version>-ios.ipa` | iOS 17 or newer, SideStore or AltStore |
## Linux
The desktop app needs libmpv, WebKitGTK 4.1 (for the CAPTCHA window when you sign in) and a
Secret Service keyring such as GNOME Keyring or KWallet. The packages pull in everything except
the keyring, which most desktops already have.
### Debian and Ubuntu
Download the `.deb`, then:
```bash
sudo apt install ./proton-stream_*_amd64.deb
```
### Fedora
Download the `.rpm`, then:
```bash
sudo dnf install ./proton-stream-*.x86_64.rpm
```
### Arch Linux
The `PKGBUILD` in `packaging/` builds the checkout it sits in. It needs network access, so cargo
can fetch crates:
```bash
git clone https://github.com/narrrl/proton-stream.git
cd proton-stream/packaging
makepkg -si
```
### Other distributions
Unpack the tarball anywhere. It holds `proton-stream` (the app), `pstr` (the
[command line](CLI.md)) and the icon. Install libmpv 2 and WebKitGTK 4.1 from your
distribution first.
Both packages install:
- `/usr/bin/proton-stream`, the app, listed as **Proton Stream** in your applications menu
- `/usr/bin/pstr`, the command line
## Windows
Download either file from the release:
- **The `.msi` installer** installs to `Program Files\Proton Stream`, adds a Start menu entry
and puts `pstr` on the PATH.
- **The portable `.zip`** runs from any folder. Keep every file in it together: the app does not
start without `libmpv-2.dll` and `WebView2Loader.dll` beside it.
The binaries are not code-signed yet, so SmartScreen may warn the first time. Choose **More
info**, then **Run anyway**. The sign-in window uses the WebView2 runtime that comes with Windows
10 and 11.
## Android and Android TV
1. Download `proton-stream-<version>-android.apk` on the device.
2. Open it. Android asks once to allow installs from your browser or file manager.
3. Install.
The APK is signed, so later versions install over it and keep your library. On Android TV the app
appears in the launcher; installing needs a file manager or `adb install`.
## iOS and iPadOS
The `.ipa` is unsigned. [SideStore](https://sidestore.io) or [AltStore](https://altstore.io)
signs it with your own Apple ID when it installs:
1. Install SideStore or AltStore by following its own guide.
2. Download `proton-stream-<version>-ios.ipa`.
3. Open it with SideStore or AltStore.
A free Apple ID has to refresh the app every 7 days. SideStore and AltStore do that for you.
## Updating
Install the new release over the old one. Your shares, library, watch history and settings stay.
The app does not check for updates by itself; watch the
[releases](https://github.com/narrrl/proton-stream/releases) on GitHub.
## Uninstalling
Remove the package the way you installed it, for example `sudo apt remove proton-stream`.
This leaves your data behind. To remove that too:
1. In the app, remove each share on the **Shares** page. This deletes its link and password
from the keyring and its downloads from the disk.
2. Sign out on the **Shares** page, if you signed in.
3. Delete the app's folders, listed in [Privacy](PRIVACY.md#what-stays-on-your-device).
On Android and iOS, uninstalling the app deletes everything it stored.
## Building from source
Rust 1.96 or newer, plus the libmpv (≥ 2.0) development headers, and WebKitGTK 4.1's on Linux:
```bash
git clone https://github.com/narrrl/proton-stream.git
cd proton-stream
cargo build --release
```
[Development](DEVELOPMENT.md) and `packaging/README.md` have the details, including Windows, the
Android toolchain and iOS builds on macOS.
+11
View File
@@ -16,6 +16,7 @@ file of the same name is the one to change with it.
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` | | `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/*` | | `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
| `download/*` | `Download/*` | | `download/*` | `Download/*` |
| `sync/NewEpisodesWorker.kt` | `App/NewEpisodes.swift` |
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` | | `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` | | `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
@@ -79,6 +80,16 @@ Each of these is the platform's doing, not a choice to diverge.
place of Android's `WatchSyncWorker`, and every five minutes while it is open. 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 - **Background audio off pauses** the episode when the app leaves the screen, where
Android stops it. Android stops it.
- **New-episode checks run when iOS chooses.** Android's `NewEpisodesWorker`
crawls every six hours on a network. iOS's `BGProcessingTask` is asked for no
sooner than six hours, and the system decides when, mostly overnight on a
charger. An app refresh would come more often but gets about thirty seconds,
which a crawl of a large share does not fit in.
- **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 - **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would 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 arrive without them, and a restored `sync.json` would give two devices one sync
+84
View File
@@ -0,0 +1,84 @@
# Android and iOS
The Android and iOS apps share the desktop app's engine, so a library, its watch history and its
settings behave the same everywhere. This page covers what is different on a phone, a tablet and
a TV. [Installation](INSTALL.md) explains how to install each one.
<table>
<tr>
<td><img src="../images/android-home.png" alt="Home" width="200"></td>
<td><img src="../images/android-library.png" alt="All titles" width="200"></td>
<td><img src="../images/android-title.png" alt="A title page" width="200"></td>
<td><img src="../images/android-episodes.png" alt="Episodes and downloads" width="200"></td>
</tr>
</table>
## Adding a share
Tap **Add share** on the **Shares** tab, paste the link, and add a custom password if the link has
one. The app checks the link before it saves it.
- **Android:** you can also share a link to the app from your browser or a chat app, or open a
`drive.proton.me/urls/…` link directly if you allow the app to open supported links in the
system settings.
- **iOS:** there is no share sheet. Copy the link, then tap **Paste link** in the dialog.
Folders of your own Drive work as on the desktop: sign in on the **Shares** tab, then **Add from
your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md).
## The player
- Tap to show or hide the controls.
- **Double-tap** the left or right third of the screen to go back or forward 10 seconds. Double-tap
the middle to pause.
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks, and
**Lock to landscape**.
- Sound keeps playing with the screen off or the app in the background, with controls on the lock
screen. Turn off **Background audio** in Settings to stop that.
Picture-in-Picture is not available on either platform.
## Android TV
The app appears in the TV launcher. With the controls hidden:
| Remote | Does |
|---|---|
| Left / right | Back or forward 10 seconds |
| Centre, up or down | Show the controls |
| Play/pause | Pause and play |
There is no separate TV layout yet, and the app has not been tested on a real TV. Reports are
welcome.
## Downloads
Download an episode, a season or a show from its title page, and play it from the **Downloads**
tab without a connection.
- **Android** waits for Wi-Fi unless you turn off **Download on Wi-Fi only**. Downloads continue
with the app closed.
- **iOS** downloads only while the app is open, and for the short time iOS allows after you leave
it. A stopped download resumes where it stopped.
**Delete all offline episodes** in Settings frees the space in one step.
## New episodes
When a share gains episodes of a show, the app sends a notification.
- **Android** checks about every 6 hours, when the device has a network and the battery is not
low. Turn it off with **New episode notifications** in Settings.
- **iOS** asks the system to check every 6 hours or so; iOS decides when it actually runs.
## Where your secrets are kept
- **Android:** in the app's private storage, encrypted with a key in the Android Keystore. If you
change or remove the device's screen lock, Android may delete that key. The app then asks you
to **Re-enter link** for each share.
- **iOS:** in the Keychain, only on this device, and not in backups.
## Requirements
- **Android 12** or newer, on an arm64 or x86_64 device. 32-bit devices are not supported.
- **iOS 17** or newer, on iPhone or iPad.
+69
View File
@@ -0,0 +1,69 @@
# Privacy
proton-stream has no server and no account with its developer. Each app talks to Proton directly,
and to nothing else unless you turn on posters and descriptions. This page lists what goes where.
## What the app sends, and to whom
| To | What | When |
|---|---|---|
| Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always |
| AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on |
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
**Posters and descriptions** are off by default. When they are on, the provider learns the names
of the titles in your library. It does not learn your files, your watch history or your shares.
Turning them off deletes every stored answer.
## Secrets
- **The part of a share link after `#`** is the key that decrypts the share. The app treats it as
a password.
- **A link's own password**, if it has one.
- **Your Proton session**, if you signed in. The app keeps the session, not your password.
All of these go into the system's credential store, never into a config file:
| Platform | Store |
|---|---|
| Linux | Secret Service (GNOME Keyring, KWallet, KeePassXC) |
| Windows | Credential Manager |
| Android | App storage, encrypted with a key in the Android Keystore |
| iOS | Keychain, this device only |
If you sign in, your shares, with their links and passwords, also sync through your own Drive.
Like every file there, they are end-to-end encrypted. See [Account and sync](ACCOUNT_AND_SYNC.md).
## How the player gets the video
The player is fed through a callback inside the app's own process. There is no local web server,
so there is no port another program on the computer could ask for the decrypted video. The
player is handed an opaque token, not a link, so no secret ends up in its log or window title.
## What stays on your device
| What | Where on Linux |
|---|---|
| Settings and the share list (without secrets) | `~/.config/proton-stream/` |
| The library and watch history (`catalog.db`) and downloads (`offline/`) | `~/.local/share/proton-stream/` |
| Streamed video (`blocks/`), thumbnails and posters | `~/.cache/proton-stream/` |
On Windows the same folders are under `%APPDATA%\narl\proton-stream\` and
`%LOCALAPPDATA%\narl\proton-stream\`. On macOS they are under `~/Library/Application Support/`
and `~/Library/Caches/`, in `io.narl.proton-stream`.
**The streaming cache and downloads hold decrypted video.** Anyone who can read your user's files
can play them. Empty the cache in **Settings → Storage**, and delete downloads on the
**Downloads** page.
The library database holds file names and your watch history, in plain text. It holds no
secrets.
## Other people on the same computer
- A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in
the process list while the command runs, and may land in your shell history. Paste links into
the window instead.
- Your desktop's media controls are given the title, the episode and a picture already on disk.
They are never given a provider link, so the desktop does not contact a provider by itself.
+41
View File
@@ -0,0 +1,41 @@
# proton-stream documentation
proton-stream turns the videos in your Proton Drive, and the share links people send you, into
a library you can browse and stream. It runs on Linux, Windows, Android, Android TV and iOS.
This is an unofficial client, not a Proton product.
<img src="../images/desktop-home.png" alt="The library in the desktop app" width="820">
## Start here
- [Getting started](GETTING_STARTED.md): install, add your first share, press play.
- [Installation](INSTALL.md): every platform, and how to update and uninstall.
- [FAQ](FAQ.md): short answers to the questions people ask first.
## Using it
- [User guide](USER_GUIDE.md): the library, title pages, the player, downloads, history and
settings.
- [Account and sync](ACCOUNT_AND_SYNC.md): signing in, folders of your own Drive, and keeping
your place on every device.
- [Android and iOS](MOBILE.md): what is different on a phone, a tablet and Android TV.
## Reference
- [Command-line reference](CLI.md): the `pstr` tool and the `proton-stream` launcher.
- [Privacy](PRIVACY.md): what goes where, and what stays on your device.
## When something goes wrong
- [Troubleshooting](TROUBLESHOOTING.md): links that will not add, sign-in problems, playback
problems, and where the logs are.
## Contributing
- [Development](DEVELOPMENT.md): the state of each layer, the build order and the traps.
- [Architecture](ARCHITECTURE.md): the crates, the data flow when an episode plays, and where
secrets live.
- [Testing](TESTING.md), [Metadata](METADATA.md), [Design tokens](DESIGN-TOKENS.md),
[Android development](ANDROID.md) and [iOS development](IOS.md).
Bugs are tracked in [BUGS.md](BUGS.md) in the repository.
+114
View File
@@ -0,0 +1,114 @@
# Troubleshooting
## Adding a share
### "Proton share URL has no #password fragment; copy the full share link"
The link is missing the part after `#`, which is its decryption key. Copy the link again from
where you got it, all of it.
### "This link has a password of its own. Enter it to add the link."
The person who shared it also set a password. Turn on **The link asks for a password** (on a
phone, fill in **Custom password**) and type it.
### "That share is already added."
The link, or the same folder of your Drive, is already on the **Shares** page.
### "Last crawl failed: …"
The share could not be read. The link may have expired or been revoked, or its password
changed. If the password changed, choose **Re-enter link**.
## The keyring
### "share … has no stored credentials; remove and re-add it"
The link and password are gone from the keyring. This happens when a keyring is reset, or on
Android when the screen lock changes. Choose **Re-enter link** on the **Shares** page. Your
library, history and downloads stay.
### "credential store: …" on Linux
The app found no Secret Service. Install and start one, such as GNOME Keyring, KWallet or
KeePassXC with its Secret Service integration, and unlock it.
## Signing in
### "Proton did not accept that address and password"
Check both. Sign in on proton.me to make sure the account works.
### "Proton has blocked sign-ins from this app for now"
Proton's abuse protection stopped the sign-in. Wait a while and try again, or sign in once on
proton.me first.
### "This account's only second factor is a security key"
The app cannot use security keys yet. Add an authenticator app as a second method in your
Proton account settings, then sign in with its code.
### The CAPTCHA window does not open on Linux
The window needs WebKitGTK 4.1. The `.deb`, `.rpm` and Arch packages pull it in; with the
tarball, install it yourself.
### `pstr login` asks for a CAPTCHA
The command line cannot show one. Sign in once in the app.
## Playback
### Green blocks, a black picture or torn frames
Turn off **Hardware decoding** in Settings. It applies from the next file.
### An episode takes long to start
The first seconds of a file are fetched over the network. On a slow connection, start the
episode and give it a moment, or download it first. A second viewing comes from the disk cache.
### No skip button for the opening
The skip button comes from the file's chapter names. A file without named chapters gets none.
### The wrong subtitle track plays
Choose the track in the player. The app remembers it for the rest of the show. Set the
language to start in under **Settings → Playback**.
## Windows
### The app does not start from the portable `.zip`
Keep every file from the archive in one folder. The app needs `libmpv-2.dll` and
`WebView2Loader.dll` next to `proton-stream.exe`.
### SmartScreen warns about the installer
The binaries are not code-signed yet. Choose **More info**, then **Run anyway**.
### A pinned taskbar icon shows a blank picture after an update
Unpin it and pin it again once.
## Linux
### "Not responding" when the window is on another workspace
Fixed. On Wayland the app limits its own frame rate instead of waiting for vsync, which a
compositor withholds from a window it is not showing. Update to the latest release.
## Logs
Start the app from a terminal to see its log:
```bash
RUST_LOG=debug proton-stream
```
The same works for `pstr`. When you
[open an issue](https://github.com/narrrl/proton-stream/issues), include the lines around the
error, but **remove any share link first**: the part after `#` is a key.
+198
View File
@@ -0,0 +1,198 @@
# User guide
This page describes the desktop app. The Android and iOS apps have the same pages as tabs at the
bottom of the screen; [Android and iOS](MOBILE.md) covers what is different there.
The app has five pages, in the bar at the top: **Library**, **History**, **Shares**,
**Downloads** and **Settings**. **Ctrl+1** to **Ctrl+5** open them.
## Library
<img src="../images/desktop-library.png" alt="The whole library, filtered and sorted" width="820">
The library shows every title from every share in one place. Two shares that hold the same show
show it once.
- **The banner** at the top shows the title you watched last, with **Resume** and **More info**.
- **Continue watching** has the episode you stopped in for each show, or the next one when you
finished an episode. To take a show off this row, right-click it and choose **Remove from
Continue watching**.
- **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when
posters and descriptions are on. The app shows at most four, and only for a director, studio or
genre with at least three titles.
- **All titles** is the full grid. Choose a filter and a sort order above it.
| Filters | Sort orders |
|---|---|
| All, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
**Group franchises** folds a series, its sequels and its films into one tile with a **+N** badge.
It is on by default, and needs posters and descriptions to know what belongs together.
**Search** with **Ctrl+F** or **/**. It matches every name a title has: the English one, the
romaji one and the name of its folder.
**Refresh** (or **F5**) reads every share again. The app also does this by itself when a share
has not been read for 6 hours.
### How the library reads your folders
The folder decides which show a file belongs to. The file name gives the season and episode.
| Layout | Read as |
|---|---|
| `Frieren/Season 01/S01E05.mkv` | Frieren, season 1, episode 5 |
| `Frieren/S01E05 - The Hero's Statue.mkv` | The same, with the episode title |
| `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 |
| `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 |
| `Akira (1988).mkv` | A film, from 1988 |
- Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`.
- Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name;
the folder above them is.
- An episode title is read only after a dash, as in `S01E01 - Name`.
- A file with no episode number is a film.
The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video
containers.
## A title's page
Click a title to open its page. **Esc** goes back.
- **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from
the beginning.
- **Download show** keeps every episode on this device. See [Downloads](#downloads).
- **Match…** opens the matcher, where you choose which AniList or TMDB entry the title is. Use it
when the app guessed wrong or did not guess at all. A match you pick is never replaced.
- **More on AniList** (or TMDB) opens the title's page there.
- Each season has **Mark season watched** and **Download season**.
- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark
watched up to here** to tick it and every episode before it.
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
An episode counts as watched once you have seen 90% of it.
## The player
The picture plays inside the app's window. Move the mouse to show the controls.
- **Skip opening** and **Skip ending** appear when the file's chapters mark an opening, an ending
or a preview. Files without chapter names get no skip button. Turn on **Skip openings and
credits** in Settings to skip them without asking.
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
**Play now**, **Cancel** or **Watch till the end**.
- **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your
choice for each show.
- **Speed** goes from 0.5× to 3×, and stays for the next episode.
### Keyboard
Press **?** anywhere to see this list in the app.
| Key | Anywhere |
|---|---|
| Ctrl+F or / | Search the library |
| F5 or Ctrl+R | Read every share again |
| Ctrl+1 – 5 | Library, History, Shares, Downloads, Settings |
| Ctrl+, | Settings |
| Ctrl+V | Paste a share link to add it |
| Tab or arrows | Move between titles and episodes |
| Enter | Open or play what is highlighted |
| Alt+← or mouse back | Back |
| Key | Playing |
|---|---|
| Space or K | Pause and play |
| ← / → | Back 10 s, forward 30 s |
| ↑ / ↓ | Volume |
| M | Mute |
| N / P | Next and previous episode |
| [ / ] | Slower and faster |
| S | Skip the opening or the credits |
| E | The list of episodes |
| F or double-click | Fullscreen |
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
| Esc | Leave fullscreen, then the player |
Media keys and your desktop's media controls work too.
### Subtitle files beside the video
The app picks up `.srt`, `.ass`, `.ssa` and `.vtt` files next to a video:
```text
Frieren/Season 01/
S01E01.mkv
S01E01.en.srt English
S01E01.en.forced.ass English, "forced"
Subs/
S01E01/
2_English.srt English
```
A `Subs` folder beside the videos works, and so does any subtitle in a folder that holds a single
video. The language comes from the last word in the name that names one.
### Dialogue, not signs and songs
Many anime releases have two subtitle tracks in one language: the full dialogue, and a short
*Signs & Songs* track that only translates on-screen text. When a file would start on the short
track, the app switches to the full one in the same language.
## History
Everything you played, newest first, grouped by day. An entry says **Watched**, **Stopped at
12:04 of 24:00** or **Played**. Removing an entry marks a watched episode unwatched, or forgets
where you stopped in one you had not finished.
## Downloads
A download keeps a full copy of an episode on this device, to play without a connection. Start
one from a title's page: a whole show, a season or a single episode.
The **Downloads** page lists them with **Pause**, **Resume**, **Cancel**, **Retry**, **Play** and
**Delete**. Three run at a time. Downloads do not count against the streaming cache.
## Shares
The **Shares** page lists every share, with what it holds and when it was last read.
- **Crawl** reads the share again.
- **Rename** changes the name shown in the app.
- **Re-enter link** gives the app the link again, for a keyring that lost it or a password that
changed. The library, history and downloads stay.
- **Remove** takes the share's titles out of the library, deletes its downloads and removes its
link and password from the keyring.
The **Proton account** section on this page is where you sign in. See
[Account and sync](ACCOUNT_AND_SYNC.md).
## Settings
**Appearance**
- **Palette:** Proton (the near-black default), Catppuccin Latte, Frappé, Macchiato and Mocha, or
Persona 5.
- **Accent:** Mauve, Pink, Sky, Pink → Sky, Lavender, Blue, Teal, Peach or Red. **Gradient
accent** draws it as a gradient.
- **Text size:** 90% to 150%. This one stays on this computer.
- **Library tiles:** posters or stills. **Title names:** as in the share, English or romaji.
**Playback**
- **Audio language** and **Subtitles:** the language to start in, or the file's own default.
- **Play the next episode** (on by default) and **Skip openings and credits** (off).
- **Hardware decoding** (on). Turn it off if the picture shows green blocks or stays black.
**Storage**
- **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed,
so watching again needs no network. **Clear the streaming cache** empties it.
**Posters and descriptions**
- Off by default. When it is on, the app sends the names of your titles to the provider you pick:
**AniList** (no key needed) or **TMDB** (needs a free API key). See [Privacy](PRIVACY.md).
- **Match the library** looks up new titles now. **Match everything again** looks up every title,
except those you matched by hand.
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="proton-stream">
<defs>
<linearGradient id="ps-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#A48BFF"/>
<stop offset="0.55" stop-color="#7552FF"/>
<stop offset="1" stop-color="#5B36F2"/>
</linearGradient>
<linearGradient id="ps-glyph" x1="0" y1="36" x2="0" y2="88" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#ECE6FF"/>
</linearGradient>
<!-- The play mark, cut into the blocks a revision is fetched in: each one
decrypts on its own, which is the whole reason this app can seek. -->
<mask id="ps-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
<path d="M50 41.5v41a6 6 0 0 0 9.1 5.1l34.2-20.5a6 6 0 0 0 0-10.3L59.1 36.4A6 6 0 0 0 50 41.5z" fill="#FFFFFF"/>
<g fill="#000000">
<rect x="63" y="0" width="5.5" height="128"/>
<rect x="78.5" y="0" width="5.5" height="128"/>
</g>
</mask>
</defs>
<!-- base: a rounded tile with a darker lower edge for depth -->
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#ps-face)"/>
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
<!-- glyph shadow, then the glyph itself. The mark's box centres on x=73 and
its centroid on x=66; it sits at 70 -> 64, between the two, because the
box centre reads as too far left and the centroid as too far right. The
Android and iOS icons centre it the same way. -->
<g transform="translate(-6 0)">
<g transform="translate(0 3)">
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
</g>
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+199 -79
View File
@@ -38,14 +38,18 @@ struct WatchSnapshot: Equatable {
final class AppModel { final class AppModel {
var loading = true var loading = true
var refreshing = false var refreshing = false
/// A crawl is running, which the banner offers to stop.
private(set) var crawling = false
private(set) var query = "" private(set) var query = ""
var titles: [TitleRecord] = [] var titles: [TitleRecord] = []
/// The grid: `titles` ordered, each franchise folded into one tile when /// The grid: `titles` ordered, each franchise folded into one tile when
/// `grouped`, and the shelves above it. Nil until the bridge has answered, /// `grouped`, and the shelves above it. Nil until the bridge has answered,
/// when the grid falls back to `titles` as they are. /// when the grid falls back to `titles` as they are.
var arrangement: ArrangementRecord? var arrangement: ArrangementRecord?
var sort: LibrarySort = .name /// As the viewer last left them.
var grouped = true var filter: LibraryFilter = SettingsStore().libraryFilter
var sort: LibrarySort = SettingsStore().librarySort
var grouped = SettingsStore().libraryGrouped
var shares: [ShareRecord] = [] var shares: [ShareRecord] = []
var offline: [OfflineRecord] = [] var offline: [OfflineRecord] = []
var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true) var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
@@ -64,6 +68,13 @@ final class AppModel {
@ObservationIgnored private var started = false @ObservationIgnored private var started = false
/// Launch and the first `.active` both ask; one crawl answers. /// Launch and the first `.active` both ask; one crawl answers.
@ObservationIgnored private var catchingUp = false @ObservationIgnored private var catchingUp = false
/// Every banner line whose work is still running, oldest first.
@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 /// Five minutes, as on Android: cheap when nothing changed — one folder
/// listing, and no upload. /// listing, and no upload.
@@ -102,6 +113,16 @@ final class AppModel {
func arrange(_ sort: LibrarySort, _ grouped: Bool) { func arrange(_ sort: LibrarySort, _ grouped: Bool) {
self.sort = sort self.sort = sort
self.grouped = grouped self.grouped = grouped
let settings = SettingsStore()
settings.librarySort = sort
settings.libraryGrouped = grouped
Task { await reloadLibrary(query) }
}
/// Narrow the grid to some titles, and keep the choice for the next launch.
func setFilter(_ filter: LibraryFilter) {
self.filter = filter
SettingsStore().libraryFilter = filter
Task { await reloadLibrary(query) } Task { await reloadLibrary(query) }
} }
@@ -130,6 +151,30 @@ final class AppModel {
Task { await crawlNow(shareId) } Task { await crawlNow(shareId) }
} }
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
///
/// Held under a background task: a crawl takes minutes, and without one
/// iOS suspends the app the moment it leaves the screen and tears down its
/// connections, so a glance at another app failed the crawl.
private func crawlShares(_ id: String?) async throws {
crawlsRunning += 1
crawling = true
let task = BackgroundTask(name: "crawl")
defer {
task.end()
crawlsRunning -= 1
crawling = crawlsRunning > 0
}
try await NativeRuntime.engine().crawl(shareId: id)
}
/// Stop the crawls running now. Shares already read stay in the library;
/// the one being read keeps what it had.
func stopCrawl() {
crawlStopped = true
Task { try? await NativeRuntime.engine().stopCrawl() }
}
/// The crawl itself, for pull to refresh, which holds its spinner until /// The crawl itself, for pull to refresh, which holds its spinner until
/// this returns. /// this returns.
func crawlNow(_ shareId: String?) async { func crawlNow(_ shareId: String?) async {
@@ -137,12 +182,24 @@ final class AppModel {
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") { await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
message = nil message = nil
do { do {
try await NativeRuntime.engine().crawl(shareId: shareId) try await crawlShares(shareId)
} catch { } catch {
message = errorMessage(error) message = errorMessage(error)
} }
} }
reload() 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)
}
} }
/// Run enrichment again. With `force` every title is looked up afresh, /// Run enrichment again. With `force` every title is looked up afresh,
@@ -151,11 +208,16 @@ final class AppModel {
Task { await match(force: force) } Task { await match(force: force) }
} }
private func match(force: Bool) async { /// 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…") { await busy("Looking up titles…") {
do { do {
let summary = try await NativeRuntime.engine().matchTitles(force: force) let summary = try await NativeRuntime.engine().matchTitles(force: force)
message = Self.describe(summary) let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
if !quiet || found {
message = Self.describe(summary)
}
} catch { } catch {
message = errorMessage(error) message = errorMessage(error)
} }
@@ -163,15 +225,25 @@ final class AppModel {
reload() reload()
} }
/// Say `what` in the activity banner while `work` runs. Nested work keeps /// Say `what` in the activity banner while `work` runs.
/// the outer line once the inner one is done. ///
/// 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 { private func busy(_ what: String, _ work: () async -> Void) async {
let outer = activity let token = nextActivity
activity = what nextActivity += 1
refreshing = true activities.append((token, what))
showActivity()
await work() await work()
activity = outer activities.removeAll { $0.token == token }
refreshing = outer != nil showActivity()
}
private func showActivity() {
activity = activities.last?.line
refreshing = activity != nil
} }
// MARK: - Shares // MARK: - Shares
@@ -201,27 +273,32 @@ final class AppModel {
guard !ids.isEmpty else { return } guard !ids.isEmpty else { return }
let before = Set(titles.map(\.key)) let before = Set(titles.map(\.key))
var failed: [String] = [] var failed: [String] = []
crawlStopped = false
for id in ids { for id in ids {
if crawlStopped { break }
let name = shares.first { $0.id == id }?.name ?? "the new share" let name = shares.first { $0.id == id }?.name ?? "the new share"
await busy("Reading \(name)…") { await busy("Reading \(name)…") {
do { do {
try await NativeRuntime.engine().crawl(shareId: id) try await crawlShares(id)
} catch { } catch {
failed.append("\(name): \(errorMessage(error))") failed.append("\(name): \(errorMessage(error))")
} }
} }
} }
await reloadNow() await reloadNow()
if !failed.isEmpty { // 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") message = failed.joined(separator: "\n")
return return
} }
if metadataSettings.enabled { await matchAfterCrawl()
await match(force: false)
}
if announce { if announce {
let added = Set(titles.map(\.key)).subtracting(before).count let added = Set(titles.map(\.key)).subtracting(before).count
message = added == 1 ? "1 title added" : "\(added) titles added" 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" }
} }
} }
@@ -229,11 +306,27 @@ final class AppModel {
/// Re-entering the link rewrites the secret and leaves the catalog and the /// Re-entering the link rewrites the secret and leaves the catalog and the
/// offline files alone. /// offline files alone.
func repairShare(id: String, url: String, password: String?) { func repairShare(id: String, url: String, password: String?) {
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()
}
}
/// Call a share something else. The library, history and downloads stay.
func renameShare(id: String, name: String) {
Task { Task {
do { do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } _ = try await run { try $0.renameShare(shareId: id, name: name) }
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
try await NativeRuntime.engine().crawl(shareId: id)
} catch { } catch {
reportError(error) reportError(error)
} }
@@ -243,21 +336,24 @@ final class AppModel {
func removeShare(_ id: String) { func removeShare(_ id: String) {
Task { Task {
do { let name = shares.first { $0.id == id }?.name ?? "the share"
// Wait for the transfers to stop before Rust removes catalog await busy("Removing \(name)…") {
// and files. Rust also rejects any late publication. do {
await DownloadCoordinator.shared.cancelShare(id) // Wait for the transfers to stop before Rust removes catalog
let store = DownloadStateStore.shared // and files. Rust also rejects any late publication.
let engine = try await NativeRuntime.engine() await DownloadCoordinator.shared.cancelShare(id)
for download in store.records() where download.shareId == id { let store = DownloadStateStore.shared
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId) 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)
} }
store.removeShare(id) await reloadNow()
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
} }
reload()
} }
} }
@@ -326,13 +422,15 @@ final class AppModel {
func signOut() { func signOut() {
Task { Task {
do { await busy("Signing out…") {
try await NativeRuntime.engine().signOut() do {
} catch { try await NativeRuntime.engine().signOut()
reportError(error) } catch {
reportError(error)
}
account = AccountUiState(state: .signedOut)
await reloadNow()
} }
account = AccountUiState(state: .signedOut)
reload()
} }
} }
@@ -393,9 +491,10 @@ final class AppModel {
} }
} }
/// On launch and back on screen: crawl shares the catalog has nothing of — /// On launch and back on screen: crawl shares due a look — ones the
/// brought by a sync that ran in the background, or an add whose crawl /// catalog has nothing of, brought by a sync that ran in the background or
/// never finished. /// 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() { func enteredForeground() {
guard !catchingUp else { return } guard !catchingUp else { return }
catchingUp = true catchingUp = true
@@ -441,7 +540,15 @@ final class AppModel {
/// 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.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) { func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in 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 let duration = before?.durationSecs
try engine.saveWatchState( try engine.saveWatchState(
shareId: episode.shareId, shareId: episode.shareId,
@@ -478,13 +585,7 @@ final class AppModel {
do { do {
try await run { engine in try await run { engine in
for snapshot in snapshots { for snapshot in snapshots {
try engine.saveWatchState( try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state)
shareId: snapshot.shareId,
linkId: snapshot.linkId,
positionSecs: snapshot.state?.positionSecs ?? 0,
durationSecs: snapshot.state?.durationSecs,
watched: snapshot.state?.watched ?? false
)
} }
} }
} catch { } catch {
@@ -571,12 +672,14 @@ final class AppModel {
func removeOffline(_ file: OfflineRecord) { func removeOffline(_ file: OfflineRecord) {
Task { Task {
do { await busy("Deleting the download…") {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId) do {
} catch { try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
message = errorMessage(error) } catch {
message = errorMessage(error)
}
await reloadNow()
} }
reload()
} }
} }
@@ -599,14 +702,16 @@ final class AppModel {
func removeAllOffline() { func removeAllOffline() {
Task { Task {
await DownloadCoordinator.shared.cancelAll() await busy("Deleting every download…") {
do { await DownloadCoordinator.shared.cancelAll()
try await NativeRuntime.engine().removeAllOffline() do {
DownloadStateStore.shared.clear() try await NativeRuntime.engine().removeAllOffline()
} catch { DownloadStateStore.shared.clear()
reportError(error) } catch {
reportError(error)
}
await reloadNow()
} }
reload()
} }
} }
@@ -650,23 +755,36 @@ final class AppModel {
/// Unwatching rewinds, matching the desktop client. Marking one seen puts /// Unwatching rewinds, matching the desktop client. Marking one seen puts
/// the position at its duration where one is known, so the progress bar /// the position at its duration where one is known, so the progress bar
/// agrees with the tick. /// agrees with the tick.
/// One episode's tick, with the same Undo a season's has.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) { func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
Task { markEpisodes([episode], watched, episode.label)
do { }
try await run { engine in
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs /// Draw a watched mark the moment it is tapped, before the bridge has
try engine.saveWatchState( /// stored it. The reload that shows the stored state is a second away on
shareId: episode.shareId, /// an old iPad, and a tick that does not appear gets tapped again. That
linkId: episode.linkId, /// reload still follows, and puts back whatever was really stored.
positionSecs: watched ? duration ?? 0 : 0, private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
durationSecs: duration, let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
watched: watched 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
} }
} catch { return season
reportError(error)
} }
reload() title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
return title
} }
} }
@@ -679,6 +797,7 @@ final class AppModel {
/// `reload`, for a caller that goes on to read what it loaded. /// `reload`, for a caller that goes on to read what it loaded.
func reloadNow() async { func reloadNow() async {
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
let filter = filter
let sort = sort let sort = sort
let grouped = grouped let grouped = grouped
do { do {
@@ -690,7 +809,7 @@ final class AppModel {
return Reloaded( return Reloaded(
shares: try engine.shares(), shares: try engine.shares(),
titles: try engine.library(search: query), titles: try engine.library(search: query),
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped), arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
offline: try engine.offlineFiles(), offline: try engine.offlineFiles(),
metadataSettings: try engine.metadataSettings(), metadataSettings: try engine.metadataSettings(),
storage: try engine.storageUsage() storage: try engine.storageUsage()
@@ -711,11 +830,12 @@ final class AppModel {
private func reloadLibrary(_ query: String) async { private func reloadLibrary(_ query: String) async {
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
let filter = filter
let sort = sort let sort = sort
let grouped = grouped let grouped = grouped
do { do {
let (titles, arrangement) = try await run { engine in let (titles, arrangement) = try await run { engine in
(try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped)) (try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
} }
self.titles = titles self.titles = titles
self.arrangement = arrangement self.arrangement = arrangement
+82
View File
@@ -0,0 +1,82 @@
import BackgroundTasks
import Foundation
import UserNotifications
/// Look at the shares while the app is away, and say when a show gained
/// episodes — what `NewEpisodesWorker` does on Android.
///
/// A processing task rather than an app refresh: a crawl of a large share
/// takes minutes, and an app refresh gets about thirty seconds. iOS runs it
/// when it sees fit, mostly overnight on a charger, so it is a best effort
/// where Android's is a schedule.
enum NewEpisodes {
static let identifier = "io.narl.protonstream.new-episodes"
private static let interval: TimeInterval = 6 * 60 * 60
/// At launch, before the app finishes launching: iOS refuses a handler
/// registered later.
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
guard let task = task as? BGProcessingTask else { return }
run(task)
}
}
/// Keep the next look scheduled, or cancel it when the viewer turned it off.
static func schedule() {
guard SettingsStore().newEpisodeNotifications else {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier)
return
}
let request = BGProcessingTaskRequest(identifier: identifier)
request.requiresNetworkConnectivity = true
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
try? BGTaskScheduler.shared.submit(request)
}
/// Ask once, when the viewer has the setting on; iOS remembers the answer.
static func requestPermission() {
guard SettingsStore().newEpisodeNotifications else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
private static func run(_ task: BGProcessingTask) {
schedule()
let work = Task {
let news = (try? await NativeRuntime.engine().crawlForNews()) ?? []
await post(news)
task.setTaskCompleted(success: !Task.isCancelled)
}
task.expirationHandler = {
work.cancel()
Task { try? await NativeRuntime.engine().stopCrawl() }
}
}
private static func post(_ news: [NewEpisodesRecord]) async {
let center = UNUserNotificationCenter.current()
guard await center.notificationSettings().authorizationStatus == .authorized else { return }
for item in news {
let content = UNMutableNotificationContent()
content.title = item.title
content.body = describe(item)
content.threadIdentifier = "new-episodes"
// Keyed by the show, so a second look's news replaces the first.
let request = UNNotificationRequest(identifier: "new-episodes-\(item.titleKey)", content: content, trigger: nil)
try? await center.add(request)
}
}
/// What one show's notification says: "S01E04 and 2 more are new".
static func describe(_ news: NewEpisodesRecord) -> String {
let more = Int(news.count) - 1
guard let first = news.first else {
return news.count == 1 ? "1 new episode" : "\(news.count) new episodes"
}
switch more {
case 0: return "\(first) is new"
case 1: return "\(first) and 1 more are new"
default: return "\(first) and \(more) more are new"
}
}
}
+36 -7
View File
@@ -33,6 +33,13 @@ struct ProtonStreamApp: App {
} }
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
NewEpisodes.register()
NewEpisodes.schedule()
NewEpisodes.requestPermission()
return true
}
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask { func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
MainActor.assumeIsolated { OrientationLock.mask } MainActor.assumeIsolated { OrientationLock.mask }
} }
@@ -107,6 +114,9 @@ struct RootView: View {
.animation(.easeInOut(duration: 0.2), value: model.activity) .animation(.easeInOut(duration: 0.2), value: model.activity)
} }
.background(scheme.background.ignoresSafeArea()) .background(scheme.background.ignoresSafeArea())
// Covered by the player, but still there to VoiceOver, which
// would otherwise walk the library behind the picture.
.accessibilityHidden(host.isOpen && !host.minimized)
} }
.overlay { .overlay {
// Over the shell rather than instead of it: the page behind stays // Over the shell rather than instead of it: the page behind stays
@@ -181,9 +191,14 @@ struct RootView: View {
selectedTitleKey = title.key selectedTitleKey = title.key
}) })
case .shares: case .shares:
SharesView(incomingLink: $incomingLink) // 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: case .downloads:
DownloadsView() DownloadsView(onPlay: play, onOpen: { title in
destination = .library
selectedTitleKey = title.key
})
case .settings: case .settings:
SettingsView() SettingsView()
} }
@@ -194,7 +209,16 @@ struct RootView: View {
/// tap rather than back and in again. /// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View { @ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } } 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 } }) 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 { if width >= twoPaneWidth {
HStack(spacing: 0) { HStack(spacing: 0) {
library.frame(width: width * 0.45) library.frame(width: width * 0.45)
@@ -262,7 +286,7 @@ private struct NavigationBar: View {
.padding(.bottom, 6) .padding(.bottom, 6)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityLabel(item.label) .accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
@@ -295,7 +319,7 @@ private struct NavigationRail: View {
.frame(width: 80) .frame(width: 80)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityLabel(item.label) .accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
@@ -321,13 +345,18 @@ private struct ActivityBanner: View {
.foregroundStyle(scheme.onSurface) .foregroundStyle(scheme.onSurface)
.lineLimit(2) .lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading) .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(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight)) .background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityElement(children: .combine) .accessibilityElement(children: model.crawling ? .contain : .combine)
.accessibilityAddTraits(.updatesFrequently) .accessibilityAddTraits(.updatesFrequently)
} }
} }
@@ -350,7 +379,7 @@ private struct Snackbar: View {
Button("Undo", action: model.performUndo) Button("Undo", action: model.performUndo)
.textStyle(.labelLarge) .textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary) .foregroundStyle(scheme.inversePrimary)
.buttonStyle(.plain) .buttonStyle(.pressable)
.padding(.horizontal, 8) .padding(.horizontal, 8)
} }
} }
+44
View File
@@ -42,6 +42,45 @@ struct SettingsStore {
static func gibToBytes(_ gib: Int) -> UInt64 { UInt64(gib) * 1024 * 1024 * 1024 } 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) }
}
/// Whether a background crawl may say that a show gained episodes.
var newEpisodeNotifications: Bool {
get { bool(Key.newEpisodeNotifications, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
}
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 { private func bool(_ key: String, default value: Bool) -> Bool {
defaults.object(forKey: key) as? Bool ?? value defaults.object(forKey: key) as? Bool ?? value
} }
@@ -51,5 +90,10 @@ struct SettingsStore {
static let backgroundAudio = "background_audio" static let backgroundAudio = "background_audio"
static let hardwareDecoding = "hardware_decoding" static let hardwareDecoding = "hardware_decoding"
static let cacheBudgetGib = "cache_budget_gib" 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"
static let newEpisodeNotifications = "new_episode_notifications"
} }
} }
@@ -19,6 +19,11 @@ final class DownloadCoordinator {
/// Every retained download, sorted by label. Re-read after each write. /// Every retained download, sorted by label. Re-read after each write.
private(set) var records: [RetainedDownload] = [] 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. /// A download finished or was removed: the catalog has changed.
@ObservationIgnored var onFinished: (() -> Void)? @ObservationIgnored var onFinished: (() -> Void)?
@@ -125,6 +130,10 @@ final class DownloadCoordinator {
/// Re-apply "Wi-Fi only" to what is queued and running: a season queued /// 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. /// before the setting was turned on must not then download over cellular.
func applyNetworkPolicy() { func applyNetworkPolicy() {
queueReason = switch path?.status {
case .satisfied?: allowed ? nil : "Waiting for Wi-Fi"
default: "Waiting for a connection"
}
if allowed { if allowed {
resumeQueued() resumeQueued()
} else { } else {
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more