Compare commits

98 Commits
Author SHA1 Message Date
nvrl a475ad3575 docs: 2.0 screenshots of the library, the whole library and a title page 2026-10-31 14:38:47 +01:00
nvrl da13f0547e chore(release): bump version to 2.0.1 2026-10-31 14:14:45 +01:00
nvrl 4ca57d9449 fix(android): a chooser in the player no longer closes on its own
The episode, track, speed, chapter and sleep choosers and the More menu
live inside the player's controls, and the controls hide four seconds
after they come up while the video plays. Hiding them took the open
chooser with them, so a sheet being picked from closed at what looked
like random. The controls now hold still while a chooser is open.
2026-10-31 14:12:28 +01:00
nvrl c9925b4b09 chore(release): bump version to 2.0.0 2026-10-31 03:42:32 +01:00
nvrl fe69d2fae4 fix(android): clear the two lint errors that failed the Android build
The banner height came from Configuration.screenHeightDp, which lint now
rejects in favour of LocalWindowInfo's container size; the four phone
library screenshots move by a pixel with it. The mood colour scaled its
picture with Bitmap.createScaledBitmap where lint asks for the KTX
Bitmap.scale.
2026-10-31 03:42:32 +01:00
nvrl 3fe4900337 fix(android,ios): the track tick moves when a track is chosen, and no file inherits another's languages
Choosing an audio or subtitle track read the track list back before mpv
had made the switch, so the tick stayed on the old track until the next
poll. The tick now moves on the choice, and the list is read again
300 ms later to confirm it.

On Android, `load` set `alang` and `slang` only when they were not
empty. mpv keeps both across `loadfile`, so a file with no language
preference took the previous file's. Both are now set on every load.
This closes the last open item of B41.
2026-10-31 03:26:39 +01:00
nvrl 973a8f2749 feat(app,android,ios): swipe gestures, touch lock and sleep timer
On Android and iOS a swipe up or down on the left half of the picture
sets the brightness and on the right half the volume; a sideways swipe
seeks, showing where it will land and seeking only when the finger
lifts. Swipes that start at the top or bottom edge are left to the
system. Android turns the device's media volume; iOS lets no app do
that, so there the swipe turns the player's own, as its slider does.
Brightness set by a swipe is handed back when the player goes.

Lock touch, in the player's menu, makes the picture ignore touches and
the back gesture; a tap brings up Unlock for a few seconds.

The sleep timer pauses after 30 or 60 minutes, or stops at the end of
the episode playing instead of going on to the next, with no up-next
card. On the desktop it is a panel like the others, behind a moon
button that shows what is left; on Android and iOS it lives with the
player host, so it keeps running with the screen off.
2026-10-31 03:19:04 +01:00
nvrl cf8ee58f6b feat(meta,core,app,android,ios): AniSkip where files have no chapters
Most files that are not fansub releases carry no chapters, so they had no
skip button. AniSkip knows where an episode's opening and ending are, by
MyAnimeList id and episode number. ani.zip sends each entry's MyAnimeList
id and enrichment now keeps it on every episode row; schema v14 adds the
column and marks AniList titles un-enriched, deleting nothing, as v13 did.

When a file has loaded with no chapter naming an opening or ending, and
its length is known, each client asks once, with enrichment on only. The
answer lives as long as the file is open. with_skip_ranges cuts it into
the chapters as chapters named Opening and Ending, so the skip button,
auto-skip, the up-next countdown and the seek bar's marks all work
through the code a chaptered file uses. A file's own opening or ending
always wins over AniSkip's.

PRIVACY.md lists what is sent: the MyAnimeList id, the episode number and
the file's length.
2026-10-31 03:13:41 +01:00
nvrl 466630a9ba feat(app): player side panel, seek HUD and pause screen
The audio, subtitle, speed and chapter pickers were stock popup menus
hanging off the transport. They are now panels down the right of the
picture, in the place and style of the episode list: one at a time, a
whole row per choice, the chosen one filled and ticked, closed by its
button again or by Esc. The library's mini player keeps its menus.

What a key did is shown in the middle of the picture with an icon, and
the volume with a bar. Seeks the same way in quick succession add up, so
three presses of the right arrow read "+ 90 s".

After five seconds paused with the mouse still, the picture dims and the
controls give way to what is playing: the logo or the name, the episode,
and its synopsis, or the show's when the episode has none. Moving the
mouse brings the controls back.
2026-10-31 02:52:55 +01:00
nvrl a3bc9023c7 fix(android): the banner no longer stops half way through a turn
The turn was a LaunchedEffect keyed on the pager's current page, and the
current page flips half way through a scroll. The effect restarted there,
cancelling its own animation and leaving two titles side by side until
the next swipe. It is keyed on the settled page now.
2026-10-31 02:41:22 +01:00
nvrl 7bfebc5fd5 feat(meta,core,app,android,ios): cast and trailer
AniList's fragment now asks for the first ten characters, main roles
first, with their Japanese voice actor, and for the trailer's site and
id. Both are stored in the existing details JSON as TitleDetails.cast and
.trailer with serde defaults, so no migration is needed and details
stored before them still read. The details REVISION goes to 2, so a
stored match is refetched by its id on the next metadata run and the
cast fills in without anyone matching the title again.

The title page shows a Cast row of round portraits with the character's
name over the actor's. Clicking a tile opens the Person browse page for
that actor, which now also admits voice actors, and search counts them
under People and studios. A Trailer button opens the YouTube or
Dailymotion page in the browser; the app itself makes no request to
either, and an unknown site or an id with odd characters gives no link.
2026-10-31 01:56:41 +01:00
nvrl 87ccaf26e1 feat(app,android,ios): search across episodes and people
A search now also finds, with posters and descriptions on, the directors
and studios and the genres whose names hold it, as chips that open their
page, and the episodes whose provider names hold it, which play from the
result (pstr_core::discover::find). The titles it found follow under
their own heading.

The last eight searches are kept in recent-searches.json on this device
only (pstr_core::recent), written only when a search led somewhere, and
offered under the empty search box with a way to clear them. The bridge
exposes search_extras, recent_searches, remember_search and
forget_searches.

On Android and iOS the library's titles are now the whole library
whatever is searched for; the grid narrows through the arrangement, as
it already did. A title opened from an episode result can then always be
shown, and History and Downloads no longer shrink to the search.
2026-10-30 23:07:51 +01:00
nvrl fafe0c3087 feat(core,app,android,ios): more like this and browse by genre, studio, person
pstr_core::discover scores every title against another by shared
director, studio, genres, leading tags and format, leaves out its own
franchise and keeps one title per other franchise. It drives a "More
like this" row under a title's episodes and, seeded by the title most
recently watched to the end, a "Because you watched" row on the library
of titles not yet started.

Genre chips on the title page now open a page of every title with that
genre, and the studios and director move from the line of facts onto
chips of their own that do the same. The desktop has a Browse page whose
back link returns to the title; Android and iOS show it over the library
pane. The bridge exposes more_like, browse and facet_kind.

Also moves a doc comment the My List commit left on the wrong function.
2026-10-30 22:46:08 +01:00
nvrl f66faf6b75 feat(core,app,android,ios): My List, synced, and New badges with new-episode rows
My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
2026-10-30 22:15:51 +01:00
nvrl cd9e8eea81 feat(app,android,ios): status, season dropdown, watched ticks
A title page now says where the show stands as a chip beside the
quality marks: Airing with the day the next episode comes out, Finished,
Upcoming, On hiatus or Cancelled. The words come from
TitleDetails::status_label, which the bridge hands the phones as
TitleRecord.status; the airing note leaves the line of facts.

A show with more than five seasons picks its season from a drop-down
rather than a row of pills that ran off the page, and a watched
episode's still carries a tick in its corner as well as the mark at the
end of its row.
2026-10-30 21:34:47 +01:00
nvrl c026f81283 feat(android,ios): parallax title page with a sticky bar
On a phone the backdrop at the top of a title page now moves at half
the speed of the page scrolling over it, and the back button sits in a
bar that fills in with the title's name once the backdrop has gone, so
a long episode list still says whose it is. The fade into the page is
tinted with the backdrop's colour, as on the library's banner.

iOS keeps its slide in from the edge: the zoom transition needs a
NavigationStack, and the title page is shown over the library by hand.
2026-10-30 21:22:56 +01:00
nvrl c6d303f269 feat(app,android,ios): full-bleed hero carousel with ambient colour
The library's banner now runs edge to edge and turns through up to five
titles: the one watched last, then titles with a wide picture, starting
from a different one each day. It moves on every eight seconds, holds
still under the pointer, and has dots to pick a title. A part-watched
title shows how far in the viewer is under its buttons.

The veils over the banner and over a title page's band are tinted with
the picture's own colour. The weighting and the veil live in
pstr_core::appearance (mood, mood_veil), so the desktop and both phones,
which reach them through the bridge, land on the same colour.

On Android the banner is a HorizontalPager under a clear bar that fills
in as it scrolls away; iOS uses a paged TabView the same way.
2026-10-30 21:19:37 +01:00
nvrl 255e463fc8 feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves
A first pass at making the three clients look like a streaming app rather
than a file browser, with no new data behind it.

- Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter
  draws for large text, for the banner's name and a title page's name where
  no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and
  iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use
  mappings stripped like the other two cuts.
- Artwork fades in over its skeleton on Android and iOS, as it already did
  on desktop cards; art already decoded draws at once. Desktop episode
  stills and logos fade in too.
- Skeletons shimmer: a soft diagonal highlight instead of a pulse.
- Desktop tiles lift towards the viewer under the pointer, capped so a
  lifted tile never reaches its neighbour, and keyboard focus gets a ring
  of its own.
- Desktop shelves share one `ui::shelf`: edges fade into the page and, under
  the pointer, round buttons page the row along.
- Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio)
  moves out of the facts line into small outlined marks on all three.
- Desktop title page and hero buttons use a tonal style beside the accent
  one, and the player's top bar a dark glass style, instead of egui's stock
  buttons. Episode numbers leave monospace for the type ramp.
- The phone players' top and bottom bars are gradients rather than flat
  bands, and a double-tap seek is felt as well as seen.
2026-10-30 20:36:09 +01:00
nvrl 3ebfd1a57b feat(app,android,ios): a blurred poster where a title has no wide art
A title with no fanart, banner or backdrop drew its poster cropped to the
band's shape across the title page and the library's banner: a blown-up
slice of the middle of it, which read as broken. Now the poster is
squeezed to 64x36 and blurred once when it is fetched, the cache keeps
that wash, and it is stretched across the band. The library's banner
stands the sharp poster at its right over it. Android blurs the poster
it already had (Android 12 and later; older versions decode it tiny),
and iOS likewise.

METADATA.md also records the quality badges of the commit before.
2026-10-29 16:59:46 +01:00
nvrl f4c08d03c7 feat(core,app,android,ios): quality badges from file names
Release names say what their files are — 2160p, HDR, 10-bit, Dual-Audio —
and nothing showed it. naming::quality reads those words, whatever they
are separated by, and Title::quality keeps what more than half a title's
files state, so one odd special does not mark a show down. The facts line
of a title's page ends with them: 4K, 1080p, HDR, 10-bit, Dual audio.
Release groups' names are never read.
2026-10-29 16:56:19 +01:00
nvrl a7b87cdc79 feat(meta,app,android,ios,cli): film backdrops and logos from TMDB by id
TVDB has no films, so ani.zip has no fanart or logo for one, and most of
the titles in a real library with nothing wide to draw were films. ani.zip
does name the TMDB entry of every AniList match. With a TMDB key stored
and AniList chosen, an entry left without a backdrop or a logo is asked
for /{movie,tv}/{id}/images: one request, by id, so no title name goes to
TMDB. A bare backdrop and a PNG logo in the viewer's language are
preferred, and a failure there only costs the art.

The TMDB key field is now offered with either provider, labelled optional
with AniList. TMDB as the provider fetches its logo the same way, and
backdrops at w1280 rather than w780. A forced run re-enriches hand-picked
titles too, so saving a key and matching everything again reaches them.
2026-10-29 16:50:01 +01:00
nvrl 70952fa7ca feat(core): episode titles from dotted release names
Scene releases name the episode in dotted words before their tags:
Game.of.Thrones.S01E01.Winter.Is.Coming.2160p... Read the words up to the
first quality, source or codec tag; a dotted run that no tag ends is not taken.
2026-10-29 16:33:16 +01:00
nvrl 3ca431feca feat(core,meta,app,android,ios,cli): TVmaze for the shows AniList does not have
With AniList chosen, a title AniList has no match for is now searched on
TVmaze, keyless, before it is given up on. One provider for the whole library
left live-action shows in a share of anime unmatched, and switching to TMDB
would have dropped every AniList match, chain and shelf.

The service holds a chain of providers. A match is stored under the provider
that gave it, and enrichment and refreshes go to that provider; a miss is
stored under the last provider asked, and is_fresh only trusts a miss from the
end of the chain, so AniList misses stored before this are asked again at once.
A provider failure still ends the lookup instead of falling through.

The hand matcher searches every provider in the chain and labels each result
with its source. TVmaze is never offered in the settings and is not asked
about titles known to be films.
2026-10-29 16:33:16 +01:00
nvrl bb5a6638c4 feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
2026-10-29 16:11:24 +01:00
nvrl b5ba59b41d fix(android,ios): the player names the episode, not its file
The header under the episode's numbering showed `Episode::detail`, which
falls back to the file name — `[Judas] Chainsaw Man - S01E01.mkv` on a
phone, where the desktop shows `Dog & Chainsaw`. It now shows the
provider's name for the episode, else the one the file name gave it, and
nothing rather than the file name: the release group's tag is no caption,
and it is what a screenshot of the player would carry away.
2026-10-29 15:44:55 +01:00
nvrl 79f0d74e1a chore(release): bump version to 1.7.0 2026-10-28 02:43:25 +01:00
nvrl 648c35e92a fix(app): the subtitle preview is drawn in the chosen font
The preview drew every font in the app's own Inter, so picking one changed
nothing on screen. On Linux it now asks fontconfig for the face libass would
use — the family with the weight and slant wanted, or fontconfig's fallback
when it is not installed — loads it on a thread of its own and draws the
sample in it. A bold or italic the face lacks is made up, as libass does.
Windows and macOS have no fc-match and keep the app's font, as the hint there
still says.

The CJK fallback and the subtitle face are now both kept, so installing one
no longer drops the other.
2026-10-27 23:08:43 +01:00
nvrl ffd6e959bb fix(core,player,app): styled subtitles keep their size and place unless restyled
mpv's default sub-ass-override, `scale`, hands an ASS track sub-scale,
and sub-pos reaches it under every mode but `no`. So with "Restyle
styled subtitles" off, the subtitle size and position settings still
resized and moved typeset dialogue and signs.

The override is now `yes` when restyling is off, which keeps sub-scale
off ASS. A styled track also ignores the style's position, and moves
only to stay clear of the player's controls. A frame from an ASS track
rendered with every other option changed now matches the untouched one
pixel for pixel. Plain (SRT) tracks still take the whole style.
2026-10-27 22:44:35 +01:00
nvrl 69d961d7b8 feat(core,player,app): full subtitle styling, applied live, with a preview
The subtitle style was three fixed choices. It is now size, position,
font, bold and italic, free colours with opacity for the text, outline,
box and shadow, outline thickness, shadow distance and blur, and an
opt-in "Restyle styled subtitles" that repaints ASS dialogue too
(sub-ass-override=force). The defaults are mpv's own, so a viewer who
never opens the section sees no change.

Settings has a section of its own with a sample line drawn on a bright
and a dark picture. Every change also reaches the playing file at once,
and is written when the mouse is let go rather than once per frame of a
drag. The player's subtitle menu has size, position and a per-file
delay; Z and X shift subtitles by 0.1 s, as in mpv.

Every option is sent every time, so a change back to a default lands on
a live player. A refused sub-border-style still skips the border options
that depend on it, so an older libmpv loses the box rather than drawing
a smear.
2026-10-27 22:25:53 +01:00
nvrl 238ecd8220 fix(core,meta): a folder of films is one title per film
The four Rebuild of Evangelion films in one folder were one series:
`- 1.11` read as episode 1, and every file took the folder's name, so
the whole folder matched a single film. A dash followed by a decimal is
now part of the film's name, and a folder whose files are all
unnumbered, outside any grouping folder and each named differently
gives each file its own title.

AniList needs every query word in a title, and a film's version number
is not in it, so a search that finds nothing is retried without it.
All four films now match their own entries.
2026-10-27 19:50:26 +01:00
nvrl 65a669e231 feat(core,player,app): subtitle size, background and colour settings
Plain-text subtitles (SRT) were drawn in mpv's bare default, thin white
text that bright scenes swallow. Settings → Playback now picks a size,
an outline or a dark box, and white or yellow text. Styled (ASS) tracks
keep their own look and take only the size.

The box options are mpv 0.38's, so they are set best effort: an older
libmpv logs and plays on. The style syncs with the other playback
settings, and the Android bridge keeps the stored style instead of
resetting it on save.
2026-10-27 19:50:21 +01:00
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
nvrl 2f3be1d193 chore(version): bump to 1.4.0
Minor. Sync now carries the whole library, not only watch positions:
shares with their links and passwords, playback, appearance and metadata
settings, the TMDB key, hand-picked matches and per-title tracks, so every
signed-in device shows the same library. Devices share it once all of them
run 1.4.0; an older build keeps syncing watch positions and ignores the
rest.

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

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

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

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

iOS sizes the Metal drawable on every layout, so the picture no longer
stretches after a rotation or a Split View resize. Settings show a save at
once rather than after the match run, and a failed save reverts and says
why. Both phone clients gain an activity banner naming what is running, and
the add-share, metadata and change-match forms show progress and errors in
place.
2026-10-09 03:15:39 +02:00
166 changed files with 24102 additions and 2943 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",
]
[[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]]
name = "adler2"
version = "2.0.1"
@@ -360,6 +464,20 @@ dependencies = [
"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]]
name = "async-io"
version = "2.6.0"
@@ -482,6 +600,43 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "autocfg"
version = "1.5.1"
@@ -2017,6 +2172,7 @@ version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db"
dependencies = [
"accesskit_winit",
"arboard",
"bytemuck",
"egui",
@@ -4688,9 +4844,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pgp"
version = "0.20.0"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
dependencies = [
"aead 0.5.2",
"aes 0.8.4",
@@ -5062,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proton-drive-rs"
version = "0.7.3"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f"
checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54"
dependencies = [
"async-trait",
"base64 0.23.0",
@@ -5089,9 +5245,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.7.3"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0"
checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875"
dependencies = [
"aes-gcm 0.11.0",
"async-trait",
@@ -5118,11 +5274,12 @@ dependencies = [
"thiserror 2.0.19",
"tokio",
"tracing",
"zeroize",
]
[[package]]
name = "pstr-android"
version = "1.3.0"
version = "2.0.1"
dependencies = [
"android_logger",
"futures",
@@ -5142,7 +5299,7 @@ dependencies = [
[[package]]
name = "pstr-app"
version = "1.3.0"
version = "2.0.1"
dependencies = [
"anyhow",
"chrono",
@@ -5176,7 +5333,7 @@ dependencies = [
[[package]]
name = "pstr-cli"
version = "1.3.0"
version = "2.0.1"
dependencies = [
"anyhow",
"clap",
@@ -5195,7 +5352,7 @@ dependencies = [
[[package]]
name = "pstr-core"
version = "1.3.0"
version = "2.0.1"
dependencies = [
"directories",
"futures",
@@ -5215,7 +5372,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
version = "1.3.0"
version = "2.0.1"
dependencies = [
"pstr-core",
"reqwest",
@@ -5228,7 +5385,7 @@ dependencies = [
[[package]]
name = "pstr-player"
version = "1.3.0"
version = "2.0.1"
dependencies = [
"async-trait",
"libloading",
@@ -5243,7 +5400,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
version = "1.3.0"
version = "2.0.1"
dependencies = [
"async-trait",
"futures",
@@ -5870,7 +6027,7 @@ dependencies = [
"rand 0.8.7",
"serde",
"sha2 0.10.9",
"zbus",
"zbus 4.4.0",
]
[[package]]
@@ -7229,6 +7386,11 @@ name = "uuid"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "valuable"
@@ -8544,9 +8706,68 @@ dependencies = [
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"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]]
@@ -8559,7 +8780,22 @@ dependencies = [
"proc-macro2",
"quote",
"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]]
@@ -8570,7 +8806,39 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"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]]
@@ -8704,7 +8972,22 @@ dependencies = [
"enumflags2",
"serde",
"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]]
@@ -8717,7 +9000,20 @@ dependencies = [
"proc-macro2",
"quote",
"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]]
@@ -8730,3 +9026,16 @@ dependencies = [
"quote",
"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]
version = "1.3.0"
version = "2.0.1"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2
[workspace.dependencies]
proton-sdk = { version = "0.7.3" }
proton-drive-rs = { version = "0.7.3" }
proton-sdk = { version = "0.9.0" }
proton-drive-rs = { version = "0.9.0" }
# Internal crates.
pstr-core = { path = "crates/pstr-core" }
@@ -73,8 +73,10 @@ keyring = { version = "3" }
clap = { version = "4", features = ["derive"] }
# 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
# 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 = [
"accesskit",
"default_fonts",
"glow",
"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
tests) passes, and Android lint runs in CI.
A source review on 2026-10-12 found these still short of the text below:
- **Title page.** The backdrop scrolls away with the back button instead of
collapsing to a pinned bar, and the icon row has no mark-watched action.
- **Episode rows** show the file size where runtime was asked for.
- **Player episode list** is a bottom sheet in landscape too, not a side
panel.
- **Storage** has a usage bar for the streaming cache only; offline episodes
have none.
- **Metadata language** is still a free-text field.
- **Touch targets.** The player's icons are 40 dp, under the 48 dp floor.
- **Screenshots** do not cover the player, and only the library is taken at
font scale 1.5.
Share rows now say how many files the last crawl found, when it ran and why it
failed (section 2, Shares), as of the catalog's `share_crawls` table.
## 0. CI (done)
Every `Android` workflow run since August failed in `:app:buildRustHost`:
+144 -109
View File
@@ -1,83 +1,145 @@
<div align="center">
<img src="images/logo.svg" alt="" width="112" height="112">
# 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 account, no server to host, no download step — episodes start playing in
seconds and the seek bar works.
**[proton-stream.narl.io](https://proton-stream.narl.io)**
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust
Proton Drive SDK.
[![CI](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml/badge.svg)](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
[![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
A Proton Drive file's content blocks are each a self-contained PGP packet
decrypted under one shared session key. There is no sequential streaming session
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode
is playable without downloading 2 GB of it.
A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
shared session key. There is no sequential streaming session to resume, so seeking costs exactly
the blocks the seek lands on: a 2 GB episode plays 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
mpv window of its own. The SDK streaming surface, the catalog, the streaming
layer and the player are done and verified against a real share, and the app has
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`.
The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
whole data flow.
A native Android 12+ client is under `android/`, sharing the Rust catalog and
streaming implementation through UniFFI. It features a modern Material 3 interface,
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).
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
SDK.
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
[`docs/IOS.md`](docs/IOS.md).
## Privacy
```bash
cargo run --release -p pstr-app # the app
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
```
- **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
posters and descriptions, which sends your title names to AniList or TMDB.
- **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
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. |
[Privacy](docs/PRIVACY.md) lists every connection and every file.
## Building
@@ -85,53 +147,26 @@ Rust 2024, MSRV 1.96.
```bash
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-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
On Windows the libmpv ones
come from the separate **mpv-dev** archive rather than mpv's player build, and
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
crates.io; nothing here builds against a sibling checkout.
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`,
and a portable `.zip` plus an `.msi` on Windows — are produced by
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
per-platform detail.
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
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.
- **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
`scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
[`packaging/README.md`](packaging/README.md).
- **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
- **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
[`docs/IOS.md`](docs/IOS.md).
## License
The shared Rust crates and desktop application are MIT licensed. The Android and
iOS applications 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
the bundled-component notices.
The desktop application embeds the Inter typeface, under the SIL Open Font
The shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
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 the bundled
component notices. The desktop application embeds the Inter typeface, under the SIL Open Font
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
and the Phosphor icon font, under MIT.
@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
*/
@Test
fun chapterPlanningCrossesTheBoundaryIntact() {
val plan = chapterPlan(emptyList(), 1560.0)
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
assertNotNull(plan)
assertNull(skipOffer(plan, 12.0))
}
+9 -1
View File
@@ -7,6 +7,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<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
android:name=".ProtonStreamApplication"
android:allowBackup="false"
@@ -14,6 +21,7 @@
android:enableOnBackInvokedCallback="true"
tools:targetApi="33"
android:icon="@mipmap/ic_launcher"
android:banner="@drawable/tv_banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ProtonStream">
@@ -35,11 +43,11 @@
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
android:exported="true"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!--
Proton Drive public links. autoVerify="false", said explicitly:
+109 -6
View File
@@ -27,6 +27,7 @@ extern "C" {
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
namespace {
@@ -202,7 +203,7 @@ struct PlaybackState {
/// and must not be attributed to the file being loaded.
bool loading = false;
/// 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_height = 0.0;
/// 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
/// the viewer left it and resuming it where the *previous* one ended.
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 {
@@ -311,8 +323,11 @@ class Player {
// needs no window, and a load that cannot get one plays audio anyway —
// waiting for a surface here is what made background playback fail after
// a five-second stall.
if (!audio.empty()) set_string("alang", audio);
if (!subtitle.empty()) set_string("slang", subtitle);
//
// Set even when empty: mpv keeps both across `loadfile`, so a file
// with no language preference used to inherit the last one's.
set_string("alang", audio);
set_string("slang", subtitle);
set_string("sid", subtitles ? "auto" : "no");
pending_start_.store(std::max(0.0, start));
// Retire the outgoing file's position, duration and end flag *before*
@@ -338,7 +353,10 @@ class Player {
state_.generation = generation;
state_.loading = true;
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 char *command[] = {"loadfile", url.c_str(), nullptr};
if (mpv_command(mpv_, command) < 0) {
@@ -372,6 +390,27 @@ class Player {
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 {
std::lock_guard lock(state_mutex_);
return state_;
@@ -395,6 +434,8 @@ class Player {
char *lang_raw = nullptr;
char *title_raw = nullptr;
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 + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
MpvString type(type_raw);
@@ -403,10 +444,14 @@ class Player {
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
MpvString title(title_raw);
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 += ',';
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
"\",\"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 + ']';
}
@@ -434,6 +479,35 @@ class Player {
}
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 {
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
};
@@ -534,6 +608,17 @@ class Player {
if (event->event_id == MPV_EVENT_FILE_LOADED) {
const double start = pending_start_.exchange(0.0);
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) {
const auto *end = static_cast<mpv_event_end_file *>(event->data);
@@ -757,6 +842,12 @@ class Player {
bool surface_changed_ = false;
std::atomic<bool> frame_ready_{false};
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); }
@@ -850,6 +941,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
: 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 {
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
}
@@ -881,8 +983,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
state.video_width, state.video_height,
static_cast<jdouble>(state.end_reason),
state.buffering ? 1.0 : 0.0, state.cache_percent,
state.seeking ? 1.0 : 0.0, state.cached_until};
constexpr jsize kFields = 14;
state.seeking ? 1.0 : 0.0, state.cached_until,
state.loaded ? 1.0 : 0.0};
constexpr jsize kFields = 15;
jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast<jdoubleArray>(nullptr);
env->SetDoubleArrayRegion(result, 0, kFields, values);
@@ -1,54 +1,38 @@
package io.narl.protonstream
import android.Manifest
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Rational
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.NewEpisodesWorker
import io.narl.protonstream.sync.WatchSyncWorker
import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import uniffi.pstr_android.PaletteRecord
class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
private val inPictureInPicture = mutableStateOf(false)
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
private val incomingShareLink = mutableStateOf<String?>(null)
@@ -69,30 +53,10 @@ class MainActivity : ComponentActivity() {
}
}
/**
* The transport behind the buttons Android draws over the PiP window.
*
* A broadcast rather than a service intent: PiP is one of the states in
* which a background service start is refused, and the receiver is
* registered for as long as the activity that owns the window exists.
*/
private val pictureInPictureControls = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val host = playerHost.value ?: return
when (intent?.getStringExtra(EXTRA_CONTROL)) {
CONTROL_PLAY -> host.setPaused(false)
CONTROL_PAUSE -> host.setPaused(true)
CONTROL_NEXT -> host.onSkipNext?.invoke()
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
}
}
}
private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before anything draws. The palette decides the window's own
@@ -116,34 +80,17 @@ class MainActivity : ComponentActivity() {
playbackConnection,
Context.BIND_AUTO_CREATE,
)
ContextCompat.registerReceiver(
this,
pictureInPictureControls,
IntentFilter(ACTION_PIP_CONTROL),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
NewEpisodesWorker.schedule(this)
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
// Auto-enter has to be armed *before* the gesture that would use it:
// building the params inside onUserLeaveHint is too late for the swipe
// home that gesture navigation sends, which never calls it. Re-register
// whenever the picture's shape or the transport's does.
lifecycleScope.launch {
snapshotFlow { playerHost.value }
.flatMapLatest { host -> host?.state ?: flowOf(null) }
.map { state -> PictureInPictureShape.of(state) }
.distinctUntilChanged()
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
}
setContent {
ProtonStreamTheme {
ProtonStreamApp(
playerHost.value,
inPictureInPicture.value,
shareLink = incomingShareLink.value,
onShareLinkTaken = { incomingShareLink.value = null },
)
@@ -178,27 +125,7 @@ class MainActivity : ComponentActivity() {
intent.shareLink()?.let { incomingShareLink.value = it }
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// The backstop for launchers that still send this instead of arming
// auto-enter. Entering twice is not an error; not entering at all is.
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
runCatching {
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
}
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// The window is now a thumbnail with its own controls. Anything the
// player would draw over it — the transport, the title bar, the skip
// offer — is unreadable at that size and covers the picture.
inPictureInPicture.value = isInPictureInPictureMode
}
override fun onDestroy() {
runCatching { unregisterReceiver(pictureInPictureControls) }
if (bound) {
unbindService(playbackConnection)
bound = false
@@ -213,87 +140,9 @@ class MainActivity : ComponentActivity() {
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
if (
!isChangingConfigurations &&
!isInPictureInPictureMode &&
!SettingsStore(this).backgroundAudio
) {
playerHost.value?.stop()
}
}
/** Everything the PiP window's shape depends on, and nothing that polls. */
private data class PictureInPictureShape(
val active: Boolean,
val paused: Boolean,
val width: Int,
val height: Int,
) {
companion object {
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
active = state != null && state.duration > 0.0 && !state.ended,
paused = state?.paused != false,
width = state?.videoWidth?.toInt() ?: 0,
height = state?.videoHeight?.toInt() ?: 0,
)
}
}
private fun PictureInPictureShape.params(): PictureInPictureParams =
PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio(width, height))
.setAutoEnterEnabled(active)
.setSeamlessResizeEnabled(false)
// The player owns the whole window, so the picture's rectangle is
// the window's. Without it the transition into PiP cross-fades from
// a screenshot instead of shrinking the video that is already there.
.setSourceRectHint(windowBounds())
.setActions(
listOf(
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
),
)
.build()
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
RemoteAction(
Icon.createWithResource(this, icon),
label,
label,
PendingIntent.getBroadcast(
this,
requestCode,
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
private companion object {
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
const val EXTRA_CONTROL = "control"
const val CONTROL_PLAY = "play"
const val CONTROL_PAUSE = "pause"
const val CONTROL_NEXT = "next"
const val CONTROL_PREVIOUS = "previous"
/**
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
* and refuses the request outright rather than clamping it — so a
* 2.40:1 scope release would take PiP away entirely.
*/
fun aspectRatio(width: Int, height: Int): Rational {
if (width <= 0 || height <= 0) return Rational(16, 9)
val ratio = width.toDouble() / height
return when {
ratio > MAX_RATIO -> Rational(239, 100)
ratio < 1 / MAX_RATIO -> Rational(100, 239)
else -> Rational(width, height)
}
}
const val MAX_RATIO = 2.39
}
}
@@ -2,10 +2,15 @@ package io.narl.protonstream
import android.app.Application
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
class ProtonStreamApplication : Application() {
override fun onCreate() {
super.onCreate()
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
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.content.Context
import android.os.Build
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
@@ -25,12 +29,27 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
createNotificationChannel()
setForeground(createForegroundInfo(null))
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 linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext)
setForeground(createForegroundInfo(label, null))
postSummary(store)
try {
return transfer(store, shareId, volumeId, linkId, label)
} finally {
postSummary(store)
}
}
private suspend fun transfer(
store: DownloadStateStore,
shareId: String,
volumeId: String,
linkId: String,
label: String,
): Result {
// Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means
// this one has a slot.
@@ -62,7 +81,7 @@ class OfflineDownloadWorker(
reportedAt = now
reportedPercent = percent
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
// this read-modify-write used to put RUNNING back over the
// 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)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle(
if (progress == null) "Waiting to download" else "Making episode available offline",
)
.setContentTitle(label)
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
.setProgress(100, progress ?: 0, progress == null || progress == 0)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP)
.build()
return ForegroundInfo(
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()
companion object {
@@ -149,6 +203,10 @@ class OfflineDownloadWorker(
const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3
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. */
private const val PROGRESS_INTERVAL_MS = 1_000L
@@ -186,4 +244,4 @@ internal class TransferRate {
}
internal fun stableNotificationId(workId: UUID): Int =
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
@@ -1,5 +1,6 @@
package io.narl.protonstream.playback
import android.os.SystemClock
import android.view.Surface
import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope
@@ -19,7 +20,11 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SkipRangeRecord
import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.SubtitleTrackRecord
import uniffi.pstr_android.chapterPlan
import uniffi.pstr_android.fullSubtitlesInstead
/**
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
@@ -85,9 +90,9 @@ data class MpvPlaybackState(
/**
* What is on screen, for the surfaces that are not the player.
*
* The media session, its notification and Picture-in-Picture all have to name
* the episode, and none of them can reach the composition that knows it. The
* player publishes this once per episode and they read it.
* The media session and its notification have to name the episode, and
* neither can reach the composition that knows it. The player publishes this
* once per episode and they read it.
*/
data class NowPlaying(
val media: String,
@@ -101,12 +106,25 @@ data class NowPlaying(
val hasNext: Boolean,
)
/** A sleep timer that is running. */
sealed interface SleepTimer {
/** Stop when [media] ends, rather than going on to the next episode. */
data class EndOf(val media: String) : SleepTimer
/** Pause at [at], on the [SystemClock.elapsedRealtime] clock. */
data class At(val at: Long) : SleepTimer
}
data class MpvTrack(
val id: Long,
val type: String,
val language: String,
val title: String,
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)
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
@@ -123,19 +141,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(null)
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
private var sleepJob: Job? = null
private var poller: Job? = null
/** The episode key and load generation the native player currently holds. */
@Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null
/** What AniSkip said about the open episode, keyed by that episode. */
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
/** The load generation whose subtitle pick has been looked at. */
@Volatile private var subtitlesCheckedFor: Double = -1.0
var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null
/**
* Where a transport outside the player sends previous/next.
*
* The player owns which episode is open, so the notification, the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
* The player owns which episode is open, so the notification and the lock
* screen cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives.
*/
var onSkipPrevious: (() -> Unit)? = null
@@ -166,6 +190,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
*/
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
/**
* The sleep timer. Kept here rather than on the screen, because the
* episode it is about keeps playing with the screen off.
*/
val sleep: StateFlow<SleepTimer?> = mutableSleep.asStateFlow()
/** Start a sleep timer, replacing any running one, or turn it off. */
fun setSleepTimer(timer: SleepTimer?) {
sleepJob?.cancel()
mutableSleep.value = timer
if (timer is SleepTimer.At) {
sleepJob = scope.launch {
delay((timer.at - SystemClock.elapsedRealtime()).coerceAtLeast(0))
mutableSleep.value = null
setPaused(true)
}
}
}
/** Dismiss the current [problem]. */
fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
@@ -217,6 +260,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null
skipRanges = null
}
}
} ?: false
@@ -245,7 +289,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
*/
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 setPaused(paused: Boolean) {
@@ -259,6 +303,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
/**
* Take AniSkip's answer for [media], and plan the chapters again with it.
* Dropped when another episode has opened since it was asked for.
*/
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
if (media != currentMedia) return
skipRanges = media to ranges
plannedFor = null
refreshChapters(mutableState.value)
}
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
@@ -267,7 +322,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
fun selectTrack(track: MpvTrack?) {
val audio = track?.type == "audio"
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
refreshTracks()
// mpv makes the switch on its own thread and lists it a moment later,
// so a track list read back now still ticks the old track. The tick
// moves here, and a read shortly after confirms it.
val type = if (audio) "audio" else "sub"
mutableTracks.value = mutableTracks.value.map {
if (it.type == type) it.copy(selected = it.id == track?.id) else it
}
scope.launch(Dispatchers.Default) {
delay(TRACK_SETTLE_MS)
refreshTracks()
}
}
/**
@@ -292,6 +357,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
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() {
if (poller?.isActive == true) return
// Off the main thread deliberately: `nativeState` and `nativeTracks`
@@ -322,6 +411,12 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
// both of which are the main thread's.
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
refreshChapters(next)
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
subtitlesCheckedFor != currentGeneration
) {
subtitlesCheckedFor = currentGeneration
preferFullSubtitles()
}
}
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
if (tick++ % 4 == 0) refreshTracks()
@@ -355,17 +450,37 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
}.onSuccess { chapters ->
plannedFor = wanted
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
}
}
/**
* 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() {
runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return
val values = JSONArray(encoded)
List(values.length()) { index ->
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 }
@@ -414,6 +529,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
subtitles: Boolean,
hardwareDecoding: 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 nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double)
@@ -434,7 +557,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
private const val FIELDS = 14
private const val FIELDS = 15
private const val GENERATION = 6
private const val WIDTH = 7
private const val HEIGHT = 8
@@ -443,6 +566,10 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private const val CACHE_PERCENT = 11
private const val SEEKING = 12
private const val CACHED_UNTIL = 13
private const val LOADED = 14
/** How long after a track switch mpv's track list is read again. */
private const val TRACK_SETTLE_MS = 300L
init {
System.loadLibrary("pstr_android")
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Binder
import android.os.IBinder
import android.os.SystemClock
import androidx.core.content.ContextCompat
import io.narl.protonstream.MainActivity
import io.narl.protonstream.R
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.loadArtwork
import io.narl.protonstream.ui.loadThumbnail
@@ -33,9 +35,11 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
/** Owns libmpv so audio survives Activity recreation and rotation. */
class PlaybackService : Service() {
inner class PlaybackBinder : Binder() {
val host: NativeMpvHost? get() = this@PlaybackService.host
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
private var notified: NotificationShape? = null
/** The pending teardown, held so an episode transition can call it off. */
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. */
private var artwork: Pair<String, Bitmap>? = null
private val binder = PlaybackBinder()
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
}
mediaSession.isActive = true
}
onStateChanged = { updateSession(it) }
onStateChanged = { updateSession(it); track(it) }
onExplicitStop = { retirePlayback() }
}
mediaSession = MediaSession(this, "proton-stream").apply {
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
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() {
scope.cancel()
runCatching { unregisterReceiver(becomingNoisy) }
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
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) {
refreshSession(state, host?.nowPlaying?.value)
if (state.ended) scheduleRetire() else cancelRetire()
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
private const val TRANSITION_GRACE_MS = 30_000L
/** How far the volume drops for a navigation prompt or a notification. */
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. */
private const val ACCENT = 0xFFCBA6F7.toInt()
@@ -0,0 +1,220 @@
package io.narl.protonstream.playback
import android.app.Activity
import android.content.Context
import android.media.AudioManager
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BrightnessMedium
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt
/** What a swipe on the picture is doing, for the bubble that says so. */
sealed interface Swipe {
/** The window's brightness, from 0 to 1. */
data class Brightness(val level: Float) : Swipe
/** The device's media volume, from 0 to 1. */
data class Volume(val level: Float) : Swipe
/** Where the playhead will land when the finger lifts, and where it was. */
data class Seek(val from: Double, val to: Double) : Swipe
}
/** How much of the file one swipe across the whole width is worth, in seconds. */
private const val SEEK_SPAN_SECONDS = 90.0
/**
* A finger that comes down this close to the top or bottom of the screen is
* the system's: pulling down the shade, swiping home.
*/
private val SYSTEM_EDGE = 48.dp
/** Which way a swipe went, settled by its first movement past the touch slop. */
private enum class Axis { Brightness, Volume, Seek, Ignored }
/**
* The swipes every phone video player has taught: up and down on the left for
* brightness, on the right for volume, and sideways to seek — shown while the
* finger is down and only seeking when it lifts, because a seek per movement
* would throw away every block in flight.
*/
fun Modifier.playerSwipes(
enabled: Boolean,
activity: Activity?,
position: () -> Double,
duration: () -> Double,
onSwipe: (Swipe?) -> Unit,
onSeek: (Double) -> Unit,
): Modifier = pointerInput(enabled, activity) {
if (!enabled) return@pointerInput
val audio = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
val edge = SYSTEM_EDGE.toPx()
var axis = Axis.Ignored
var start = Offset.Zero
var travelled = Offset.Zero
var level = 0f
var seekFrom = 0.0
var seekTo = 0.0
detectDragGestures(
onDragStart = { offset ->
start = offset
travelled = Offset.Zero
axis = if (offset.y < edge || offset.y > size.height - edge) Axis.Ignored else Axis.Seek
},
onDragEnd = {
if (axis == Axis.Seek && seekTo != seekFrom) onSeek(seekTo)
onSwipe(null)
},
onDragCancel = { onSwipe(null) },
) { change, amount ->
if (axis == Axis.Ignored) return@detectDragGestures
change.consume()
val first = travelled == Offset.Zero
travelled += amount
if (first) {
// The first movement past the slop decides what the swipe is for.
axis = when {
abs(amount.x) > abs(amount.y) -> Axis.Seek
start.x < size.width / 2 -> Axis.Brightness
else -> Axis.Volume
}
level = when (axis) {
Axis.Brightness -> activity?.brightness() ?: 0.5f
Axis.Volume -> audio?.mediaLevel() ?: 0f
else -> 0f
}
seekFrom = position()
seekTo = seekFrom
}
when (axis) {
// A swipe the whole height of the screen goes from none to all.
Axis.Brightness -> {
level = (level - amount.y / size.height).coerceIn(0f, 1f)
activity?.setBrightness(level)
onSwipe(Swipe.Brightness(level))
}
Axis.Volume -> {
level = (level - amount.y / size.height).coerceIn(0f, 1f)
audio?.setMediaLevel(level)
onSwipe(Swipe.Volume(level))
}
Axis.Seek -> {
val length = duration()
if (length <= 0.0) return@detectDragGestures
seekTo = (seekFrom + travelled.x / size.width * SEEK_SPAN_SECONDS).coerceIn(0.0, length)
onSwipe(Swipe.Seek(seekFrom, seekTo))
}
Axis.Ignored -> {}
}
}
}
/** The window's own brightness, or the screen's when the window has none. */
private fun Activity.brightness(): Float {
val own = window.attributes.screenBrightness
if (own >= 0f) return own
return runCatching {
android.provider.Settings.System.getInt(contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) / 255f
}.getOrDefault(0.5f)
}
private fun Activity.setBrightness(level: Float) {
window.attributes = window.attributes.apply {
// Never quite off: a black screen under a playing film looks broken.
screenBrightness = level.coerceAtLeast(0.01f)
}
}
/** Hand brightness back to the system, as the player is left. */
fun Activity.releaseBrightness() {
window.attributes = window.attributes.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
private fun AudioManager.mediaLevel(): Float {
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
return getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
}
private fun AudioManager.setMediaLevel(level: Float) {
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC)
// No flags: the bubble is the feedback, and the system's own volume panel
// over the picture would be a second one.
setStreamVolume(AudioManager.STREAM_MUSIC, (level * max).roundToInt(), 0)
}
/** The bubble in the middle of the picture while a swipe is under way. */
@Composable
fun SwipeBubble(swipe: Swipe, format: (Double) -> String, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(20.dp))
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
when (swipe) {
is Swipe.Brightness -> Level(Icons.Default.BrightnessMedium, "Brightness", swipe.level)
is Swipe.Volume -> Level(Icons.AutoMirrored.Filled.VolumeUp, "Volume", swipe.level)
is Swipe.Seek -> {
val delta = swipe.to - swipe.from
Icon(
if (delta < 0) Icons.Default.FastRewind else Icons.Default.FastForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp),
)
Text(
(if (delta < 0) "−" else "+") + format(abs(delta)),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
Text(format(swipe.to), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelMedium)
}
}
}
}
@Composable
private fun Level(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, level: Float) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
Text("$label ${(level * 100).roundToInt()}%", color = Color.White, style = MaterialTheme.typography.labelLarge)
Box(Modifier.width(120.dp)) {
LinearProgressIndicator(
progress = { level },
modifier = Modifier.fillMaxWidth().height(4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = Color.White.copy(alpha = 0.25f),
drawStopIndicator = {},
)
}
}
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
package io.narl.protonstream.settings
import android.content.Context
import androidx.core.content.edit
/**
* 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)
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 {
/** The desktop's range, in the steps a phone's storage is bought in. */
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
/** Load the file into memory, so no later read waits on the disk. */
fun preload(context: Context) {
context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all
}
private const val NAME = "proton_stream_settings"
private const val KEY_WIFI_ONLY = "wifi_only"
private const val KEY_BACKGROUND_AUDIO = "background_audio"
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
private const val KEY_LIBRARY_SORT = "library_sort"
private const val KEY_LIBRARY_GROUPED = "library_grouped"
private const val KEY_LIBRARY_FILTER = "library_filter"
private const val KEY_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)
}
}
}
@@ -143,8 +143,9 @@ internal fun AccountCard(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Proton account", style = MaterialTheme.typography.titleMedium)
Text(
"Optional. Signed in, watch history is kept in your own Drive so every device " +
"resumes where another left off, and folders of your Drive can join the library.",
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
"Drive, so every device shows the same library and resumes where another left off, " +
"and folders of your Drive can join the library.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -156,8 +157,8 @@ internal fun AccountCard(
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
internal fun syncLine(account: AccountUiState, now: Long): String {
account.syncError?.let { return "Watch history did not sync: $it" }
val at = account.syncedAt ?: return "Watch history syncs with your Drive"
account.syncError?.let { return "Did not sync: $it" }
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
val ago = when {
minutes < 1 -> "just now"
@@ -25,7 +25,12 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.SearchExtrasRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord
@@ -38,20 +43,33 @@ import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.SignInOutcome
import uniffi.pstr_android.SyncRecord
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
data class AppUiState(
val loading: Boolean = true,
val refreshing: Boolean = false,
/** A crawl is running, which the banner offers to stop. */
val crawling: Boolean = false,
val query: String = "",
/**
* The whole library, whatever is searched for: the grid narrows to the
* search through [arrangement], and a title opened from a search, or from
* an episode it found, stays one the app can show.
*/
val titles: List<TitleRecord> = emptyList(),
/** What the search found besides titles; null with no search. */
val searchExtras: SearchExtrasRecord? = null,
/** The last few searches on this device, newest first. */
val recentSearches: List<String> = emptyList(),
/**
* The grid: [titles] ordered, each franchise folded into one tile when
* [grouped], and the shelves above it. Null until the bridge has answered,
* when the grid falls back to [titles] as they are.
*/
val arrangement: ArrangementRecord? = null,
val filter: LibraryFilter = LibraryFilter.ALL,
val sort: LibrarySort = LibrarySort.NAME,
val grouped: Boolean = true,
val shares: List<ShareRecord> = emptyList(),
@@ -64,6 +82,12 @@ data class AppUiState(
),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null,
/**
* What is running in the background right now, said in a banner for as
* long as it runs: an add, a crawl, a match, a sync. Without it a
* minute-long crawl looked like a tap that did nothing.
*/
val activity: String? = null,
/** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null,
val account: AccountUiState = AccountUiState(),
@@ -99,6 +123,21 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private val mutableState = MutableStateFlow(AppUiState())
private val searchQuery = MutableStateFlow("")
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
/** Launch and coming back both ask; one crawl answers. */
private var catchingUp = false
/**
* Every banner line whose work is still running, oldest first. Touched on
* the main thread only, as everything in [viewModelScope] is.
*/
private val activities = mutableListOf<Pair<Long, String>>()
private var nextActivity = 0L
/** Crawls running now; main thread only, like [activities]. */
private var crawlsRunning = 0
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
private var crawlStopped = false
init {
if (!NativeRuntime.tlsReady) {
@@ -106,7 +145,22 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
}
}
reload()
// The arrangement first, so the first grid is drawn the way the viewer
// left it rather than A–Z and then re-sorted under them.
viewModelScope.launch {
val (filter, sort, grouped) = withContext(Dispatchers.IO) {
SettingsStore(appContext).run {
Triple(
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
libraryGrouped,
)
}
}
mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
reload()
}
catchUp()
viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
.getOrDefault(AccountState.SignedOut)
@@ -116,12 +170,19 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
// and no upload.
while (isActive) {
syncWatchHistory()
// A sync WatchSyncWorker ran while the app was away may have
// brought shares this one has not crawled.
catchUp()
delay(SYNC_INTERVAL_MS)
}
}
viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
}
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().recentSearches() } }
.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
}
viewModelScope.launch {
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
@@ -131,10 +192,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) {
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) {
@@ -142,16 +220,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
searchQuery.value = query
}
/**
* Keep the search being shown as one to offer again: called when a viewer
* opens something it found. Nothing for an empty one.
*/
fun rememberSearch() {
val query = mutableState.value.query.takeIf(String::isNotBlank) ?: return
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().run {
rememberSearch(query)
recentSearches()
}
}
}.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
}
}
fun forgetSearches() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetSearches() } }
.onSuccess { mutableState.update { it.copy(recentSearches = emptyList()) } }
.onFailure(::reportError)
}
}
fun refresh() {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
busy("Refreshing the library…") {
mutableState.update { it.copy(message = null) }
runCatching { crawlShares(null) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reloadNow()
matchAfterCrawl()
}
}
/**
* Look up whatever a crawl brought that has no answer yet: a new show, or
* a new episode its stored guide cannot name. When everything already has
* one the bridge asks nothing, so this is cheap after every crawl.
*/
private suspend fun matchAfterCrawl() {
val settings = mutableState.value.metadataSettings
if (settings.enabled && settings.ready) match(force = false, quiet = true)
}
/**
* Say [what] in the activity banner while [work] runs.
*
* The banner shows the newest line still running. Work overlaps — a sync
* starts during a crawl and ends after it — so each line is removed by
* itself rather than by putting back whatever was showing before, which
* once brought back a finished crawl's line and spun it forever.
*/
private suspend fun busy(what: String, work: suspend () -> Unit) {
val token = nextActivity++
activities += token to what
showActivity()
try {
work()
} finally {
activities.removeAll { it.first == token }
showActivity()
}
}
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
private suspend fun crawlShares(id: String?) {
crawlsRunning++
mutableState.update { it.copy(crawling = true) }
try {
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
} finally {
crawlsRunning--
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
}
}
/**
* Stop the crawls running now. Shares already read stay in the library;
* the one being read keeps what it had.
*/
fun stopCrawl() {
crawlStopped = true
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
}
private fun showActivity() {
val newest = activities.lastOrNull()?.second
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
}
/**
* Recrawl one share rather than the whole library.
*
@@ -162,11 +324,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
*/
fun refreshShare(id: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
busy("Reading $name…") {
mutableState.update { it.copy(message = null) }
runCatching { crawlShares(id) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reloadNow()
matchAfterCrawl()
}
}
@@ -179,26 +344,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* changed.
*/
fun matchTitles(force: Boolean) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
viewModelScope.launch { match(force) }
}
/**
* With [quiet], a run nobody asked for says nothing when it found nothing
* to do.
*/
private suspend fun match(force: Boolean, quiet: Boolean = false) {
busy("Looking up titles…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.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) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
reloadNow()
}
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
/**
* Add a link, and return why it was refused, if it was.
*
* The link is opened before anything is stored, so the form can stay up
* with the reason under it and a retry is a retry — not "already in the
* library" for a share that never opened. Only the new share is crawled:
* another share that fails to open must not make this one look broken.
*/
suspend fun addShare(name: String, url: String, password: String?): String? {
val share = runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
}
}.getOrElse { return it.message ?: "That link could not be added" }
reload()
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
return null
}
/**
* Crawl shares that are new here — added on this device or by sync — then
* match them when enrichment is on.
*/
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
if (ids.isEmpty()) return
val before = mutableState.value.titles.map { it.key }.toSet()
val failed = mutableListOf<String>()
crawlStopped = false
for (id in ids) {
if (crawlStopped) break
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
busy("Reading $name…") {
runCatching { crawlShares(id) }
.onFailure { failed += "$name: ${it.message}" }
}
}
reloadNow()
// A catch-up is the app's doing, not the viewer's: a failure goes on
// the share's row rather than in their face, and only news is said.
if (failed.isNotEmpty() && announce) {
mutableState.update { it.copy(message = failed.joinToString("\n")) }
return
}
matchAfterCrawl()
if (announce) {
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
} else {
val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL }
if (files > 0uL) {
mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") }
}
}
}
fun addShare(name: String, url: String, password: String?) {
/**
* Crawl shares due a look: ones the catalog has nothing of — brought by a
* sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
* whose crawl never finished — and ones not crawled for six hours, so new
* episodes turn up without a pull to refresh.
*/
fun catchUp() {
if (catchingUp) return
catchingUp = true
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
engine.crawl(null)
}
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload()
try {
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
.getOrDefault(emptyList())
crawlAdded(ids, announce = false)
} finally {
catchingUp = false
}
}
}
@@ -220,6 +459,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.crawl(id)
}
}.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()
}
}
@@ -354,7 +603,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
syncError = null,
)
}
if (report.applied > 0u) reload()
takeSynced(report)
true
},
onFailure = { error ->
@@ -368,6 +617,35 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
)
}
/**
* What another device changed: its shares crawled or dropped here, its
* settings repainted.
*/
private suspend fun takeSynced(report: SyncRecord) {
if (report.sharesRemoved.isNotEmpty()) {
withContext(Dispatchers.IO) {
val downloads = DownloadStateStore(appContext)
report.sharesRemoved.forEach { id ->
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
downloads.removeShare(id)
}
}
}
if (report.settingsChanged) AppearanceState.reload()
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
report.sharesRemoved.isNotEmpty()
) {
reloadNow()
}
if (report.sharesAdded.isNotEmpty()) {
crawlAdded(report.sharesAdded, announce = false)
val count = report.sharesAdded.size
mutableState.update {
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
}
}
}
/** After the player closes: where it stopped is what another device wants next. */
fun playerClosed() {
reload()
@@ -376,16 +654,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true) }
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val share = engine.addAccountFolder(name, volumeId, linkId)
engine.crawl(share.id)
}
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
}.onSuccess { share ->
reload()
crawlAdded(listOf(share.id), announce = true)
}.onFailure(::reportError)
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
@@ -394,11 +668,37 @@ 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
* menu should not cost them.
*/
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
val episodes = title.seasons.flatMap { it.episodes }
/** Put a title on My List, or take it off. */
fun setOnList(title: TitleRecord, on: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
}.onFailure(::reportError)
reload()
}
}
/** The keys of the titles most like [key], best first; none if the engine cannot say. */
suspend fun moreLike(key: String): List<String> =
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().moreLike(key) } }.getOrDefault(emptyList())
/** The keys of every title [facet] stands for, in library order. */
suspend fun browse(facet: BrowseFacet): List<String> =
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().browse(facet) } }
.onFailure(::reportError)
.getOrDefault(emptyList())
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
/**
* Some episodes seen or unseen — a season, or a whole title — with an
* Undo; [what] names them in the message.
*/
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
changeWatch(
episodes,
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
episodes.filter { it.watched != watched },
"$what marked ${if (watched) "watched" else "unwatched"}",
) { engine, episode, before ->
val duration = before?.durationSecs
engine.saveWatchState(
@@ -446,14 +746,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
snapshots.forEach { snapshot ->
val state = snapshot.state
engine.saveWatchState(
snapshot.shareId,
snapshot.linkId,
state?.positionSecs ?: 0.0,
state?.durationSecs,
state?.watched ?: false,
)
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
}
}
}.onFailure(::reportError)
@@ -487,22 +780,29 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
}
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
if (enabled) engine.matchTitles(false) else null
/**
* Store the enrichment settings, and return why not, if they were not.
*
* The page shows the new settings as soon as they are stored; the match
* run that follows can take minutes and says so in the activity banner.
*/
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
val stored = runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// TMDB's is the only key: the provider's with TMDB chosen, and
// film art's with AniList.
if (apiKey.isNotBlank()) {
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
}
}.onSuccess { summary ->
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
}.onFailure(::reportError)
reload()
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
engine.metadataSettings()
}
}.getOrElse { return it.message ?: "The settings could not be saved" }
mutableState.update { it.copy(metadataSettings = stored) }
reloadNow()
if (enabled) viewModelScope.launch { match(force = false) }
return null
}
fun reloadAfterMetadataChange() = reload()
@@ -585,24 +885,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* at its duration where one is known, so the progress bar agrees with the
* tick.
*/
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
viewModelScope.launch {
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()
}
}
/** One episode's tick, with the same Undo a season's has. */
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
fun removeAllOffline() {
viewModelScope.launch {
@@ -629,40 +913,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
private fun reload() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
val current = mutableState.value
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(query),
engine.arrangement(query, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
engine.storageUsage(),
)
}
}.onSuccess { result ->
mutableState.update { it.copy(
loading = false,
shares = result.shares,
titles = result.titles,
arrangement = result.arrangement,
offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
) }
}
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
}
viewModelScope.launch { reloadNow() }
}
/** [reload], for a caller that goes on to read what it loaded. */
private suspend fun reloadNow() {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
val current = mutableState.value
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(null),
engine.arrangement(query, current.filter, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
engine.storageUsage(),
)
}
}.onSuccess { result ->
mutableState.update { it.copy(
loading = false,
shares = result.shares,
titles = result.titles,
arrangement = result.arrangement,
offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
) }
}
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
}
private suspend fun reloadLibrary(query: String) {
@@ -671,10 +958,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.sort, current.grouped)
Triple(
engine.library(null),
engine.arrangement(search, current.filter, current.sort, current.grouped),
search?.let(engine::searchExtras),
)
}
}.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
}.onSuccess { (titles, arrangement, extras) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
@@ -1,13 +1,33 @@
package io.narl.protonstream.ui
import android.net.ConnectivityManager
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.border
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -15,11 +35,22 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.EpisodeRecord
/**
@@ -93,24 +124,47 @@ internal fun Modifier.sharedArt(key: String): Modifier {
}
@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) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(body, style = MaterialTheme.typography.bodyMedium)
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
Text(
body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
action?.let { (label, onClick) ->
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
}
}
}
}
/**
* Where Play on a title lands: the most recently played part-watched episode,
* else the first unwatched one, else the first — the desktop's `next_up`.
* Where Play on a title lands: what Continue watching offers
* ([TitleRecord.continueIndex]), else the most recently played part-watched
* episode, else the first unwatched one, else the first — the desktop's
* `next_up`.
*/
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
continueIndex?.toInt()?.takeIf { it in playlist.indices }
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
internal fun formatRuntime(minutes: UInt): String {
val hours = minutes / 60u
val rest = minutes % 60u
return when {
hours == 0u -> "$rest min"
rest == 0u -> "$hours h"
else -> "$hours h $rest min"
}
}
internal fun formatBytes(bytes: ULong): String {
val value = bytes.toDouble()
return when {
@@ -122,11 +176,52 @@ internal fun formatBytes(bytes: ULong): String {
}
/**
* 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.
* Download episodes, asking first when it is more than one: a season or a
* show is tens of gigabytes, and one tap should not fill a phone. The
* question says how many and how big. Call it in composition; it draws the
* dialog where it is called and returns what starts the download.
*/
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
@Composable
internal fun rememberDownload(): (List<EpisodeRecord>, String) -> Unit {
val context = LocalContext.current
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, String>?>(null) }
asking?.let { (episodes, what) ->
val bytes = episodes.sumOf { it.size ?: 0uL }
AlertDialog(
onDismissRequest = { asking = null },
title = { Text("Download ${episodes.size} episodes of $what?") },
text = {
Text(
"They are kept on this device for watching without a connection" +
(if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") +
". Delete them from Downloads.",
)
},
confirmButton = {
AccentButton(onClick = {
asking = null
DownloadCoordinator.enqueue(context, episodes)
}) { Text("Download") }
},
dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } },
)
}
return remember(context) {
{ episodes, what ->
val wanted = episodes.filterNot(EpisodeRecord::offline)
if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted)
}
}
}
/**
* One line under a download that has not finished: "Running · 120.0 MiB of
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
* [waitingFor], what it is really waiting on.
*/
internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
?: download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
@@ -135,6 +230,32 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
?.let { "${formatBytes(it.toULong())}/s" },
).joinToString(" · ")
/**
* Why a queued download is not running, when it is not merely a free slot:
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
* changes — the download's own updates are the clock.
*/
@Composable
internal fun rememberQueueReason(key: Any?): String? {
val context = LocalContext.current
return remember(key) {
val network = context.getSystemService(ConnectivityManager::class.java)
when {
network?.activeNetwork == null -> "Waiting for a connection"
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
else -> null
}
}
}
/**
* The episode's own name: the provider's, else the one the filename gave it.
* `null` rather than the filename, which is a release group's tag and the
* episode's numbering again.
*/
internal fun EpisodeRecord.shownName(): String? = providerName ?: detail.takeIf { it != name && it.isNotBlank() }
/**
* What an episode row is headed with: "1. Mother and Children".
*
@@ -143,7 +264,7 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/
internal fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name }
val named = shownName()
val number = number?.toInt()
return when {
named != null && number != null -> "$number. $named"
@@ -152,3 +273,86 @@ internal fun EpisodeRecord.heading(position: Int): String {
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
}
}
/**
* Something the title is filed under — a genre — as a small rounded label.
* The desktop's `widgets::chip`.
*/
@Composable
internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Browse $text", onClick = onClick) else Modifier)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
/**
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
* [GenreChip] with a dot in front: the accent while more is coming, muted once
* it is all out. The desktop's `widgets::status_chip`.
*/
@Composable
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
Row(
modifier
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(6.dp)
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
)
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
}
}
/**
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
* The desktop's `widgets::spec_badge`.
*/
@Composable
internal fun SpecBadge(text: String) {
val ink = MaterialTheme.colorScheme.onSurfaceVariant
Text(
text,
style = MaterialTheme.typography.labelSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = ink,
modifier = Modifier
.border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall)
.padding(horizontal = 5.dp, vertical = 1.dp),
)
}
/**
* My List's button: a plus to put the title on it, a tick once it is, which
* takes it off again. [compact] is the icon alone, for the banner's row, where
* a third labelled button does not fit a phone.
*/
@Composable
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
val label = if (onList) "Remove from My List" else "Add to My List"
if (compact) {
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
Icon(icon, contentDescription = label)
}
} else {
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
Icon(icon, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("My List")
}
}
}
@@ -3,6 +3,7 @@ package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -40,7 +42,7 @@ import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.OfflineRecord
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
padding: PaddingValues,
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
onOpen: (TitleRecord) -> Unit = {},
) {
val context = LocalContext.current
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
)
}
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 = {
AccentButton(onClick = {
DangerButton(onClick = {
deleting = null
onRemove(file)
}) { 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. */
@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
// What a download is for: tapping it plays it, from where it was left,
// without going back through the library to find it.
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
ListItem(
modifier = if (title != null && index >= 0) {
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
} else {
Modifier
},
leadingContent = {
RemoteArtwork(
episode?.stillUrl ?: title?.backdropUrl,
@@ -179,8 +197,15 @@ private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> U
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
},
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
Row {
title?.let {
IconButton(onClick = { onOpen(it) }) {
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -204,7 +229,7 @@ private fun PartialRow(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Column {
Text(downloadStatusLine(download))
Text(downloadStatusLine(download, rememberQueueReason(download)))
if (download.total > 0L) {
AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
@@ -1,6 +1,28 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.background
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -19,8 +41,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Brush
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import androidx.compose.material3.Surface
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.QuietButton
import java.time.LocalDate
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
@@ -35,8 +62,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.download.DownloadCoordinator
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -46,6 +71,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
@@ -61,6 +87,7 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
@@ -70,6 +97,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
@@ -81,11 +111,21 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.BrowseFacet
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material.icons.filled.History
import uniffi.pstr_android.FacetCountRecord
import uniffi.pstr_android.SearchExtrasRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.airingCountdown
import uniffi.pstr_android.facetKind
/**
* How many shows the continue-watching shelf holds.
@@ -96,11 +136,51 @@ import uniffi.pstr_android.TitleType
private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
private data class Resumable(
val title: TitleRecord,
val episode: EpisodeRecord,
val index: Int,
/** When the title was last played, which orders the shelf. */
val lastPlayed: Long,
)
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
private data class Tile(val title: TitleRecord, val folded: Int)
/** The desktop's names for the filters, from `browse::Filter::label`. */
internal fun LibraryFilter.label(): String = when (this) {
LibraryFilter.ALL -> "All"
LibraryFilter.SERIES -> "Series"
LibraryFilter.FILMS -> "Films"
LibraryFilter.WATCHING -> "Watching"
LibraryFilter.UNWATCHED -> "Not started"
LibraryFilter.DOWNLOADED -> "Downloaded"
LibraryFilter.MY_LIST -> "My List"
}
/** The offer of posters and synopses, and its two answers. */
internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit)
@Composable
private fun MetadataOfferCard(offer: MetadataOffer) {
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
Text("Add posters and synopses?", style = MaterialTheme.typography.titleSmall)
Text(
"Turning it on in Settings sends the titles in your library — not your files, and " +
"nothing about what you have watched — to AniList or TMDB.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, end = 8.dp),
)
Row(Modifier.align(Alignment.End)) {
QuietButton(onClick = offer.onDismiss) { Text("Not now") }
QuietButton(onClick = offer.onOpenSettings) { Text("Open settings") }
}
}
}
}
/** A row above the grid: titles that share a director, a studio or a genre. */
private data class Shelf(val label: String, val titles: List<TitleRecord>)
@@ -118,22 +198,35 @@ internal fun LibraryScreen(
onMatchChanged: () -> Unit = {},
onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
/** Open every title sharing a genre, studio or director a search named. */
onBrowse: (BrowseFacet) -> Unit = {},
onForgetSearches: () -> Unit = {},
) {
val download = rememberDownload()
val context = LocalContext.current
// Posters and synopses, offered once there is a library to put them on
// and never turned on unasked: switching them on sends title names out.
var offerDismissed by remember { mutableStateOf(SettingsStore(context).metadataOfferDismissed) }
// What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) }
var matching by remember { mutableStateOf<TitleRecord?>(null) }
// Most recently played first, one episode per show: a shelf that lists four
// episodes of the same series is a shelf with room for nothing else.
// What to go on with is the engine's call (`continueIndex`): the
// part-watched episode, or the one after the episode finished last.
val resumable = remember(state.titles) {
state.titles.mapNotNull { title ->
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
playlist.withIndex()
.filter { it.value.resumeAt != null && !it.value.watched }
.maxByOrNull { it.value.lastPlayed }
?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
playlist.getOrNull(index)?.let { episode ->
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
}
}.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}
// Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them.
@@ -143,66 +236,107 @@ internal fun LibraryScreen(
onSearch("")
}
BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back
// only at the top: the page says what it is on arrival, then gets out of
// the way.
// The banner turns through these. Worked out here rather than in the grid,
// because whether there is one decides the shape of the whole page.
val featured = remember(state.titles, resumable) { featured(state.titles, resumable) }
// With a banner, the bar lies over it, clear, and fills in as the banner
// scrolls away; without one, the large title folds into the bar as the
// grid scrolls and comes back only at the top.
val overBanner = !searching && !state.loading && state.query.isBlank() && featured.isNotEmpty()
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else {
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
val grid = rememberLazyGridState()
val actions: @Composable RowScope.() -> Unit = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive public link under Shares, then refresh.",
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a viewer who
// does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().then(if (overBanner) Modifier else Modifier.nestedScroll(scroll.nestedScrollConnection))) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else if (!overBanner) {
LargeTopAppBar(
title = { Text("Library") },
actions = actions,
colors = pageBarColors(),
scrollBehavior = scroll,
)
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
// Empty and with the field open: the last few searches.
searching && state.query.isBlank() && state.recentSearches.isNotEmpty() -> RecentSearches(
state.recentSearches,
onPick = onSearch,
onForget = onForgetSearches,
)
state.query.isNotBlank() &&
state.arrangement?.tiles?.isEmpty() == true &&
state.searchExtras?.isEmpty() != false -> EmptyState(
"Nothing matches",
"Nothing in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
featured = if (overBanner) featured else emptyList(),
grid = grid,
onSetOnList = onSetOnList,
onBrowse = onBrowse,
)
}
}
}
if (overBanner) BarOverBanner(grid, actions)
}
menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
onResume(title, nextUpIndex(playlist))
onResume(title, nextUpIndex(playlist, title.continueIndex))
}
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
MenuRow(
if (title.onList) Icons.Default.Check else Icons.Default.Add,
if (title.onList) "Remove from My List" else "Add to My List",
) { onSetOnList(title, !title.onList) }
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
if (allWatched) "Mark unwatched" else "Mark watched",
@@ -384,6 +518,12 @@ private fun LibraryGrid(
onTitle: (TitleRecord) -> Unit,
onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> Unit,
onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null,
featured: List<TitleRecord> = emptyList(),
grid: LazyGridState = rememberLazyGridState(),
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
onBrowse: (BrowseFacet) -> Unit = {},
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
@@ -402,24 +542,31 @@ private fun LibraryGrid(
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(),
state = grid,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
// No room above a banner: it starts under the status bar, and the bar
// lies over it.
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = if (featured.isEmpty()) 8.dp else 0.dp, bottom = 24.dp),
) {
if (featured.isNotEmpty()) {
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
featured,
resumable,
onPlay = onResume,
onOpen = onTitle,
onSetOnList = onSetOnList,
modifier = Modifier.bleed(16.dp),
)
}
}
// Above the grid, and only when there is something in it: what a viewer
// opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest.
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
title,
resume = resumable.firstOrNull { it.title.key == title.key },
onPlay = { onResume(title, it) },
onOpen = { onTitle(title) },
)
}
if (metadataOffer != null && state.query.isBlank()) {
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
}
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
@@ -441,7 +588,10 @@ private fun LibraryGrid(
}
}
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) }) {
Column {
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
@@ -456,15 +606,70 @@ private fun LibraryGrid(
onLongClick = { onTitleMenu(title) },
modifier = Modifier.width(SHELF_TILE_WIDTH),
shared = false,
// On a shelf, an airing show says when its
// next episode is out.
badge = title.badge ?: title.countdown(),
)
}
}
}
}
}
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Column {
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(LibraryFilter.entries, key = { it.name }) { filter ->
FilterChip(
selected = filter == state.filter,
onClick = { onFilter(filter) },
label = { Text(filter.label()) },
)
}
}
}
}
if (tiles.isEmpty() && state.filter != LibraryFilter.ALL) {
item(key = "filtered-out", span = { GridItemSpan(maxLineSpan) }) {
Text(
"Nothing in the library fits that filter.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// What the search found besides titles, above the titles it found.
state.searchExtras?.takeIf { state.query.isNotBlank() }?.let { extras ->
facetChips("People and studios", extras.people, onBrowse)
facetChips("Genres", extras.genres, onBrowse)
if (extras.episodes.isNotEmpty()) {
item(key = "search-episodes", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text("Episodes", style = MaterialTheme.typography.titleMedium)
extras.episodes.forEach { hit ->
val title = byKey[hit.titleKey] ?: return@forEach
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
val index = playlist.indexOfFirst { it.linkId == hit.linkId }
if (index < 0) return@forEach
ListItem(
headlineContent = { Text(hit.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Text("${title.displayName} · ${hit.label}", maxLines = 1, overflow = TextOverflow.Ellipsis)
},
leadingContent = { Icon(Icons.Default.PlayArrow, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClickLabel = "Play") { onResume(title, index) },
)
}
}
}
}
if (tiles.isNotEmpty() && !extras.isEmpty()) {
item(key = "search-titles", span = { GridItemSpan(maxLineSpan) }) {
Text("Titles", style = MaterialTheme.typography.titleMedium)
}
}
}
@@ -479,6 +684,55 @@ private fun LibraryGrid(
}
}
/** Whether a search found nothing besides titles. */
internal fun SearchExtrasRecord.isEmpty(): Boolean = episodes.isEmpty() && people.isEmpty() && genres.isEmpty()
/** A row of the genres, studios or people a search named, each opening its page. */
private fun LazyGridScope.facetChips(heading: String, facets: List<FacetCountRecord>, onBrowse: (BrowseFacet) -> Unit) {
if (facets.isEmpty()) return
item(key = "search-$heading", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text(heading, style = MaterialTheme.typography.titleMedium)
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
facets.forEach { found ->
GenreChip("${found.facet.label} · ${found.count}") { onBrowse(found.facet) }
}
}
}
}
}
/** The last few searches, under an empty search field. */
@Composable
private fun RecentSearches(recent: List<String>, onPick: (String) -> Unit, onForget: () -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 8.dp)) {
item(key = "heading") {
Text(
"Recent searches",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(recent, key = { it }) { query ->
ListItem(
headlineContent = { Text(query) },
leadingContent = { Icon(Icons.Default.History, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { onPick(query) },
)
}
item(key = "forget") {
QuietButton(onClick = onForget, modifier = Modifier.padding(horizontal = 8.dp)) {
Text("Clear recent searches")
}
}
}
}
/**
* The grid's shape before the catalog has answered: posters and their two
* lines of text as blocks, so the page does not jump when the titles land.
@@ -511,73 +765,251 @@ private val SHELF_TILE_WIDTH = 112.dp
/** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18
/** How many titles the banner turns through: the desktop's `FEATURED`. */
private const val FEATURED_MAX = 5
/** How long each title holds the banner: the desktop's `HOLD`. */
private const val HOLD_MS = 8_000L
/** How long one title takes to give way to the next: the desktop's `TURN`. */
private const val TURN_MS = 600
/**
* The title the banner shows: the one watched last, or with nothing part
* watched, one of the titles the provider has a backdrop for, turning over
* once a day — the desktop's `featured`.
* The titles the banner turns through: the one watched last first, then titles
* the provider has a backdrop for, starting from a different one each day —
* the desktop's `featured`.
*/
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
resumable.firstOrNull()?.let { return it.title }
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): List<TitleRecord> {
val picked = listOfNotNull(resumable.firstOrNull()?.title).toMutableList()
val pictured = titles.filter { it.backdropUrl != null }
if (pictured.isEmpty()) return null
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
if (pictured.isEmpty()) return picked
val start = (LocalDate.now().toEpochDay() % pictured.size).toInt()
for (offset in pictured.indices) {
if (picked.size == FEATURED_MAX) break
val title = pictured[(start + offset) % pictured.size]
if (picked.none { it.key == title.key }) picked += title
}
return picked
}
/** Large art behind the name, rating, genres and three lines of synopsis. */
/**
* Wider than the space it is given by [horizontal] on each side: for the
* banner, which runs edge to edge inside a grid with a margin.
*/
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
val extra = horizontal.roundToPx() * 2
val placeable = measurable.measure(
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
)
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
}
/**
* The bar over the banner: clear at the top, with white icons over the
* banner's own scrim, and filling in to the page's bar as the banner scrolls
* away — at which point it is the page's bar, title and all.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
private fun BarOverBanner(grid: LazyGridState, actions: @Composable RowScope.() -> Unit) {
val reveal = with(LocalDensity.current) { BAR_REVEAL.toPx() }
val solid by remember(grid) {
derivedStateOf {
if (grid.firstVisibleItemIndex > 0) 1f else (grid.firstVisibleItemScrollOffset / reveal).coerceIn(0f, 1f)
}
}
val scheme = MaterialTheme.colorScheme
val ink = lerp(Color.White, scheme.onSurface, solid)
TopAppBar(
title = { Text("Library", modifier = Modifier.graphicsLayer { alpha = solid }) },
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = scheme.surfaceContainer.copy(alpha = solid),
titleContentColor = ink,
actionIconContentColor = ink,
),
)
}
/** How far the banner scrolls before the bar over it is solid. */
private val BAR_REVEAL = 280.dp
/**
* The banner at the top of the library: one title at a time, edge to edge
* under the status bar, turning to the next every [HOLD_MS] unless a finger is
* on it. Each title's veil is tinted with its own picture's colour.
*/
@Composable
private fun FeaturedBanner(
titles: List<TitleRecord>,
resumable: List<Resumable>,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
onSetOnList: (TitleRecord, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val pager = rememberPagerState { titles.size }
val dragged by pager.interactionSource.collectIsDraggedAsState()
if (titles.size > 1 && !dragged) {
// Keyed on the settled page, not the current one: the current page
// flips half way through a turn, and restarting this effect there
// cancelled the turn and left the banner stuck between two titles.
LaunchedEffect(pager.settledPage, titles.size) {
delay(HOLD_MS)
pager.animateScrollToPage((pager.settledPage + 1) % titles.size, animationSpec = tween(TURN_MS))
}
}
// Most of a phone's first screen, but never so much of a tablet's that
// nothing under it shows.
val screenHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
val height = (screenHeight * 0.62f).coerceIn(360.dp, 620.dp)
Box(modifier.fillMaxWidth().height(height)) {
HorizontalPager(pager, Modifier.fillMaxSize(), key = { titles[it].key }) { page ->
val title = titles[page]
FeaturedPage(
title,
resumable.firstOrNull { it.title.key == title.key },
onPlay = { onPlay(title, it) },
onOpen = { onOpen(title) },
onSetOnList = { onSetOnList(title, it) },
)
}
if (titles.size > 1) {
val scope = rememberCoroutineScope()
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
titles.forEachIndexed { index, title ->
val shown = index == pager.currentPage
val width by animateDpAsState(if (shown) 18.dp else 6.dp, label = "dot")
Box(
Modifier
.size(width, 6.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = if (shown) 1f else 0.4f))
.clickable(onClickLabel = "Show ${title.displayName}") {
scope.launch { pager.animateScrollToPage(index, animationSpec = tween(TURN_MS)) }
}
.semantics { selected = shown },
)
}
}
}
}
}
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
@Composable
private fun FeaturedPage(
title: TitleRecord,
resume: Resumable?,
onPlay: (Int) -> Unit,
onOpen: () -> Unit,
onSetOnList: (Boolean) -> Unit,
) {
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val background = MaterialTheme.colorScheme.background
val veil = moodVeil(mood)
val ink = MaterialTheme.colorScheme.onBackground
// A logo is lettering made for dark art, white as often as not; over a
// light palette's veil it is gone, so the light palette keeps the name.
val dark = background.luminance() < 0.5f
Box(Modifier.fillMaxSize().clickable(onClickLabel = "Open ${title.displayName}", onClick = onOpen)) {
Backdrop(title, Modifier.fillMaxSize(), onMood = { mood = it })
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
Brush.verticalGradient(
// Dark under the status bar and the bar's white icons…
0f to Color.Black.copy(alpha = 0.5f),
0.22f to Color.Transparent,
// …and the picture's own colour under the text, ending in
// the page it hands over to.
0.45f to veil.copy(alpha = 0.35f),
0.78f to veil.copy(alpha = 0.92f),
1f to background,
),
),
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text(
title.displayName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.align(Alignment.BottomStart).widthIn(max = 560.dp).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
) {
if (dark) {
RemoteLogo(
title.logoUrl,
title.displayName,
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 8.dp),
) { HeroName(title.displayName, ink) }
} else {
HeroName(title.displayName, ink)
}
Text(
listOfNotNull(
title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
).joinToString(" · ").ifEmpty { title.caption() },
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
color = ink.copy(alpha = 0.8f),
)
title.overview?.let {
Text(
it.trim(),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 3,
color = ink.copy(alpha = 0.8f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play")
Text(if (resume?.episode?.resumeAt != null) "Resume" else "Play")
}
TonalButton(onClick = onOpen) {
Icon(Icons.Default.Info, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("More info")
}
ListButton(title.onList, onSetOnList, compact = true)
}
val episode = resume?.episode
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
if (progress != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.width(120.dp),
)
episode.timeLeft()?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = ink.copy(alpha = 0.8f),
modifier = Modifier.padding(start = 8.dp),
)
}
}
TonalButton(onClick = onOpen) { Text("More info") }
}
}
}
}
@Composable
private fun HeroName(name: String, ink: Color) {
Text(
name,
style = MaterialTheme.typography.displayLarge,
color = ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 4.dp),
)
}
/** A part-watched episode: its still, where it was left, and what is left of it. */
@Composable
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
@@ -585,14 +1017,16 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
Modifier
.width(220.dp)
.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)) {
// Named by the text under it; read as well, the name came twice.
RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.displayName,
Modifier.fillMaxSize(),
Modifier.fillMaxSize().clearAndSetSemantics {},
fallback = entry.episode.thumbnailSource,
maxEdge = 220.dp,
)
entry.episode.progress?.let { progress ->
AccentProgress(
@@ -609,7 +1043,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
modifier = Modifier.padding(top = 8.dp),
)
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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -634,11 +1072,12 @@ private fun PosterTile(
modifier: Modifier = Modifier,
folded: Int = 0,
shared: Boolean = true,
badge: String? = title.badge,
) {
Column(
modifier
.clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
) {
Box {
RemoteArtwork(
@@ -647,8 +1086,11 @@ private fun PosterTile(
(if (shared) Modifier.sharedArt(title.key) else Modifier)
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium),
.clip(MaterialTheme.shapes.medium)
.clearAndSetSemantics {},
fallback = title.thumbnailSource,
// The tile's height, at the widest a grid column gets.
maxEdge = 280.dp,
)
if (folded > 0) {
Text(
@@ -660,6 +1102,21 @@ private fun PosterTile(
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(Color.Black.copy(alpha = 0.7f))
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = "and $folded more" },
)
}
if (badge != null) {
Text(
badge,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.align(Alignment.TopStart)
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
@@ -704,3 +1161,79 @@ private fun EpisodeRecord.timeLeft(): String? {
val minutes = ((at / fraction - at) / 60).toInt()
return if (minutes < 1) "under a minute left" else "$minutes min left"
}
/** `Ep 8 · in 2 d` for a show whose next episode is still to air. */
internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000): String? {
val at = nextAiringAt?.takeIf { it > now } ?: return null
val episode = nextEpisode ?: return null
return airingCountdown(episode, at, now)
}
/**
* Every title sharing a genre, a studio or a director: what a chip on a title
* page opens. The desktop's `library::browse`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun BrowseScreen(
facet: BrowseFacet,
titles: List<TitleRecord>,
onBack: () -> Unit,
onTitle: (TitleRecord) -> Unit,
padding: PaddingValues,
) {
BackHandler(onBack = onBack)
Column(Modifier.fillMaxSize()) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
title = {
Column {
Text(
facetKind(facet),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(facet.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
colors = pageBarColors(),
)
if (titles.isEmpty()) {
// A recrawl or a new match took the last one away.
Box(Modifier.padding(padding)) {
EmptyState(facet.label, "Nothing in the library has this any more.")
}
return@Column
}
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize().padding(padding),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
) {
item(key = "count", span = { GridItemSpan(maxLineSpan) }) {
Text(
if (titles.size == 1) "1 title" else "${titles.size} titles",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(titles, key = { it.key }) { title ->
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitle(title) }, shared = false)
}
}
}
}
/** The name a [BrowseFacet] stands for: the chip's text and its page's heading. */
internal val BrowseFacet.label: String
get() = when (this) {
is BrowseFacet.Genre -> name
is BrowseFacet.Studio -> name
is BrowseFacet.Person -> name
}
@@ -1,5 +1,16 @@
package io.narl.protonstream.ui
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.fadeOut
import androidx.compose.animation.fadeIn
import androidx.compose.animation.AnimatedVisibility
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -51,6 +62,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -65,7 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
@@ -98,7 +112,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
@Composable
fun ProtonStreamApp(
playerHost: LibmpvHost? = null,
inPictureInPicture: Boolean = false,
shareLink: String? = null,
onShareLinkTaken: () -> Unit = {},
model: AppViewModel = viewModel(
@@ -123,6 +136,29 @@ fun ProtonStreamApp(
// is what says so. Saved, because a rotation must not throw the viewer back
// into the video they had just left.
var playerMinimized by rememberSaveable { mutableStateOf(false) }
var addingShare by remember { mutableStateOf(false) }
// What a genre, studio or director chip opened, over the library pane.
// As its two parts, which survive process recreation where the record
// would not.
var browseKind by rememberSaveable { mutableStateOf<String?>(null) }
var browseName by rememberSaveable { mutableStateOf("") }
// The title it was opened from, which is where back goes, as on the
// desktop.
var browseFrom by rememberSaveable { mutableStateOf<String?>(null) }
val browsing = when (browseKind) {
"genre" -> BrowseFacet.Genre(browseName)
"studio" -> BrowseFacet.Studio(browseName)
"person" -> BrowseFacet.Person(browseName)
else -> null
}
val browse = { facet: BrowseFacet ->
browseKind = when (facet) {
is BrowseFacet.Genre -> "genre"
is BrowseFacet.Studio -> "studio"
is BrowseFacet.Person -> "person"
}
browseName = facet.label
}
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
val playing = playingTitleKey
@@ -160,7 +196,7 @@ fun ProtonStreamApp(
Destination.entries.forEach { item ->
item(
selected = destination == item,
onClick = { destination = item; selectedTitleKey = null },
onClick = { destination = item; selectedTitleKey = null; browseKind = null },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
colors = navigation,
@@ -199,16 +235,34 @@ fun ProtonStreamApp(
// library stays in view while a title is open, so moving
// between titles is one tap rather than back and in again.
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = maxWidth >= TWO_PANE_WIDTH
val browsed by produceState(emptyList<String>(), browsing, state.titles) {
value = browsing?.let { model.browse(it) }.orEmpty()
}
val libraryPane = @Composable {
LibraryScreen(
browsing?.let { facet ->
BrowseScreen(
facet,
browsed.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
onBack = {
browseKind = null
browseFrom?.let { selectedTitleKey = it }
},
onTitle = { selectedTitleKey = it.key },
padding = body,
)
} ?: LibraryScreen(
state,
model::search,
{ title, index ->
// A search that led somewhere is one
// worth offering again.
model.rememberSearch()
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = it.key },
{ model.rememberSearch(); selectedTitleKey = it.key },
model::refresh,
model::setTitleWatched,
model::forgetPosition,
@@ -216,9 +270,25 @@ fun ProtonStreamApp(
onMatchChanged = model::reloadAfterMetadataChange,
onError = model::reportError,
onArrange = model::arrange,
onAddShare = {
destination = Destination.Shares
addingShare = true
},
onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter,
onSetOnList = model::setOnList,
onBrowse = { facet ->
model.rememberSearch()
browse(facet)
browseFrom = null
},
onForgetSearches = model::forgetSearches,
)
}
val titlePane = @Composable { selected: TitleRecord ->
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
value = model.moreLike(selected.key)
}
TitleScreen(
selected,
playerHost != null,
@@ -232,13 +302,23 @@ fun ProtonStreamApp(
model::reloadAfterMetadataChange,
model::setWatched,
body,
onMarkEpisodes = model::markEpisodes,
franchise = selected.franchise.mapNotNull { key ->
state.titles.firstOrNull { it.key == key }
},
onOpenTitle = { selectedTitleKey = it.key },
onSetOnList = model::setOnList,
moreLike = similar.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
onBrowse = { facet ->
browse(facet)
browseFrom = selected.key
// On a phone the list is under the title;
// the page it opens is the library pane's.
if (!twoPane) selectedTitleKey = null
},
)
}
if (maxWidth >= TWO_PANE_WIDTH) {
if (twoPane) {
Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(0.45f)) { libraryPane() }
VerticalDivider()
@@ -263,12 +343,21 @@ fun ProtonStreamApp(
}
Destination.Shares -> SharesScreen(
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::refreshShare,
model::removeShare,
shareLink,
onShareLinkTaken,
// The empty library's button opens the form as a
// pasted link would, only with nothing in it.
shareLink ?: "".takeIf { addingShare },
{ addingShare = false; onShareLinkTaken() },
body,
account = state.account,
accountActions = AccountActions(
@@ -282,6 +371,7 @@ fun ProtonStreamApp(
addFolder = model::addAccountFolder,
onError = model::reportError,
),
onRename = model::renameShare,
)
Destination.History -> HistoryScreen(
state,
@@ -301,6 +391,15 @@ fun ProtonStreamApp(
model::resumeDownload,
model::deletePartial,
body,
onPlay = { title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
onOpen = { title ->
destination = Destination.Library
selectedTitleKey = title.key
},
)
Destination.Settings -> SettingsScreen(
state,
@@ -327,22 +426,28 @@ fun ProtonStreamApp(
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
)
}
ActivityBanner(
state.activity,
onStop = model::stopCrawl.takeIf { state.crawling },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = body.calculateBottomPadding() + 8.dp),
)
}
}
}
// Drawn over the scaffold rather than instead of it. The player owns the
// whole window while it is up — it is a screen, not a dialog over one, which
// is what lets it draw into the cutout and hand the right window to
// Picture-in-Picture — but the page behind it stays composed, so leaving the
// player finds the title page where it was rather than rebuilt from the top.
// is what lets it draw into the cutout — but the page behind it stays
// composed, so leaving the player finds the title page where it was rather
// than rebuilt from the top.
playing?.let { request ->
PlayerScreen(
title = request.title,
episodes = request.episodes,
index = request.index,
host = playerHost,
inPictureInPicture = inPictureInPicture,
minimized = playerMinimized,
onIndexChange = { playingIndex = it },
onSaveProgress = model::saveProgress,
@@ -400,3 +505,37 @@ private fun MiniTransport(
}
}
}
/**
* What the app is busy with, for as long as it is: a spinner and one line.
* Not dismissible and not timed — it goes when the work does.
*/
@Composable
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = activity != null,
modifier = modifier,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
) {
// Held through the exit, so the line does not blank before it fades.
var shown by remember { mutableStateOf(activity.orEmpty()) }
if (activity != null) shown = activity
Row(
Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics { liveRegion = LiveRegionMode.Polite },
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
// A crawl of a large share can take minutes, and is the viewer's
// to call off; what it has read so far stays.
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
}
}
}
@@ -6,13 +6,16 @@ import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -24,21 +27,30 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight
import androidx.core.graphics.scale
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.moodVeil as sharedMoodVeil
import uniffi.pstr_android.pictureMood as sharedPictureMood
import java.net.HttpURLConnection
import java.net.URI
import java.io.ByteArrayOutputStream
@@ -76,9 +88,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one. Until either answers, the box pulses as a skeleton; when neither has
* one. Until either answers, the box shimmers as a skeleton; when neither has
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it.
*
* Art that arrives fades in over the skeleton rather than replacing it in one
* frame, which across a grid reads as flicker. Art already decoded is drawn
* at once: a tile scrolled back into view has nothing to arrive.
*/
@Composable
internal fun RemoteArtwork(
@@ -87,26 +103,201 @@ internal fun RemoteArtwork(
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
labelled: Boolean = true,
maxEdge: Dp? = null,
onMood: ((Color) -> Unit)? = null,
) {
val context = LocalContext.current
val enabled = LocalArtworkLoading.current
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
// 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(alreadyDecoded(url, fallback, edge)) }
LaunchedEffect(url, fallback, enabled) {
if (!enabled) {
image = ArtworkLoad.Missing
return@LaunchedEffect
}
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
if (bitmap != null && onMood != null) {
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
}
}
when (val loaded = image) {
ArtworkLoad.Pending -> Skeleton(modifier)
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
when (loaded) {
ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled)
is ArtworkLoad.Loaded -> Image(
loaded.image,
description,
Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}
/** What the decoded cache already holds for this art, so it skips the fade. */
private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad {
val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) }
return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending
}
/**
* How long art takes to fade in over its skeleton: the desktop's
* `motion::ARRIVE`.
*/
private const val ARRIVE_MS = 240
/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */
private const val SHIMMER_MS = 1_400
/**
* A title's art across the top of a page: its backdrop, or with none, its
* poster as a wash of its own colours — decoded tiny and blurred, so it reads as
* the poster's light rather than a blown-up slice of it. The desktop's
* `Fit::Ambient`. Android 11 and older cannot blur; there the tiny decode,
* stretched, is what softens it.
*/
@Composable
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
val wash = title.backdropUrl == null && title.posterUrl != null
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
if (wash) modifier.blur(WASH_BLUR) else modifier,
fallback = title.thumbnailSource,
labelled = false,
maxEdge = if (wash) WASH_EDGE else null,
onMood = onMood,
)
}
/**
* The colour a picture leaves behind — the shared `appearance::mood`, over the
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
* banner paging back to a title does not work it out again.
*/
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
moods.get(key)?.let { return Color(it) }
val argb = withContext(Dispatchers.Default) {
runCatching {
val small = bitmap.scale(MOOD_EDGE, MOOD_EDGE)
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
}.getOrNull()
} ?: return null
moods.put(key, argb)
return Color(argb)
}
private val moods = LruCache<String, Int>(64)
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
private const val MOOD_EDGE = 24
/**
* [background] tinted by a picture's [mood], for the veils over that picture —
* the shared `appearance::mood_veil`. [background] itself until the mood is
* known.
*/
@Composable
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
val light = background.luminance() > 0.5f
val target = remember(mood, background) {
mood?.let {
runCatching {
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
}.getOrNull()
} ?: background
}
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
}
private val WASH_EDGE = 48.dp
private val WASH_BLUR = 24.dp
/**
* A title's logo — its name as lettering — in place of [fallback], once it has
* loaded. [fallback] stands until then, and for good when there is none, so a
* title without a logo looks as it always did and one with a logo only gains.
*
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
* logo to a square, and the padding would otherwise decide its size and place.
*/
@Composable
internal fun RemoteLogo(
url: String?,
description: String,
modifier: Modifier = Modifier,
fallback: @Composable () -> Unit,
) {
val context = LocalContext.current
val enabled = LocalArtworkLoading.current
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, enabled) {
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
}
Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
when (loaded) {
null -> fallback()
else -> Image(
loaded,
description,
modifier,
alignment = Alignment.BottomStart,
contentScale = ContentScale.Fit,
)
}
}
}
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
val requested = url ?: return null
val key = "logo:$requested"
decoded.get(key)?.let { return it }
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
}
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
private fun trimTransparent(bitmap: Bitmap): Bitmap {
if (!bitmap.hasAlpha()) return bitmap
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
}
/**
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
* row-major image of [width] that are not (nearly) transparent; `null` when
* none are. Below the threshold is anti-aliasing haze, not lettering.
*/
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
var left = Int.MAX_VALUE
var top = Int.MAX_VALUE
var right = -1
var bottom = -1
for (index in pixels.indices) {
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
val x = index % width
val y = index / width
if (x < left) left = x
if (x > right) right = x
if (y < top) top = y
if (y > bottom) bottom = y
}
return if (right < 0) null else intArrayOf(left, top, right, bottom)
}
private const val VISIBLE_ALPHA = 8
/** A logo is drawn a few hundred pixels wide at most. */
private const val LOGO_EDGE = 800
private sealed interface ArtworkLoad {
data object Pending : ArtworkLoad
data object Missing : ArtworkLoad
@@ -123,20 +314,38 @@ private sealed interface ArtworkLoad {
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
/**
* Something being loaded: the surface tint, breathing.
* Something being loaded: the surface tint, with a soft highlight sweeping
* across it on a diagonal.
*
* A spinner per tile is a grid of spinners; a shape where the poster will be
* says what is coming and where, and settles the layout before it arrives.
* The sweep is measured in the tile's own width, so neighbouring tiles of one
* size shimmer in step, as one surface.
*/
@Composable
internal fun Skeleton(modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.55f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "skeleton alpha",
val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
label = "skeleton sweep",
)
val base = MaterialTheme.colorScheme.surfaceVariant
val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f)
Box(
modifier.drawBehind {
drawRect(base)
val centre = size.width * sweep
val half = size.width * 0.6f
drawRect(
Brush.linearGradient(
listOf(Color.Transparent, highlight, Color.Transparent),
start = Offset(centre - half, 0f),
end = Offset(centre + half, size.height),
),
)
},
)
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
}
/**
@@ -186,8 +395,8 @@ internal fun initials(name: String): String {
* loader's protocol checks and exactly this disk cache, so the artwork the shade
* shows costs no second download.
*/
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
decoded.get(requested) ?: withContext(Dispatchers.IO) {
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
@@ -197,7 +406,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
val cached = directory.resolve("$digest.img")
val fresh = !cached.isFile
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) {
// Written only once it has decoded. Caching the bytes first is
// what turns one hostile poster into a crash on every launch
@@ -210,7 +419,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
cached.setLastModified(System.currentTimeMillis())
}
bitmap
}.getOrNull()?.also { decoded.put(requested, it) }
}.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
}
}
@@ -224,21 +433,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
* 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 key = file.key
decoded.get(key)?.let { return it }
decoded.get(sized(key, edge))?.let { return it }
if (key in missing) return null
val fetched = withContext(Dispatchers.IO) {
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
}
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) {
missing.add(key)
return null
}
decoded.put(key, bitmap)
decoded.put(sized(key, edge), bitmap)
return bitmap
}
@@ -257,28 +466,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
* 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
* 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 }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
if (longest <= 0) return null
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(
bytes,
0,
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.
*
@@ -315,7 +539,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
* is a full-resolution decode per item.
*/
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
}
@@ -59,12 +59,14 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.NewEpisodesWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.TonalButton
@@ -86,7 +88,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
@Composable
internal fun SettingsScreen(
state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
/** Resolves to why the settings were not stored, or null once they are. */
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
onMatchAgain: () -> Unit,
onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit,
@@ -102,6 +105,7 @@ internal fun SettingsScreen(
val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
// Playback preferences are the shared store's, not this app's: they are the
@@ -205,6 +209,15 @@ internal fun SettingsScreen(
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) {
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
@@ -315,7 +328,7 @@ internal fun SettingsScreen(
)
},
confirmButton = {
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
},
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
)
@@ -327,10 +340,7 @@ internal fun SettingsScreen(
MetadataSettingsDialog(
current = state.metadataSettings,
onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key ->
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
onSave = onSaveMetadata,
)
}
}
@@ -411,57 +421,95 @@ private fun SettingAction(
private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
) {
var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") }
var saving by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
val forgets = current.enabled && (!enabled || provider != current.provider)
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = { if (!saving) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option ->
listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).forEach { option ->
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
}
}
Text(
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
else "Film and television; requires a free TMDB API key.",
if (provider == MetadataProvider.ANI_LIST) {
"Anime, and television from TVmaze; no account or API key required. " +
"With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone."
} else {
"Film and television; requires a free TMDB API key."
},
style = MaterialTheme.typography.bodySmall,
)
if (provider == MetadataProvider.TMDB) {
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
OutlinedTextField(
apiKey,
{ apiKey = it },
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
}
OutlinedTextField(
apiKey,
{ apiKey = it },
label = {
Text(
when {
provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)"
current.ready -> "TMDB API key (leave blank to keep)"
else -> "TMDB API key"
},
)
},
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
)
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
// Said before Save rather than after: what is forgotten
// includes matches chosen by hand, which no look-up redoes.
if (forgets) {
Text(
"Saving forgets every poster, description and match stored now, " +
"including ones you chose by hand.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
AccentButton(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
) { Text("Save") }
enabled = !needsKey && !saving,
onClick = {
saving = true
refusal = null
scope.launch {
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
saving = false
if (refusal == null) onDismiss()
}
},
) { Text(if (saving) "Saving…" else "Save") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
)
}
internal fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB"
MetadataProvider.TV_MAZE -> "TVmaze"
}
private data class LegalDocument(val title: String, val assetPath: String)
@@ -1,5 +1,10 @@
package io.narl.protonstream.ui
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -25,13 +30,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.TonalButton
import androidx.compose.material.icons.filled.Edit
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box
@@ -45,12 +54,15 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FolderShared
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.ui.graphics.Color
@Composable
internal fun SharesScreen(
shares: List<ShareRecord>,
onAdd: (String, String, String?) -> Unit,
/** Resolves to why the link was refused, or null once it is added. */
onAdd: suspend (String, String, String?) -> String?,
onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit,
onRemove: (String) -> Unit,
@@ -59,8 +71,10 @@ internal fun SharesScreen(
padding: PaddingValues,
account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null,
onRename: (String, String) -> Unit = { _, _ -> },
) {
var showAdd by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
var signingIn by remember { mutableStateOf(false) }
var browsing by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
@@ -97,6 +111,7 @@ internal fun SharesScreen(
onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share },
onRemove = { removing = share },
onRename = { renaming = share },
)
}
}
@@ -135,6 +150,21 @@ internal fun SharesScreen(
onRepair = { url, password -> onRepair(share.id, url, password) },
)
}
renaming?.let { share ->
var name by remember(share.id) { mutableStateOf(share.name) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text("Rename ${share.name}") },
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
confirmButton = {
AccentButton(enabled = name.isNotBlank(), onClick = {
renaming = null
onRename(share.id, name.trim())
}) { Text("Save") }
},
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
)
}
removing?.let { share ->
// Asked, because it cannot be undone: the link's secret and its
// offline files go. Watch positions stay (`Catalog::remove_share`).
@@ -148,7 +178,7 @@ internal fun SharesScreen(
)
},
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") } },
)
@@ -161,24 +191,58 @@ internal fun SharesScreen(
* Three differently styled controls beside the name squeezed it into a column
* a word wide; one icon leaves the row its width.
*/
/** "120 files · crawled 2 h ago", or that it has not been yet. */
internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String {
val at = share.crawledAt ?: return "Not crawled yet"
val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" }
return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ")
}
/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */
internal fun ago(at: Long, now: Long): String {
val seconds = (now - at).coerceAtLeast(0)
return when {
seconds < 60 -> "just now"
seconds < 3_600 -> "${seconds / 60} min ago"
seconds < 86_400 -> "${seconds / 3_600} h ago"
seconds < 172_800 -> "1 day ago"
else -> "${seconds / 86_400} days ago"
}
}
@Composable
private fun ShareRow(
share: ShareRecord,
onRefresh: () -> Unit,
onRepair: () -> Unit,
onRemove: () -> Unit,
onRename: () -> Unit = {},
) {
var menu by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
supportingContent = {
Text(
when {
share.fromAccount -> "Folder of your Drive"
share.hasCustomPassword -> "Link and custom password, stored securely"
else -> "Public link"
},
)
Column {
Text(
when {
share.fromAccount -> "Folder of your Drive"
share.hasCustomPassword -> "Link and custom password"
else -> "Public link"
},
)
Text(crawlLine(share), style = MaterialTheme.typography.bodySmall)
// Until a crawl succeeds: a link revoked upstream otherwise
// just stops growing.
share.crawlError?.let {
Text(
"Last crawl failed: $it",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
},
leadingContent = {
Icon(
@@ -200,6 +264,11 @@ private fun ShareRow(
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
onClick = { menu = false; onRefresh() },
)
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
onClick = { menu = false; onRename() },
)
// Not a remove: a share whose secret has become unreadable
// cannot be removed either, since removal deletes a secret
// the store can no longer touch.
@@ -277,32 +346,55 @@ private fun RepairShareDialog(
)
}
/**
* Add a link. The form stays up while the link is opened, and with the reason
* under it when the link is refused, so fixing a typo is a retry rather than
* starting over.
*/
@Composable
private fun AddShareDialog(
initialUrl: String,
onDismiss: () -> Unit,
onAdd: (String, String, String?) -> Unit,
onAdd: suspend (String, String, String?) -> String?,
) {
var name by remember { mutableStateOf("") }
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var showLink by remember { mutableStateOf(false) }
var password by remember { mutableStateOf("") }
var adding by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = { if (!adding) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
OutlinedTextField(
url,
{ url = it },
label = { Text("Public share URL") },
label = { Text("Share link") },
singleLine = true,
// The URL fragment is a secret. Password input disables
// IME learning/suggestions while normal long-press paste
// remains available.
visualTransformation = PasswordVisualTransformation(),
// remains available; the eye shows what was pasted.
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { showLink = !showLink }) {
Icon(
if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showLink) "Hide link" else "Show link",
)
}
},
)
OutlinedTextField(
name,
{ name = it },
label = { Text("Name (optional)") },
supportingText = { Text("Defaults to the shared folder's name") },
singleLine = true,
)
OutlinedTextField(
password,
@@ -312,14 +404,30 @@ private fun AddShareDialog(
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
if (adding) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Opening the link…")
}
} else {
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
},
confirmButton = {
AccentButton(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
enabled = name.isNotBlank() && url.isNotBlank(),
) { Text("Add") }
onClick = {
adding = true
refusal = null
scope.launch {
refusal = onAdd(name.trim(), url.trim(), password)
adding = false
if (refusal == null) onDismiss()
}
},
enabled = url.isNotBlank() && !adding,
) { Text(if (adding) "Adding…" else "Add") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
)
}
@@ -5,12 +5,20 @@ import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -30,6 +38,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -38,14 +48,20 @@ import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -68,12 +84,16 @@ import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.CastRecord
import androidx.compose.material.icons.filled.Movie
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord
@@ -81,10 +101,13 @@ import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.FilterChip
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
@@ -97,11 +120,19 @@ internal fun TitleScreen(
onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues,
/** Several episodes seen or unseen at once, named for the message. */
onMarkEpisodes: (List<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
/** The titles most like it, best first. */
moreLike: List<TitleRecord> = emptyList(),
/** Open every title sharing a genre, studio or director. */
onBrowse: (BrowseFacet) -> Unit = {},
) {
val context = LocalContext.current
val download = rememberDownload()
var showMatch by remember(title.key) { mutableStateOf(false) }
var overviewOpen by remember(title.key) { mutableStateOf(false) }
@@ -122,7 +153,7 @@ internal fun TitleScreen(
// Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
val nextUp = remember(playlist) { nextUpIndex(playlist) }
val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) }
val upNext = playlist.getOrNull(nextUp)
// One season on screen at a time, opened on the one Play would land in: a
// forty-episode show as stacked sections is one long scroll to the part
@@ -138,7 +169,11 @@ internal fun TitleScreen(
// without a trip to the Downloads tab.
val work by WorkManager.getInstance(context)
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
val downloads = remember(work) { DownloadStateStore(context).records() }
// Decoded off the main thread: WorkManager reports progress several times
// a second while a season downloads, and each report re-reads every record.
val downloads by produceState(emptyList<RetainedDownload>(), work) {
value = withContext(Dispatchers.IO) { DownloadStateStore(context).records() }
}
// On a tablet the page keeps a phone's reading width, centred: a Play
// button and synopsis lines two thousand pixels wide are read by turning
@@ -158,8 +193,12 @@ internal fun TitleScreen(
) {
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
val list = rememberLazyListState()
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val veil = moodVeil(mood)
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = list,
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -167,37 +206,46 @@ internal fun TitleScreen(
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight).clipToBounds()) {
// At half the speed of the page, so the picture stays
// behind the page rather than being part of it.
Backdrop(
title,
Modifier.fillMaxSize().graphicsLayer {
translationY = if (list.firstVisibleItemIndex == 0) list.firstVisibleItemScrollOffset * PARALLAX else 0f
},
onMood = { mood = it },
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
0.4f to Color.Transparent,
// The picture's own colour on the way into the
// page, as on the library's banner.
0.75f to veil.copy(alpha = 0.55f),
1f to MaterialTheme.colorScheme.background,
),
),
)
IconButton(
onClick = onBack,
modifier = Modifier
.statusBarsPadding()
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
}
item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
val heading = @Composable {
Text(title.displayName, style = MaterialTheme.typography.displayLarge)
}
// A light page keeps the name: a clear logo is lettering
// made for dark art, white as often as not.
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
RemoteLogo(
title.logoUrl,
title.displayName,
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 4.dp),
heading,
)
} else {
heading()
}
// The other names it goes by: the share's when the shown one
// is the provider's, and the original-language one.
listOfNotNull(
@@ -210,6 +258,8 @@ internal fun TitleScreen(
listOfNotNull(
title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
// A series' running time is its episodes', which each row says.
title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
title.rating?.let { "★ %.1f".format(it) },
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
@@ -217,19 +267,20 @@ internal fun TitleScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
// Who made it, and whether more is coming — the desktop's
// line of facts, on a line of its own so neither wraps.
listOfNotNull(
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
title.directors.firstOrNull()?.let { "dir. $it" },
airingNote(title),
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
// Where the title stands, then what the files are rather
// than what the title is.
val status = statusText(title)
if (title.quality.isNotEmpty() || status != null) {
Row(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
status?.let { (text, live) ->
StatusChip(text, live, Modifier.padding(end = 4.dp))
}
title.quality.forEach { SpecBadge(it) }
}
}
// The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which
@@ -258,6 +309,13 @@ internal fun TitleScreen(
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
TitleAction(
if (title.onList) Icons.Default.Check else Icons.Default.Add,
"My List",
Modifier.weight(1f).semantics {
stateDescription = if (title.onList) "On My List" else "Not on My List"
},
) { onSetOnList(title, !title.onList) }
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
@@ -266,7 +324,7 @@ internal fun TitleScreen(
}
}
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 }
// The provider's own page for this title: where a viewer goes
@@ -295,15 +353,63 @@ internal fun TitleScreen(
.clickable { overviewOpen = !overviewOpen },
)
}
// In the browser: the app itself never requests it.
title.trailerUrl?.let { url ->
QuietButton(
onClick = {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
},
modifier = Modifier.padding(top = 4.dp),
) {
Icon(Icons.Default.Movie, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Trailer")
}
}
// Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it
// is filed as.
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
if (title.genres.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
title.genres.forEach { genre -> GenreChip(genre) { onBrowse(BrowseFacet.Genre(genre)) } }
}
}
// Who made it, as chips that open everything else by them:
// the desktop's credits row.
val studios = title.studios.take(2)
val director = title.directors.take(1)
if (studios.isNotEmpty() || director.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
listOf(
Triple("Studio", studios) { name: String -> BrowseFacet.Studio(name) },
Triple("Director", director) { name: String -> BrowseFacet.Person(name) },
).filter { it.second.isNotEmpty() }.forEach { (label, names, facet) ->
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
names.forEach { name -> GenreChip(name) { onBrowse(facet(name)) } }
}
}
}
if (title.tags.isNotEmpty()) {
Text(
(title.genres + title.tags.take(4)).joinToString(" · "),
title.tags.take(4).joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
modifier = Modifier.padding(top = 8.dp),
)
}
// Said plainly, because it changes what a re-match will do: a
@@ -342,17 +448,34 @@ internal fun TitleScreen(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
if (title.seasons.size > SEASON_CHIPS) {
// A long-running show's row of chips is a scroll of
// its own; past a handful it is a list to pick from.
SeasonList(title.seasons, seasonIndex, { seasonIndex = it }, Modifier.weight(1f))
} else {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// By position: two seasons can share a label, and a
// repeated key is a crash in a lazy list.
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
}
}
}
season?.let {
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}")
}
}
@@ -374,11 +497,61 @@ internal fun TitleScreen(
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
onWatchedUpTo = {
val upTo = playlist.indexOfFirst { it.linkId == episode.linkId }
onMarkEpisodes(
playlist.take(upTo + 1),
true,
"${title.displayName} up to ${episode.label}",
)
},
)
}
}
}
if (title.cast.isNotEmpty()) {
item(key = "cast") {
Column(Modifier.padding(top = 16.dp)) {
Text(
"Cast",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
// Unkeyed: two characters can share a name.
items(title.cast) { member ->
CastTile(member) { actor -> onBrowse(BrowseFacet.Person(actor)) }
}
}
}
}
}
// Under the episodes rather than over them: where to go once this
// one is done, not what the page is for.
if (moreLike.isNotEmpty()) {
item(key = "more-like-this") {
Column(Modifier.padding(top = 16.dp)) {
Text(
"More like this",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
items(moreLike, key = { it.key }) { other ->
FranchiseTile(other, here = false) { onOpenTitle(other) }
}
}
}
}
}
}
TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack)
}
if (showMatch) {
ChangeMatchDialog(
@@ -390,9 +563,108 @@ internal fun TitleScreen(
}
}
/** How fast the backdrop moves against the page scrolling over it. */
private const val PARALLAX = 0.5f
/**
* One part of the franchise: its cover, what it is and when. This title's own
* is marked rather than left out, so the row says where it sits in the story.
* The bar over the title page: only the back button while the backdrop is on
* screen, then — once the backdrop has scrolled away — a solid bar with the
* title's name, so the page still says whose episodes these are.
*/
@Composable
private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack: () -> Unit) {
val reach = with(LocalDensity.current) { TITLE_BAR.toPx() }
val solid by remember(list, backdrop) {
derivedStateOf {
if (list.firstVisibleItemIndex > 0) {
1f
} else {
((list.firstVisibleItemScrollOffset - (backdrop - 2 * reach)) / reach).coerceIn(0f, 1f)
}
}
}
val scheme = MaterialTheme.colorScheme
Row(
Modifier
.fillMaxWidth()
.background(scheme.surfaceContainer.copy(alpha = solid))
.statusBarsPadding()
.height(TITLE_BAR),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onBack,
modifier = Modifier
.padding(8.dp)
.clip(CircleShape)
// A disc behind the arrow over the picture, gone once the bar
// is there to sit on.
.background(Color.Black.copy(alpha = 0.4f * (1f - solid))),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to library",
tint = lerp(Color.White, scheme.onSurface, solid),
)
}
Text(
name,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp).graphicsLayer { alpha = solid },
)
}
}
private val TITLE_BAR = 56.dp
/**
* One character: a round portrait over their name and who voices them. A voice
* actor opens every title in the library they are in.
*/
@Composable
private fun CastTile(member: CastRecord, onActor: (String) -> Unit) {
Column(
Modifier
.width(88.dp)
.clip(MaterialTheme.shapes.medium)
.then(
member.actor?.let { actor ->
Modifier.clickable(onClickLabel = "Browse $actor") { onActor(actor) }
} ?: Modifier,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RemoteArtwork(
member.characterImage ?: member.actorImage,
member.character,
Modifier.size(76.dp).clip(CircleShape),
labelled = false,
)
Text(
member.character,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
member.actor?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* One part of the franchise, or one title like this one: its cover, what it is
* and when. In the franchise row this title's own is marked rather than left
* out, so the row says where it sits in the story.
*/
@Composable
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
@@ -439,17 +711,51 @@ private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit
}
/**
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
* no date yet, else nothing.
* What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
* and whether more is coming. The desktop's `status_text`.
*/
private fun airingNote(title: TitleRecord): String? {
internal fun statusText(title: TitleRecord): Pair<String, Boolean>? {
val at = title.nextAiringAt
val episode = title.nextEpisode
if (at != null && episode != null) {
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
val next = at != null && episode != null
val label = title.status ?: (if (next || title.airing) "Airing" else return null)
val live = next || title.airing
if (at == null || episode == null) return label to live
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
return "$label · episode $episode " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) to live
}
/** The most seasons picked from a row of chips; past it, a list. The desktop's `SEASON_PILLS`. */
private const val SEASON_CHIPS = 5
/** The season shown, as a button that opens the list of all of them. */
@Composable
private fun SeasonList(seasons: List<SeasonRecord>, picked: Int, onPick: (Int) -> Unit, modifier: Modifier = Modifier) {
var open by remember { mutableStateOf(false) }
Box(modifier) {
FilterChip(
selected = true,
onClick = { open = true },
label = { Text(seasons.getOrNull(picked)?.label.orEmpty()) },
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
)
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
seasons.forEachIndexed { index, season ->
DropdownMenuItem(
text = { Text(season.label) },
onClick = {
open = false
onPick(index)
},
trailingIcon = if (index == picked) {
{ Icon(Icons.Default.Check, contentDescription = "Shown") }
} else {
null
},
)
}
}
}
return if (title.airing) "airing" else null
}
/** How wide the page's text and controls run on a window wider than a phone. */
@@ -521,16 +827,35 @@ private fun EpisodeRow(
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onSetWatched: (Boolean) -> Unit,
onWatchedUpTo: () -> Unit = {},
) {
// A long press, for someone joining a show partway: everything before
// this one ticked at once rather than one by one.
var menu by remember { mutableStateOf(false) }
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED
val heading = episode.heading(position)
Column(
Modifier
.fillMaxWidth()
.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),
) {
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Mark watched up to here") },
leadingIcon = { Icon(Icons.Default.DoneAll, contentDescription = null) },
onClick = {
menu = false
onWatchedUpTo()
},
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
// With metadata off the provider has no still, and Proton's own
// thumbnail is a frame of this very episode. Where it was left sits
@@ -555,6 +880,21 @@ private fun EpisodeRow(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
// Seen, said on the picture as well as by the mark at the
// row's end: a season is scanned by its stills.
if (episode.watched) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(5.dp)
.size(18.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(2.dp),
)
}
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
@@ -573,7 +913,9 @@ private fun EpisodeRow(
Text(
listOfNotNull(
numbering,
episode.size?.let(::formatBytes),
// How long it runs says more than how big it is; the
// size is what there is when no provider said.
episode.runtime?.let(::formatRuntime) ?: episode.size?.let(::formatBytes),
episode.airDate,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -636,7 +978,7 @@ private fun EpisodeRow(
fill = solid(MaterialTheme.colorScheme.tertiary),
)
Text(
downloadStatusLine(active),
downloadStatusLine(active, rememberQueueReason(active)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -656,13 +998,39 @@ internal fun ChangeMatchDialog(
// Choosing fetches the entry's episodes too, which is a few requests.
var choosing by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
var confirmForget by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (confirmForget) {
// Asked, because a match chosen by hand is work the next look-up
// does not redo: it decides again on its own.
AlertDialog(
onDismissRequest = { confirmForget = false },
title = { Text("Forget this match?") },
text = {
Text(
"Its poster, description and episode names go, and the next look-up " +
"matches it again on its own. A match you chose by hand is lost.",
)
},
confirmButton = {
DangerButton(onClick = {
confirmForget = false
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget") }
},
dismissButton = { TonalButton(onClick = { confirmForget = false }) { Text("Cancel") } },
)
return
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Change match") },
text = {
Column {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
searching = true
@@ -697,7 +1065,10 @@ internal fun ChangeMatchDialog(
) {
Column(Modifier.fillMaxWidth()) {
Text(option.name, fontWeight = FontWeight.SemiBold)
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
Text(
listOfNotNull(option.year?.toString(), option.originalName, option.provider.displayName())
.joinToString(" · "),
)
}
}
}
@@ -705,12 +1076,7 @@ internal fun ChangeMatchDialog(
}
},
confirmButton = {
QuietButton(onClick = {
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget match") }
QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
)
@@ -142,6 +142,28 @@ internal fun QuietButton(
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.
*
@@ -56,6 +56,12 @@ object AppearanceState {
.onSuccess { current.value = it }
}
/** Read the stored choice again, after sync rewrote it. */
suspend fun reload() {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) {
current.value = palette
@@ -24,6 +24,13 @@ internal val Inter = FontFamily(
Font(R.font.inter_semibold, FontWeight.SemiBold),
)
/**
* Inter Display Bold, the optical size drawn for large text, for the one
* [HERO] line: the banner's name and the name at the top of a title's page.
* The desktop's `theme::display` family, from the same file.
*/
internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold))
/**
* The type ramp, which is the desktop client's type ramp.
*
@@ -33,7 +40,10 @@ internal val Inter = FontFamily(
* were built to share one palette; sharing one scale is the other half of why
* they should read as one app.
*
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
* The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
* The top one is the hero line, in [InterDisplay] and set tighter, at
* [HERO_LEADING]: at that size the text face's leading pushes a two-line name
* apart.
*
* Where a role maps is decided by what actually draws with it here, not by
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
@@ -44,7 +54,8 @@ internal val Inter = FontFamily(
* [TypeRampTest] pins every one of these against the Rust ramp.
*/
internal val ProtonTypography = Typography(
displayLarge = role(DISPLAY, FontWeight.SemiBold),
// The banner's name and the title page's name, where no logo stands in.
displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING),
displayMedium = role(DISPLAY, FontWeight.SemiBold),
displaySmall = role(TITLE, FontWeight.SemiBold),
// The name of the title you opened — the desktop's display line.
@@ -69,6 +80,7 @@ internal val ProtonTypography = Typography(
labelSmall = role(MICRO, FontWeight.Medium),
)
internal const val HERO = 34f
internal const val DISPLAY = 26f
internal const val TITLE = 20f
internal const val HEADING = 18f
@@ -79,6 +91,9 @@ internal const val LABEL = 13f
internal const val CAPTION = 12f
internal const val MICRO = 11f
internal const val LEADING = 1.35f
internal const val HERO_LEADING = 1.1f
/**
* One rung as a `TextStyle`.
*
@@ -87,9 +102,14 @@ internal const val MICRO = 11f
* enough for the two-line title names in the grid and tight enough that a
* label on a control does not float.
*/
private fun role(size: Float, weight: FontWeight) = TextStyle(
private fun role(
size: Float,
weight: FontWeight,
family: FontFamily = Inter,
leading: Float = LEADING,
) = TextStyle(
fontSize = size.sp,
lineHeight = (size * 1.35f).sp,
lineHeight = (size * leading).sp,
fontWeight = weight,
fontFamily = Inter,
fontFamily = family,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
@@ -9,7 +9,7 @@ class AccountUiTest {
@Test
fun `the sync line says how long ago and what came in`() {
val minute = 60_000L
assertEquals("Watch history syncs with your Drive", syncLine(AccountUiState(), 0))
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
assertEquals(
@@ -21,7 +21,7 @@ class AccountUiTest {
@Test
fun `a failed sync is said in place of when it last worked`() {
assertEquals(
"Watch history did not sync: offline",
"Did not sync: offline",
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
)
}
@@ -0,0 +1,24 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import uniffi.pstr_android.EpisodeRecord
class EpisodeNameTest {
private fun episode(name: String, detail: String, providerName: String? = null) = EpisodeRecord(
shareId = "s", volumeId = "v", linkId = "l", name = name, label = "S01E01", detail = detail,
season = 1u, number = 1u, size = null, progress = null, resumeAt = null,
watched = false, offline = false, providerName = providerName, providerOverview = null, stillUrl = null,
airDate = null, lastPlayed = 0,
)
@Test
fun `the player captions an episode with its name and never its filename`() {
val file = "[Group] Show - S01E01.mkv"
assertEquals("Dog & Chainsaw", episode(file, file, providerName = "Dog & Chainsaw").shownName())
assertEquals("Winter Is Coming", episode(file, "Winter Is Coming").shownName())
assertNull(episode(file, file).shownName())
assertNull(episode(file, "").shownName())
}
}
@@ -26,6 +26,7 @@ class HistoryTest {
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
franchise = emptyList(),
continueIndex = null,
)
@Test
@@ -0,0 +1,30 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LogoAndRuntimeTest {
@Test
fun `a padded logo is bounded by its lettering and not its haze`() {
val width = 10
val pixels = IntArray(width * 6)
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
pixels[5 * width + 9] = 0x03FFFFFF
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
}
@Test
fun `a logo with nothing visible has no bounds`() {
assertNull(opaqueBounds(IntArray(16), 4))
}
@Test
fun `a running time reads in hours and minutes`() {
assertEquals("24 min", formatRuntime(24u))
assertEquals("2 h", formatRuntime(120u))
assertEquals("2 h 4 min", formatRuntime(124u))
}
}
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord
@@ -34,6 +36,7 @@ import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.CastRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
@@ -79,18 +82,23 @@ class ScreenshotTest {
@Test
fun `the library on a phone`() = capture("library-phone") {
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
@@ -116,7 +124,7 @@ class ScreenshotTest {
// reads it from, so the scale is set there.
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
}
@@ -137,22 +145,22 @@ class ScreenshotTest {
@Test
fun `the shares page`() = capture("shares-phone") {
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
}
@Test
fun `the settings page`() = capture("settings-phone") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
}
@Test
fun `the playback settings`() = capture("settings-playback") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
}
@Test
fun `the storage settings`() = capture("settings-storage") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
}
@Test
@@ -264,6 +272,9 @@ class ScreenshotTest {
nextEpisode = null,
nextAiringAt = null,
franchise = emptyList(),
// What the engine would pick for these fixtures: the part-watched episode.
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
.takeIf { it >= 0 }?.toUInt(),
)
val SERIES = title(
@@ -281,6 +292,11 @@ class ScreenshotTest {
directors = listOf("Daisuke Hiramaki"),
tags = listOf("Idol", "Reincarnation"),
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
trailerUrl = "https://www.youtube.com/watch?v=trailer",
cast = listOf(
CastRecord("Ai Hoshino", null, "Rie Takahashi", null),
CastRecord("Aqua", null, "Takeo Ootsuka", null),
),
)
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
@@ -290,6 +306,16 @@ class ScreenshotTest {
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 1988u)
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
val MATCHED get() = LIBRARY.copy(
metadataSettings = MetadataSettingsRecord(
enabled = true,
provider = MetadataProvider.ANI_LIST,
language = "en",
ready = true,
),
)
val LIBRARY = AppUiState(
loading = false,
titles = listOf(
@@ -306,9 +332,17 @@ class ScreenshotTest {
},
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
),
// Crawled relative to the clock, so the row reads "2 h ago" on
// whatever day the test runs.
shares = listOf(
ShareRecord("anime", "anime", true),
ShareRecord("films", "films", false),
ShareRecord(
"anime",
"anime",
true,
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
crawlFiles = 120uL,
),
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
),
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
)
@@ -0,0 +1,39 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
class StatusTest {
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
episodeCount = 0uL, canonicalName = null, originalName = null, overview = null,
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
manualMatch = false, seasons = emptyList(),
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
)
@Test
fun `the status chip names the next episode while one is due`() {
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
assertTrue(text, text.startsWith("Airing · episode 8 "))
assertTrue(live)
}
@Test
fun `a finished title is said plainly and is not live`() {
assertEquals("Finished" to false, statusText(title("Finished")))
}
@Test
fun `a title with no status and nothing airing has no chip`() {
assertNull(statusText(title(null)))
assertEquals("Airing" to true, statusText(title(null, airing = true)))
}
}
@@ -22,10 +22,10 @@ import org.junit.Test
*/
class TypeRampTest {
/** `Role::size()`, in the order `theme.rs` declares it. */
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
private val ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
private val expected = mapOf(
"displayLarge" to 26f,
"displayLarge" to 34f,
"displayMedium" to 26f,
"displaySmall" to 20f,
"headlineLarge" to 26f,
@@ -62,12 +62,14 @@ class TypeRampTest {
/**
* Leading is derived from the size, so the only thing worth asserting is
* that it *was* derived — a style that kept Material's line height carries
* a number with no relation to its own size.
* a number with no relation to its own size. The hero line is the one set
* tighter.
*/
@Test
fun `line height follows the size it belongs to`() {
styles().forEach { (name, style) ->
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
val leading = if (name == "displayLarge") HERO_LEADING else LEADING
assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f)
}
}
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.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

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: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -25,6 +25,8 @@ tracing-subscriber.workspace = true
eframe.workspace = true
egui.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
sha2.workspace = true
parking_lot.workspace = true
@@ -39,8 +41,6 @@ tao.workspace = true
[build-dependencies]
embed-resource.workspace = true
[dev-dependencies]
skrifa.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
dbus.workspace = true
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+1082 -155
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -11,6 +11,9 @@ use serde::{Deserialize, Serialize};
/// What the settings page offers for the streaming cache, in GiB.
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)]
// A file from an older version, or edited by hand, missing a field: the rest
// still counts, and the missing one takes its default.
@@ -21,6 +24,15 @@ pub struct DesktopPrefs {
pub hardware_decoding: bool,
/// How much decrypted video the disk cache may keep.
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 {
@@ -28,11 +40,23 @@ impl Default for DesktopPrefs {
Self {
hardware_decoding: true,
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 {
/// [`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 {
u64::from(self.cache_gib.clamp(1, 64)) << 30
}
@@ -93,6 +117,17 @@ mod tests {
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]
fn an_unparseable_file_is_never_overwritten() {
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
File diff suppressed because it is too large. Load diff
+218 -16
View File
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
use pstr_core::library::{Episode, Title};
use pstr_core::prefs::PlaybackPrefs;
use pstr_player::{
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
VideoOutput,
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
TrackKind, VideoOutput,
};
use pstr_stream::VideoStream;
@@ -147,6 +147,10 @@ pub enum Command {
/// Move subtitles, as a percentage of the window's height. See
/// [`Playback::lift_subtitles`].
SetSubtitlePosition(f64),
/// Restyle subtitles, the line already showing included.
SetSubtitleStyle(pstr_core::prefs::SubtitleStyle),
/// Shift subtitles this many seconds later; negative is earlier.
SetSubtitleDelay(f64),
/// 1.0 is normal speed.
SetSpeed(f64),
/// Save the current frame to this file.
@@ -184,10 +188,26 @@ pub struct Playback {
/// Where subtitles were last put, so they are only moved when that changes
/// rather than once a frame.
subtitle_position: f64,
/// How the viewer has subtitles drawn, as last handed to this player.
pub subtitle_style: pstr_core::prefs::SubtitleStyle,
/// Where the controls would lift subtitles to. The higher of this and the
/// style's own position wins.
lifted_position: f64,
/// How far subtitles are shifted, in seconds. Per file rather than a
/// preference: an offset belongs to one release, not to the viewer.
pub subtitle_delay: f64,
/// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them.
/// The chapters to show and act on, in order: the file's own, with
/// AniSkip's opening and ending cut in when the file names neither. Empty
/// for a file muxed without chapters that AniSkip does not know.
pub chapters: Vec<Chapter>,
/// The chapters as the file has them.
file_chapters: Vec<Chapter>,
/// What AniSkip said, once it has.
skip_ranges: Vec<pstr_core::chapters::SkipRange>,
/// Whether AniSkip has been asked about this file, so it is asked once.
pub skips_asked: bool,
/// What each of those chapters is, resolved against the rest of the file —
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
/// whenever either the chapters or the duration change.
@@ -259,10 +279,16 @@ impl Playback {
paused: false,
volume: prefs.volume,
muted: prefs.muted,
subtitle_position: 100.0,
subtitle_position: prefs.subtitle_style.position,
subtitle_style: prefs.subtitle_style.clone(),
lifted_position: 100.0,
subtitle_delay: 0.0,
speed: prefs.speed,
tracks: Vec::new(),
chapters: Vec::new(),
file_chapters: Vec::new(),
skip_ranges: Vec::new(),
skips_asked: false,
roles: Vec::new(),
credits_at: None,
position: target.resume_at.unwrap_or(0.0),
@@ -282,17 +308,57 @@ impl Playback {
/// the mouse is moving, which is exactly when someone is looking at the
/// screen.
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
let position = if covered > 0.0 && height > 0.0 {
self.lifted_position = if covered > 0.0 && height > 0.0 {
(100.0 * (1.0 - f64::from(covered / height))).round()
} else {
100.0
};
self.place_subtitles();
}
/// Move subtitles to [`Self::subtitle_position`], when that has changed.
fn place_subtitles(&mut self) {
let position = self.subtitle_position();
if position != self.subtitle_position {
self.subtitle_position = position;
self.send(Command::SetSubtitlePosition(position));
}
}
/// Where subtitles go: where the style puts them, unless the controls
/// would cover that, in which case just above the controls.
///
/// A styled track ignores the style's position unless it is restyled:
/// mpv applies `sub-pos` to it either way, and its signs were placed by
/// hand. The lift still applies, because a line under the seek bar cannot
/// be read however carefully it was placed.
fn subtitle_position(&self) -> f64 {
let styled = self
.selected_track(TrackKind::Subtitle)
.is_some_and(|track| is_styled(track.codec.as_deref()));
let position = if styled && !self.subtitle_style.restyle_styled {
100.0
} else {
self.subtitle_style.position
};
position.min(self.lifted_position)
}
/// Restyle the subtitles of this file, now.
pub fn restyle_subtitles(&mut self, style: &pstr_core::prefs::SubtitleStyle) {
self.subtitle_style = style.clone();
self.subtitle_position = self.subtitle_position();
let mut style = style.clone();
style.position = self.subtitle_position;
self.send(Command::SetSubtitleStyle(style));
}
/// Shift subtitles to `seconds`, rounded to the tenth a keypress moves.
pub fn set_subtitle_delay(&mut self, seconds: f64) {
self.subtitle_delay = (seconds * 10.0).round() / 10.0;
self.send(Command::SetSubtitleDelay(self.subtitle_delay));
}
/// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool {
self.video.is_some()
@@ -338,6 +404,18 @@ impl Playback {
(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
/// the file — what "the episode is over" means when a file states its
/// chapters.
@@ -350,6 +428,11 @@ impl Playback {
/// changed. Both are needed: the length of a chapter is what settles a name
/// that could mean two things.
fn reclassify(&mut self) {
self.chapters = pstr_core::chapters::with_skip_ranges(
&self.file_chapters,
&self.skip_ranges,
self.duration,
);
self.roles = pstr_player::roles(&self.chapters, self.duration);
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
}
@@ -360,9 +443,13 @@ impl Playback {
PlayerEvent::FileLoaded => self.loaded = true,
PlayerEvent::Volume(volume) => self.volume = *volume,
PlayerEvent::Muted(muted) => self.muted = *muted,
PlayerEvent::Tracks(tracks) => self.tracks = tracks.clone(),
PlayerEvent::Tracks(tracks) => {
self.tracks = tracks.clone();
// A switch between a plain and a styled track moves the line.
self.place_subtitles();
}
PlayerEvent::Chapters(chapters) => {
self.chapters = chapters.clone();
self.file_chapters = chapters.clone();
self.reclassify();
}
PlayerEvent::Position(position) => {
@@ -395,6 +482,26 @@ impl Playback {
}
}
/// The file's length, when it is time to ask AniSkip where its opening
/// and ending are: once, after it has loaded, and only when its own
/// chapters name neither.
pub fn wants_skip_times(&self) -> Option<f64> {
if self.skips_asked || !self.loaded {
return None;
}
let own = pstr_player::roles(&self.file_chapters, self.duration);
let named = own
.iter()
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending));
self.duration.filter(|duration| *duration > 0.0 && !named)
}
/// Take AniSkip's answer.
pub fn set_skip_ranges(&mut self, ranges: Vec<pstr_core::chapters::SkipRange>) {
self.skip_ranges = ranges;
self.reclassify();
}
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
pub fn progress(&self) -> Option<f32> {
let duration = self.duration.filter(|duration| *duration > 0.0)?;
@@ -467,6 +574,11 @@ fn build_player(
Ok((Arc::new(player), None))
}
/// Whether a subtitle codec carries its own styling, as mpv names them.
fn is_styled(codec: Option<&str>) -> bool {
matches!(codec, Some("ass" | "ssa"))
}
/// The player defaults with the viewer's preferences folded in.
fn from_prefs(
prefs: &PlaybackPrefs,
@@ -482,6 +594,7 @@ fn from_prefs(
.and_then(|p| p.subtitle_language.clone())
.or_else(|| prefs.subtitle_language.clone()),
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
subtitle_style: prefs.subtitle_style.clone(),
// Carried from file to file, like the volume: somebody who watches at
// 1.25 watches the next episode at 1.25 too.
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
@@ -504,7 +617,11 @@ fn run(
orders: std::sync::mpsc::Receiver<Command>,
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,
Err(error) => {
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
@@ -514,10 +631,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 duration: Option<f64> = None;
let mut watched = false;
let mut resumed = target.resume_at.is_none();
let mut last_save = std::time::Instant::now();
'playback: loop {
@@ -543,6 +668,10 @@ fn run(
match &event {
PlayerEvent::FileLoaded => {
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
// demuxed the file and made its own default selection. Put the
// title's own choice over it before the menus are told, so they
@@ -550,14 +679,16 @@ fn run(
if let Some(show) = target.track_prefs.as_deref() {
restore_tracks(&player, show);
}
emit_tracks(&engine, id, &player);
// Only now is there a timeline to seek within.
if !resumed && let Some(at) = target.resume_at {
resumed = true;
if let Err(error) = player.seek_to(at) {
tracing::warn!("resume at {at}: {error}");
}
// A subtitle track picked by name stays; one mpv picked by
// language may be the signs and songs, not the dialogue.
let picked = target
.track_prefs
.as_deref()
.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::Position(value) => {
@@ -599,6 +730,7 @@ fn run(
// the other `Arc`, and mpv must be destroyed there, on the thread whose
// OpenGL context its render context was built against.
drop(handle);
drop(subtitles);
drop(player);
engine.release(
@@ -627,6 +759,11 @@ fn apply_command(
Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted),
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
Command::SetSubtitleStyle(style) => {
player.apply_subtitle_style(&style);
Ok(())
}
Command::SetSubtitleDelay(seconds) => player.set_subtitle_delay(seconds),
Command::SetSpeed(speed) => player.set_speed(speed),
Command::Screenshot(path) => player.screenshot(&path),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
@@ -713,6 +850,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.
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
engine.emit(Event::Player {
@@ -738,3 +926,17 @@ fn save(
watch_state(position, duration, watched),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_ass_and_ssa_tracks_count_as_styled() {
assert!(is_styled(Some("ass")));
assert!(is_styled(Some("ssa")));
assert!(!is_styled(Some("subrip")));
assert!(!is_styled(Some("hdmv_pgs_subtitle")));
assert!(!is_styled(None));
}
}
+425 -19
View File
@@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0;
pub const POSTER_WIDTH: f32 = 168.0;
/// Covers are 2:3.
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
/// How much a tile's picture grows under the pointer, as a fraction of its
/// size. Half of it lands on each side, which has to fit in half of
/// [`CARD_GAP`] at the widest card, or a lifted tile overlaps its neighbour.
pub const TILE_LIFT: f32 = 0.04;
/// The type ramp, named once.
///
@@ -55,6 +59,11 @@ pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
/// the one monospace style in the app is egui's own, set in [`apply`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// The name in the library's banner and at the top of a title's page,
/// where there is no logo to stand in for it. Set in Inter Display, the
/// cut drawn for this size: tighter and heavier than the text face, which
/// at forty points looks like body copy that was zoomed.
Hero,
/// The name of the thing you are looking at, once per page.
Display,
/// An empty state, or the heading of a page that has no display line.
@@ -78,6 +87,7 @@ pub enum Role {
impl Role {
pub const fn size(self) -> f32 {
match self {
Self::Hero => 34.0,
Self::Display => 26.0,
Self::Title => 20.0,
Self::Heading => 18.0,
@@ -90,10 +100,11 @@ impl Role {
}
}
/// Headings are set in the semibold cut; everything below them in the
/// regular one.
/// The hero line is set in the display cut, headings in the semibold
/// one, and everything below them in the regular one.
pub fn family(self) -> egui::FontFamily {
match self {
Self::Hero => display(),
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
_ => egui::FontFamily::Proportional,
}
@@ -159,6 +170,9 @@ pub mod motion {
/// Artwork arriving over its placeholder. Slower, because it replaces
/// something rather than adding to it, and a fast swap reads as a flicker.
pub const ARRIVE: f32 = 0.24;
/// One pass of the highlight across a loading skeleton. Slow, because it
/// is something to wait in front of, not something happening.
pub const SHIMMER: f32 = 1.4;
/// Fast out, slow in: the curve every hand-driven transition uses.
pub fn ease_out(t: f32) -> f32 {
@@ -170,7 +184,7 @@ pub mod motion {
/// sets the same on every desktop. Inter, under the OFL — the licence is
/// beside the files.
///
/// Both files have their private use area mappings removed, because the
/// All three files have their private use area mappings removed, because the
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
/// own alternate glyphs, and as the first font in the family it won: the
/// refresh arrow drew as an accented letter. The glyphs themselves are still
@@ -179,6 +193,9 @@ pub mod motion {
/// U+E000..=U+F8FF).
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
/// Inter Display Bold, the optical size for the hero line. Stripped like the
/// other two.
const INTER_DISPLAY: &[u8] = include_bytes!("../assets/fonts/InterDisplay-Bold.ttf");
/// The family the headings are set in. egui has no notion of weight, so the
/// semibold cut is a family of its own.
@@ -186,9 +203,53 @@ pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into())
}
/// The family [`Role::Hero`] is set in.
pub fn display() -> egui::FontFamily {
egui::FontFamily::Name("display".into())
}
/// The family the subtitle preview is drawn in: the face the chosen subtitle
/// font resolves to, ahead of the app's own fonts, which stand in until it has
/// loaded and for any glyph it lacks.
pub fn subtitle_family() -> egui::FontFamily {
egui::FontFamily::Name("subtitle".into())
}
/// The system fonts loaded so far. Each install builds the definitions from
/// all of them, so loading one does not drop the other.
#[derive(Default)]
struct SystemFonts {
cjk: Option<Arc<egui::FontData>>,
subtitle: Option<(SubtitleRequest, Arc<egui::FontData>, SubtitleFace)>,
/// The subtitle face last asked for, loading or loaded.
wanted: Option<SubtitleRequest>,
}
static SYSTEM_FONTS: parking_lot::Mutex<SystemFonts> = parking_lot::Mutex::new(SystemFonts {
cjk: None,
subtitle: None,
wanted: None,
});
#[derive(Debug, Clone, PartialEq, Eq)]
struct SubtitleRequest {
family: String,
bold: bool,
italic: bool,
}
/// What the loaded subtitle face draws by itself. A style it lacks is one
/// libass makes up, and so must the preview.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubtitleFace {
pub bold: bool,
pub italic: bool,
}
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
/// symbols Inter does not carry. `cjk` goes last, when there is one.
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
/// symbols Inter does not carry. The CJK fallback goes last, when there is
/// one.
fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
use egui::FontFamily::{Monospace, Proportional};
let mut fonts = egui::FontDefinitions::default();
@@ -199,6 +260,10 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
"inter-semibold".into(),
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
);
fonts.font_data.insert(
"inter-display".into(),
Arc::new(egui::FontData::from_static(INTER_DISPLAY)),
);
fonts
.families
.entry(Proportional)
@@ -207,21 +272,32 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
// Right behind Inter. The icons are in the private use area, which the
// bundled Inter no longer maps, so neither font shadows the other.
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
if let Some(cjk) = cjk {
if let Some(cjk) = &system.cjk {
let name = "system-cjk-fallback".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(cjk));
fonts.font_data.insert(name.clone(), cjk.clone());
for family in [Proportional, Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
}
let fallbacks = fonts.families[&Proportional]
let fallbacks: Vec<String> = fonts.families[&Proportional]
.iter()
.filter(|name| *name != "inter")
.cloned();
let semibold_chain = std::iter::once("inter-semibold".to_owned())
.chain(fallbacks)
.cloned()
.collect();
fonts.families.insert(semibold(), semibold_chain);
let chain = |first: &str| {
std::iter::once(first.to_owned())
.chain(fallbacks.iter().cloned())
.collect()
};
fonts.families.insert(semibold(), chain("inter-semibold"));
fonts.families.insert(display(), chain("inter-display"));
let mut subtitle_chain = fonts.families[&Proportional].clone();
if let Some((_, face, _)) = &system.subtitle {
let name = "system-subtitle".to_owned();
fonts.font_data.insert(name.clone(), face.clone());
subtitle_chain.insert(0, name);
}
fonts.families.insert(subtitle_family(), subtitle_chain);
fonts
}
@@ -234,7 +310,7 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
/// and native desktops already provide one.
pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(None));
ctx.set_fonts(font_definitions(&SYSTEM_FONTS.lock()));
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("fonts".into())
@@ -248,13 +324,98 @@ fn install_cjk_fallback(ctx: &egui::Context) {
let Some((path, index)) = system_cjk_font() else {
return;
};
let Ok(bytes) = std::fs::read(path) else {
let Ok(bytes) = std::fs::read(&path) else {
return;
};
let Some(data) = system_font_data(bytes, index) else {
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
return;
};
let mut system = SYSTEM_FONTS.lock();
system.cjk = Some(Arc::new(data));
ctx.set_fonts(font_definitions(&system));
ctx.request_repaint();
}
/// The face the subtitle preview should be drawn in for this font, bold and
/// italic, once [`subtitle_family`] holds it.
///
/// `None` while it loads, and for good where the system cannot say which file
/// a family is — everywhere but Linux. The first call for a font starts the
/// load on a thread of its own, since `fc-match` and reading the file are too
/// slow for a frame, and asks for a repaint when it is in.
pub fn subtitle_face(
ctx: &egui::Context,
family: &str,
bold: bool,
italic: bool,
) -> Option<SubtitleFace> {
let request = SubtitleRequest {
family: family.to_owned(),
bold,
italic,
};
let mut system = SYSTEM_FONTS.lock();
if let Some((loaded, _, face)) = &system.subtitle
&& *loaded == request
{
return Some(*face);
}
if system.wanted.as_ref() != Some(&request) {
system.wanted = Some(request.clone());
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("subtitle-font".into())
.spawn(move || install_subtitle_face(&ctx, request));
if let Err(error) = spawned {
tracing::debug!("subtitle font thread: {error}");
}
}
None
}
fn install_subtitle_face(ctx: &egui::Context, request: SubtitleRequest) {
let Some((path, index, face)) = system_subtitle_font(&request) else {
return;
};
let Ok(bytes) = std::fs::read(&path) else {
return;
};
let Some(data) = system_font_data(bytes, index) else {
tracing::debug!("subtitle font {}#{index} does not parse", path.display());
return;
};
let mut system = SYSTEM_FONTS.lock();
// Another font was picked while this one loaded.
if system.wanted.as_ref() != Some(&request) {
return;
}
system.subtitle = Some((request, Arc::new(data), face));
ctx.set_fonts(font_definitions(&system));
ctx.request_repaint();
}
/// A system 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 system_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;
ctx.set_fonts(font_definitions(Some(data)));
ctx.request_repaint();
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")]
@@ -269,10 +430,73 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let index = lines
.next()
.and_then(|value| value.parse().ok())
.map(fontconfig_face)
.unwrap_or(0);
path.is_file().then_some((path, index))
}
/// The file fontconfig picks for a subtitle font, as libass asks for it: the
/// family with the weight and slant wanted, falling back as libass would to
/// whatever fontconfig likes best when the family is not installed.
#[cfg(target_os = "linux")]
fn system_subtitle_font(
request: &SubtitleRequest,
) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
let output = std::process::Command::new("fc-match")
.args([
"-f",
"%{file}\\n%{index}\\n%{weight}\\n%{slant}",
&fontconfig_pattern(request),
])
.output()
.ok()?;
let text = std::str::from_utf8(&output.stdout).ok()?;
let mut lines = text.lines();
let path = std::path::PathBuf::from(lines.next()?);
let mut number = || lines.next().and_then(|value| value.parse::<u32>().ok());
let index = number().unwrap_or(0);
let (weight, slant) = (number().unwrap_or(0), number().unwrap_or(0));
// A variable font's named instance is drawn by egui as the default one,
// which is upright and regular, whatever fontconfig says it matched.
let instance = index != fontconfig_face(index);
let face = SubtitleFace {
bold: !instance && weight >= FC_WEIGHT_BOLD,
italic: !instance && slant > 0,
};
path.is_file()
.then_some((path, fontconfig_face(index), face))
}
#[cfg(any(target_os = "linux", test))]
const FC_WEIGHT_BOLD: u32 = 200;
/// A fontconfig pattern for the request. A family name is free text, and `-`,
/// `:`, `,` and `\\` mean something in a pattern, so those are escaped.
#[cfg(any(target_os = "linux", test))]
fn fontconfig_pattern(request: &SubtitleRequest) -> String {
let mut pattern = String::new();
for character in request.family.chars() {
if matches!(character, '-' | ':' | ',' | '\\') {
pattern.push('\\');
}
pattern.push(character);
}
if request.bold {
pattern.push_str(&format!(":weight={FC_WEIGHT_BOLD}"));
}
if request.italic {
pattern.push_str(":slant=100");
}
pattern
}
/// Elsewhere there is no asking which file a family is, and the preview
/// stays in the app's own font.
#[cfg(not(target_os = "linux"))]
fn system_subtitle_font(_: &SubtitleRequest) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
None
}
#[cfg(target_os = "windows")]
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
@@ -290,6 +514,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
None
}
/// The font families installed here, by name, for the subtitle font picker.
///
/// `None` until they have been read. The first call starts that on a thread
/// of its own and asks for a repaint when it is done: `fc-list` over a large
/// font collection takes long enough to be seen as a stalled frame.
pub fn font_families(ctx: &egui::Context) -> Option<&'static [String]> {
static FAMILIES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if let Some(families) = FAMILIES.get() {
return Some(families);
}
if !STARTED.swap(true, std::sync::atomic::Ordering::Relaxed) {
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("font-families".into())
.spawn(move || {
let _ = FAMILIES.set(system_font_families());
ctx.request_repaint();
});
if let Err(error) = spawned {
tracing::debug!("font families thread: {error}");
}
}
None
}
#[cfg(target_os = "linux")]
fn system_font_families() -> Vec<String> {
let Ok(output) = std::process::Command::new("fc-list")
.args([":", "family"])
.output()
else {
return Vec::new();
};
font_families_from_fc_list(&String::from_utf8_lossy(&output.stdout))
}
/// Elsewhere the picker takes a typed name only. mpv's font lookup there is
/// the system's own, so any installed family's name works.
#[cfg(not(target_os = "linux"))]
fn system_font_families() -> Vec<String> {
Vec::new()
}
/// One family per line of `fc-list : family`, sorted and without repeats.
///
/// A line lists a family's names in every language it has, comma-separated,
/// and one family appears once per style. The first name is the one libass
/// matches on.
#[cfg(any(target_os = "linux", test))]
fn font_families_from_fc_list(listing: &str) -> Vec<String> {
let mut families: Vec<String> = listing
.lines()
.filter_map(|line| line.split(',').next())
.map(|family| family.trim().to_owned())
.filter(|family| !family.is_empty() && !family.starts_with('.'))
.collect();
families.sort_by_key(|family| family.to_lowercase());
families.dedup();
families
}
/// Every colour the app draws with, as egui wants them.
///
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
@@ -537,6 +823,11 @@ pub fn ramp_fill(
/// scrim that starts opaque and lets the picture out, `true` for one that lets
/// the picture *into* the page — which is the one the title backdrop wants,
/// since the page is below the band.
///
/// The ramp itself is white and the colour is the shape's fill, which the GPU
/// multiplies in: one texture per direction, however many colours fade —
/// a banner tinted per title would otherwise build a ramp for every frame of
/// its cross-fade.
pub fn fade_shape(
ctx: &egui::Context,
rect: Rect,
@@ -544,8 +835,29 @@ pub fn fade_shape(
direction: Direction,
arriving: bool,
) -> egui::Shape {
let texture = alpha_ramp(ctx, color, direction, arriving);
textured(rect, CornerRadius::ZERO, &texture)
let texture = alpha_ramp(ctx, Color32::WHITE, direction, arriving);
let mut shape = egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, color);
shape.brush = Some(Arc::new(egui::epaint::Brush {
fill_texture_id: texture.id(),
uv: Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
}));
shape.into()
}
/// The page colour tinted by a picture's colour, for the veils over that
/// picture: see [`pstr_core::appearance::mood_veil`]. The page colour itself
/// while the picture has not arrived.
pub fn mood_veil(mood: Option<Color32>) -> Color32 {
let palette = palette();
let Some(mood) = mood else {
return palette.background;
};
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
color(pstr_core::appearance::mood_veil(
rgb(palette.background),
rgb(mood),
palette.light,
))
}
/// The one gradient that is not the accent: the top bar, which runs from the
@@ -611,6 +923,43 @@ fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::
}
}
/// A soft highlight sweeping across a loading skeleton on a diagonal: what
/// says the shape is waiting for something rather than empty.
///
/// The phase runs off the clock rather than per tile, so every skeleton on
/// screen sweeps in step, as one surface. Asks for the next frame itself; a
/// skeleton is only ever on screen for a moment.
pub fn shimmer(painter: &egui::Painter, rect: Rect) {
let time = painter.ctx().input(|input| input.time);
let phase = (time / f64::from(motion::SHIMMER)).fract() as f32;
let span = rect.width() * 0.6;
let centre = rect.left() - span + phase * (rect.width() + 2.0 * span);
let skew = rect.height() * 0.4;
let highlight = if palette().light {
Color32::from_black_alpha(10)
} else {
Color32::from_white_alpha(12)
};
let mut mesh = egui::Mesh::default();
for (x, color) in [
(centre - span, Color32::TRANSPARENT),
(centre, highlight),
(centre + span, Color32::TRANSPARENT),
] {
mesh.colored_vertex(egui::pos2(x + skew, rect.top()), color);
mesh.colored_vertex(egui::pos2(x - skew, rect.bottom()), color);
}
for quad in 0..2 {
let i = quad * 2;
mesh.add_triangle(i, i + 1, i + 2);
mesh.add_triangle(i + 1, i + 3, i + 2);
}
painter
.with_clip_rect(rect.intersect(painter.clip_rect()))
.add(mesh);
painter.ctx().request_repaint();
}
/// The hover and press film on its own, for a control whose fill was painted by
/// somebody else — the selected tab, which sits on the pill.
pub fn veil_over(
@@ -855,11 +1204,68 @@ const RAMP_STOPS: usize = 64;
mod tests {
use super::*;
#[test]
fn each_font_family_is_listed_once_by_its_first_name() {
let listing = "Noto Sans,Noto Sans Regular\nDejaVu Sans\nnoto sans mono\nNoto Sans,Noto Sans Bold\n\n.LastResort\n";
assert_eq!(
font_families_from_fc_list(listing),
["DejaVu Sans", "Noto Sans", "noto sans mono"]
);
}
#[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_system_font_egui_cannot_read_is_left_out() {
assert!(system_font_data(INTER.to_vec(), 0).is_some());
assert!(system_font_data(INTER.to_vec(), 4).is_none());
assert!(system_font_data(b"not a font".to_vec(), 0).is_none());
}
#[test]
fn a_subtitle_font_is_asked_of_fontconfig_with_its_style_and_escaped() {
let request = |family: &str, bold, italic| SubtitleRequest {
family: family.to_owned(),
bold,
italic,
};
assert_eq!(
fontconfig_pattern(&request("Noto Sans", false, false)),
"Noto Sans"
);
assert_eq!(
fontconfig_pattern(&request("Noto Sans", true, true)),
"Noto Sans:weight=200:slant=100"
);
assert_eq!(
fontconfig_pattern(&request("A-B:C,D\\E", false, true)),
"A\\-B\\:C\\,D\\\\E:slant=100"
);
}
#[test]
fn the_subtitle_preview_family_exists_before_any_font_has_loaded() {
let fonts = font_definitions(&SystemFonts::default());
assert_eq!(
fonts.families[&subtitle_family()],
fonts.families[&egui::FontFamily::Proportional]
);
}
#[test]
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _;
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
for (name, bytes) in [
("regular", INTER),
("semibold", INTER_SEMIBOLD),
("display", INTER_DISPLAY),
] {
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
let shadowed: Vec<u32> = font
.charmap()
+11 -13
View File
@@ -14,6 +14,7 @@ use crate::app::Action;
use crate::engine::AccountStatus;
use crate::theme;
use crate::ui;
use crate::ui::widgets::Named;
/// What the account section holds between frames.
#[derive(Default)]
@@ -133,9 +134,10 @@ impl DriveBrowser {
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui::section(ui, "Proton account");
ui.label(ui::muted(
"Optional. Signed in, your watch history is kept in your own Drive and every device \
resumes where another left off, and folders of your Drive — your files, other \
devices, what was shared with you — can be added to the library.",
"Optional. Signed in, your shares, settings and watch history are kept in your own \
Drive, so every device shows the same library and resumes where another left off, \
and folders of your Drive — your files, other devices, what was shared with you — \
can be added to the library.",
));
ui.add_space(theme::space::L);
@@ -281,9 +283,11 @@ fn signed_in(
.strong(),
);
let status = match (&panel.sync_error, &panel.synced) {
(Some(error), _) => format!("Watch history did not sync: {error}"),
(Some(error), _) => format!("Did not sync: {error}"),
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
(None, None) => "Watch history syncs with your Drive".to_owned(),
(None, None) => {
"Library, settings and watch history sync through your Drive".to_owned()
}
};
ui.label(ui::muted(status));
});
@@ -312,13 +316,7 @@ fn signed_in(
/// "Synced 3 min ago · 2 positions from other devices".
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
let ago = (now - at).max(0);
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}");
let mut line = format!("Synced {}", ui::ago(at, now));
if report.applied > 0 {
line.push_str(&format!(
" · {} from other devices",
@@ -382,7 +380,7 @@ pub fn browser(
if !browser.trail.is_empty()
&& ui
.button(egui_phosphor::regular::ARROW_LEFT)
.on_hover_text("Up a folder")
.hint("Up a folder")
.clicked()
{
browser.up(actions);
+10 -2
View File
@@ -1,6 +1,6 @@
//! Inspectable desktop download queue.
use crate::app::Action;
use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadState};
use crate::{theme, ui};
@@ -222,12 +222,20 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
}
}
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
.button("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.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::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, Art};
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
@@ -161,7 +162,7 @@ fn row(
)
.frame(false),
)
.on_hover_text(if episode.is_watched() {
.hint(if episode.is_watched() {
"Remove from history — marks it unwatched"
} else {
"Remove from history — forgets where you stopped"
File diff suppressed because it is too large. Load diff
+25 -6
View File
@@ -55,8 +55,8 @@ pub fn show(
.color(theme::text()),
);
ui.label(ui::muted(format!(
"Everything {} answers with, in its own order — nothing is scored here.",
provider.label()
"Everything {} answer with, in their own order — nothing is scored here.",
pstr_meta::service::chain_label(provider)
)));
ui.add_space(theme::space::L);
@@ -130,12 +130,29 @@ fn current_row(
)));
// Only worth offering when there is something to undo: with no
// 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")
.on_hover_text("Forget this match and let the next match run decide again")
.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, _)) => {
@@ -262,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
/// simply absent.
/// `Film · 2017 · 26 episodes · ★ 8.2 · AniList`, with whatever the provider
/// left out simply absent. The provider last, because one search asks more
/// than one, and the same show can come back from each.
fn facts(found: &TitleMetadata) -> String {
let mut parts: Vec<String> = vec![
match found.kind {
@@ -281,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String {
if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
parts.push(found.provider.label().to_string());
parts.join(" · ")
}
+489 -9
View File
@@ -23,8 +23,10 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
use crate::app::Action;
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme;
use widgets::Named;
/// Everything the pages need to draw one title's picture.
///
@@ -79,6 +81,17 @@ impl Art<'_> {
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
}
/// A provider's picture that belongs to no one title's tile — a cast
/// member's portrait — fetched like a poster the first time it is asked
/// for.
pub fn picture(&mut self, url: &str) -> Option<egui::TextureHandle> {
let key = format!("picture:{url}");
let engine = self.engine;
let url = url.to_owned();
self.posters
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// The name to show for `title`. See [`title_name`].
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
title_name(title, self.metadata, self.names)
@@ -106,7 +119,9 @@ impl Art<'_> {
}
/// Art for a strip across the whole window, at full size: fanart, else
/// AniList's banner, else whatever [`Art::of`] has.
/// AniList's banner, else the poster blurred into a wash of its colours —
/// see [`crate::engine::Engine::request_ambient`] — else whatever
/// [`Art::of`] has.
///
/// Its own texture rather than the tile's: a 640 px tile picture
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
@@ -127,10 +142,61 @@ impl Art<'_> {
{
return Some((texture, ArtShape::Landscape));
}
} else if let Some(url) = self.poster_url(title) {
let key = ambient_key(&title.key);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_ambient(key.clone(), url))
{
return Some((texture, ArtShape::Landscape));
}
}
self.of(title)
}
/// The colour of whatever [`Art::wide`] is drawing for `title`, once it
/// has arrived — what that title's veils are tinted with. See
/// [`theme::mood_veil`].
pub fn mood(&self, title: &Title) -> Option<egui::Color32> {
[wide_key, ambient_key, poster_key]
.into_iter()
.map(|key| key(&title.key))
// The tile's own picture last: what `wide` falls back on.
.chain(std::iter::once(title.key.clone()))
.find_map(|key| self.posters.mood(&key))
}
/// Whether `title` has a picture made for a strip across the window, rather
/// than [`Art::wide`] standing one in.
pub fn has_wide_art(&self, title: &Title) -> bool {
self.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|metadata| metadata.wide_art().is_some())
}
fn poster_url(&self, title: &Title) -> Option<String> {
self.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.poster_url.clone())
}
/// The title's name as lettering, trimmed to it, when the provider has
/// one and it has arrived. Drawn over wide art in place of the name.
pub fn logo(&mut self, title: &Title) -> Option<egui::TextureHandle> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.logo_url.clone())?;
let key = logo_key(&title.key);
let engine = self.engine;
self.posters
.texture(&key, || engine.request_logo(key.clone(), url))
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
@@ -161,6 +227,10 @@ impl Art<'_> {
}
}
/// The central panel's margin around every page. Anything drawn edge to edge —
/// the library's banner, the title page's band — reaches past it.
pub const PAGE_MARGIN: egui::Vec2 = egui::vec2(18.0, 12.0);
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
/// title key itself.
pub fn poster_key(title_key: &str) -> String {
@@ -172,6 +242,74 @@ pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}")
}
/// The texture key of a title's poster blurred for a band.
pub fn ambient_key(title_key: &str) -> String {
format!("ambient:{title_key}")
}
/// A title's logo in place of its name in Display type, when there is one to
/// draw; `None` leaves the caller to set the name as it does.
///
/// At most `max_height` tall and as wide as the space allows, up to 420 px —
/// a logo the width of the column shouts. A light palette keeps the name: a
/// clear logo is lettering made for dark art, white as often as not, and over
/// a near-white veil it is gone.
pub fn logo_heading(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
max_height: f32,
) -> Option<egui::Response> {
if theme::palette().light {
return None;
}
let texture = art.logo(title);
// From nothing rather than from the name it replaces: the name is gone the
// frame the logo is ready, and a logo easing in reads as it arriving.
let shown = ui.ctx().animate_bool_with_time(
ui.id().with(("logo", &title.key)),
texture.is_some(),
theme::motion::ARRIVE,
);
let texture = texture?;
let aspect = texture.aspect_ratio().max(0.1);
let width = ui.available_width().min(420.0).min(max_height * aspect);
let size = egui::vec2(width, width / aspect);
Some(
ui.add(
egui::Image::new((texture.id(), size)).tint(egui::Color32::WHITE.gamma_multiply(shown)),
)
.on_hover_text(art.name(title)),
)
}
/// The texture key of a title's logo.
pub fn logo_key(title_key: &str) -> String {
format!("logo:{title_key}")
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
pub fn format_runtime(minutes: u32) -> String {
match (minutes / 60, minutes % 60) {
(0, minutes) => format!("{minutes} min"),
(hours, 0) => format!("{hours} h"),
(hours, minutes) => format!("{hours} h {minutes} min"),
}
}
/// "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.
pub fn format_time(seconds: f64) -> String {
if !seconds.is_finite() || seconds < 0.0 {
@@ -192,6 +330,18 @@ pub fn format_speed(speed: f64) -> String {
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
}
/// `+0.3 s`, `−1.2 s` or `0 s`: a subtitle delay, signed so the direction
/// reads without knowing which way mpv counts.
pub fn format_delay(seconds: f64) -> String {
if seconds.abs() < 0.05 {
"0 s".to_owned()
} else if seconds > 0.0 {
format!("+{seconds:.1} s")
} else {
format!("−{:.1} s", -seconds)
}
}
/// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
@@ -230,11 +380,53 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
}
/// The button beside the accent one: the same shape, in a film of the text
/// colour, so "Start over" next to "Resume" reads as the alternative rather
/// than as a second, unrelated kind of control.
pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0))
}
/// My List's button: `+ My List` to put the title on it, `✓ My List` once it
/// is, which takes it off again.
pub fn list_button(ui: &mut egui::Ui, key: &str, on_list: bool, actions: &mut Vec<Action>) {
let label = if on_list {
format!("{} My List", egui_phosphor::regular::CHECK)
} else {
format!("{} My List", egui_phosphor::regular::PLUS)
};
let response = tonal_button(ui, &label).on_hover_text(if on_list {
"On My List. Click to take it off"
} else {
"Put it on My List, to find it again on every device"
});
if response.clicked() {
actions.push(Action::SetOnList {
key: key.to_owned(),
on: !on_list,
});
}
}
/// A button laid over the picture: dark glass and white ink, whatever the
/// theme, because what is under it is a video frame and not the page.
pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Glass, Vec2::new(0.0, 0.0))
}
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
///
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
///
/// Empty and with the caret in it, it offers the `recent` searches under it.
/// Picking one fills the box; the return says the viewer asked to forget them.
pub fn search_field(
ui: &mut egui::Ui,
search: &mut String,
focus: bool,
recent: &[String],
) -> bool {
let id = egui::Id::new("library-search");
let width = 240.0;
let height = ui.spacing().interact_size.y;
@@ -273,7 +465,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
.id(id)
.frame(egui::Frame::NONE)
.hint_text(if has_focus {
"Titles and filenames"
"Titles, episodes, people"
} else {
"Search Ctrl+F"
})
@@ -311,12 +503,62 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
);
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Clear (Esc)")
.hint("Clear (Esc)")
.clicked()
{
search.clear();
}
}
// Kept up while the pointer is over it as well as while the box has the
// caret: pressing on it takes the caret away before the click lands.
let popup = id.with("recent");
let over = ui
.data(|data| data.get_temp::<bool>(popup))
.unwrap_or(false);
let mut forget = false;
if search.is_empty() && !recent.is_empty() && (response.has_focus() || over) {
let mut picked = None;
let shown = egui::Area::new(popup)
.order(egui::Order::Foreground)
.fixed_pos(outer.left_bottom() + Vec2::new(0.0, 6.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(width - 12.0);
ui.label(
theme::Role::Caption
.rich("Recent searches")
.color(theme::muted()),
);
for query in recent {
if ui
.add(
egui::Button::selectable(false, query.as_str())
.frame_when_inactive(false),
)
.clicked()
{
picked = Some(query.clone());
}
}
ui.separator();
forget = ui
.add(
egui::Button::selectable(false, muted("Clear recent searches"))
.frame_when_inactive(false),
)
.clicked();
});
});
let over = shown.response.contains_pointer();
ui.data_mut(|data| data.insert_temp(popup, over && !forget && picked.is_none()));
if let Some(query) = picked {
*search = query;
}
} else {
ui.data_mut(|data| data.remove::<bool>(popup));
}
forget
}
/// A thin bar in the accent, for how far something has got.
@@ -374,6 +616,19 @@ pub enum Answer {
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
/// the click.
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
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 modal = egui::Modal::new(egui::Id::new(("confirm", id)))
.frame(
@@ -394,7 +649,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.add_space(theme::space::XL);
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;
}
if ui.button("Cancel").clicked() {
@@ -418,6 +678,10 @@ enum Fill {
Ink,
/// Nothing at rest, the hover colour under the pointer.
None,
/// A film of the text colour over whatever is behind it.
Tonal,
/// Dark glass over the picture.
Glass,
}
/// A row of tabs with one accent pill that slides between them.
@@ -498,6 +762,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 response = response.named(text);
if !ui.is_rect_visible(rect) {
return response;
}
@@ -537,6 +802,35 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
}
theme::muted().lerp_to_gamma(theme::text(), hover)
}
Fill::Tonal => {
let film = if theme::palette().light {
Color32::from_black_alpha(16)
} else {
Color32::from_white_alpha(22)
};
ui.painter().rect_filled(rect, radius, film);
theme::veil_over(ui.painter(), rect, radius, lift);
theme::text()
}
Fill::Glass => {
ui.painter()
.rect_filled(rect, radius, Color32::from_black_alpha(140));
ui.painter().rect_stroke(
rect,
radius,
Stroke::new(1.0, Color32::from_white_alpha(18)),
egui::StrokeKind::Inside,
);
theme::veil_over(ui.painter(), rect, radius, lift);
Color32::WHITE
}
};
// A disabled control is drawn at part strength: the ink is what says what
// the button is, so that is what fades.
let ink = if ui.is_enabled() {
ink
} else {
ink.gamma_multiply(0.4)
};
let position = rect.center() - galley.size() / 2.0;
@@ -595,6 +889,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let (rect, response) =
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
// early return below would skip a tile that is just off screen.
if response.gained_focus() {
@@ -604,18 +903,27 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
return response;
}
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
let resting = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
// Hover is a value, not a flag: the border, the lift and the shadow all
// ride it, so they arrive together rather than each snapping on its own.
// egui drives this from wall-clock time, so it is the same speed whatever
// the frame rate — and it needs no state kept here.
// Focus lights a tile the way the pointer does, so the keyboard can be
// followed across the grid.
// Focus lifts a tile the way the pointer does, and adds a ring of its own,
// so the keyboard can be followed across the grid.
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered() || response.has_focus(),
theme::motion::HOVER,
);
let focus = ui.ctx().animate_bool_with_time(
response.id.with("focus"),
response.has_focus(),
theme::motion::HOVER,
);
// The picture grows towards the viewer into the gap around it, which is
// what says "this one" across a shelf of near-identical rectangles. The
// name under it stays put, so the row's text never wobbles.
let image_rect = resting.expand2(resting.size() * lift_per_side(width) * hover);
// The picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle.
@@ -731,9 +1039,17 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
egui::StrokeKind::Inside,
);
}
if focus > 0.0 {
painter.rect_stroke(
image_rect.expand(3.0),
CornerRadius::same(theme::radius::MD + 3),
Stroke::new(2.0, theme::text().gamma_multiply(focus)),
egui::StrokeKind::Outside,
);
}
let text_rect = Rect::from_min_size(
image_rect.left_bottom() + Vec2::new(0.0, 6.0),
resting.left_bottom() + Vec2::new(0.0, 6.0),
Vec2::new(width, text_height - 6.0),
);
let painter = painter.with_clip_rect(text_rect);
@@ -764,6 +1080,146 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Every sideways row in the app: the shelves on the home page and the
/// franchise on a title's page.
///
/// What runs past either edge fades into the page rather than being cut off
/// by it, so a row says there is more without a scroll bar saying it. Under
/// the pointer, a round button at each edge with more beyond it pages the row
/// by most of its width: a mouse has no sideways wheel, and the bar was the
/// only other way across.
///
/// The row is padded by half a gap all round inside its scroll area, and the
/// area reaches the same half gap past the column on either side, so a lifted
/// tile is not clipped by the area and the tiles still line up with the
/// headings above them.
pub fn shelf<R>(
ui: &mut egui::Ui,
id_salt: impl std::hash::Hash + std::fmt::Debug,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let half = (theme::CARD_GAP / 2.0) as i8;
let id = ui.id().with(("shelf", &id_salt));
let page = id.with("page");
let pending: Option<f32> = ui.ctx().data_mut(|data| data.remove_temp(page));
let output = egui::Frame::new()
.outer_margin(egui::Margin {
left: -half,
right: -half,
top: -half,
bottom: -half,
})
.show(ui, |ui| {
egui::ScrollArea::horizontal()
.id_salt(id)
.auto_shrink([false, true])
.show(ui, |ui| {
if let Some(delta) = pending {
ui.scroll_with_delta_animation(
Vec2::new(delta, 0.0),
egui::style::ScrollAnimation::duration(SHELF_PAGE_SECONDS),
);
}
egui::Frame::new()
.inner_margin(egui::Margin::same(half))
.show(ui, add)
.inner
})
})
.inner;
let rect = output.inner_rect;
let offset = output.state.offset.x;
let overflow = (output.content_size.x - rect.width()).max(0.0);
let ends = [
(offset > 1.0, false, egui_phosphor::regular::CARET_LEFT, 1.0),
(
offset < overflow - 1.0,
true,
egui_phosphor::regular::CARET_RIGHT,
-1.0,
),
];
let pointer_in = ui.rect_contains_pointer(rect);
let painter = ui.painter_at(rect);
for (more, right, icon, direction) in ends {
if !more {
continue;
}
let strip = if right {
Rect::from_min_max(egui::pos2(rect.right() - SHELF_FADE, rect.top()), rect.max)
} else {
Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + SHELF_FADE, rect.bottom()),
)
};
painter.add(theme::fade_shape(
ui.ctx(),
strip,
theme::background(),
theme::Direction::Horizontal,
right,
));
let button_id = id.with(right);
let shown = ui
.ctx()
.animate_bool_with_time(button_id, pointer_in, theme::motion::HOVER);
if shown == 0.0 {
continue;
}
let centre = egui::pos2(
if right {
rect.right() - SHELF_BUTTON / 2.0 - theme::space::M
} else {
rect.left() + SHELF_BUTTON / 2.0 + theme::space::M
},
rect.center().y,
);
let button = Rect::from_center_size(centre, Vec2::splat(SHELF_BUTTON));
let response = ui
.interact(button, button_id.with("button"), Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
let response = response.named(if right { "Scroll right" } else { "Scroll left" });
let lit = if response.hovered() { 1.0 } else { 0.0 };
painter.circle_filled(
centre,
SHELF_BUTTON / 2.0,
Color32::from_black_alpha((150.0 + 60.0 * lit) as u8).gamma_multiply(shown),
);
painter.text(
centre,
Align2::CENTER_CENTER,
icon,
egui::FontId::proportional(18.0),
Color32::WHITE.gamma_multiply(shown),
);
if response.clicked() {
let delta = direction * rect.width() * SHELF_PAGE;
ui.ctx().data_mut(|data| data.insert_temp(page, delta));
ui.ctx().request_repaint();
}
}
output.inner
}
/// How wide the fade at a shelf's edge is.
const SHELF_FADE: f32 = 56.0;
/// The diameter of a shelf's paging buttons.
const SHELF_BUTTON: f32 = 40.0;
/// How much of a shelf's width one page moves: most of it, so the tile that
/// was cut by the edge is whole on the next page.
const SHELF_PAGE: f32 = 0.85;
const SHELF_PAGE_SECONDS: f32 = 0.35;
/// How far a lifted tile grows past each edge, as a fraction of its size:
/// [`theme::TILE_LIFT`], but never so far that it reaches into the gap's other
/// half, which belongs to the neighbour. A wide window makes wide tiles.
fn lift_per_side(width: f32) -> f32 {
(theme::TILE_LIFT / 2.0).min((theme::CARD_GAP / 2.0 - 1.0) / width.max(1.0))
}
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
///
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
@@ -879,8 +1335,32 @@ pub fn columns_of(available: f32, minimum: f32) -> Grid {
#[cfg(test)]
mod tests {
#[test]
fn a_subtitle_delay_reads_with_its_direction() {
assert_eq!(super::format_delay(0.3), "+0.3 s");
assert_eq!(super::format_delay(-1.2), "−1.2 s");
assert_eq!(super::format_delay(0.0000001), "0 s");
}
use super::*;
#[test]
fn a_lifted_tile_never_reaches_its_neighbour_however_wide_the_window() {
for available in [400.0, 1280.0, 1919.0, 2560.0, 3840.0] {
for minimum in [theme::CARD_WIDTH, theme::POSTER_WIDTH] {
let width = columns_of(available, minimum).width;
assert!(width * lift_per_side(width) < theme::CARD_GAP / 2.0);
}
}
}
#[test]
fn a_running_time_reads_in_hours_and_minutes() {
assert_eq!(format_runtime(24), "24 min");
assert_eq!(format_runtime(120), "2 h");
assert_eq!(format_runtime(124), "2 h 4 min");
}
#[test]
fn times_are_formatted_by_length() {
assert_eq!(format_time(0.0), "0:00");
File diff suppressed because it is too large. Load diff
+508 -32
View File
@@ -7,11 +7,14 @@
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
use pstr_core::prefs::{Colour, PlaybackPrefs, SubtitleBackdrop, SubtitleStyle};
use pstr_player::LANGUAGES;
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::ui;
use crate::ui::widgets::Named;
/// 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
/// function past the argument count anybody can read.
#[derive(Debug, Clone, Copy)]
pub struct Prefs {
pub struct Prefs<'a> {
pub autoplay: bool,
pub auto_skip: bool,
/// The languages, read from here.
pub playback: &'a PlaybackPrefs,
pub appearance: Appearance,
pub desktop: DesktopPrefs,
/// What the streaming cache holds, once the shares are open.
@@ -35,7 +40,7 @@ const COLUMN: f32 = 760.0;
pub fn show(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
prefs: Prefs,
prefs: Prefs<'_>,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
@@ -51,8 +56,9 @@ pub fn show(
ui.vertical(|ui| {
ui.set_width(width);
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);
subtitle_form(ui, &prefs.playback.subtitle_style, actions);
storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions);
about(ui);
@@ -66,7 +72,12 @@ pub fn show(
/// 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
/// 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.add_space(theme::space::L);
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
@@ -75,6 +86,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL {
if palette_card(ui, appearance, flavor)
.named(flavor.label())
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
@@ -102,6 +114,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,
"Library tiles",
@@ -265,22 +298,24 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
}
}
response
.on_hover_text(accent.label())
.hint(accent.label())
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The playback settings that are not per-file.
///
/// Volume, audio language, subtitle language and speed are all set from the
/// player itself, where a viewer can hear the result.
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
/// Volume and speed are set from the player itself, where a viewer can hear
/// the result. The languages are here as well as there: what a first episode
/// 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,
"Playback",
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| {
languages(ui, prefs.playback, actions);
let mut autoplay = prefs.autoplay;
ui::widgets::settings_row(
ui,
@@ -322,8 +357,442 @@ 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(),
});
}
}
});
},
);
}
/// How subtitles look, with a sample line to judge it by.
///
/// Every change reaches a playing file as it is made, so the film itself is
/// the other preview. A styled track takes none of it unless it is asked to;
/// see [`SubtitleStyle`].
fn subtitle_form(ui: &mut egui::Ui, style: &SubtitleStyle, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Subtitles",
Some(
"Changes show on the playing video straight away. Plain subtitles (SRT) take all of this; styled ones (ASS) keep their own look unless you restyle them below.",
),
|ui| {
let mut chosen = style.clone();
ui.add_space(theme::space::L);
subtitle_preview(ui, &chosen);
ui.add_space(theme::space::L);
ui::widgets::settings_row(ui, "Size", None, |ui| {
percent_slider(
ui,
&mut chosen.scale,
SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE,
);
});
ui::widgets::settings_row(
ui,
"Position",
Some("How far up the picture the line sits."),
|ui| subtitle_position_slider(ui, &mut chosen.position),
);
ui::widgets::settings_row(
ui,
"Font",
Some(if cfg!(target_os = "linux") {
"Any font installed here."
} else {
"Any font installed here. The sample above is drawn in the app's own font."
}),
|ui| {
ui::widgets::toggle(ui, &mut chosen.italic, "Italic");
ui::widgets::toggle(ui, &mut chosen.bold, "Bold");
font_picker(ui, &mut chosen.font);
},
);
ui::widgets::settings_row(ui, "Text colour", None, |ui| {
colour_button(ui, &mut chosen.colour);
});
ui::widgets::settings_row(
ui,
"Background",
Some("A box reads on bright scenes; an outline hides less of the picture."),
|ui| {
let choices =
SubtitleBackdrop::ALL.map(|backdrop| (backdrop, backdrop.label()));
if let Some(backdrop) = ui::widgets::segmented(ui, chosen.backdrop, &choices) {
chosen.backdrop = backdrop;
}
},
);
match chosen.backdrop {
SubtitleBackdrop::Outline => {
ui::widgets::settings_row(ui, "Outline", None, |ui| {
colour_button(ui, &mut chosen.outline_colour);
ui.add(
egui::Slider::new(
&mut chosen.outline_size,
0.0..=SubtitleStyle::MAX_OUTLINE,
)
.step_by(0.05)
.fixed_decimals(1),
);
});
}
SubtitleBackdrop::Box => {
ui::widgets::settings_row(
ui,
"Box colour",
Some("Its opacity is how much of the picture shows through."),
|ui| colour_button(ui, &mut chosen.box_colour),
);
}
}
ui::widgets::settings_row(ui, "Shadow", Some("How far it falls. 0 is none."), |ui| {
colour_button(ui, &mut chosen.shadow_colour);
ui.add(
egui::Slider::new(&mut chosen.shadow_offset, 0.0..=SubtitleStyle::MAX_SHADOW)
.step_by(0.1)
.fixed_decimals(1),
);
});
ui::widgets::settings_row(
ui,
"Blur",
Some("Softens the edges. Not shown in the sample."),
|ui| {
ui.add(
egui::Slider::new(&mut chosen.blur, 0.0..=SubtitleStyle::MAX_BLUR)
.step_by(0.1)
.fixed_decimals(1),
);
},
);
ui::widgets::settings_row(
ui,
"Restyle styled subtitles",
Some(
"Draw styled (ASS) dialogue in this style too, size and position included. Signs, songs and karaoke can come out wrong.",
),
|ui| {
ui::widgets::toggle(ui, &mut chosen.restyle_styled, "");
},
);
ui::widgets::settings_row(ui, "Reset", Some("Back to mpv's own look."), |ui| {
if ui
.add_enabled(
*style != SubtitleStyle::default(),
egui::Button::new("Reset"),
)
.clicked()
{
chosen = SubtitleStyle::default();
}
});
if chosen != *style {
actions.push(Action::SetSubtitleStyle(chosen.sanitized()));
}
},
);
}
/// A scale as a percentage slider, 100% being mpv's own size.
pub(crate) fn percent_slider(
ui: &mut egui::Ui,
scale: &mut f64,
range: std::ops::RangeInclusive<f64>,
) -> egui::Response {
let mut percent = *scale * 100.0;
let response = ui.add(
egui::Slider::new(&mut percent, range.start() * 100.0..=range.end() * 100.0)
.step_by(5.0)
.suffix("%")
.fixed_decimals(0),
);
*scale = percent / 100.0;
response
}
/// Where the line sits, 0 at the top of the picture and 100 at the bottom —
/// shown the other way up, as height above the bottom edge, which is how
/// anyone would describe moving subtitles up.
pub(crate) fn subtitle_position_slider(ui: &mut egui::Ui, position: &mut f64) -> egui::Response {
let mut height = 100.0 - *position;
let response = ui.add(
egui::Slider::new(&mut height, 0.0..=90.0)
.step_by(1.0)
.suffix("% up")
.fixed_decimals(0),
);
*position = 100.0 - height;
response
}
/// A swatch that opens a colour wheel with an opacity slider.
fn colour_button(ui: &mut egui::Ui, colour: &mut Colour) {
let [r, g, b, a] = colour.to_array();
let mut picked = egui::Color32::from_rgba_unmultiplied(r, g, b, a);
// The `Color32` form rather than `Hsva`, because egui remembers the hue
// behind it: rebuilt from RGB each frame, black and grey have none, and
// the wheel would snap back to red under the mouse.
let response = egui::color_picker::color_edit_button_srgba(
ui,
&mut picked,
egui::color_picker::Alpha::OnlyBlend,
);
// Only on a change: a round trip through premultiplied alpha can nudge
// an untouched colour by a step.
if response.changed() {
*colour = Colour::from(picked.to_srgba_unmultiplied());
}
}
/// The font: the installed families to pick from, and a box to type any name
/// into — the only way on a system whose families the app cannot list.
fn font_picker(ui: &mut egui::Ui, font: &mut Option<String>) {
let filter_id = egui::Id::new("subtitle-font-filter");
let mut filter: String = ui.data(|data| data.get_temp(filter_id)).unwrap_or_default();
let families = theme::font_families(ui.ctx());
egui::ComboBox::from_id_salt("subtitle-font")
.selected_text(font.as_deref().unwrap_or("Default"))
.width(200.0)
.height(320.0)
// Not on any click: the search box is clicked into.
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show_ui(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut filter)
.hint_text("Search or type a font name")
.desired_width(f32::INFINITY),
)
.request_focus();
let typed = filter.trim();
if ui.selectable_label(font.is_none(), "Default").clicked() {
*font = None;
ui.close();
}
let wanted = typed.to_lowercase();
let mut exact = false;
for family in families.unwrap_or_default() {
if !family.to_lowercase().contains(&wanted) {
continue;
}
exact |= family.eq_ignore_ascii_case(typed);
let label = egui::RichText::new(family.as_str());
if ui
.selectable_label(font.as_deref() == Some(family.as_str()), label)
.clicked()
{
*font = Some(family.clone());
ui.close();
}
}
if families.is_none() {
ui.label(ui::muted("Reading the installed fonts…"));
}
if !typed.is_empty() && !exact && ui.button(format!("Use “{typed}”")).clicked() {
*font = Some(typed.to_owned());
ui.close();
}
});
ui.data_mut(|data| data.insert_temp(filter_id, filter));
}
/// A sample line, half on a bright sky and half on night, drawn in the
/// chosen style — so whether it reads on both is visible before a film is.
///
/// An approximation. The font is the face fontconfig picks for the chosen one,
/// as libass's is, but only on Linux; elsewhere, and while it loads, it is the
/// app's own. A bold or italic the face lacks is made up, as libass does, and
/// there is no blur. Proportions follow mpv's, where sizes are in pixels of a
/// 38-pixel font.
fn subtitle_preview(ui: &mut egui::Ui, style: &SubtitleStyle) {
const HEIGHT: f32 = 132.0;
/// mpv's `sub-font-size`, the unit every other size is relative to.
const MPV_FONT: f32 = 38.0;
let width = ui.available_width();
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, HEIGHT), egui::Sense::hover());
let painter = ui.painter_at(rect);
let radius = egui::CornerRadius::same(theme::radius::MD);
// Sky above haze on the left, a night street on the right.
let half = egui::Rect::from_min_max(rect.min, egui::pos2(rect.center().x, rect.max.y));
let mut sky = egui::Mesh::default();
sky.colored_vertex(half.left_top(), egui::Color32::from_rgb(0x8E, 0xC5, 0xF0));
sky.colored_vertex(half.right_top(), egui::Color32::from_rgb(0xB8, 0xDC, 0xF8));
sky.colored_vertex(
half.right_bottom(),
egui::Color32::from_rgb(0xFA, 0xF7, 0xEE),
);
sky.colored_vertex(
half.left_bottom(),
egui::Color32::from_rgb(0xF2, 0xF0, 0xE8),
);
sky.add_triangle(0, 1, 2);
sky.add_triangle(0, 2, 3);
painter.rect_filled(rect, radius, egui::Color32::from_rgb(0x14, 0x16, 0x22));
painter.add(sky);
let font_px = 22.0 * style.scale as f32;
let unit = font_px / MPV_FONT;
let face = theme::subtitle_face(
ui.ctx(),
style.font.as_deref().unwrap_or(SubtitleStyle::DEFAULT_FONT),
style.bold,
style.italic,
);
let (family, made_up_bold, made_up_italic) = match face {
Some(face) => (
theme::subtitle_family(),
style.bold && !face.bold,
style.italic && !face.italic,
),
None if style.bold => (theme::semibold(), false, style.italic),
None => (egui::FontFamily::Proportional, false, style.italic),
};
let job = egui::text::LayoutJob::single_section(
"Can you read this on both sides?".to_owned(),
egui::TextFormat {
font_id: egui::FontId::new(font_px, family),
color: egui::Color32::PLACEHOLDER,
italics: made_up_italic,
..Default::default()
},
);
let galley = painter.layout_job(job);
let size = galley.size();
// Bottom of the line at `position` percent down, as mpv places it, kept
// inside the strip.
let margin = 8.0;
let bottom = rect.top() + HEIGHT * style.position as f32 / 100.0 - margin;
let top = bottom
.min(rect.bottom() - margin)
.max(rect.top() + margin + size.y)
- size.y;
let at = egui::pos2(rect.center().x - size.x / 2.0, top);
let colour = |colour: Colour| {
let [r, g, b, a] = colour.to_array();
egui::Color32::from_rgba_unmultiplied(r, g, b, a)
};
let shadow = style.shadow_offset as f32 * unit;
match style.backdrop {
SubtitleBackdrop::Box => {
let padding = 6.0 * unit;
let line = egui::Rect::from_min_size(at, size).expand(padding);
if shadow > 0.0 {
painter.rect_filled(
line.translate(egui::vec2(shadow, shadow)),
0.0,
colour(style.shadow_colour),
);
}
painter.rect_filled(line, 0.0, colour(style.box_colour));
}
SubtitleBackdrop::Outline => {
let outline = style.outline_size as f32 * unit;
let rings = |offset: egui::Vec2, ink: egui::Color32| {
if outline <= 0.0 {
painter.galley_with_override_text_color(at + offset, galley.clone(), ink);
return;
}
for step in 0..8 {
let angle = step as f32 * std::f32::consts::TAU / 8.0;
let ring = egui::vec2(angle.cos(), angle.sin()) * outline;
painter.galley_with_override_text_color(
at + offset + ring,
galley.clone(),
ink,
);
}
};
if shadow > 0.0 {
rings(egui::vec2(shadow, shadow), colour(style.shadow_colour));
}
if outline > 0.0 {
rings(egui::Vec2::ZERO, colour(style.outline_colour));
}
}
}
if made_up_bold {
// libass thickens a face with no bold by a stroke of its own colour.
let thicken = (font_px / 40.0).max(0.5);
painter.galley_with_override_text_color(
at + egui::vec2(thicken, 0.0),
galley.clone(),
colour(style.colour),
);
}
painter.galley_with_override_text_color(at, galley, colour(style.colour));
}
/// 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,
"Storage",
@@ -405,8 +874,8 @@ fn metadata_form(
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears. With AniList, \
the ids it matched — not the titles — then go to ani.zip for episode names and \
wide artwork.",
a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \
not the titles — go to ani.zip for episode names and wide artwork.",
));
ui.add_space(theme::space::L);
@@ -420,7 +889,7 @@ fn metadata_form(
if settings.enabled {
ui.add_space(theme::space::L);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
for provider in ProviderId::CHOOSABLE {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
@@ -432,28 +901,35 @@ fn metadata_form(
}
}
// TMDB's is the only key there is: the provider's with TMDB chosen,
// and with AniList, the art of films TVDB has none for.
ui.add_space(theme::space::L);
if settings.provider.needs_api_key() {
ui.add_space(theme::space::L);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted("TMDB API key"));
} else {
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
"TMDB API key, optional. With one, films get TMDB's backdrops and logos, \
asked for by the film's TMDB id alone.",
));
}
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: ProviderId::Tmdb,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
+180 -29
View File
@@ -6,7 +6,10 @@
//! below is the only place a link is ever visible, and only while it is being
//! typed.
use std::collections::HashMap;
use pstr_core::Share;
use pstr_core::catalog::CrawlRecord;
use pstr_core::library::Library;
use crate::app::{Action, ShareForm};
@@ -16,6 +19,7 @@ use crate::ui;
pub fn show(
ui: &mut egui::Ui,
shares: &[Share],
crawls: &HashMap<String, CrawlRecord>,
library: &Library,
form: &mut ShareForm,
account: &mut ui::account::AccountPanel,
@@ -36,7 +40,7 @@ pub fn show(
.iter()
.filter(|title| title.share_ids.contains(&share.id))
.count();
share_row(ui, share, titles, actions);
share_row(ui, share, titles, crawls.get(&share.id), form, actions);
}
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()
.fill(theme::card())
.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 {
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);
// 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| {
@@ -81,12 +108,121 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
if ui.button("Crawl").clicked() {
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);
}
/// 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>) {
ui::section(ui, "Add a share");
ui.label(ui::muted(
@@ -102,24 +238,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Name"));
ui.add(
egui::TextEdit::singleline(&mut form.name)
.hint_text("Anime")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::M);
// Enter in any field of the form submits it, as the sign-in
// form's do.
let mut entered = false;
let mut submitted_from = |response: egui::Response| {
entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
};
ui.label(ui::muted("Link"));
ui.horizontal(|ui| {
let paste = 60.0;
let field = egui::Id::new("share-link");
ui.add(
egui::TextEdit::singleline(&mut form.url)
.id(field)
.password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(ui.available_width() - paste),
let field = link_field_id();
if std::mem::take(&mut form.focus) {
ui.memory_mut(|memory| memory.request_focus(field));
}
submitted_from(
ui.add(
egui::TextEdit::singleline(&mut form.url)
.id(field)
.password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(ui.available_width() - paste),
),
);
// The link is masked, so pasting is the only sensible way in —
// and a button says that more plainly than the field does.
@@ -144,20 +284,31 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
}
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");
if form.has_password {
ui.add_space(theme::space::S);
ui.add(
egui::TextEdit::singleline(&mut form.password)
.password(true)
.hint_text("Link password")
.desired_width(f32::INFINITY),
submitted_from(
ui.add(
egui::TextEdit::singleline(&mut form.password)
.password(true)
.hint_text("Link password")
.desired_width(f32::INFINITY),
),
);
}
ui.add_space(theme::space::L);
let ready = !form.name.trim().is_empty()
&& !form.url.trim().is_empty()
let ready = !form.url.trim().is_empty()
&& problem.is_none()
&& (!form.has_password || !form.password.is_empty())
&& !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.horizontal(|ui| {
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
.inner
.clicked()
{
.clicked();
if clicked || (ready && entered) {
actions.push(Action::AddShare {
name: form.name.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 {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("adding…"));
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
ui.label(ui::muted("A name and a link are needed."));
} else if form.url.trim().is_empty() {
ui.label(ui::muted("Paste a share link to add it."));
}
});
});
+5 -1
View File
@@ -36,9 +36,13 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
("N / P", "Next and previous episode"),
("[ / ]", "Slower and faster"),
("S", "Skip the opening or the credits"),
("Z / X", "Subtitles 0.1 s earlier and later"),
("E", "The list of episodes"),
("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"),
(
"Esc",
"Close a panel, leave fullscreen, then leave the player",
),
],
),
];
+510 -127
View File
@@ -8,6 +8,7 @@ use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
use crate::playback::PlaybackTarget;
use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, Art, Card};
pub struct OfflineView<'a> {
@@ -15,15 +16,29 @@ pub struct OfflineView<'a> {
pub files: &'a std::collections::HashSet<DownloadKey>,
}
/// Where the title stands beyond its own files.
pub struct Context<'a> {
/// The keys of its whole franchise in release order, if it has one.
pub franchise: Option<&'a [String]>,
/// The keys of the titles most like it, best first.
pub similar: &'a [String],
pub on_list: bool,
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
key: &str,
franchise: Option<&[String]>,
context: Context<'_>,
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let Context {
franchise,
similar,
on_list,
} = context;
let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell.
@@ -31,7 +46,7 @@ pub fn show(
return;
};
if back_link(ui).clicked() {
if back_link(ui, "Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(theme::space::S);
@@ -39,14 +54,21 @@ pub fn show(
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, &offline, actions);
header(ui, art, title, on_list, &offline, actions);
ui.add_space(theme::space::XL);
if let Some(keys) = franchise {
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
if members.len() > 1 {
ui::section(ui, "In this franchise");
franchise_row(ui, art, title, &members, actions);
cover_row(
ui,
art,
("franchise", &title.key),
Some(title),
&members,
actions,
);
ui.add_space(theme::space::XL);
}
}
@@ -84,7 +106,27 @@ pub fn show(
.enumerate()
.map(|(index, label)| (index, label.as_str()))
.collect();
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
// A long-running show's row of pills ran off the page;
// past a handful it is a list to pick from instead.
let chosen = if choices.len() > SEASON_PILLS {
egui::ComboBox::from_id_salt(("season-list", &title.key))
.selected_text(labels[picked].as_str())
.height(360.0)
.show_ui(ui, |ui| {
let mut chosen = None;
for &(index, label) in &choices {
if ui.selectable_label(index == picked, label).clicked() {
chosen = Some(index);
}
}
chosen
})
.inner
.flatten()
} else {
ui::widgets::segmented(ui, picked, &choices)
};
if let Some(index) = chosen {
picked = index;
ui.data_mut(|data| data.insert_temp(picked_id, index));
}
@@ -96,6 +138,7 @@ pub fn show(
)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
season_download(ui, title, season, &offline, actions);
season_watched(ui, title, season, actions);
});
});
ui.add_space(theme::space::L);
@@ -107,61 +150,173 @@ pub fn show(
episode_row(ui, art, title, season, episode, &offline, actions);
}
ui.add_space(theme::space::L);
let cast = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.details.as_ref())
.map(|details| details.cast.clone())
.unwrap_or_default();
if !cast.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "Cast");
cast_row(ui, art, &title.key, &cast, actions);
ui.add_space(theme::space::L);
}
// Under the episodes rather than over them: it is where to go
// once this one is done, not what the page is for.
let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect();
if !similar.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "More like this");
cover_row(ui, art, ("similar", &title.key), None, &similar, actions);
ui.add_space(theme::space::L);
}
});
}
/// Every title of the franchise, in release order, as covers — this one
/// marked rather than left out, so the row says where it sits in the story.
fn franchise_row(
/// The characters, each a round portrait over their name and who voices
/// them. A voice actor opens every title in the library they are in.
fn cast_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
key: &str,
cast: &[pstr_core::metadata::CastMember],
actions: &mut Vec<Action>,
) {
const SIZE: f32 = 88.0;
ui::shelf(ui, ("cast", key), |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::L;
for (i, member) in cast.iter().enumerate() {
let tile = ui.allocate_ui(egui::vec2(SIZE + 16.0, SIZE + 52.0), |ui| {
ui.vertical_centered(|ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::hover());
let picture = member
.character_image
.as_deref()
.or(member.actor_image.as_deref())
.and_then(|url| art.picture(url));
let painter = ui.painter();
match picture {
Some(texture) => {
let shape = egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::same((SIZE / 2.0) as u8),
egui::Color32::WHITE,
)
.with_texture(
texture.id(),
super::cover_uv(texture.size_vec2(), rect.size()),
);
painter.add(shape);
}
None => {
painter.circle_filled(rect.center(), SIZE / 2.0, theme::card());
}
}
ui.add(
egui::Label::new(
theme::Role::Caption
.rich(&member.character)
.color(theme::text()),
)
.truncate()
.selectable(false),
);
if let Some(actor) = &member.actor {
ui.add(
egui::Label::new(ui::muted(actor))
.truncate()
.selectable(false),
);
}
rect
})
.inner
});
// The whole tile opens the actor's page, names included, not only the portrait.
let Some(actor) = &member.actor else { continue };
let response = ui
.interact(
tile.response.rect,
ui.id().with(("cast", i)),
egui::Sense::click(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!("Everything in the library with {actor}"));
if response.hovered() {
ui.painter().circle_stroke(
tile.inner.center(),
SIZE / 2.0,
egui::Stroke::new(2.0, theme::accent()),
);
}
if response.clicked() {
actions.push(Action::Goto(Page::Browse {
facet: pstr_core::discover::Facet::Person(actor.clone()),
from: Some(key.to_owned()),
}));
}
}
});
});
}
/// A row of covers: every title of the franchise in release order, with
/// `current` marked rather than left out, so the row says where it sits in
/// the story — or, with no `current`, the titles like this one.
fn cover_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
id: impl std::hash::Hash + std::fmt::Debug,
current: Option<&Title>,
members: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt(("franchise", &current.key))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = member.key == current.key;
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
ui::shelf(ui, id, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = current.is_some_and(|current| member.key == current.key);
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
});
}
});
});
}
/// A title's poster, at the poster's own shape, with how far into it the
@@ -190,9 +345,10 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
}
/// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
/// filled button beside the page's real actions competed with them. `to`
/// names where it goes.
pub(crate) fn back_link(ui: &mut egui::Ui, to: &str) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} {to}", egui_phosphor::regular::ARROW_LEFT));
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() {
ui.painter().hline(
@@ -207,6 +363,29 @@ fn back_link(ui: &mut egui::Ui) -> egui::Response {
}
/// 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::tonal_button(ui, 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(
ui: &mut egui::Ui,
title: &Title,
@@ -222,9 +401,10 @@ fn season_download(
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
for key in season_keys {
actions.push(Action::RemoveDownload(key, false));
}
actions.push(Action::DeleteOffline(
season_keys,
format!("{} {}", title.name, season.label()),
));
}
} else if ui
.button("Download season")
@@ -246,6 +426,7 @@ fn header(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
on_list: bool,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
@@ -297,7 +478,9 @@ fn header(
// Text in a column a person can read, with the picture to the
// right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&shown).strong());
if ui::logo_heading(ui, art, title, 120.0).is_none() {
ui.label(theme::Role::Hero.rich(&shown).color(theme::text()));
}
// Whichever of the share's name and the provider's is not the one
// shown. Worth showing rather than only replacing: a viewer should
// be able to tell what the match actually matched, and find the
@@ -320,6 +503,30 @@ fn header(
}
ui.add_space(theme::space::XS);
ui.label(ui::muted(meta_line(title, found.as_ref())));
// What the files are, rather than what the title is: set apart
// from the line above as small outlined marks, the way a box
// prints them.
//
// After where the title stands, which is the one fact here that
// changes week to week.
let quality = title.quality().badges();
let status = found
.as_ref()
.and_then(|found| found.details.as_ref())
.and_then(|details| status_text(details, now()));
if !quality.is_empty() || status.is_some() {
ui.add_space(theme::space::XS);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::XS;
if let Some((text, live)) = &status {
ui::widgets::status_chip(ui, text, *live);
ui.add_space(theme::space::XS);
}
for badge in quality {
ui::widgets::spec_badge(ui, badge);
}
});
}
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
@@ -346,8 +553,7 @@ fn header(
}
// Beside the button it is the alternative to.
if resuming
&& ui
.button("Start over")
&& ui::tonal_button(ui, "Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
@@ -357,20 +563,17 @@ fn header(
None,
)));
}
ui::list_button(ui, &title.key, on_list, actions);
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
if all_offline {
if ui
.button("Delete show")
if ui::tonal_button(ui, "Delete show")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
for key in title_keys {
actions.push(Action::RemoveDownload(key, false));
}
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
}
} else if ui
.button("Download show")
} else if ui::tonal_button(ui, "Download show")
.on_hover_text("Download every episode for disconnected playback")
.clicked()
{
@@ -405,13 +608,15 @@ fn header(
// wrongly. Offered whether or not anything matched: the two
// cases it exists for are a title with no poster at all and a
// title wearing someone else's.
if ui
.button(match &found {
if ui::tonal_button(
ui,
match &found {
Some(_) => "Change match",
None => "Match…",
})
.on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked()
},
)
.on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked()
{
actions.push(Action::OpenMatcher(title.key.clone()));
}
@@ -419,14 +624,14 @@ fn header(
if let Some(found) = &found {
ui.add_space(theme::space::L);
description(ui, found);
description(ui, found, &title.key, actions);
}
});
});
if let Some((texture, index)) = backdrop {
ui.add_space(theme::space::L);
band(ui, &texture, index);
band(ui, &texture, index, art.mood(title));
}
}
@@ -440,10 +645,15 @@ fn header(
/// page colour so text laid over it stays readable whatever the still happens to
/// be, and a fade into the page along the bottom so the band ends rather than
/// stops.
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
fn band(
ui: &mut egui::Ui,
texture: &egui::TextureHandle,
index: egui::layers::ShapeIdx,
mood: Option<egui::Color32>,
) {
// Full width of the window, not of the content: the panel's own margin is
// what the band has to reach past, or it reads as a picture in a box.
const BLEED: f32 = 18.0;
const BLEED: f32 = super::PAGE_MARGIN.x;
let content = ui.min_rect();
let rect = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
@@ -477,7 +687,12 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
//
// Heavier still when there is no fade coming, since then these are the only
// things standing between the text and the picture.
//
// Both in the page colour tinted by the picture's own, so each title's
// page carries its light; the fade at the bottom stays the page's, since
// that is what it ends in.
let ramps = theme::ramps_on();
let veil = theme::mood_veil(mood);
layers.push(
egui::epaint::RectShape::filled(
rect,
@@ -485,7 +700,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
// Lighter with the ramps on: the horizontal one below is solid
// where the text is, so this coat only has to calm the picture's
// right side, not make it disappear.
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
veil.gamma_multiply(if ramps { 0.72 } else { 0.94 }),
)
.into(),
);
@@ -497,7 +712,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
layers.push(theme::fade_shape(
ui.ctx(),
text_side,
theme::background(),
veil,
theme::Direction::Horizontal,
// Solid at the left, where the poster and the text are.
false,
@@ -524,17 +739,31 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
}
/// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
let tags = found
.details
.as_ref()
///
/// Its genres, studios and director are chips that open every title in the
/// library sharing them.
fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mut Vec<Action>) {
use pstr_core::discover::Facet;
let browse = |facet: Facet| {
Action::Goto(Page::Browse {
facet,
from: Some(key.to_owned()),
})
};
let details = found.details.as_ref();
let tags = details
.map(|details| details.tags.as_slice())
.unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres {
ui::widgets::chip(ui, genre);
if ui::widgets::link_chip(ui, genre)
.on_hover_text(format!("Every {genre} title"))
.clicked()
{
actions.push(browse(Facet::Genre(genre.clone())));
}
}
// After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as.
@@ -544,6 +773,38 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
});
ui.add_space(theme::space::M);
}
// Who made it, out of the grey line and onto chips, so the names lead
// somewhere.
let studios = details
.map(|details| &details.studios[..details.studios.len().min(2)])
.unwrap_or_default();
let directors = details
.map(|details| &details.directors[..details.directors.len().min(1)])
.unwrap_or_default();
if !studios.is_empty() || !directors.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (label, names, facet) in [
("Studio", studios, Facet::Studio as fn(String) -> Facet),
("Director", directors, Facet::Person),
] {
if names.is_empty() {
continue;
}
ui.label(ui::muted(label));
for name in names {
if ui::widgets::link_chip(ui, name)
.on_hover_text(format!("Everything in the library by {name}"))
.clicked()
{
actions.push(browse(facet(name.clone())));
}
}
ui.add_space(theme::space::S);
}
});
ui.add_space(theme::space::M);
}
if let Some(overview) = &found.overview {
// Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes —
@@ -581,12 +842,28 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
}
}
}
if let Some(url) = &found.url {
let trailer = details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url());
if found.url.is_some() || trailer.is_some() {
ui.add_space(theme::space::M);
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
ui.horizontal(|ui| {
// In the browser: the app itself never requests the trailer.
if let Some(trailer) = &trailer {
ui.hyperlink_to(
ui::muted(format!("{} Trailer", egui_phosphor::regular::FILM_STRIP)),
trailer,
)
.on_hover_text("Opens in your browser");
ui.add_space(theme::space::L);
}
if let Some(url) = &found.url {
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
}
});
}
}
@@ -605,6 +882,15 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(year) = title.year {
parts.push(year.to_string());
}
// A film's running time is what it costs to start it. A series' is its
// episodes', which each row says.
if title.kind == TitleKind::Film
&& let Some(minutes) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.runtime)
{
parts.push(ui::format_runtime(minutes));
}
if title.kind == TitleKind::Series {
if title.seasons.len() > 1 {
parts.push(ui::library::plural(title.seasons.len(), "season"));
@@ -616,29 +902,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.push(format!("{watched} watched"));
}
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
if let Some(season) = details.season_label() {
parts.push(season);
}
parts.extend(details.studios.iter().take(2).cloned());
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
if let Some((episode, at)) = details.next_airing(now) {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
parts.push(format!("episode {episode} airs {when}"));
} else if details.is_airing() {
parts.push("airing".to_owned());
}
if let Some(season) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.season_label())
{
parts.push(season);
}
if let Some(found) = found {
if let Some(rating) = found.rating {
@@ -657,6 +925,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.join(" · ")
}
/// The most seasons picked from a row of pills; past it, a list.
const SEASON_PILLS: usize = 5;
/// Unix seconds, now.
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
/// What the status chip says — `Airing · episode 8 Sat 1 Nov, 14:00`,
/// `Finished` — and whether more is coming.
fn status_text(details: &pstr_core::metadata::TitleDetails, now: i64) -> Option<(String, bool)> {
let next = details.next_airing(now);
let label = details.status_label().or(next.map(|_| "Airing"))?;
let live = next.is_some() || details.is_airing();
Some(match next {
Some((episode, at)) => {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
(format!("{label} · episode {episode} {when}"), live)
}
None => (label.to_owned(), live),
})
}
/// How big an episode's still is drawn.
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
@@ -677,6 +976,7 @@ fn episode_row(
found.name.clone(),
found.overview.clone(),
found.air_date.clone(),
found.runtime,
)
});
let watched = episode.is_watched();
@@ -708,14 +1008,17 @@ fn episode_row(
.unwrap_or_default();
let name = found
.as_ref()
.and_then(|(name, _, _)| name.clone())
.and_then(|(name, ..)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let mut job = egui::text::LayoutJob::default();
if !numbering.is_empty() {
job.append(
&format!("{numbering} "),
0.0,
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
egui::TextFormat::simple(
egui::FontId::new(theme::Role::Label.size(), theme::semibold()),
theme::muted(),
),
);
}
job.append(
@@ -738,9 +1041,10 @@ fn episode_row(
ui.label(ui::muted(details(
episode,
found.as_ref().and_then(|f| f.2.as_deref()),
found.as_ref().and_then(|f| f.3),
)));
if let Some((_, Some(overview), _)) = &found {
if let Some((_, Some(overview), ..)) = &found {
let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Caption.font(),
@@ -760,14 +1064,33 @@ fn episode_row(
let cluster = egui::vec2(ui.available_width(), STILL.y);
let layout = egui::Layout::right_to_left(egui::Align::Center);
ui.allocate_ui_with_layout(cluster, layout, |ui| {
if ui::widgets::watched_mark(ui, watched)
.on_hover_text(if watched {
"Mark unwatched"
} else {
"Mark watched"
})
.clicked()
{
let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
"Mark unwatched. Right-click for more."
} else {
"Mark watched. Right-click for more."
});
// Joining a show partway is the usual reason to mark
// 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 {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
@@ -793,9 +1116,9 @@ fn episode_row(
ui.add_space(theme::space::XXS);
}
/// The grey line under an episode's name: when it aired, how big it is, and
/// where it would resume.
fn details(episode: &Episode, air_date: Option<&str>) -> String {
/// The grey line under an episode's name: when it aired, how long it runs,
/// how big it is, and where it would resume.
fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
let mut parts = Vec::new();
if let Some(at) = episode.resume_at() {
let left = episode
@@ -810,6 +1133,9 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
if let Some(date) = air_date {
parts.push(date.to_owned());
}
if let Some(minutes) = runtime {
parts.push(ui::format_runtime(minutes));
}
match episode.node.size {
// A file the share says is empty is one that will not play — an upload
// that never finished, usually. Worth saying on the row rather than
@@ -836,6 +1162,7 @@ pub(crate) fn still(
actions: &mut Vec<Action>,
) {
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) {
return;
}
@@ -844,12 +1171,20 @@ pub(crate) fn still(
painter.rect_filled(rect, radius, theme::card_hover());
// Asked for only once the row is on screen: a season of twenty-five is
// twenty-five requests, and most of them are never scrolled to.
if let Some(texture) = art.still(&title.key, episode) {
let texture = art.still(&title.key, episode);
// Faded in like a tile's art, so a season's stills settle rather than pop.
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
texture.is_some(),
theme::motion::ARRIVE,
);
if let Some(texture) = texture {
let tint = if watched {
egui::Color32::from_gray(150)
} else {
egui::Color32::WHITE
};
}
.gamma_multiply(art_in);
painter.add(
egui::epaint::RectShape::filled(rect, radius, tint)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
@@ -910,6 +1245,21 @@ pub(crate) fn still(
theme::accent_fill(&painter, played, radius, 0.0);
}
// Seen, said on the picture as well as by the mark at the row's end: a
// season is scanned by its stills, and a dimmed one alone read as one that
// had not loaded.
if watched {
let corner = rect.right_top() + egui::vec2(-14.0, 14.0);
painter.circle_filled(corner, 9.0, theme::accent());
painter.text(
corner,
egui::Align2::CENTER_CENTER,
egui_phosphor::regular::CHECK,
theme::Role::Micro.font(),
theme::on_accent(),
);
}
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(if episode.resume_at().is_some() {
@@ -941,7 +1291,7 @@ fn download(
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
.clicked()
{
actions.push(Action::RemoveDownload(key, false));
actions.push(Action::DeleteOffline(vec![key], episode.label()));
}
return;
}
@@ -993,3 +1343,36 @@ fn key_of(episode: &Episode) -> DownloadKey {
link_id: episode.node.link_id.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn details(status: &str, next: Option<(u32, i64)>) -> pstr_core::metadata::TitleDetails {
pstr_core::metadata::TitleDetails {
status: Some(status.to_owned()),
next_episode: next.map(|(episode, _)| episode),
next_airing_at: next.map(|(_, at)| at),
..Default::default()
}
}
#[test]
fn the_status_chip_says_when_the_next_episode_airs_and_only_while_it_is_ahead() {
let (text, live) = status_text(&details("RELEASING", Some((8, 2_000))), 1_000).unwrap();
assert!(text.starts_with("Airing · episode 8 "), "{text}");
assert!(live);
// Fetched a while ago: the date has passed, so it says nothing of it.
let (text, live) = status_text(&details("RELEASING", Some((8, 500))), 1_000).unwrap();
assert_eq!(text, "Airing");
assert!(live);
assert_eq!(
status_text(&details("FINISHED", None), 1_000),
Some(("Finished".to_owned(), false))
);
assert_eq!(
status_text(&pstr_core::metadata::TitleDetails::default(), 0),
None
);
}
}
+13 -1
View File
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::app::Action;
use crate::theme;
use crate::ui::widgets::Named;
/// How long a message stays up.
const SECONDS: f64 = 5.0;
@@ -52,6 +53,17 @@ impl Toasts {
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(
&mut self,
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)],
Stroke::new(1.5, ink),
);
if response.on_hover_text("Dismiss").clicked() {
if response.hint("Dismiss").clicked() {
pressed = Pressed::Close;
}
if let Some((label, _)) = &toast.action {
+132 -54
View File
@@ -11,12 +11,15 @@
//! and neither mutates: this module draws.
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
use pstr_core::prefs::SubtitleStyle;
use pstr_player::{MAX_VOLUME, Track, TrackKind};
use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui;
use crate::ui::player::{PlayerPanel, SleepChoice};
use crate::ui::widgets::Named;
/// 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.
@@ -40,6 +43,9 @@ const ROW_HEIGHT_FULL: f32 = 44.0;
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
const ROW_HEIGHT_MINI: f32 = 34.0;
/// The speeds on offer, and the steps `[` and `]` walk.
pub const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
/// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours {
@@ -52,6 +58,8 @@ pub fn full(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
panel: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>,
) {
seek_bar(ui, playback, actions, 6.0);
@@ -97,13 +105,11 @@ pub fn full(
}));
}
// Right cluster: everything that is a choice rather than a move.
// Right cluster: everything that is a choice rather than a move. Each
// opens its panel down the right of the picture rather than a menu
// under the pointer: a list of twelve chapters is read, not aimed at.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(theme::space::M);
speed_menu(ui, playback, actions);
ui.add_space(theme::space::M);
chapter_menu(ui, playback, actions);
panel_buttons(ui, playback, panel, sleep, actions);
ui.add_space(theme::space::M);
volume(ui, playback, actions);
});
@@ -163,7 +169,7 @@ pub fn mini(
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));
}
}
@@ -313,7 +319,7 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
}
if response
.on_hover_text(if playback.paused {
.hint(if playback.paused {
"Play (Space)"
} else {
"Pause (Space)"
@@ -354,7 +360,7 @@ fn step_button(
enabled,
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);
}
}
@@ -436,7 +442,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
};
if ui
.button(theme::Role::Subhead.rich(icon))
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.hint(format!("Mute (M) — {:.0}%", playback.volume))
.clicked()
{
actions.push(Action::ToggleMute);
@@ -469,6 +475,8 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
}
ui.separator();
track_options(ui, &subtitles, TrackKind::Subtitle, actions);
ui.separator();
subtitle_adjustments(ui, playback, actions);
})
.response
.on_hover_text("Subtitle track");
@@ -488,6 +496,49 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
}
}
/// What is worth changing mid-episode without a trip to Settings: the size and
/// place of the line, and its timing for a release that is out of step.
pub fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let mut style = playback.subtitle_style.clone();
egui::Grid::new("subtitle-adjustments")
.num_columns(2)
.spacing([theme::space::M, theme::space::S])
.show(ui, |ui| {
ui.label("Size");
ui::settings::percent_slider(
ui,
&mut style.scale,
SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE,
);
ui.end_row();
ui.label("Position");
ui::settings::subtitle_position_slider(ui, &mut style.position);
ui.end_row();
ui.label("Delay");
ui.horizontal(|ui| {
if ui.button("−").on_hover_text("Earlier (Z)").clicked() {
actions.push(Action::NudgeSubtitleDelay(-0.1));
}
ui.label(ui::format_delay(playback.subtitle_delay));
if ui.button("+").on_hover_text("Later (X)").clicked() {
actions.push(Action::NudgeSubtitleDelay(0.1));
}
if ui
.add_enabled(playback.subtitle_delay != 0.0, egui::Button::new("Reset"))
.clicked()
{
actions.push(Action::ResetSubtitleDelay);
}
});
ui.end_row();
});
if style != playback.subtitle_style {
actions.push(Action::SetSubtitleStyle(style.sanitized()));
}
}
fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
for track in tracks {
if ui.selectable_label(track.selected, track.label()).clicked() {
@@ -500,53 +551,73 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
}
}
/// How fast it plays. Says so on the button only when it is not normal speed,
/// because that is the one case worth noticing at a glance.
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let label = if (playback.speed - 1.0).abs() < 1e-6 {
"Speed".to_owned()
} else {
format!("Speed · {}", ui::format_speed(playback.speed))
/// The buttons that open the player's panels, right to left: tracks, speed,
/// chapters, sleep. Each shows only when there is a choice to make, and says
/// what is chosen when that is worth noticing at a glance.
fn panel_buttons(
ui: &mut egui::Ui,
playback: &Playback,
open: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>,
) {
let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| {
let response = if open == Some(panel) {
ui::accent_button(ui, &text)
} else {
ui::glass_button(ui, &text)
};
if response.on_hover_text(hint).clicked() {
actions.push(Action::TogglePanel(panel));
}
ui.add_space(theme::space::S);
};
ui.menu_button(label, |ui| {
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
if ui
.selectable_label(
(playback.speed - speed).abs() < 1e-6,
ui::format_speed(speed),
)
.clicked()
{
actions.push(Action::SetSpeed(speed));
ui.close();
}
}
})
.response
.on_hover_text("Playback speed ([ and ])");
}
/// Jump to a chapter. Only drawn for a file that has more than one, which for
/// most releases means anime and not much else.
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
if playback.chapters.len() < 2 {
return;
let subtitles = playback.tracks_of(TrackKind::Subtitle).count();
let audio = playback.tracks_of(TrackKind::Audio).count();
if subtitles > 0 || audio > 1 {
let shown = match playback.selected_track(TrackKind::Subtitle) {
Some(track) => short(track),
None if subtitles > 0 => "Off".to_owned(),
None => playback
.selected_track(TrackKind::Audio)
.map(short)
.unwrap_or_default(),
};
button(
ui,
PlayerPanel::Tracks,
format!("{} {shown}", egui_phosphor::regular::SUBTITLES),
"Audio and subtitles",
);
}
let current = playback.chapter().map(|chapter| chapter.index);
ui.menu_button("Chapters", |ui| {
for chapter in &playback.chapters {
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label());
if ui
.selectable_label(current == Some(chapter.index), label)
.clicked()
{
actions.push(Action::Player(Command::SeekTo(chapter.start)));
ui.close();
}
}
})
.response
.on_hover_text("Chapters");
let speed = if (playback.speed - 1.0).abs() < 1e-6 {
egui_phosphor::regular::GAUGE.to_owned()
} else {
format!(
"{} {}",
egui_phosphor::regular::GAUGE,
ui::format_speed(playback.speed)
)
};
button(ui, PlayerPanel::Speed, speed, "Playback speed ([ and ])");
// Only for a file with more than one, which for most releases means anime
// and not much else.
if playback.chapters.len() > 1 {
button(
ui,
PlayerPanel::Chapters,
egui_phosphor::regular::LIST_NUMBERS.to_owned(),
"Chapters",
);
}
let moon = egui_phosphor::regular::MOON;
let sleep = match sleep {
None => moon.to_owned(),
Some(SleepChoice::EndOfEpisode) => format!("{moon} End"),
Some(SleepChoice::Minutes(left)) => format!("{moon} {left} min"),
};
button(ui, PlayerPanel::Sleep, sleep, "Sleep timer");
}
/// A track in as few words as fit on a button: what distinguishes it from the
@@ -566,6 +637,13 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
let width = ui.available_width();
let (rect, response) =
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();
// Grows rather than jumps when the pointer arrives.
+106
View File
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
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.
///
/// 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 {
const SIZE: f32 = 20.0;
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) {
let ctx = ui.ctx();
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 {
const SIZE: f32 = 30.0;
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) {
return response;
}
@@ -356,6 +390,52 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A fact about the files rather than the title — 4K, HDR, Dual audio — as
/// a small outlined mark: quieter than a [`chip`], which is something the
/// title is filed under.
pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
.stroke(egui::Stroke::new(1.0, theme::muted().gamma_multiply(0.7)))
.corner_radius(CornerRadius::same(theme::radius::BADGE))
.inner_margin(egui::Margin::symmetric(5, 1))
.show(ui, |ui| {
ui.label(
theme::Role::Micro
.rich(text)
.family(theme::semibold())
.color(theme::muted()),
);
})
.response
}
/// Where a title stands — `Airing · episode 8 Sat 1 Nov, 14:00`, `Finished` —
/// as a [`chip`] with a dot in front: the accent while more is coming, muted
/// once it is all out.
pub fn status_chip(ui: &mut egui::Ui, text: &str, live: bool) -> egui::Response {
egui::Frame::new()
.fill(theme::card_hover().gamma_multiply(0.8))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::XS;
let (dot, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
ui.painter().circle_filled(
dot.center(),
3.0,
if live {
theme::accent()
} else {
theme::muted()
},
);
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
});
})
.response
}
/// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
@@ -367,3 +447,29 @@ pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
})
.response
}
/// A [`chip`] that opens something: lighter under the pointer, with a ring
/// when keyboard focus is on it.
pub fn link_chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
let mut prepared = egui::Frame::new()
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.begin(ui);
prepared
.content_ui
.label(theme::Role::Caption.rich(text).color(theme::text()));
let response = prepared
.allocate_space(ui)
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
prepared.frame.fill = if response.hovered() {
theme::card_hover()
} else {
theme::card_hover().gamma_multiply(0.8)
};
if response.has_focus() {
prepared.frame.stroke = egui::Stroke::new(1.5, theme::accent());
}
prepared.paint(ui);
response
}
+84 -35
View File
@@ -9,7 +9,7 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::account::{PlaceKind, SignIn};
use pstr_core::catalog::{Catalog, build_rows};
use pstr_core::catalog::{Catalog, build_rows, build_sidecars};
use pstr_core::config::AppDirs;
use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync;
@@ -256,8 +256,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else {
None
};
let share = store
.add(&name, &url, password.as_deref())
let (share, _) = store
.add_verified(&name, &url, password.as_deref())
.await
.context("add share")?;
println!("added {} ({})", share.name, share.id);
}
@@ -323,11 +324,29 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let sync = WatchSync::open(&dirs).context("open sync state")?;
let catalog =
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
let report = sync.sync(&account, &catalog).await.context("sync")?;
let report = sync
.sync(&account, &catalog, &store)
.await
.context("sync")?;
let profile = &report.profile;
for id in &profile.shares_removed {
catalog
.lock()
.remove_share(id)
.context("drop a removed share")?;
}
println!(
"read {} other device(s), took {} newer position(s), {}",
"read {} other device(s), took {} newer position(s), \
{} share(s) added, {} removed, settings {}, {}",
report.devices,
report.applied,
profile.shares_added.len() + profile.shares_changed.len(),
profile.shares_removed.len(),
if profile.settings {
"updated"
} else {
"unchanged"
},
if report.uploaded {
"uploaded this device's history"
} else {
@@ -358,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
Command::Remove { id } => {
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}");
}
@@ -388,6 +425,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
catalog
.replace_share(&share_id, &rows)
.and_then(|()| {
catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes))
})
.with_context(|| format!("store {share_id}"))?;
println!(
@@ -506,15 +546,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
settings.provider.label(),
settings.provider.description()
);
if settings.provider.needs_api_key() {
println!(
"api key: {}",
match pstr_meta::settings::api_key(settings.provider) {
Some(_) => "stored in the credential store",
None => "not set — run `pstr metadata set --api-key`",
}
);
}
let stored_key = pstr_meta::settings::api_key(ProviderId::Tmdb).is_some();
println!(
"tmdb key: {}",
match (stored_key, settings.provider.needs_api_key()) {
(true, true) => "stored in the credential store",
(true, false) => "stored — used for the art of films TVDB has none for",
(false, true) => "not set — run `pstr metadata set --api-key`",
(false, false) => "not set (optional, for film art)",
}
);
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let stored = catalog.all_metadata()?;
@@ -535,12 +576,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
api_key,
} => {
if let Some(name) = provider {
settings.provider = ProviderId::parse(&name).ok_or_else(|| {
anyhow::anyhow!(
"unknown provider {name:?}; try one of: {}",
ProviderId::ALL.map(|provider| provider.as_str()).join(", ")
)
})?;
settings.provider = ProviderId::parse(&name)
.filter(|provider| ProviderId::CHOOSABLE.contains(provider))
.ok_or_else(|| {
anyhow::anyhow!(
"unknown provider {name:?}; try one of: {}",
ProviderId::CHOOSABLE
.map(|provider| provider.as_str())
.join(", ")
)
})?;
}
if on {
settings.enabled = true;
@@ -550,12 +595,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
}
if api_key {
let key = rpassword::prompt_password(format!(
"{} API key (empty to forget it): ",
settings.provider.label()
))
.context("read the API key")?;
pstr_meta::settings::set_api_key(settings.provider, &key)
// TMDB's is the only key there is: the provider with TMDB
// chosen, and film art with AniList.
let key = rpassword::prompt_password("TMDB API key (empty to forget it): ")
.context("read the API key")?;
pstr_meta::settings::set_api_key(ProviderId::Tmdb, &key)
.context("store the API key")?;
}
@@ -566,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// `Engine::set_metadata_config`.
if off || settings.enabled {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let chain = pstr_meta::service::chain_of(settings.provider);
let stale = catalog
.all_metadata()?
.values()
.any(|record| record.provider != settings.provider);
.any(|record| !chain.contains(&record.provider));
if off || stale {
catalog.clear_metadata()?;
println!("cleared the stored answers");
@@ -587,13 +632,13 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
anyhow::bail!(
"lookups are off — run `pstr metadata set --on` first.\n\
Matching sends the titles in your catalog to {}.",
settings.provider.label()
pstr_meta::service::chain_label(settings.provider)
);
}
let service = pstr_meta::MetadataService::new(
&settings,
pstr_meta::settings::api_key(settings.provider),
pstr_meta::settings::api_key(ProviderId::Tmdb),
)
.context("build the metadata provider")?;
@@ -606,8 +651,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
library.titles,
&stored,
&enriched,
settings.provider,
service.has_details(),
&service.chain(),
force,
);
if let Some(limit) = limit {
@@ -621,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
println!(
"looking up {} titles against {}…",
pending.len(),
settings.provider.label()
service.label()
);
// Serial, not concurrent: both providers rate-limit by the minute,
@@ -651,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
Some(found) => {
matched += 1;
println!(
" {name:<40} → {} ({})",
" {name:<40} → {} ({}){}",
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
.unwrap_or_else(|| "-".into()),
if found.provider == service.provider() {
String::new()
} else {
format!(" on {}", found.provider.label())
}
);
}
None => println!(" {name:<40} → no match"),
@@ -677,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
);
catalog.set_enrichment(
&outcome.title_key,
settings.provider,
outcome.provider,
unix_now(),
enrichment,
)?;
+44 -12
View File
@@ -33,7 +33,8 @@ use std::collections::HashMap;
use std::sync::Arc;
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::ids::NodeUid;
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 \
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 —
/// see `ShareStore::open`.
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
.iter()
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
.map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
.collect()
}
@@ -424,9 +431,9 @@ impl AccountStore {
/// back the account.
///
/// The key passphrases are derived here, while the token still has the scope
/// the key salts need. Loading the addresses is what proves the mailbox
/// password: it unlocks the user keys, and a wrong password unlocks none of
/// them. Only the passphrases are stored; the password goes no further.
/// the key salts need. Unlocking the user keys is what proves the mailbox
/// password: a wrong one unlocks none of them. Only the passphrases are
/// stored; the password goes no further.
async fn finish(
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
@@ -435,10 +442,10 @@ async fn finish(
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
probe
.account()
.addresses()
.unlock()
.await
.map_err(|error| match error {
ProtonError::InvalidOperation(_) => {
ProtonError::KeysLocked => {
Error::SignIn("that mailbox password did not unlock the account".to_owned())
}
other => other.into(),
@@ -447,10 +454,10 @@ async fn finish(
.account()
.key_passphrases()
.await?
.into_iter()
.iter()
.map(|(id, passphrase)| {
String::from_utf8(passphrase)
.map(|passphrase| (id, passphrase))
std::str::from_utf8(passphrase)
.map(|passphrase| (id.to_owned(), passphrase.to_owned()))
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
})
.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
/// [`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));
}
if api.code == ResponseCode::Blocked {
return Error::SignIn(SIGN_IN_BLOCKED.to_owned());
}
if api.http_status == 422 && !api.is_human_verification_required() {
return Error::SignIn(if api.message.is_empty() {
"Proton did not accept that address and password".to_owned()
@@ -642,6 +653,27 @@ mod tests {
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]
fn a_stored_session_keeps_its_password_mode() {
let params = stored().resume_parameters();
+120
View File
@@ -280,6 +280,81 @@ pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
/// a tab, a play glyph, never body copy.
pub const MIN_CONTRAST: f32 = 3.0;
/// The colour a picture leaves behind: its pixels averaged, with the vivid
/// ones counting for more. `None` for a picture with nothing opaque in it.
///
/// Weighted rather than a plain mean, because a plain mean of almost any
/// still is a brownish grey — the sky and the shadows cancel out, and the one
/// red coat that makes the poster what it is barely moves the sum. Each pixel
/// counts once plus [`MOOD_CHROMA_WEIGHT`] times its chroma, so a saturated
/// patch pulls the result towards itself without a single pixel deciding it.
///
/// Takes whatever pixels the caller has, in any order and at any size: every
/// client hands over a picture already squeezed small, so this is a few
/// hundred values, not a frame.
pub fn mood(pixels: impl IntoIterator<Item = (Rgb, u8)>) -> Option<Rgb> {
let (mut r, mut g, mut b, mut total) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
for (pixel, alpha) in pixels {
// Half transparent and below is a logo's anti-aliasing or a poster's
// rounded corner, not the picture.
if alpha < 128 {
continue;
}
let high = pixel.r.max(pixel.g).max(pixel.b);
let low = pixel.r.min(pixel.g).min(pixel.b);
let weight = 1.0 + MOOD_CHROMA_WEIGHT * f32::from(high - low) / 255.0;
r += weight * f32::from(pixel.r);
g += weight * f32::from(pixel.g);
b += weight * f32::from(pixel.b);
total += weight;
}
(total > 0.0).then(|| {
let channel = |sum: f32| (sum / total).round().clamp(0.0, 255.0) as u8;
Rgb::new(channel(r), channel(g), channel(b))
})
}
/// How much more a fully saturated pixel counts towards [`mood`] than a grey
/// one.
pub const MOOD_CHROMA_WEIGHT: f32 = 4.0;
/// The brightest channel a mood is brought down to before it tints a dark
/// palette. Any brighter and the veil stops being the page with a hint of the
/// picture in it, and text over it loses the contrast the veil is there for.
pub const MOOD_DEPTH: u8 = 60;
/// How far a dark palette's veil moves from the page colour towards the mood.
pub const MOOD_DARK: f32 = 0.55;
/// The same for a light palette — much less, because a light page tinted as
/// hard reads as a coloured page rather than a lit one.
pub const MOOD_LIGHT: f32 = 0.12;
/// The page colour, tinted by a picture's [`mood`]: what the veils over a
/// title's backdrop are painted in, so each title's banner carries its own
/// light rather than the same flat colour.
///
/// On a dark palette the mood is first darkened to [`MOOD_DEPTH`] — only its
/// hue and saturation are wanted, never its brightness — and the page moves
/// [`MOOD_DARK`] of the way to it. A light palette moves [`MOOD_LIGHT`] of the
/// way to the mood as it is.
pub fn mood_veil(background: Rgb, mood: Rgb, light: bool) -> Rgb {
if light {
return mix(background, mood, MOOD_LIGHT);
}
let high = mood.r.max(mood.g).max(mood.b);
if high == 0 {
return mix(background, mood, MOOD_DARK);
}
let scale = f32::from(MOOD_DEPTH.min(high)) / f32::from(high);
let deep = |channel: u8| (f32::from(channel) * scale).round() as u8;
mix(
background,
Rgb::new(deep(mood.r), deep(mood.g), deep(mood.b)),
MOOD_DARK,
)
}
/// Every colour a client draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
///
@@ -627,6 +702,51 @@ mod tests {
}
}
#[test]
fn a_picture_of_one_colour_has_that_colour_as_its_mood() {
let teal = Rgb::new(20, 140, 150);
assert_eq!(mood(std::iter::repeat_n((teal, 255), 16)), Some(teal));
}
#[test]
fn a_vivid_patch_outweighs_as_much_grey_in_the_mood() {
let red = Rgb::new(220, 30, 30);
let grey = Rgb::new(120, 120, 120);
let found = mood([(red, 255), (grey, 255)]).unwrap();
assert!(found.r > 180, "{found:?}");
assert!(found.g < 60 && found.b < 60, "{found:?}");
}
#[test]
fn transparent_pixels_do_not_count_towards_the_mood() {
let blue = Rgb::new(30, 60, 200);
assert_eq!(
mood([(blue, 255), (Rgb::new(255, 255, 255), 0)]),
Some(blue)
);
assert_eq!(mood([(blue, 10)]), None);
assert_eq!(mood(std::iter::empty()), None);
}
#[test]
fn a_dark_veil_takes_the_moods_hue_but_never_its_brightness() {
let background = Rgb::new(11, 11, 15);
let veil = mood_veil(background, Rgb::new(255, 240, 40), false);
// Warmer than the page…
assert!(veil.r > background.r && veil.g > background.g, "{veil:?}");
// …and still dark enough to be a page under white text.
assert!(luminance(veil) < 0.05, "{veil:?}");
assert!(contrast(veil, Rgb::new(255, 255, 255)) > 12.0, "{veil:?}");
}
#[test]
fn a_light_veil_moves_only_a_little_way_to_the_mood() {
let background = Rgb::new(239, 241, 245);
let veil = mood_veil(background, Rgb::new(200, 20, 20), true);
assert!(veil.r >= veil.g, "{veil:?}");
assert!(luminance(veil) > 0.6, "{veil:?}");
}
#[test]
fn a_first_run_gets_the_palette_the_app_shipped_with() {
let appearance = Appearance::default();
+127 -2
View File
@@ -7,11 +7,14 @@
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::mylist::MyList;
/// 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 {
/// By the name shown, ignoring a leading article.
#[default]
@@ -50,6 +53,70 @@ 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,
/// On My List.
MyList,
}
impl Filter {
pub const ALL: [Self; 7] = [
Self::All,
Self::MyList,
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",
Self::MyList => "My List",
}
}
/// 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,
list: &MyList,
) -> 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)),
Self::MyList => list.contains(&title.key),
}
}
}
/// The name to show for `title`: the provider's, in the form the viewer
/// picked, else the share's.
pub fn display_name<'a>(
@@ -256,6 +323,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: Some(rating),
genres: Vec::new(),
episodes: None,
@@ -392,4 +460,61 @@ mod tests {
assert_eq!(found[0].key, "sousou no frieren");
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;
let list = MyList::default();
assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list));
assert!(
Filter::Films.admits(&film, none, &list) && !Filter::Films.admits(&series, none, &list)
);
assert!(
Filter::Series.admits(&series, none, &list)
&& !Filter::Series.admits(&film, none, &list)
);
// Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film, none, &list));
assert!(!Filter::Watching.admits(&film, none, &list));
}
#[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];
let list = MyList::default();
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b", &list));
assert!(!Filter::Downloaded.admits(show, |_| false, &list));
}
#[test]
fn the_my_list_filter_admits_only_what_is_on_the_list() {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let mut list = MyList::default();
list.add("s", 1);
assert!(Filter::MyList.admits(&series, |_| false, &list));
assert!(!Filter::MyList.admits(&film, |_| false, &list));
}
}
File diff suppressed because it is too large. Load diff
+229
View File
@@ -294,6 +294,116 @@ pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
/// A stretch of a file someone has marked as its opening or its ending, for
/// a file whose own chapters do not say: AniSkip's answer, in the file's
/// seconds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkipRange {
/// [`ChapterRole::Opening`] or [`ChapterRole::Ending`].
pub role: ChapterRole,
pub start: f64,
pub end: f64,
}
impl SkipRange {
/// What the chapter cut for it is called, which is also what [`roles`]
/// reads it back as.
fn label(self) -> Option<&'static str> {
match self.role {
ChapterRole::Opening => Some("Opening"),
ChapterRole::Ending => Some("Ending"),
ChapterRole::Preview | ChapterRole::Content => None,
}
}
}
/// How close two chapter starts may be before they are the same place.
const SAME_PLACE_SECONDS: f64 = 1.0;
/// The file's chapters with `ranges` cut into them, as chapters named
/// `Opening` and `Ending` — so that skipping, skipping on its own, the up-next
/// countdown and the marks on the seek bar all work for a file that came with
/// no such chapters, through the same code as one that did.
///
/// **The file wins.** When its own chapters already mark an opening or an
/// ending, it is returned as it is: what the release says about its own cut
/// beats what a viewer of some other release said. A file chapter that starts
/// inside a range is dropped, since it would split the range in two; one that
/// starts where a range ends gives that place its name. Every chapter is
/// numbered again from zero, which is what the players key "already skipped"
/// by.
pub fn with_skip_ranges(
chapters: &[Chapter],
ranges: &[SkipRange],
duration: Option<f64>,
) -> Vec<Chapter> {
let own = roles(chapters, duration);
if ranges.is_empty()
|| own
.iter()
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending))
{
return chapters.to_vec();
}
let mut ranges: Vec<SkipRange> = ranges
.iter()
.copied()
.filter(|range| range.label().is_some() && range.start >= 0.0 && range.end > range.start)
.collect();
ranges.sort_by(|a, b| a.start.total_cmp(&b.start));
// Two answers that overlap cannot both be right; the earlier one stays.
let mut kept: Vec<SkipRange> = Vec::with_capacity(ranges.len());
for range in ranges {
if kept.last().is_none_or(|last| range.start >= last.end) {
kept.push(range);
}
}
let title_at = |position: f64| {
chapter_at(chapters, position).and_then(|index| chapters[index].title.clone())
};
let mut points: Vec<(f64, Option<String>)> = Vec::new();
for range in &kept {
points.push((range.start, range.label().map(str::to_owned)));
let ends_the_file =
duration.is_some_and(|duration| range.end >= duration - SAME_PLACE_SECONDS);
if !ends_the_file {
points.push((range.end, title_at(range.end)));
}
}
let near_a_point = |start: f64, points: &[(f64, Option<String>)]| {
points
.iter()
.any(|(at, _)| (at - start).abs() < SAME_PLACE_SECONDS)
};
let inside_a_range = |start: f64| {
kept.iter()
.any(|range| start > range.start && start < range.end)
};
let from_ranges = points.clone();
for chapter in chapters {
if !near_a_point(chapter.start, &from_ranges) && !inside_a_range(chapter.start) {
points.push((chapter.start, chapter.title.clone()));
}
}
// Whatever comes before the first mark is a place in the file too.
if !points.iter().any(|(at, _)| *at < SAME_PLACE_SECONDS) {
points.push((0.0, None));
}
points.sort_by(|a, b| a.0.total_cmp(&b.0));
points
.into_iter()
.enumerate()
.map(|(index, (start, title))| Chapter {
index: index as i64,
title,
start,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -480,4 +590,123 @@ mod tests {
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
fn range(role: ChapterRole, start: f64, end: f64) -> SkipRange {
SkipRange { role, start, end }
}
fn shape(chapters: &[Chapter]) -> Vec<(i64, Option<&str>, f64)> {
chapters
.iter()
.map(|chapter| (chapter.index, chapter.title.as_deref(), chapter.start))
.collect()
}
#[test]
fn a_file_with_no_chapters_gets_its_opening_and_ending_as_chapters_that_skip() {
let ranges = [
range(ChapterRole::Opening, 90.0, 180.0),
range(ChapterRole::Ending, 1300.0, 1390.0),
];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![
(0, None, 0.0),
(1, Some("Opening"), 90.0),
(2, None, 180.0),
(3, Some("Ending"), 1300.0),
(4, None, 1390.0),
]
);
let roles = roles(&chapters, Some(1420.0));
assert_eq!(roles[1], ChapterRole::Opening);
assert_eq!(roles[3], ChapterRole::Ending);
}
#[test]
fn a_files_own_opening_beats_anything_aniskip_says() {
let own = vec![
Chapter {
index: 0,
title: Some("Prologue".into()),
start: 0.0,
},
Chapter {
index: 1,
title: Some("OP".into()),
start: 60.0,
},
Chapter {
index: 2,
title: Some("Part A".into()),
start: 150.0,
},
];
let ranges = [range(ChapterRole::Opening, 70.0, 160.0)];
assert_eq!(with_skip_ranges(&own, &ranges, Some(1420.0)), own);
}
#[test]
fn unnamed_chapters_inside_a_range_give_way_and_one_at_its_end_keeps_its_name() {
let own = vec![
Chapter {
index: 0,
title: None,
start: 0.0,
},
Chapter {
index: 1,
title: None,
start: 89.6,
},
Chapter {
index: 2,
title: None,
start: 120.0,
},
Chapter {
index: 3,
title: Some("Part A".into()),
start: 180.0,
},
];
let ranges = [range(ChapterRole::Opening, 90.0, 180.0)];
let chapters = with_skip_ranges(&own, &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![
(0, None, 0.0),
(1, Some("Opening"), 90.0),
(2, Some("Part A"), 180.0)
]
);
}
#[test]
fn an_ending_that_runs_to_the_end_of_the_file_adds_no_chapter_after_it() {
let ranges = [range(ChapterRole::Ending, 1330.0, 1419.8)];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![(0, None, 0.0), (1, Some("Ending"), 1330.0)]
);
assert_eq!(
credits_start(&chapters, &roles(&chapters, Some(1420.0))),
Some(1330.0)
);
}
#[test]
fn overlapping_answers_keep_the_earlier_one() {
let ranges = [
range(ChapterRole::Opening, 0.0, 90.0),
range(ChapterRole::Ending, 60.0, 150.0),
];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![(0, Some("Opening"), 0.0), (1, None, 90.0)]
);
}
}
+599
View File
@@ -0,0 +1,599 @@
//! Getting from one title to the next: "More like this", the "Because you
//! watched" row, and the pages a genre, studio or person chip opens.
//!
//! All of it is read off what the provider already said about each title —
//! genres, tags, studios, directors, format — so none of it costs a request,
//! and it only ever offers what the library holds.
use std::collections::{HashMap, HashSet};
use crate::library::Title;
use crate::metadata::{MetadataRecord, TitleMetadata, TitleNames};
use crate::shelves::Shelf;
/// What a chip on a title page stands for, and the page it opens.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Facet {
Genre(String),
Studio(String),
/// A director, or a voice actor in the cast.
Person(String),
}
impl Facet {
/// The name on the chip, and the heading of its page.
pub fn name(&self) -> &str {
match self {
Self::Genre(name) | Self::Studio(name) | Self::Person(name) => name,
}
}
/// What kind of thing the name is, said over the heading.
pub fn kind(&self) -> &'static str {
match self {
Self::Genre(_) => "Genre",
Self::Studio(_) => "Studio",
Self::Person(_) => "Person",
}
}
/// Whether a title the provider described as `found` belongs on the page.
pub fn admits(&self, found: &TitleMetadata) -> bool {
let details = found.details.as_ref();
match self {
Self::Genre(name) => found.genres.iter().any(|genre| genre == name),
Self::Studio(name) => {
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
}
Self::Person(name) => details.is_some_and(|details| {
details.directors.iter().any(|person| person == name)
|| details
.cast
.iter()
.any(|member| member.actor.as_deref() == Some(name.as_str()))
}),
}
}
/// The keys of the titles in `titles` the page lists, in library order.
pub fn titles(
&self,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
) -> Vec<String> {
titles
.iter()
.filter(|title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| self.admits(found))
})
.map(|title| title.key.clone())
.collect()
}
}
/// One episode a search found by the name the provider gave it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpisodeMatch {
pub title_key: String,
pub share_id: String,
pub link_id: String,
/// `S01E05`, as the row everywhere else says it.
pub label: String,
/// The provider's name for it.
pub name: String,
}
/// What a search found besides titles: episodes by name, and the people,
/// studios and genres whose names hold the query, each with how many titles
/// in the library carry it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Found {
pub episodes: Vec<EpisodeMatch>,
/// Directors and studios.
pub people: Vec<(Facet, usize)>,
pub genres: Vec<(Facet, usize)>,
}
impl Found {
pub fn is_empty(&self) -> bool {
self.episodes.is_empty() && self.people.is_empty() && self.genres.is_empty()
}
}
/// How many episodes a search lists: past this the query is too loose for a
/// list of episodes to help.
const EPISODES: usize = 12;
/// Everything but titles that `query` finds. Nothing for a query under two
/// letters, which would hold half of every name.
pub fn find(
library: &crate::library::Library,
metadata: &HashMap<String, MetadataRecord>,
guides: &HashMap<String, crate::metadata::EpisodeGuide>,
query: &str,
) -> Found {
let needle = query.trim().to_lowercase();
if needle.chars().count() < 2 {
return Found::default();
}
let holds = |name: &str| name.to_lowercase().contains(&needle);
let mut episodes = Vec::new();
'titles: for title in &library.titles {
let Some(guide) = guides.get(&title.key) else {
continue;
};
for episode in title.episodes() {
let node = &episode.node;
let Some(name) = node
.parsed
.episode
.and_then(|number| guide.get(node.parsed.season, number))
.and_then(|found| found.name.as_deref())
.filter(|name| holds(name))
else {
continue;
};
episodes.push(EpisodeMatch {
title_key: title.key.clone(),
share_id: node.share_id.clone(),
link_id: node.link_id.clone(),
label: episode.numbering().unwrap_or_else(|| episode.label()),
name: name.to_owned(),
});
if episodes.len() == EPISODES {
break 'titles;
}
}
}
let mut people: HashMap<Facet, usize> = HashMap::new();
let mut genres: HashMap<Facet, usize> = HashMap::new();
for title in &library.titles {
let Some(found) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
else {
continue;
};
for genre in found.genres.iter().filter(|genre| holds(genre)) {
*genres.entry(Facet::Genre(genre.clone())).or_default() += 1;
}
let Some(details) = &found.details else {
continue;
};
// One count per title, however many characters a voice actor has
// in it.
let made: HashSet<Facet> = details
.directors
.iter()
.chain(
details
.cast
.iter()
.filter_map(|member| member.actor.as_ref()),
)
.map(|name| Facet::Person(name.clone()))
.chain(
details
.studios
.iter()
.map(|name| Facet::Studio(name.clone())),
)
.filter(|facet| holds(facet.name()))
.collect();
for facet in made {
*people.entry(facet).or_default() += 1;
}
}
// Most titles first, then by name, so the order does not change from one
// keystroke to the next.
let ranked = |found: HashMap<Facet, usize>| {
let mut found: Vec<(Facet, usize)> = found.into_iter().collect();
found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name())));
found
};
Found {
episodes,
people: ranked(people),
genres: ranked(genres),
}
}
/// How much one shared trait says two titles are alike. A director is close
/// to a signature; a genre is one of three or four on every entry.
const DIRECTOR: u32 = 4;
const STUDIO: u32 = 3;
const GENRE: u32 = 2;
const TAG: u32 = 1;
const FORMAT: u32 = 1;
/// How many of a title's tags count. A provider ranks them, and the tail of
/// the list ("Male Protagonist", "Primarily Teen Cast") is on half of
/// everything.
const TAGS: usize = 8;
/// The least score a title needs to be offered: one director, or a studio and
/// a tag, or two genres. One genre in common is not a recommendation.
const MIN_SCORE: u32 = 4;
/// What a title is, for comparing: only the parts that score.
struct Traits<'a> {
genres: HashSet<&'a str>,
tags: HashSet<&'a str>,
studios: HashSet<&'a str>,
directors: HashSet<&'a str>,
format: Option<&'a str>,
popularity: u32,
}
impl<'a> Traits<'a> {
fn of(found: &'a TitleMetadata) -> Self {
let details = found.details.as_ref();
let strings = |list: Option<&'a Vec<String>>, take: usize| -> HashSet<&'a str> {
list.into_iter()
.flatten()
.take(take)
.map(String::as_str)
.collect()
};
Self {
genres: found.genres.iter().map(String::as_str).collect(),
tags: strings(details.map(|details| &details.tags), TAGS),
studios: strings(details.map(|details| &details.studios), usize::MAX),
directors: strings(details.map(|details| &details.directors), usize::MAX),
format: details.and_then(|details| details.format.as_deref()),
popularity: details.and_then(|details| details.popularity).unwrap_or(0),
}
}
fn score(&self, other: &Self) -> u32 {
let shared = |a: &HashSet<&str>, b: &HashSet<&str>| a.intersection(b).count() as u32;
let traits = DIRECTOR * shared(&self.directors, &other.directors)
+ STUDIO * shared(&self.studios, &other.studios)
+ GENRE * shared(&self.genres, &other.genres)
+ TAG * shared(&self.tags, &other.tags);
// Only on top of something else: a TV series is like every other one.
if traits > 0 && self.format.is_some() && self.format == other.format {
traits + FORMAT
} else {
traits
}
}
}
/// Up to `limit` keys of the titles most like `key`, best first.
///
/// Never its own franchise — that is the row above it — and one title per
/// other franchise, its best scoring: three films of one story are one
/// suggestion, not three.
pub fn more_like(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
limit: usize,
) -> Vec<String> {
ranked(key, titles, metadata, |_| true, limit)
}
/// The row seeded by the title most recently watched to the end: titles like
/// it the viewer has not started. `None` when nothing is finished, or nothing
/// is like it.
pub fn because_you_watched(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: TitleNames,
limit: usize,
) -> Option<Shelf> {
let seed = titles
.iter()
.filter(|title| {
title.episode_count() > 0
&& title.watched_count() == title.episode_count()
&& metadata
.get(&title.key)
.is_some_and(|record| record.metadata.is_some())
})
.max_by_key(|title| title.last_played())?;
let keys = ranked(
&seed.key,
titles,
metadata,
|title| title.last_played() == 0,
limit,
);
(!keys.is_empty()).then(|| Shelf {
label: format!(
"Because you watched {}",
crate::browse::display_name(seed, metadata, names)
),
keys,
})
}
fn ranked(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
admit: impl Fn(&Title) -> bool,
limit: usize,
) -> Vec<String> {
let found = |title: &Title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
};
let Some(seed) = titles
.iter()
.find(|title| title.key == key)
.and_then(found)
.map(Traits::of)
else {
return Vec::new();
};
let refs: Vec<&Title> = titles.iter().collect();
let franchise_of: Vec<usize> = {
let mut of = vec![0; titles.len()];
for (at, franchise) in crate::franchise::group(&refs, metadata)
.into_iter()
.enumerate()
{
for member in franchise.members {
of[member] = at;
}
}
of
};
let own = titles
.iter()
.position(|title| title.key == key)
.map(|at| franchise_of[at]);
// The best of each franchise: (score, popularity, index).
let mut best: HashMap<usize, (u32, u32, usize)> = HashMap::new();
for (index, title) in titles.iter().enumerate() {
if Some(franchise_of[index]) == own || !admit(title) {
continue;
}
let Some(traits) = found(title).map(Traits::of) else {
continue;
};
let score = seed.score(&traits);
if score < MIN_SCORE {
continue;
}
let candidate = (score, traits.popularity, index);
best.entry(franchise_of[index])
.and_modify(|held| {
if (candidate.0, candidate.1) > (held.0, held.1) {
*held = candidate;
}
})
.or_insert(candidate);
}
let mut picked: Vec<(u32, u32, usize)> = best.into_values().collect();
// Best score, then most followed, then library order for a stable row.
picked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
picked
.into_iter()
.take(limit)
.map(|(_, _, index)| titles[index].key.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::{Library, TitleKind};
use crate::metadata::{ProviderId, TitleDetails};
struct Entry {
key: &'static str,
genres: &'static [&'static str],
studio: &'static str,
director: &'static str,
related: &'static [&'static str],
}
fn record(entry: &Entry) -> (String, MetadataRecord) {
(
entry.key.into(),
MetadataRecord {
title_key: entry.key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: entry.key.into(),
name: entry.key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: entry.genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
url: None,
details: Some(TitleDetails {
studios: vec![entry.studio.into()],
directors: vec![entry.director.into()],
related: entry.related.iter().map(|id| id.to_string()).collect(),
format: Some("TV".into()),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
},
)
}
fn file(key: &str) -> CatalogNode {
let name = format!("{key} - 01.mkv");
CatalogNode {
share_id: "s".into(),
link_id: key.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.clone(),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&name),
}
}
const ENTRIES: [Entry; 5] = [
Entry {
key: "frieren",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "frieren film",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &["frieren"],
},
Entry {
key: "bocchi",
genres: &["Comedy", "Music"],
studio: "CloverWorks",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "mushishi",
genres: &["Adventure", "Drama", "Fantasy", "Mystery"],
studio: "Artland",
director: "Hiroshi Nagahama",
related: &[],
},
Entry {
key: "lucky star",
genres: &["Comedy"],
studio: "Kyoto Animation",
director: "Yasuhiro Takemoto",
related: &[],
},
];
fn library(watched: &[&str]) -> (Library, HashMap<String, MetadataRecord>) {
let watch: HashMap<(String, String), WatchState> = watched
.iter()
.enumerate()
.map(|(at, key)| {
(
("s".to_owned(), key.to_string()),
WatchState {
position_secs: 0.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: 100 + at as i64,
},
)
})
.collect();
let library = Library::build(
ENTRIES.iter().map(|entry| file(entry.key)).collect(),
&watch,
);
// The library's keys are the folder names, normalised; the fixture's
// already are.
(library, ENTRIES.iter().map(record).collect())
}
#[test]
fn more_like_this_leaves_out_the_franchise_and_ranks_a_shared_director_and_genres() {
let (library, metadata) = library(&[]);
let similar = more_like("frieren", &library.titles, &metadata, 10);
// Mushishi shares three genres, Bocchi only the director; both the
// format.
// Lucky Star shares nothing and its film is Frieren's own franchise.
assert_eq!(similar, ["mushishi", "bocchi"]);
}
#[test]
fn one_genre_in_common_is_not_a_recommendation() {
let (library, metadata) = library(&[]);
assert!(more_like("lucky star", &library.titles, &metadata, 10).is_empty());
}
#[test]
fn because_you_watched_follows_the_last_finished_title_and_skips_what_was_started() {
let (library, metadata) = library(&["mushishi", "frieren"]);
let row =
because_you_watched(&library.titles, &metadata, TitleNames::default(), 10).unwrap();
assert_eq!(row.label, "Because you watched frieren");
// Mushishi is like it, but already watched.
assert_eq!(row.keys, ["bocchi"]);
assert!(
because_you_watched(&library.titles, &HashMap::new(), TitleNames::default(), 10)
.is_none()
);
}
#[test]
fn a_search_finds_episodes_by_name_and_the_people_and_genres_it_names() {
let (library, metadata) = library(&[]);
let guide = crate::metadata::EpisodeGuide::new(vec![crate::metadata::EpisodeMetadata {
season: None,
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("The Journey's End".into()),
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}]);
let guides = HashMap::from([("frieren".to_owned(), guide)]);
let found = find(&library, &metadata, &guides, "journey");
assert_eq!(found.episodes.len(), 1);
assert_eq!(found.episodes[0].title_key, "frieren");
assert_eq!(found.episodes[0].name, "The Journey's End");
let found = find(&library, &metadata, &guides, "saitou");
assert_eq!(
found.people,
[(Facet::Person("Keiichirou Saitou".into()), 3)]
);
let found = find(&library, &metadata, &guides, "com");
assert_eq!(found.genres, [(Facet::Genre("Comedy".into()), 2)]);
assert!(find(&library, &metadata, &guides, "c").is_empty());
}
#[test]
fn a_facet_page_lists_the_titles_that_carry_it() {
let (library, metadata) = library(&[]);
let person = Facet::Person("Keiichirou Saitou".into());
assert_eq!(
person.titles(&library.titles, &metadata).len(),
3,
"both Frieren titles and Bocchi"
);
assert_eq!(person.kind(), "Person");
let genre = Facet::Genre("Comedy".into());
let mut comedy = genre.titles(&library.titles, &metadata);
comedy.sort();
assert_eq!(comedy, ["bocchi", "lucky star"]);
assert!(
Facet::Studio("MAPPA".into())
.titles(&library.titles, &metadata)
.is_empty()
);
}
}
+68 -2
View File
@@ -46,15 +46,53 @@ pub enum Error {
/// mailbox password.
#[error("{0}")]
SignIn(String),
/// A public link with a password of its own was opened without one. The
/// handshake refusal says nothing a viewer can act on; this does.
#[error("This link has a password of its own. Enter it to add the link.")]
PasswordNeeded,
}
impl Error {
/// 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
/// was revoked or expired, or the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it.
/// was revoked or expired, the token lacks a scope only a password
/// 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 {
match self {
Self::SignedOut(_) => true,
Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true,
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
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>;
#[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());
}
}
+1
View File
@@ -177,6 +177,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+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);
}
}
+7
View File
@@ -11,14 +11,21 @@ pub mod browse;
pub mod catalog;
pub mod chapters;
pub mod config;
pub mod discover;
pub mod error;
pub mod franchise;
pub mod language;
pub mod library;
pub mod metadata;
pub mod mylist;
pub mod naming;
pub mod news;
pub mod prefs;
pub mod profile;
pub mod recent;
pub mod shares;
pub mod shelves;
pub mod subtitles;
pub mod sync;
pub use account::{Account, AccountStore};
+157 -6
View File
@@ -18,6 +18,7 @@
use std::collections::HashMap;
use crate::catalog::{CatalogNode, WatchState};
use crate::naming;
/// A file has been watched once playback passed this fraction of it. Short of
/// 1.0 because nobody sits through the credits, and a title that never reaches
@@ -158,6 +159,34 @@ impl Title {
.sum()
}
/// What most of its files' names say about their quality — see
/// [`naming::quality`]. A fact holds when more than half the files state
/// it, so one 720p special does not mark a 1080p show down.
pub fn quality(&self) -> naming::Quality {
let stated: Vec<naming::Quality> = self
.episodes()
.map(|episode| naming::quality(&episode.node.name))
.collect();
let most = |holds: &dyn Fn(&naming::Quality) -> bool| {
stated.iter().filter(|quality| holds(quality)).count() * 2 > stated.len()
};
// The height most files have, if most have the same one.
let mut heights: HashMap<u32, usize> = HashMap::new();
for height in stated.iter().filter_map(|quality| quality.height) {
*heights.entry(height).or_default() += 1;
}
let height = heights
.into_iter()
.find(|(_, files)| files * 2 > stated.len())
.map(|(height, _)| height);
naming::Quality {
height,
hdr: most(&|quality| quality.hdr),
ten_bit: most(&|quality| quality.ten_bit),
dual_audio: most(&|quality| quality.dual_audio),
}
}
pub fn watched_count(&self) -> usize {
self.episodes().filter(|e| e.is_watched()).count()
}
@@ -189,10 +218,44 @@ impl Title {
.max_by_key(|episode| episode.last_played())
}
/// What a click on the poster should play: whatever is part-watched, else
/// the first unwatched episode, else the first episode.
/// What Continue watching offers for this title: the part-watched 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> {
self.resume()
self.continue_with()
.or_else(|| self.resume())
.or_else(|| self.episodes().find(|episode| !episode.is_watched()))
.or_else(|| self.episodes().next())
}
@@ -357,13 +420,13 @@ impl Library {
self.titles.iter().find(|title| title.key == key)
}
/// Titles with something part-watched, most recent first. The top row of
/// the library.
/// Titles with something to go on with — see [`Title::continue_with`] —
/// most recent first. The top row of the library.
pub fn continue_watching(&self) -> Vec<&Title> {
let mut titles: Vec<&Title> = self
.titles
.iter()
.filter(|title| title.resume().is_some())
.filter(|title| title.continue_with().is_some())
.collect();
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
titles
@@ -533,6 +596,36 @@ mod tests {
assert_eq!(title.preceding("s1", "a"), None);
}
/// One odd file out — a 720p special, a release with no dub — does not
/// change what the title as a whole is said to be.
#[test]
fn a_title_states_the_quality_most_of_its_files_have() {
let library = Library::build(
vec![
node(
"s1",
"a",
"Show - S01E01 [1080p][Dual-Audio].mkv",
Some(1),
Some(1),
),
node(
"s1",
"b",
"Show - S01E02 [1080p][Dual-Audio].mkv",
Some(1),
Some(2),
),
node("s1", "c", "Show - S01E03 [720p].mkv", Some(1), Some(3)),
],
&HashMap::new(),
);
assert_eq!(
library.titles[0].quality().badges(),
["1080p", "Dual audio"]
);
}
#[test]
fn a_file_from_another_share_is_not_this_titles_neighbour() {
let library = Library::build(
@@ -688,6 +781,64 @@ mod tests {
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]
fn a_watch_state_set_in_place_matches_a_rebuild() {
let nodes = vec![
+208 -10
View File
@@ -39,15 +39,23 @@ pub enum ProviderId {
AniList,
/// The Movie Database. Film and television, needs a free API key.
Tmdb,
/// TVmaze. Television, no API key. Never chosen on its own: it answers
/// for what AniList has not got — see `pstr_meta::service`.
TvMaze,
}
impl ProviderId {
pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb];
/// Every provider a stored row can name.
pub const ALL: [Self; 3] = [Self::AniList, Self::Tmdb, Self::TvMaze];
/// The ones a viewer picks between in the settings.
pub const CHOOSABLE: [Self; 2] = [Self::AniList, Self::Tmdb];
pub fn as_str(self) -> &'static str {
match self {
Self::AniList => "anilist",
Self::Tmdb => "tmdb",
Self::TvMaze => "tvmaze",
}
}
@@ -56,6 +64,7 @@ impl ProviderId {
match self {
Self::AniList => "AniList",
Self::Tmdb => "TMDB",
Self::TvMaze => "TVmaze",
}
}
@@ -64,9 +73,11 @@ impl ProviderId {
match self {
Self::AniList => {
"Anime, with no API key and no account. Episode names and wide art come from \
ani.zip, which is sent the matched ids."
ani.zip, which is sent the matched ids. Shows AniList does not have are \
looked up on TVmaze."
}
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
Self::TvMaze => "Television, with no API key and no account.",
}
}
@@ -104,6 +115,11 @@ pub struct TitleMetadata {
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
/// grid cannot simply prefer one shape.
pub backdrop_url: Option<String>,
/// The title's name as its own lettering: a transparent PNG, drawn over
/// wide art in place of the name in type. Found by enrichment, like the
/// backdrop, and padded by its source — see `pstr-app`'s `trim_alpha`.
#[serde(default)]
pub logo_url: Option<String>,
/// Out of 10, however the provider scores.
pub rating: Option<f32>,
pub genres: Vec<String>,
@@ -161,6 +177,20 @@ pub struct TitleDetails {
/// side stories, recaps, retellings. What the library groups a franchise
/// by; see `crate::franchise`.
pub related: Vec<String>,
/// Minutes: an episode's length for a series, the whole running time for
/// a film.
pub runtime: Option<u32>,
/// The main characters and then the supporting ones, each with who voices
/// them in the original language.
pub cast: Vec<CastMember>,
/// Where the provider says its trailer is.
pub trailer: Option<Trailer>,
/// Which [`TitleDetails::REVISION`] wrote this. A stored record from an
/// older one lacks a field a newer build asks for, and is fetched again
/// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a
/// migration: until the refetch lands, the old details still group the
/// library and fill its shelves.
pub revision: u32,
/// The entries enrichment walked through to find the title's episodes —
/// its own sequels, by provider id. Stored apart from the rest, because
/// it is enrichment's answer rather than the match's.
@@ -168,7 +198,63 @@ pub struct TitleDetails {
pub chain: Vec<String>,
}
/// One character, and who voices them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CastMember {
pub character: String,
/// The character's portrait.
pub character_image: Option<String>,
/// `MAIN`, `SUPPORTING` or `BACKGROUND`.
pub role: Option<String>,
/// The voice actor in the original language.
pub actor: Option<String>,
pub actor_image: Option<String>,
}
/// A trailer, as the provider names it: a site and that site's id for it.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Trailer {
/// `youtube` or `dailymotion`.
pub site: String,
pub id: String,
}
impl Trailer {
/// The trailer's page, to open in the browser. The app itself never
/// requests it. `None` for a site this build does not know.
pub fn url(&self) -> Option<String> {
let id: String = self
.id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
.collect();
if id.is_empty() {
return None;
}
match self.site.to_ascii_lowercase().as_str() {
"youtube" => Some(format!("https://www.youtube.com/watch?v={id}")),
"dailymotion" => Some(format!("https://www.dailymotion.com/video/{id}")),
_ => None,
}
}
}
impl TitleDetails {
/// What a provider that fills these in today writes into
/// [`TitleDetails::revision`]. Raise it with a field that existing
/// records should be fetched again for. `1` added
/// [`TitleDetails::runtime`], `2` [`TitleDetails::cast`] and
/// [`TitleDetails::trailer`].
pub const REVISION: u32 = 2;
/// Whether these were written by this build's [`TitleDetails::REVISION`]
/// or a later one.
pub fn is_current(&self) -> bool {
self.revision >= Self::REVISION
}
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
pub fn format_label(&self) -> Option<&'static str> {
Some(match self.format.as_deref()? {
@@ -201,6 +287,20 @@ impl TitleDetails {
self.status.as_deref() == Some("RELEASING")
}
/// [`TitleDetails::status`] said for a person: `Airing`, `Finished`,
/// `Upcoming`, `On hiatus` or `Cancelled`. `None` for a status nobody
/// filled in, or one this build does not know.
pub fn status_label(&self) -> Option<&'static str> {
Some(match self.status.as_deref()? {
"RELEASING" => "Airing",
"FINISHED" => "Finished",
"NOT_YET_RELEASED" => "Upcoming",
"HIATUS" => "On hiatus",
"CANCELLED" => "Cancelled",
_ => return None,
})
}
/// The next episode and when it airs, when that is still after `now`.
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
let at = self.next_airing_at.filter(|at| *at > now)?;
@@ -317,6 +417,14 @@ pub struct EpisodeMetadata {
/// sorted on, and a provider that answers `2009` should not be a parse
/// error.
pub air_date: Option<String>,
/// Minutes.
#[serde(default)]
pub runtime: Option<u32>,
/// The provider entry's id on MyAnimeList, which is what AniSkip knows
/// openings and endings by. With `entry_number`, it names this episode
/// there.
#[serde(default)]
pub mal_id: Option<u32>,
}
/// What enriching a matched title found: its episodes, and art the search
@@ -331,6 +439,9 @@ pub struct Enrichment {
/// A 16:9 picture of the whole title, when one was found. `None` leaves
/// whatever backdrop the match already had.
pub backdrop_url: Option<String>,
/// The title's logo, when one was found. `None` leaves whatever the match
/// already had, as with the backdrop.
pub logo_url: Option<String>,
/// Every provider entry the episodes were taken from, the match's own
/// included. See [`TitleDetails::chain`].
pub chain: Vec<String>,
@@ -496,8 +607,13 @@ pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
impl MetadataRecord {
/// Whether this answer is still worth using rather than asking again.
pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool {
if self.provider != provider {
///
/// `chain` is the providers asked, in order. A match from any of them
/// stands. A miss is stored under the last one asked, and only stands for
/// a chain that ends there: one an earlier provider stored was never
/// followed by the providers after it.
pub fn is_fresh(&self, now: i64, chain: &[ProviderId]) -> bool {
if !chain.contains(&self.provider) {
return false;
}
// A hand-picked match does not go stale: nothing a re-ask could learn
@@ -507,8 +623,10 @@ impl MetadataRecord {
}
let ttl = if self.metadata.is_some() {
MATCH_TTL_SECS
} else {
} else if chain.last() == Some(&self.provider) {
MISS_TTL_SECS
} else {
return false;
};
now.saturating_sub(self.fetched_at) < ttl
}
@@ -539,6 +657,57 @@ impl Default for MetadataConfig {
mod tests {
use super::*;
#[test]
fn a_trailer_opens_on_its_own_site_and_an_unknown_site_or_odd_id_opens_nothing() {
let trailer = |site: &str, id: &str| Trailer {
site: site.into(),
id: id.into(),
};
assert_eq!(
trailer("youtube", "tR8YH0G67Rk").url().as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
assert_eq!(
trailer("dailymotion", "x7tgad0").url().as_deref(),
Some("https://www.dailymotion.com/video/x7tgad0")
);
assert_eq!(trailer("vimeo", "1").url(), None);
// Whatever the provider sent, only an id's own characters reach the
// address.
assert_eq!(
trailer("youtube", "a&b=c").url().as_deref(),
Some("https://www.youtube.com/watch?v=abc")
);
assert_eq!(trailer("youtube", "&?").url(), None);
}
#[test]
fn details_stored_before_the_cast_existed_still_read() {
let old = r#"{"studios":["MAPPA"],"revision":1}"#;
let details: TitleDetails = serde_json::from_str(old).unwrap();
assert!(details.cast.is_empty());
assert_eq!(details.trailer, None);
assert!(!details.is_current(), "fetched again for the cast");
}
#[test]
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
let status = |value: &str| {
TitleDetails {
status: Some(value.to_owned()),
..TitleDetails::default()
}
.status_label()
};
assert_eq!(status("RELEASING"), Some("Airing"));
assert_eq!(status("FINISHED"), Some("Finished"));
assert_eq!(status("NOT_YET_RELEASED"), Some("Upcoming"));
assert_eq!(status("HIATUS"), Some("On hiatus"));
assert_eq!(status("CANCELLED"), Some("Cancelled"));
assert_eq!(status("SOMETHING_NEW"), None);
assert_eq!(TitleDetails::default().status_label(), None);
}
fn metadata() -> TitleMetadata {
TitleMetadata {
provider: ProviderId::AniList,
@@ -550,6 +719,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: Some(26),
@@ -569,6 +739,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}
}
@@ -752,13 +924,13 @@ mod tests {
fetched_at: now - MISS_TTL_SECS - 1,
manual: false,
};
assert!(!miss.is_fresh(now, ProviderId::AniList));
assert!(!miss.is_fresh(now, &[ProviderId::AniList]));
let matched = MetadataRecord {
metadata: Some(metadata()),
..miss.clone()
};
assert!(matched.is_fresh(now, ProviderId::AniList));
assert!(matched.is_fresh(now, &[ProviderId::AniList]));
}
/// What the viewer picked by hand outlives every TTL: it is not an answer
@@ -772,9 +944,9 @@ mod tests {
fetched_at: 0,
manual: true,
};
assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList));
assert!(record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::AniList]));
// But it is still one provider's id, and it means nothing to the other.
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::Tmdb));
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::Tmdb]));
}
/// Switching provider must re-ask rather than trust the other one's answer —
@@ -788,6 +960,32 @@ mod tests {
fetched_at: 1_000_000_000,
manual: false,
};
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
assert!(!record.is_fresh(1_000_000_000, &[ProviderId::Tmdb]));
}
/// AniList's miss was stored before TVmaze followed it, and so says
/// nothing about what TVmaze has; TVmaze's miss, last in the chain, does.
/// A match from either stands.
#[test]
fn a_miss_only_stands_when_the_last_provider_asked_stored_it() {
let chain = [ProviderId::AniList, ProviderId::TvMaze];
let now = 1_000_000_000;
let miss = |provider| MetadataRecord {
title_key: "game of thrones".into(),
provider,
metadata: None,
fetched_at: now,
manual: false,
};
assert!(!miss(ProviderId::AniList).is_fresh(now, &chain));
assert!(miss(ProviderId::TvMaze).is_fresh(now, &chain));
assert!(miss(ProviderId::AniList).is_fresh(now, &[ProviderId::AniList]));
let found = |provider| MetadataRecord {
metadata: Some(metadata()),
..miss(provider)
};
assert!(found(ProviderId::AniList).is_fresh(now, &chain));
assert!(found(ProviderId::TvMaze).is_fresh(now, &chain));
}
}
+141
View File
@@ -0,0 +1,141 @@
//! My List: the titles the viewer put aside to watch, kept in
//! `my-list.json` beside the other settings.
//!
//! Keyed by title key, like a hand-picked match, so a title keeps its place on
//! the list across a recrawl and on every device that has the same share. A
//! title that leaves the library stays on the list, unseen, and is back on it
//! when the share brings it back.
//!
//! It travels with the profile, one `list/<title key>` register per title
//! ([`crate::profile`]): adding a title on the phone puts it on the laptop's
//! list, and taking it off on one takes it off on all of them.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// The titles on the list, by key, with when each was put there in Unix
/// seconds.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MyList {
#[serde(default)]
titles: BTreeMap<String, i64>,
}
impl MyList {
pub fn contains(&self, key: &str) -> bool {
self.titles.contains_key(key)
}
pub fn is_empty(&self) -> bool {
self.titles.is_empty()
}
/// When `key` was put on the list, if it is on it.
pub fn added_at(&self, key: &str) -> Option<i64> {
self.titles.get(key).copied()
}
/// Put `key` on the list at `at`. One already there keeps its place.
pub fn add(&mut self, key: &str, at: i64) {
self.titles.entry(key.to_owned()).or_insert(at);
}
pub fn remove(&mut self, key: &str) {
self.titles.remove(key);
}
/// Put `key` on the list or take it off, saying which.
pub fn toggle(&mut self, key: &str, at: i64) -> bool {
if self.contains(key) {
self.remove(key);
false
} else {
self.add(key, at);
true
}
}
/// Every key with when it was added, in key order.
pub fn entries(&self) -> impl Iterator<Item = (&str, i64)> {
self.titles.iter().map(|(key, at)| (key.as_str(), *at))
}
/// The keys, most recently added first: the order the row is in.
pub fn newest_first(&self) -> Vec<&str> {
let mut keys: Vec<(&str, i64)> = self.entries().collect();
keys.sort_by_key(|(_, at)| std::cmp::Reverse(*at));
keys.into_iter().map(|(key, _)| key).collect()
}
/// Set `key`'s entry as another device had it: on the list since `at`,
/// or, with `None`, off it.
pub(crate) fn set(&mut self, key: &str, at: Option<i64>) {
match at {
Some(at) => {
self.titles.insert(key.to_owned(), at);
}
None => self.remove(key),
}
}
}
pub(crate) fn file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("my-list.json")
}
/// The list, empty when there is no file yet.
pub fn load(dirs: &AppDirs) -> Result<MyList> {
Ok(read_json::<MyList>(&file(dirs))?.unwrap_or_default())
}
pub fn save(dirs: &AppDirs, list: &MyList) -> Result<()> {
write_json(&file(dirs), list)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_title_added_twice_keeps_the_time_it_was_first_added() {
let mut list = MyList::default();
list.add("frieren", 10);
list.add("frieren", 20);
assert_eq!(list.added_at("frieren"), Some(10));
}
#[test]
fn toggling_puts_a_title_on_the_list_and_takes_it_off_again() {
let mut list = MyList::default();
assert!(list.toggle("frieren", 10));
assert!(list.contains("frieren"));
assert!(!list.toggle("frieren", 20));
assert!(list.is_empty());
}
#[test]
fn the_row_shows_the_most_recently_added_title_first() {
let mut list = MyList::default();
list.add("a", 30);
list.add("b", 10);
list.add("c", 20);
assert_eq!(list.newest_first(), ["a", "c", "b"]);
}
#[test]
fn the_list_survives_a_save_and_a_load() {
let root = std::env::temp_dir().join(format!("pstr-mylist-{}", std::process::id()));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
assert!(load(&dirs).unwrap().is_empty());
let mut list = MyList::default();
list.add("one piece", 5);
save(&dirs, &list).unwrap();
assert_eq!(load(&dirs).unwrap(), list);
let _ = std::fs::remove_dir_all(&root);
}
}
+249 -12
View File
@@ -22,8 +22,9 @@ pub struct ParsedName {
/// Release year, from a parenthesised or bracketed four-digit group.
pub year: Option<u32>,
/// The episode's own name, when the filename states one after the
/// numbering (`S01E01-Mother and Children`). Never the quality and codec
/// tags that follow numbering in scene releases — see [`episode_title`].
/// numbering (`S01E01-Mother and Children`, `S01E01.Winter.Is.Coming.2160p`).
/// Never the quality and codec tags that follow numbering in scene
/// releases — see [`episode_title`].
pub episode_title: Option<String>,
}
@@ -98,17 +99,21 @@ struct NumberingMatch {
/// The episode's own name out of what followed the numbering.
///
/// Only taken when a dash introduces it. Scene releases separate their quality,
/// source and codec tags with the same character as their title words (`.`), so
/// without that requirement `Show.Name.S02E05.1080p.WEB-DL.x265` would yield an
/// "episode title" of `1080p WEB-DL x265`.
/// Taken when a dash introduces it, or from a scene release's dotted words up
/// to its first release tag. Scene releases separate their quality, source and
/// codec tags with the same character as their title words (`.`), so the tag
/// is what ends the name: `Show.S01E01.Winter.Is.Coming.2160p.BluRay` names
/// its episode, `Show.Name.S02E05.1080p.WEB-DL.x265` does not, and a dotted
/// run that no tag ends is not trusted to be a name at all.
fn episode_title(tail: &str) -> Option<String> {
let rest = tail.trim_start();
let rest = rest
let cleaned = match rest
.strip_prefix('-')
.or_else(|| rest.strip_prefix('\u{2013}'))?;
let cleaned = clean_title(rest, None);
.or_else(|| rest.strip_prefix('\u{2013}'))
{
Some(rest) => clean_title(rest, None),
None => dotted_title(rest.strip_prefix('.')?)?,
};
let cleaned = cleaned.trim().to_string();
if cleaned.is_empty() {
None
@@ -117,6 +122,110 @@ fn episode_title(tail: &str) -> Option<String> {
}
}
/// `Winter.Is.Coming.2160p.UHD…` → `Winter Is Coming`.
fn dotted_title(rest: &str) -> Option<String> {
let words: Vec<&str> = rest.split('.').collect();
let end = words.iter().position(|word| is_release_tag(word))?;
Some(words[..end].join(" "))
}
/// Whether a dotted word is a release's quality, source or codec rather than
/// part of a name.
fn is_release_tag(word: &str) -> bool {
const TAGS: &[&str] = &[
"4k", "uhd", "bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "web", "web-dl",
"webdl", "webrip", "hdtv", "dvdrip", "hdrip", "x264", "x265", "h264", "h265", "hevc",
"avc", "hdr", "hdr10", "dv", "10bit", "10-bit", "proper", "repack", "amzn", "nf", "dsnp",
"hmax", "atvp",
];
let word = word.to_ascii_lowercase();
let resolution = word.strip_suffix('p').is_some_and(|digits| {
(3..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
});
resolution || TAGS.contains(&word.as_str())
}
/// What a release name says about its picture and sound.
///
/// Read for the facts line of a title's page, and nothing else: no release
/// group's name is ever taken, and a name that says nothing yields the default.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Quality {
/// Lines of picture: `2160` for `2160p`, `4K` or `UHD`.
pub height: Option<u32>,
/// `HDR`, `HDR10`, `HDR10+` or Dolby Vision.
pub hdr: bool,
pub ten_bit: bool,
/// Two audio languages, as anime releases mark a dub beside the original.
pub dual_audio: bool,
}
impl Quality {
/// The short labels to show, in a fixed order: `4K`, `HDR`, `10-bit`,
/// `Dual audio`.
pub fn badges(&self) -> Vec<&'static str> {
let resolution = self.height.map(|height| match height {
2160.. => "4K",
1440.. => "1440p",
1080.. => "1080p",
720.. => "720p",
_ => "SD",
});
resolution
.into_iter()
.chain(self.hdr.then_some("HDR"))
.chain(self.ten_bit.then_some("10-bit"))
.chain(self.dual_audio.then_some("Dual audio"))
.collect()
}
}
/// The [`Quality`] a file name states.
///
/// Words are taken apart on everything but letters, digits and `+`, so
/// `[1080p][10-bit]`, `.10bit.` and `(BD 1080p HEVC 10bit Dual Audio)` read
/// the same.
pub fn quality(file_name: &str) -> Quality {
let lower = strip_extension(file_name).to_ascii_lowercase();
let words: Vec<&str> = lower
.split(|c: char| !c.is_ascii_alphanumeric() && c != '+')
.filter(|word| !word.is_empty())
.collect();
let mut quality = Quality::default();
for (index, word) in words.iter().enumerate() {
let next = words.get(index + 1).copied();
let height = match *word {
"4k" | "uhd" => Some(2160),
_ => resolution(word),
};
if let Some(height) = height {
quality.height = quality.height.max(Some(height));
}
match (*word, next) {
("hdr" | "hdr10" | "hdr10+" | "dv" | "dovi", _) | ("dolby", Some("vision")) => {
quality.hdr = true;
}
("10bit" | "10bits" | "hi10" | "hi10p", _) | ("10", Some("bit" | "bits")) => {
quality.ten_bit = true;
}
("dualaudio", _) | ("dual" | "multi", Some("audio")) => quality.dual_audio = true,
_ => {}
}
}
quality
}
/// `1080` out of `1080p`, and out of `bd1080p`, which some groups write.
fn resolution(word: &str) -> Option<u32> {
let digits = word
.strip_suffix('p')?
.trim_start_matches(|c: char| c.is_ascii_alphabetic());
if !(3..=4).contains(&digits.len()) {
return None;
}
digits.parse().ok()
}
/// Drop a trailing extension, but only one that looks like an extension.
///
/// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the
@@ -539,6 +648,9 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
if is_token_char(tail) && tail != Some(b'v') {
continue;
}
if is_decimal_tail(text, after) {
continue;
}
found = Some(NumberingMatch {
numbering: Numbering {
season: None,
@@ -551,6 +663,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
found
}
/// Whether the number that ended at `at` goes on as a decimal: `.11` in
/// `Evangelion - 1.11 You Are (Not) Alone`, `.0` in `3.0+1.01`.
///
/// That is a version of a film, not episode 1 of anything, and reading it as
/// one turns a folder of four films into a series of episodes 1, 2, 3 and 3.
/// One or two digits only: `- 05.1080p` is episode 5 and a resolution.
fn is_decimal_tail(text: &str, at: usize) -> bool {
let bytes = text.as_bytes();
if bytes.get(at) != Some(&b'.') {
return false;
}
let digits = bytes[at + 1..]
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
(1..=2).contains(&digits) && !is_token_char(bytes.get(at + 1 + digits).copied())
}
/// Step over an `E` / `EP` episode marker and any separator after it.
///
/// Returns `at` unchanged when there is none, so a bare number and a marked one
@@ -630,9 +760,18 @@ fn is_token_start(text: &str, at: usize) -> bool {
/// Normalize the title span: separators to spaces, then trim the debris that
/// sits between the title and whatever followed it.
fn clean_title(raw: &str, year: Option<u32>) -> String {
// A dot between two digits is a decimal point, not a separator: `1.11`
// is the film's name, `1 11` is two numbers.
let bytes = raw.as_bytes();
let mut text: String = raw
.chars()
.map(|c| match c {
.char_indices()
.map(|(at, c)| match c {
'.' if at > 0
&& bytes[at - 1].is_ascii_digit()
&& bytes.get(at + 1).is_some_and(u8::is_ascii_digit) =>
{
'.'
}
'.' | '_' => ' ',
other => other,
})
@@ -813,6 +952,15 @@ pub fn is_video_file(name: &str) -> bool {
.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)]
mod tests {
use super::*;
@@ -847,6 +995,46 @@ mod tests {
);
}
/// **What the four Rebuild of Evangelion films parsed as.** ` - 1.11` read
/// as episode 1, so the folder became a series of episodes 1, 2, 3 and 3.
/// A decimal is a version of the film, and the name keeps it.
#[test]
fn a_decimal_after_the_dash_is_part_of_a_films_name_not_an_episode() {
assert_eq!(
parsed("Evangelion - 1.11 You Are (Not) Alone.mkv"),
("Evangelion - 1.11 You Are Alone".into(), None, None, None)
);
assert_eq!(
parsed("Evangelion - 3.0+1.01 Thrice Upon a Time.mkv"),
(
"Evangelion - 3.0+1.01 Thrice Upon a Time".into(),
None,
None,
None
)
);
}
/// A recap numbered between two episodes is not a second copy of the one
/// before it.
#[test]
fn a_half_episode_is_not_the_episode_before_it() {
assert_eq!(
parsed("[Group] Show Name - 12.5 [1080p].mkv"),
("Show Name - 12.5".into(), None, None, None)
);
}
/// The decimal rule stops at two digits, so a resolution glued on with a
/// dot leaves the episode number alone.
#[test]
fn a_resolution_after_a_dot_does_not_hide_the_episode() {
assert_eq!(
parsed("Show Name - 05.1080p.mkv"),
("Show Name".into(), None, Some(5), None)
);
}
/// A version suffix still names the same episode.
#[test]
fn a_versioned_fansub_episode_keeps_its_number() {
@@ -1033,6 +1221,55 @@ mod tests {
assert_eq!(p.title, "Show Name");
}
/// A scene release names its episode in dotted words before its tags, and
/// the first tag is where the name ends.
#[test]
fn a_dotted_episode_name_before_the_release_tags_is_captured() {
let p = parse(
"Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.10bit.HDR.x265-GRP.mkv",
);
assert_eq!(p.title, "Game of Thrones");
assert_eq!((p.season, p.episode), (Some(1), Some(1)));
assert_eq!(p.episode_title.as_deref(), Some("Winter Is Coming"));
let p = parse("Game.of.Thrones.S06E05.The.Door.1080p.WEB-DL.mkv");
assert_eq!(p.episode_title.as_deref(), Some("The Door"));
}
/// Without a tag to end it, a dotted run could be a group's name or
/// anything else, and is not taken.
#[test]
fn a_dotted_run_no_tag_ends_is_not_an_episode_name() {
assert_eq!(parse("Show.S01E01.Pilot.mkv").episode_title, None);
}
#[test]
fn a_scene_release_states_4k_hdr_and_10_bit() {
let stated =
quality("Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.x265.10bit.HDR.mkv");
assert_eq!(stated.badges(), ["4K", "HDR", "10-bit"]);
}
#[test]
fn a_fansub_release_states_its_tags_in_brackets() {
let bracketed =
quality("[Group] Show - S01E01 [1080p][BD][x265][10-bit][Dual-Audio][1A2B3C4D].mkv");
assert_eq!(bracketed.badges(), ["1080p", "10-bit", "Dual audio"]);
let spaced = quality("Film (BD 1080p HEVC 10bit Dual Audio).mkv");
assert_eq!(spaced.badges(), ["1080p", "10-bit", "Dual audio"]);
assert_eq!(
quality("[Group] Show - 01 [BD1080p].mkv").height,
Some(1080)
);
}
/// Most of a real library: a group, a number and nothing else to say.
#[test]
fn a_name_with_no_tags_states_nothing() {
assert_eq!(quality("[Group] Bleach - S17E01.mkv"), Quality::default());
assert!(Quality::default().badges().is_empty());
}
/// A library that names its files with nothing but the episode marker.
#[test]
fn a_bare_episode_marker_is_read_as_an_episode() {
+304
View File
@@ -0,0 +1,304 @@
//! What arrived since the viewer last looked: the **New** and **3 new**
//! badges, the "New episodes" and "Recently added" rows, and the countdown on
//! an airing show's tile.
//!
//! All of it is read off [`crate::catalog::Catalog::first_seen`], the time a
//! crawl first found each file. A file the first crawl of its share found is
//! not new, however recent that crawl: it was there before this device looked,
//! and a share added today would otherwise badge its whole library.
use std::collections::HashMap;
use crate::library::{Episode, Title, TitleKind};
use crate::shelves::Shelf;
/// How long a title that arrived stays **New**, unless it is started first.
pub const NEW_FOR: i64 = 14 * 86_400;
/// What a tile says about what arrived.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Badge {
/// A title that arrived in the last [`NEW_FOR`] and was never played.
New,
/// A series being watched, with this many unwatched episodes that arrived
/// after it was last played.
Episodes(usize),
}
impl Badge {
pub fn label(self) -> String {
match self {
Self::New => "New".to_owned(),
Self::Episodes(count) => format!("{count} new"),
}
}
}
/// When each file arrived, read against when its share was first crawled.
pub struct Arrivals<'a> {
first_seen: &'a HashMap<(String, String), i64>,
/// The earliest sighting in each share: its first crawl.
first_crawl: HashMap<&'a str, i64>,
}
impl<'a> Arrivals<'a> {
pub fn new(first_seen: &'a HashMap<(String, String), i64>) -> Self {
let mut first_crawl: HashMap<&str, i64> = HashMap::new();
for ((share, _), seen) in first_seen {
first_crawl
.entry(share.as_str())
.and_modify(|first| *first = (*first).min(*seen))
.or_insert(*seen);
}
Self {
first_seen,
first_crawl,
}
}
/// When `episode`'s file arrived, if a crawl after its share's first one
/// found it.
pub fn arrived(&self, episode: &Episode) -> Option<i64> {
let node = &episode.node;
let seen = *self
.first_seen
.get(&(node.share_id.clone(), node.link_id.clone()))?;
let first = self.first_crawl.get(node.share_id.as_str())?;
(seen > *first).then_some(seen)
}
/// The badge for `title`, if it has one.
pub fn badge(&self, title: &Title, now: i64) -> Option<Badge> {
let played = title.last_played();
if played == 0 {
// New only if every file of it arrived: a show the first crawl
// found is not new because a crawl since added an episode.
let arrived: Option<Vec<i64>> = title
.episodes()
.map(|episode| self.arrived(episode))
.collect();
let first = arrived?.into_iter().min()?;
return (now - first < NEW_FOR).then_some(Badge::New);
}
if title.kind != TitleKind::Series {
return None;
}
let count = title
.episodes()
.filter(|episode| !episode.is_watched())
.filter(|episode| self.arrived(episode).is_some_and(|at| at > played))
.count();
(count > 0).then_some(Badge::Episodes(count))
}
/// When `title` last gained a file that counts as arrived.
fn newest(&self, title: &Title) -> Option<i64> {
title
.episodes()
.filter_map(|episode| self.arrived(episode))
.max()
}
}
/// Every badge in `titles`, by key.
pub fn badges(
titles: &[Title],
first_seen: &HashMap<(String, String), i64>,
now: i64,
) -> HashMap<String, Badge> {
let arrivals = Arrivals::new(first_seen);
titles
.iter()
.filter_map(|title| Some((title.key.clone(), arrivals.badge(title, now)?)))
.collect()
}
/// The two rows of what arrived, newest arrival first. `None` for a row with
/// nothing in it.
#[derive(Debug, Default)]
pub struct Rows {
/// "New episodes": the shows being watched that gained episodes.
pub episodes: Option<Shelf>,
/// "Recently added": the titles that are [`Badge::New`].
pub added: Option<Shelf>,
}
pub fn rows(titles: &[Title], first_seen: &HashMap<(String, String), i64>, now: i64) -> Rows {
let arrivals = Arrivals::new(first_seen);
let mut episodes: Vec<(&Title, i64)> = Vec::new();
let mut added: Vec<(&Title, i64)> = Vec::new();
for title in titles {
let (Some(badge), Some(newest)) = (arrivals.badge(title, now), arrivals.newest(title))
else {
continue;
};
match badge {
Badge::Episodes(_) => episodes.push((title, newest)),
Badge::New => added.push((title, newest)),
}
}
let shelf = |label: &str, mut row: Vec<(&Title, i64)>| {
row.sort_by_key(|(_, newest)| std::cmp::Reverse(*newest));
(!row.is_empty()).then(|| Shelf {
label: label.to_owned(),
keys: row
.into_iter()
.map(|(title, _)| title.key.clone())
.collect(),
})
};
Rows {
episodes: shelf("New episodes", episodes),
added: shelf("Recently added", added),
}
}
/// What an airing show's tile says about its next episode: `Ep 8 · in 2 d`,
/// `Ep 8 · in 5 h`, `Ep 8 · soon`.
pub fn countdown(episode: u32, at: i64, now: i64) -> String {
let left = (at - now).max(0);
let when = match left {
0..3_600 => "soon".to_owned(),
3_600..86_400 => format!("in {} h", left / 3_600),
_ => format!("in {} d", left / 86_400),
};
format!("Ep {episode} · {when}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::Library;
const DAY: i64 = 86_400;
fn file(share: &str, link: &str, title: &str, episode: u32) -> CatalogNode {
CatalogNode {
share_id: share.into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: format!("{title} - {episode:02}.mkv"),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&format!("{title} - {episode:02}.mkv")),
}
}
fn watched(at: i64) -> WatchState {
WatchState {
position_secs: 1_400.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: at,
}
}
/// One share whose first crawl at day 1 found Frieren 1–2, and Dandadan 1
/// arriving at day 20 next to Frieren 3.
fn share(frieren_played: Option<i64>) -> (Library, HashMap<(String, String), i64>) {
let files = vec![
file("s", "f1", "Frieren", 1),
file("s", "f2", "Frieren", 2),
file("s", "f3", "Frieren", 3),
file("s", "d1", "Dandadan", 1),
];
let mut watch = HashMap::new();
if let Some(at) = frieren_played {
watch.insert(("s".to_owned(), "f1".to_owned()), watched(at));
watch.insert(("s".to_owned(), "f2".to_owned()), watched(at));
}
let seen = [("f1", 1), ("f2", 1), ("f3", 20), ("d1", 20)]
.into_iter()
.map(|(link, day)| (("s".to_owned(), link.to_owned()), day * DAY))
.collect();
(Library::build(files, &watch), seen)
}
fn named<'a>(library: &'a Library, name: &str) -> &'a Title {
library
.titles
.iter()
.find(|title| title.name == name)
.unwrap()
}
#[test]
fn what_the_first_crawl_of_a_share_found_is_never_new() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), None);
}
#[test]
fn a_title_that_arrived_is_new_for_two_weeks_unless_it_is_started() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let dandadan = named(&library, "Dandadan");
assert_eq!(arrivals.badge(dandadan, 21 * DAY), Some(Badge::New));
assert_eq!(arrivals.badge(dandadan, 20 * DAY + NEW_FOR), None);
}
#[test]
fn a_show_being_watched_counts_the_episodes_that_arrived_after_the_last_watch() {
let (library, seen) = share(Some(10 * DAY));
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), Some(Badge::Episodes(1)));
assert_eq!(Badge::Episodes(1).label(), "1 new");
// Watched after episode 3 arrived: nothing is new to the viewer.
let (library, seen) = share(Some(25 * DAY));
let arrivals = Arrivals::new(&seen);
assert_eq!(arrivals.badge(named(&library, "Frieren"), 26 * DAY), None);
}
#[test]
fn the_rows_hold_new_episodes_and_recent_arrivals_and_leave_out_an_empty_one() {
let (library, seen) = share(Some(10 * DAY));
let found = rows(&library.titles, &seen, 21 * DAY);
let labels: Vec<(&str, Vec<&str>)> = [&found.episodes, &found.added]
.into_iter()
.flatten()
.map(|row| {
let names = row
.keys
.iter()
.map(|key| {
library
.titles
.iter()
.find(|title| &title.key == key)
.unwrap()
.name
.as_str()
})
.collect();
(row.label.as_str(), names)
})
.collect();
assert_eq!(
labels,
[
("New episodes", vec!["Frieren"]),
("Recently added", vec!["Dandadan"])
]
);
let (library, seen) = share(None);
let found = rows(&library.titles, &seen, 21 * DAY);
assert!(found.episodes.is_none());
assert_eq!(found.added.unwrap().label, "Recently added");
}
#[test]
fn the_countdown_says_days_then_hours_then_soon() {
assert_eq!(countdown(8, 3 * DAY + 100, 0), "Ep 8 · in 3 d");
assert_eq!(countdown(8, 5 * 3_600 + 10, 0), "Ep 8 · in 5 h");
assert_eq!(countdown(8, 600, 0), "Ep 8 · soon");
}
}
+257
View File
@@ -60,6 +60,211 @@ pub struct PlaybackPrefs {
/// everything at 1.25, and a speed that reset every episode would be a
/// setting nobody could use.
pub speed: f64,
/// How plain subtitles look. Kept with the rest because it is the same
/// kind of choice: made once, for every file after.
pub subtitle_style: SubtitleStyle,
}
/// How subtitles are drawn.
///
/// Every default is mpv's own, so a viewer who never opens the section sees
/// what any mpv shows. Plain (SRT) tracks take all of it. A styled (ASS) track
/// was typeset by somebody, its signs placed and coloured by hand, and painting
/// over that undoes the work — so by default it takes none of this, and moves
/// only to stay clear of the player's controls. All of it reaches a styled
/// track only with [`Self::restyle_styled`].
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SubtitleStyle {
/// Relative to mpv's own size, which is itself relative to the video's
/// height — so one setting reads the same on a phone and a television.
pub scale: f64,
/// A font family name. `None` is mpv's `sans-serif`. A name this device
/// does not have falls back the same way, which is what makes it safe to
/// sync.
pub font: Option<String>,
pub bold: bool,
pub italic: bool,
pub colour: Colour,
pub backdrop: SubtitleBackdrop,
pub outline_colour: Colour,
/// In mpv's scaled pixels, as `sub-outline-size`.
pub outline_size: f64,
/// The box's colour, its alpha the box's opacity. Only with
/// [`SubtitleBackdrop::Box`].
pub box_colour: Colour,
pub shadow_colour: Colour,
/// How far the shadow falls; 0 is none, which is mpv's default.
pub shadow_offset: f64,
/// How soft the edges are, 0–20.
pub blur: f64,
/// Where the line sits, as a percentage of the picture's height from the
/// top: 100 is the bottom edge, mpv's usual place.
pub position: f64,
/// Whether styled (ASS) tracks are drawn in this style too, size and
/// position included. Off by default: it turns signs and karaoke into
/// dialogue-looking text.
pub restyle_styled: bool,
}
impl SubtitleStyle {
/// The family a style with no font of its own is set in: mpv's default,
/// which fontconfig resolves to the system's sans-serif face.
pub const DEFAULT_FONT: &str = "sans-serif";
pub const MIN_SCALE: f64 = 0.5;
pub const MAX_SCALE: f64 = 2.5;
pub const MAX_OUTLINE: f64 = 10.0;
pub const MAX_SHADOW: f64 = 10.0;
pub const MAX_BLUR: f64 = 20.0;
/// The same style with every number brought back into range.
///
/// A hand-edited or synced file can hold anything, and mpv refuses an
/// out-of-range value outright — which would cost the rest of the style.
pub fn sanitized(mut self) -> Self {
let defaults = Self::default();
let clamp = |value: f64, default: f64, min: f64, max: f64| {
if value.is_finite() {
value.clamp(min, max)
} else {
default
}
};
self.scale = clamp(self.scale, defaults.scale, Self::MIN_SCALE, Self::MAX_SCALE);
self.outline_size = clamp(
self.outline_size,
defaults.outline_size,
0.0,
Self::MAX_OUTLINE,
);
self.shadow_offset = clamp(
self.shadow_offset,
defaults.shadow_offset,
0.0,
Self::MAX_SHADOW,
);
self.blur = clamp(self.blur, defaults.blur, 0.0, Self::MAX_BLUR);
self.position = clamp(self.position, defaults.position, 0.0, 100.0);
self.font = self
.font
.map(|font| font.trim().to_owned())
.filter(|font| !font.is_empty());
self
}
}
impl Default for SubtitleStyle {
fn default() -> Self {
Self {
scale: 1.0,
font: None,
bold: false,
italic: false,
colour: Colour::rgba(0xFF, 0xFF, 0xFF, 0xFF),
backdrop: SubtitleBackdrop::Outline,
outline_colour: Colour::rgba(0, 0, 0, 0xFF),
outline_size: 1.65,
// About seventy percent black.
box_colour: Colour::rgba(0, 0, 0, 0xB0),
shadow_colour: Colour::rgba(0, 0, 0, 0xAF),
shadow_offset: 0.0,
blur: 0.0,
position: 100.0,
restyle_styled: false,
}
}
}
/// What keeps the text readable against the picture.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SubtitleBackdrop {
/// An outline round each letter: mpv's own, and the default.
#[default]
Outline,
/// A box behind each line. Uglier, and the only thing that holds up on
/// snow, sky and white walls.
Box,
}
impl SubtitleBackdrop {
pub const ALL: [Self; 2] = [Self::Outline, Self::Box];
pub fn label(self) -> &'static str {
match self {
Self::Outline => "Outline",
Self::Box => "Box",
}
}
}
/// A colour with opacity, written `#RRGGBBAA` the way CSS writes one.
///
/// Not mpv's order: mpv reads `#AARRGGBB`, which nobody hand-editing a file
/// would guess. [`Self::to_mpv`] does the translation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Colour {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Colour {
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// `#RRGGBB` or `#RRGGBBAA`, the `#` optional. Six digits are opaque.
pub fn parse(text: &str) -> Option<Self> {
let hex = text.trim().strip_prefix('#').unwrap_or(text.trim());
if !(hex.len() == 6 || hex.len() == 8) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
let a = if hex.len() == 8 { byte(6)? } else { 0xFF };
Some(Self::rgba(byte(0)?, byte(2)?, byte(4)?, a))
}
/// As mpv's colour options take it.
pub fn to_mpv(self) -> String {
format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b)
}
pub fn to_array(self) -> [u8; 4] {
[self.r, self.g, self.b, self.a]
}
}
impl From<[u8; 4]> for Colour {
fn from([r, g, b, a]: [u8; 4]) -> Self {
Self::rgba(r, g, b, a)
}
}
impl std::fmt::Display for Colour {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"#{:02X}{:02X}{:02X}{:02X}",
self.r, self.g, self.b, self.a
)
}
}
impl From<Colour> for String {
fn from(colour: Colour) -> Self {
colour.to_string()
}
}
impl TryFrom<String> for Colour {
type Error = String;
fn try_from(text: String) -> std::result::Result<Self, Self::Error> {
Self::parse(&text).ok_or_else(|| format!("not a colour: {text:?}"))
}
}
impl Default for PlaybackPrefs {
@@ -73,6 +278,7 @@ impl Default for PlaybackPrefs {
autoplay_next: true,
auto_skip: false,
speed: 1.0,
subtitle_style: SubtitleStyle::default(),
}
}
}
@@ -93,6 +299,7 @@ impl PlaybackPrefs {
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_style = self.subtitle_style.sanitized();
self
}
}
@@ -149,6 +356,16 @@ mod tests {
autoplay_next: false,
auto_skip: true,
speed: 1.5,
subtitle_style: SubtitleStyle {
scale: 1.5,
font: Some("Noto Sans".into()),
italic: true,
colour: Colour::rgba(0xFF, 0xE6, 0x00, 0xFF),
backdrop: SubtitleBackdrop::Box,
box_colour: Colour::rgba(0x10, 0x10, 0x10, 0x80),
restyle_styled: true,
..SubtitleStyle::default()
},
};
save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs);
@@ -156,6 +373,46 @@ mod tests {
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn a_file_from_before_subtitle_styles_loads_with_the_default_style() {
let prefs: PlaybackPrefs =
serde_json::from_str(r#"{"volume": 60.0, "subtitles": true, "speed": 1.0}"#).unwrap();
assert_eq!(prefs.subtitle_style, SubtitleStyle::default());
assert_eq!(prefs.volume, 60.0);
}
#[test]
fn a_colour_is_written_rgba_and_handed_to_mpv_as_argb() {
let colour = Colour::parse("#FFE60080").unwrap();
assert_eq!(colour, Colour::rgba(0xFF, 0xE6, 0x00, 0x80));
assert_eq!(colour.to_string(), "#FFE60080");
assert_eq!(colour.to_mpv(), "#80FFE600");
assert_eq!(
Colour::parse("ffe600"),
Some(Colour::rgba(0xFF, 0xE6, 0, 0xFF))
);
assert_eq!(Colour::parse("#FFE6"), None);
assert_eq!(Colour::parse("#GGGGGG"), None);
}
#[test]
fn a_subtitle_style_out_of_range_is_brought_back_into_it() {
let style = SubtitleStyle {
scale: 40.0,
outline_size: -3.0,
blur: f64::NAN,
position: 400.0,
font: Some(" ".into()),
..SubtitleStyle::default()
}
.sanitized();
assert_eq!(style.scale, SubtitleStyle::MAX_SCALE);
assert_eq!(style.outline_size, 0.0);
assert_eq!(style.blur, 0.0);
assert_eq!(style.position, 100.0);
assert_eq!(style.font, None);
}
#[test]
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
let prefs = PlaybackPrefs {
+898
View File
@@ -0,0 +1,898 @@
//! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches, the per-show track choices
//! and My List. Synced next to watch history by [`crate::sync`], in the same file.
//!
//! ## Registers
//!
//! Each synced thing is one *register*: a key (`share/<id>`,
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer
//! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share, or a title taken off My List, is a
//! register with no value, so a removal travels like any other change.
//!
//! ## Noticing changes
//!
//! Nothing that writes a setting or a share knows about sync. Instead
//! `sync-state.json` remembers every register as last synced, and each sync
//! compares it with what is on this device: whatever differs changed since,
//! and is stamped with the time of the sync. That costs a change made between
//! syncs its exact time, which matters only when two devices change the same
//! setting within one sync interval of each other.
//!
//! The first sync of an installation stamps what it finds at zero rather than
//! now. A fresh install holds only defaults, and those must not outrank the
//! library every other device already agreed on; what it has that nobody else
//! does is still added, since nothing older competes with it.
//!
//! ## What does not travel
//!
//! The volume and mute, which belong to the speakers in front of the viewer,
//! and every setting the front ends keep themselves: the cache budget, Wi-Fi
//! only, background audio, hardware decoding, window geometry.
//!
//! ## Secrets
//!
//! A share's register carries its link, fragment included, and its custom
//! password, and the TMDB API key travels too: without them another device
//! could list the share but never open it. The file holding them is in the
//! viewer's own Drive, end-to-end encrypted with their account's keys, which is
//! the same trust the credential store gives them locally; they are never
//! written anywhere else in plain text.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::catalog::{Catalog, TitleTrackPrefs};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
use crate::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata};
use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/";
const TRACKS: &str = "tracks/";
const MATCH: &str = "match/";
const LIST: &str = "list/";
/// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them.
const SETTINGS: [(&str, &str, &[&str]); 3] = [
("settings/playback", "playback.json", &["volume", "muted"]),
("settings/appearance", "appearance.json", &[]),
("settings/metadata", "metadata.json", &[]),
];
const METADATA_SETTINGS: &str = "settings/metadata";
/// One synced value and when it last changed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Register {
/// `None` is a removal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
pub updated_at: i64,
}
pub(crate) type Registers = BTreeMap<String, Register>;
/// What this installation last synced, kept beside its sync identity.
#[derive(Debug, Default, Serialize, Deserialize)]
struct State {
registers: Registers,
}
/// A share as it travels: everything another device needs to open it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedShare {
name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
custom_password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
folder: Option<AccountFolder>,
}
/// A title on My List.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedListEntry {
added_at: i64,
}
/// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedMatch {
provider: ProviderId,
metadata: Option<TitleMetadata>,
fetched_at: i64,
}
/// What applying other devices' registers changed here, for the front end to
/// act on: reopen and crawl shares, repaint, reload the library.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProfileChanges {
/// Shares this device did not have, now configured. Not yet crawled.
pub shares_added: Vec<String>,
/// Shares whose name or secrets changed.
pub shares_changed: Vec<String>,
/// Shares removed elsewhere, now gone from the configuration here. Their
/// catalog rows and offline files are the front end's to drop, the same
/// way it does for a share removed on this device.
pub shares_removed: Vec<String>,
/// Whether any settings file or the TMDB key changed.
pub settings: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: usize,
/// Whether My List changed.
pub list: bool,
}
impl ProfileChanges {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn shares(&self) -> bool {
!(self.shares_added.is_empty()
&& self.shares_changed.is_empty()
&& self.shares_removed.is_empty())
}
}
/// The local side of profile sync: where the state file lives, and the stores
/// registers are read from and applied to.
pub(crate) struct Profile<'a> {
pub store: &'a ShareStore,
pub catalog: &'a Mutex<Catalog>,
}
/// What is on this device, register by register.
#[derive(Debug, Default)]
struct Observed {
values: BTreeMap<String, Value>,
/// Keys that exist but could not be read — a share whose secret will not
/// decrypt. Neither a change nor a removal.
unknown: BTreeSet<String>,
/// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed.
shares_listed: bool,
/// The same for My List.
list_read: bool,
}
fn state_file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("sync-state.json")
}
fn tmdb_secret() -> String {
format!("metadata-{}", ProviderId::Tmdb.as_str())
}
const TMDB_KEY: &str = "secret/metadata-tmdb";
impl Profile<'_> {
fn dirs(&self) -> &AppDirs {
&self.store.dirs
}
/// Read the state, notice what changed here since, take what other
/// devices have newer, and write the state back. Returns the registers to
/// upload and what changed on this device.
pub fn sync<'r>(
&self,
remote: impl IntoIterator<Item = &'r Registers>,
now: i64,
) -> Result<(Registers, ProfileChanges)> {
let path = state_file(self.dirs());
let stored = read_json::<State>(&path)?;
let first = stored.is_none();
let mut state = stored.unwrap_or_default();
let observed = self.observe();
stamp(&mut state.registers, observed, if first { 0 } else { now });
let winners = merge(&state.registers, remote);
let changes = self.apply(&mut state.registers, winners);
write_json(&path, &state)?;
Ok((state.registers, changes))
}
fn observe(&self) -> Observed {
let mut observed = Observed::default();
for (key, file, local) in SETTINGS {
match read_json::<Value>(&self.dirs().config.join(file)) {
Ok(Some(value)) => {
observed
.values
.insert(key.to_owned(), without(value, local));
}
Ok(None) => {}
Err(error) => {
tracing::warn!(%error, file, "a settings file was not synced");
observed.unknown.insert(key.to_owned());
}
}
}
match self.store.secrets.get(&tmdb_secret()) {
Ok(Some(key)) => {
observed
.values
.insert(TMDB_KEY.to_owned(), Value::String(key));
}
Ok(None) => {}
Err(_) => {
observed.unknown.insert(TMDB_KEY.to_owned());
}
}
if let Ok(shares) = self.store.list() {
observed.shares_listed = true;
for share in shares {
let key = format!("{SHARE}{}", share.id);
let secrets = if share.folder.is_some() {
None
} else {
match self.store.secrets_of(&share.id) {
Ok(secrets) => Some(secrets),
Err(_) => {
observed.unknown.insert(key);
continue;
}
}
};
let synced = SyncedShare {
name: share.name,
token: share.token,
url: secrets.as_ref().map(|secrets| secrets.url.clone()),
custom_password: secrets.and_then(|secrets| secrets.custom_password),
folder: share.folder,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(key, value);
}
}
}
match crate::mylist::load(self.dirs()) {
Ok(list) => {
observed.list_read = true;
for (title, added_at) in list.entries() {
if let Ok(value) = serde_json::to_value(SyncedListEntry { added_at }) {
observed.values.insert(format!("{LIST}{title}"), value);
}
}
}
Err(error) => tracing::warn!(%error, "My List was not synced"),
}
let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks {
if let Ok(value) = serde_json::to_value(prefs) {
observed.values.insert(format!("{TRACKS}{title}"), value);
}
}
}
if let Ok(records) = catalog.all_metadata() {
for (title, record) in records {
if !record.manual {
continue;
}
let synced = SyncedMatch {
provider: record.provider,
metadata: record.metadata,
fetched_at: record.fetched_at,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(format!("{MATCH}{title}"), value);
}
}
}
observed
}
/// Apply each winning register here, recording it in `registers` once it
/// took. One that fails is left out, so the next sync tries it again.
fn apply(&self, registers: &mut Registers, winners: Registers) -> ProfileChanges {
let mut changes = ProfileChanges::default();
for (key, register) in winners {
match self.apply_one(&key, register.value.as_ref(), &mut changes) {
Ok(()) => {
registers.insert(key, register);
}
Err(error) => tracing::warn!(%error, key, "a synced change was not applied"),
}
}
changes
}
fn apply_one(
&self,
key: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
if let Some(id) = key.strip_prefix(SHARE) {
return self.apply_share(id, value, changes);
}
if let Some(title) = key.strip_prefix(TRACKS) {
if let Some(value) = value {
let prefs: TitleTrackPrefs = parse(value)?;
self.catalog.lock().set_title_track_prefs(title, &prefs)?;
changes.titles += 1;
}
return Ok(());
}
if let Some(title) = key.strip_prefix(MATCH) {
if let Some(value) = value {
let synced: SyncedMatch = parse(value)?;
self.catalog.lock().set_metadata(&MetadataRecord {
title_key: title.to_owned(),
provider: synced.provider,
metadata: synced.metadata,
fetched_at: synced.fetched_at,
manual: true,
})?;
changes.titles += 1;
}
return Ok(());
}
if let Some(title) = key.strip_prefix(LIST) {
let added_at = value
.map(parse::<SyncedListEntry>)
.transpose()?
.map(|entry| entry.added_at);
let mut list = crate::mylist::load(self.dirs())?;
if list.added_at(title) != added_at {
list.set(title, added_at);
crate::mylist::save(self.dirs(), &list)?;
changes.list = true;
}
return Ok(());
}
if key == TMDB_KEY {
match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
None => self.store.secrets.delete(&tmdb_secret())?,
}
changes.settings = true;
return Ok(());
}
if let Some((_, file, local)) = SETTINGS.iter().find(|(name, ..)| *name == key) {
let Some(Value::Object(incoming)) = value else {
return Ok(());
};
let path = self.dirs().config.join(file);
let before = read_json::<Value>(&path)?;
let mut merged = match &before {
Some(Value::Object(existing)) => existing.clone(),
_ => serde_json::Map::new(),
};
for (field, field_value) in incoming {
if !local.contains(&field.as_str()) {
merged.insert(field.clone(), field_value.clone());
}
}
let merged = Value::Object(merged);
if key == METADATA_SETTINGS {
self.metadata_settings_changing(before.as_ref(), &merged)?;
}
write_json(&path, &merged)?;
changes.settings = true;
return Ok(());
}
// A register a newer build writes. Kept, so it travels on.
Ok(())
}
fn apply_share(
&self,
id: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
let existing = self.store.list()?.into_iter().find(|share| share.id == id);
let Some(value) = value else {
if existing.is_some() {
self.store.remove(id)?;
changes.shares_removed.push(id.to_owned());
}
return Ok(());
};
let synced: SyncedShare = parse(value)?;
let expected = match (&synced.folder, &synced.url) {
(Some(folder), _) => folder.share_id(),
(None, Some(_)) => format!("share-{}", synced.token),
(None, None) => return Err(invalid("a synced link has no URL")),
};
if expected != id {
return Err(invalid("a synced share's id does not match what it opens"));
}
let secrets = synced.url.map(|url| ShareSecrets {
url,
custom_password: synced.custom_password,
});
let share = Share {
id: id.to_owned(),
name: synced.name,
token: synced.token,
has_custom_password: secrets
.as_ref()
.is_some_and(|secrets| secrets.custom_password.is_some()),
folder: synced.folder,
};
if existing.as_ref() == Some(&share)
&& secrets.as_ref().is_none_or(|secrets| {
self.store
.secrets_of(id)
.is_ok_and(|stored| &stored == secrets)
})
{
return Ok(());
}
self.store.put_synced(share, secrets.as_ref())?;
if existing.is_some() {
changes.shares_changed.push(id.to_owned());
} else {
changes.shares_added.push(id.to_owned());
}
Ok(())
}
/// Turning enrichment off or switching provider drops every stored answer
/// on the device that did it, which the settings page also does here.
fn metadata_settings_changing(&self, before: Option<&Value>, after: &Value) -> Result<()> {
let config = |value: Option<&Value>| -> MetadataConfig {
value
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_default()
};
let (before, after) = (config(before), config(Some(after)));
if !after.enabled || before.provider != after.provider {
self.catalog.lock().clear_metadata()?;
}
Ok(())
}
}
/// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() {
let removable = (key.starts_with(SHARE) && observed.shares_listed)
|| (key.starts_with(LIST) && observed.list_read);
if register.value.is_some()
&& removable
&& !observed.values.contains_key(key)
&& !observed.unknown.contains(key)
{
*register = Register {
value: None,
updated_at: now,
};
}
}
for (key, value) in observed.values {
let unchanged = registers
.get(&key)
.is_some_and(|register| same(&key, register.value.as_ref(), Some(&value)));
if !unchanged {
registers.insert(
key,
Register {
value: Some(value),
updated_at: now,
},
);
}
}
}
/// Whether two values of one register are the same choice.
///
/// A match is compared by what it points at: its stored details move every
/// time the provider is asked again, and a register re-stamped by every
/// refresh would bounce between devices for ever.
fn same(key: &str, a: Option<&Value>, b: Option<&Value>) -> bool {
if key.starts_with(MATCH) {
let pointer = |value: Option<&Value>| {
value.map(|value| {
(
value.get("provider").cloned(),
value
.get("metadata")
.and_then(|metadata| metadata.get("remote_id"))
.cloned(),
)
})
};
return pointer(a) == pointer(b);
}
a == b
}
/// The registers from other devices that are newer than this one's.
fn merge<'r>(local: &Registers, remote: impl IntoIterator<Item = &'r Registers>) -> Registers {
let mut winners = Registers::new();
for registers in remote {
for (key, register) in registers {
let current = winners.get(key).or_else(|| local.get(key));
if current.is_none_or(|current| newer(register, current)) {
winners.insert(key.clone(), register.clone());
}
}
}
winners
}
/// Newer wins; a tie between two different values goes the same way on every
/// device, or two of them would each keep their own.
fn newer(candidate: &Register, current: &Register) -> bool {
if candidate.updated_at != current.updated_at {
return candidate.updated_at > current.updated_at;
}
let order = |register: &Register| {
register
.value
.as_ref()
.map(Value::to_string)
.unwrap_or_default()
};
order(candidate) > order(current)
}
/// `value` without the fields that stay on this device.
fn without(value: Value, local: &[&str]) -> Value {
match value {
Value::Object(mut fields) => {
for field in local {
fields.remove(*field);
}
Value::Object(fields)
}
other => other,
}
}
fn parse<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
serde_json::from_value(value.clone()).map_err(|e| invalid(&e.to_string()))
}
fn invalid(reason: &str) -> crate::Error {
crate::Error::Config(format!("synced setting: {reason}"))
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use super::*;
use crate::shares::SecretStore;
#[derive(Default)]
struct MemorySecrets(Mutex<HashMap<String, String>>);
impl SecretStore for MemorySecrets {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
/// One installation's configuration and catalog.
pub(crate) struct Device {
root: PathBuf,
pub store: ShareStore,
pub catalog: Mutex<Catalog>,
}
impl Device {
pub(crate) fn new(tag: &str) -> Self {
let mut bytes = [0_u8; 8];
getrandom::fill(&mut bytes).unwrap();
let unique: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
let root = std::env::temp_dir().join(format!("pstr-profile-{tag}-{unique}"));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
Self {
root,
store: ShareStore::with_secret_store(dirs, Arc::new(MemorySecrets::default())),
catalog: Mutex::new(Catalog::in_memory().unwrap()),
}
}
pub(crate) fn lock(&self) -> parking_lot::MutexGuard<'_, Catalog> {
self.catalog.lock()
}
pub(crate) fn profile(&self) -> Profile<'_> {
Profile {
store: &self.store,
catalog: &self.catalog,
}
}
fn sync(&self, remote: &[&Registers], now: i64) -> (Registers, ProfileChanges) {
self.profile().sync(remote.iter().copied(), now).unwrap()
}
fn share_names(&self) -> Vec<String> {
self.store
.list()
.unwrap()
.into_iter()
.map(|share| share.name)
.collect()
}
}
impl Drop for Device {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#secretpart";
#[test]
fn a_share_added_on_one_device_arrives_on_another_with_its_secrets() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
laptop.store.add("Anime", LINK, Some("hunter2")).unwrap();
let (sent, _) = laptop.sync(&[], 20);
let (_, changes) = phone.sync(&[&sent], 30);
assert_eq!(changes.shares_added, ["share-ABCDEFGHIJ"]);
assert_eq!(phone.share_names(), ["Anime"]);
let secrets = phone.store.secrets_of("share-ABCDEFGHIJ").unwrap();
assert_eq!(secrets.url, LINK);
assert_eq!(secrets.custom_password.as_deref(), Some("hunter2"));
}
#[test]
fn a_share_removed_on_one_device_is_removed_on_the_others() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.store.add("Anime", LINK, None).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
laptop.store.remove("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[&phone_sent], 30);
let (_, changes) = phone.sync(&[&sent], 40);
assert_eq!(changes.shares_removed, ["share-ABCDEFGHIJ"]);
assert!(phone.share_names().is_empty());
}
#[test]
fn a_fresh_installation_takes_the_library_rather_than_its_defaults() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::load(&laptop.store.dirs).unwrap();
prefs.autoplay_next = false;
prefs.speed = 1.5;
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
// The new phone has written its defaults before it ever synced.
crate::prefs::save(&phone.store.dirs, &crate::prefs::PlaybackPrefs::default()).unwrap();
let (_, changes) = phone.sync(&[&sent], 30);
assert!(changes.settings);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert!(!taken.autoplay_next);
assert_eq!(taken.speed, 1.5);
}
#[test]
fn volume_and_mute_stay_on_the_device_that_set_them() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::PlaybackPrefs {
volume: 20.0,
muted: true,
..Default::default()
};
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
prefs.volume = 80.0;
prefs.muted = false;
prefs.subtitles = false;
crate::prefs::save(&phone.store.dirs, &prefs).unwrap();
phone.sync(&[], 5);
phone.sync(&[&sent], 30);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert_eq!(taken.volume, 80.0);
assert!(!taken.muted);
assert!(taken.subtitles, "the laptop's later choice wins");
}
#[test]
fn the_newer_change_wins_whichever_device_syncs_first() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
phone.sync(&[], 1);
let mut appearance = crate::appearance::load(&laptop.store.dirs).unwrap();
appearance.gradients = false;
crate::appearance::save(&laptop.store.dirs, &appearance).unwrap();
let (from_laptop, _) = laptop.sync(&[], 10);
appearance.gradients = true;
appearance.accent = crate::appearance::Accent::Peach;
crate::appearance::save(&phone.store.dirs, &appearance).unwrap();
let (from_phone, _) = phone.sync(&[&from_laptop], 20);
laptop.sync(&[&from_phone], 30);
for device in [&laptop, &phone] {
let seen = crate::appearance::load(&device.store.dirs).unwrap();
assert_eq!(seen.accent, crate::appearance::Accent::Peach);
assert!(seen.gradients);
}
}
#[test]
fn an_unchanged_device_sends_the_same_registers_again() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
let (first, _) = laptop.sync(&[], 10);
let (second, changes) = laptop.sync(&[&first], 20);
assert_eq!(first, second);
assert!(changes.is_empty());
}
#[test]
fn a_share_whose_secret_cannot_be_read_is_not_removed_elsewhere() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
laptop.sync(&[], 10);
laptop.store.secrets.delete("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[], 20);
assert!(sent["share/share-ABCDEFGHIJ"].value.is_some());
}
#[test]
fn a_hand_picked_match_and_a_shows_track_choice_travel() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let metadata = TitleMetadata {
provider: ProviderId::AniList,
remote_id: "21".to_owned(),
name: "One Piece".to_owned(),
original_name: None,
overview: None,
year: Some(1999),
kind: crate::library::TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: None,
};
laptop
.catalog
.lock()
.set_metadata(&MetadataRecord {
title_key: "one piece".to_owned(),
provider: ProviderId::AniList,
metadata: Some(metadata),
fetched_at: 5,
manual: true,
})
.unwrap();
let tracks = TitleTrackPrefs {
audio_language: Some("jpn".to_owned()),
..Default::default()
};
laptop
.catalog
.lock()
.set_title_track_prefs("one piece", &tracks)
.unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert_eq!(changes.titles, 2);
let catalog = phone.catalog.lock();
let record = catalog.metadata("one piece").unwrap().unwrap();
assert!(record.manual);
assert_eq!(record.metadata.unwrap().remote_id, "21");
assert_eq!(
catalog.title_track_prefs("one piece").unwrap(),
Some(tracks)
);
}
#[test]
fn a_title_put_on_my_list_on_one_device_is_on_the_list_of_another() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert!(changes.list);
let taken = crate::mylist::load(&phone.store.dirs).unwrap();
assert_eq!(taken.added_at("frieren"), Some(5));
}
#[test]
fn a_title_taken_off_my_list_on_one_device_leaves_every_list() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
list.add("one piece", 6);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
let mut list = crate::mylist::load(&phone.store.dirs).unwrap();
list.remove("frieren");
crate::mylist::save(&phone.store.dirs, &list).unwrap();
let (phone_sent, _) = phone.sync(&[&sent, &phone_sent], 30);
let (sent, changes) = laptop.sync(&[&phone_sent], 40);
assert!(changes.list);
let left = crate::mylist::load(&laptop.store.dirs).unwrap();
assert!(!left.contains("frieren"));
assert!(left.contains("one piece"));
// Put back later, it is back everywhere: the removal is older.
let mut list = left;
list.add("frieren", 50);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[&sent], 50);
phone.sync(&[&sent], 60);
assert!(
crate::mylist::load(&phone.store.dirs)
.unwrap()
.contains("frieren")
);
}
#[test]
fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone");
let mut forged = Registers::new();
forged.insert(
"share/share-OTHER".to_owned(),
Register {
value: Some(serde_json::json!({
"name": "x", "token": "ABCDEFGHIJ", "url": LINK
})),
updated_at: 10,
},
);
let (registers, changes) = phone.sync(&[&forged], 20);
assert!(changes.shares_added.is_empty());
assert!(!registers.contains_key("share/share-OTHER"));
}
}
+81
View File
@@ -0,0 +1,81 @@
//! The last few searches, offered again under an empty search box.
//!
//! Kept on this device only, in `recent-searches.json` beside the other
//! settings, and never synced: what someone typed is not a setting, and
//! another device's list is not what they were just looking for.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// How many searches are kept.
pub const KEEP: usize = 8;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentSearches {
/// Newest first.
#[serde(default)]
pub queries: Vec<String>,
}
impl RecentSearches {
/// Put `query` first, saying whether the list changed. The same search
/// in other letter case moves rather than repeats, and one under two
/// letters is not kept.
pub fn remember(&mut self, query: &str) -> bool {
let query = query.trim();
if query.chars().count() < 2 {
return false;
}
if self.queries.first().map(String::as_str) == Some(query) {
return false;
}
let lower = query.to_lowercase();
self.queries.retain(|kept| kept.to_lowercase() != lower);
self.queries.insert(0, query.to_owned());
self.queries.truncate(KEEP);
true
}
}
fn file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("recent-searches.json")
}
/// The list, empty when there is no file yet.
pub fn load(dirs: &AppDirs) -> Result<RecentSearches> {
Ok(read_json::<RecentSearches>(&file(dirs))?.unwrap_or_default())
}
pub fn save(dirs: &AppDirs, recent: &RecentSearches) -> Result<()> {
write_json(&file(dirs), recent)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_search_repeated_moves_to_the_front_instead_of_appearing_twice() {
let mut recent = RecentSearches::default();
assert!(recent.remember("frieren"));
assert!(recent.remember("kon"));
assert!(recent.remember("Frieren "));
assert_eq!(recent.queries, ["Frieren", "kon"]);
assert!(!recent.remember("Frieren"));
assert!(!recent.remember("x"));
}
#[test]
fn only_the_newest_searches_are_kept() {
let mut recent = RecentSearches::default();
for at in 0..KEEP + 3 {
recent.remember(&format!("search {at}"));
}
assert_eq!(recent.queries.len(), KEEP);
assert_eq!(recent.queries[0], format!("search {}", KEEP + 2));
}
}
+399 -30
View File
@@ -27,11 +27,13 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use proton_drive_rs::{
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use serde::{Deserialize, Serialize};
@@ -355,19 +357,19 @@ impl AccountFolder {
}
/// The secrets for one share, as held in the credential store.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ShareSecrets {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ShareSecrets {
/// The full share URL, fragment included.
url: String,
pub(crate) url: String,
/// The custom password, when the link has one.
#[serde(default)]
custom_password: Option<String>,
pub(crate) custom_password: Option<String>,
}
/// The configured shares, and their secrets.
pub struct ShareStore {
dirs: AppDirs,
secrets: Arc<dyn SecretStore>,
pub(crate) dirs: AppDirs,
pub(crate) secrets: Arc<dyn SecretStore>,
}
impl ShareStore {
@@ -396,9 +398,9 @@ impl ShareStore {
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(format!(
"a share for token {token} is already configured"
)));
return Err(Error::Config(
"that link is already in the library".to_owned(),
));
}
let secrets = ShareSecrets {
@@ -421,6 +423,80 @@ impl ShareStore {
Ok(share)
}
/// [`Self::add`], but only once the link has been opened with the
/// password given. An empty `name` takes the shared folder's own.
///
/// A link that is mistyped, expired or behind the wrong password used to
/// be stored anyway, so the first try failed on the crawl and every retry
/// failed on "already configured" — the only way out was to remove a share
/// that had never worked. Opening first means a refused link leaves
/// nothing behind, and the client it opened is handed back for the crawl.
pub async fn add_verified(
&self,
name: &str,
url: &str,
custom_password: Option<&str>,
) -> Result<(Share, ShareClient)> {
let token = token_from_url(url)?;
let id = format!("share-{token}");
if self.list()?.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that link is already in the library".to_owned(),
));
}
let custom_password = custom_password.filter(|password| !password.is_empty());
let client = match open_link(url, custom_password).await {
Ok(client) => client,
// Asked only after a refusal: the link's public flags cost a round
// trip, and most links open without one.
Err(error) if custom_password.is_none() => {
let protected = ProtonDrivePublicLinkClient::info(client_configuration(), url)
.await
.is_ok_and(|info| info.is_custom_password_protected);
return Err(if protected {
Error::PasswordNeeded
} else {
error
});
}
Err(error) => return Err(error),
};
// A name is the viewer's to give, not theirs to invent before they
// can see anything: without one, the shared folder's own name.
let name = match name.trim() {
"" => client
.root_node()
.await
.ok()
.map(|root| root.name)
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Shared folder".to_owned()),
given => given.to_owned(),
};
let share = self.add(&name, url, custom_password)?;
Ok((share, client))
}
/// Record a share another installation has, with the secrets it opens
/// with, replacing what this one holds under the same id. See
/// [`crate::profile`].
pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> {
if let Some(secrets) = secrets {
self.store_secrets(&share.id, secrets)?;
}
let mut shares = self.list()?;
match shares.iter_mut().find(|existing| existing.id == share.id) {
Some(existing) => *existing = share,
None => shares.push(share),
}
write_json(&self.dirs.shares_file(), &shares)
}
/// A share's secrets, for syncing them to another installation.
pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> {
self.load_secrets(id)
}
/// Record a folder of the signed-in account as a share.
///
/// Nothing goes in the credential store: the folder opens through the
@@ -493,6 +569,25 @@ impl ShareStore {
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.
///
/// The config entry goes first here, for the mirror-image reason: if the
@@ -526,17 +621,7 @@ impl ShareStore {
});
}
let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open(
client_configuration(),
&secrets.url,
secrets.custom_password.as_deref(),
)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(ShareClient::PublicLink(Box::new(client)))
open_link(&secrets.url, secrets.custom_password.as_deref()).await
}
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
@@ -556,9 +641,89 @@ impl ShareStore {
}
}
/// Open a public link as a visitor.
async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClient> {
let client = ProtonDrivePublicLinkClient::open(client_configuration(), url, custom_password)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(ShareClient::PublicLink(Box::new(client)))
}
/// Several opened shares, presented as one library.
pub struct SharedLibrary {
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.
@@ -680,6 +845,9 @@ impl SharedLibrary {
) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::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()? {
if let Some(client) = reusable.remove(&share.id) {
@@ -688,13 +856,14 @@ impl SharedLibrary {
}
match store.open(&share, account).await {
Ok(client) => {
sessions.mark(&share.id);
clients.insert(share.id.clone(), client);
}
Err(e) => failures.push((share, e)),
}
}
Ok((Self { clients }, failures))
Ok((Self { clients, sessions }, failures))
}
/// The opened clients, minus `dropping`, ready to hand to
@@ -735,9 +904,37 @@ impl SharedLibrary {
.client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
client.refresh_session().await?;
self.sessions.mark(share_id);
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.
///
/// There is no recursion helper on the visitor path — the authenticated
@@ -778,7 +975,10 @@ impl SharedLibrary {
.client(share_id)
.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 found = vec![root];
@@ -786,14 +986,10 @@ impl SharedLibrary {
let listings = level.into_iter().map(|folder| {
let client = client.clone();
async move {
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
retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
list_folder(&client, &folder)
})
.await
}
});
@@ -815,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.
///
/// Each of these fans out again inside the SDK — up to four detail batches per
@@ -879,6 +1128,107 @@ fn token_from_url(url: &str) -> Result<String> {
#[cfg(test)]
mod tests {
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]
fn a_share_url_yields_its_token() {
@@ -1004,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
/// leave every catalog row and offline file describing files that are not
/// there.
+62
View File
@@ -135,6 +135,46 @@ pub fn pick(
picked
}
/// How many titles the "Because you watched" row offers.
const BECAUSE: usize = 12;
/// Every row above the library grid, in the order all three apps show them:
/// new episodes of what is being watched, My List, what was recently added,
/// what is like the last thing finished, then up to `limit` [`pick`]ed
/// shelves.
pub fn home(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: crate::metadata::TitleNames,
list: &crate::mylist::MyList,
first_seen: &HashMap<(String, String), i64>,
now: i64,
limit: usize,
) -> Vec<Shelf> {
let news = crate::news::rows(titles, first_seen, now);
let present: std::collections::HashSet<&str> =
titles.iter().map(|title| title.key.as_str()).collect();
let keys: Vec<String> = list
.newest_first()
.into_iter()
.filter(|key| present.contains(key))
.map(str::to_owned)
.collect();
let mine = (!keys.is_empty()).then(|| Shelf {
label: "My List".into(),
keys,
});
news.episodes
.into_iter()
.chain(mine)
.chain(news.added)
.chain(crate::discover::because_you_watched(
titles, metadata, names, BECAUSE,
))
.chain(pick(titles, metadata, limit))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -168,6 +208,7 @@ mod tests {
kind: TitleKind::Film,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
@@ -258,4 +299,25 @@ mod tests {
assert_eq!(shelves[0].label, "Airing now");
assert_eq!(shelves[0].keys, vec!["a"]);
}
#[test]
fn my_list_is_a_row_of_what_the_library_holds_newest_first() {
let titles = vec![title("a"), title("b")];
let mut list = crate::mylist::MyList::default();
list.add("a", 1);
list.add("b", 3);
list.add("gone", 5);
let rows = home(
&titles,
&HashMap::new(),
Default::default(),
&list,
&HashMap::new(),
10,
4,
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "My List");
assert_eq!(rows[0].keys, ["b", "a"]);
}
}
+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);
}
}
+151 -74
View File
@@ -1,5 +1,7 @@
//! Watch history kept in the viewer's own Drive, so every device resumes where
//! another left off.
//! another left off — and, in the same file, the shares, settings and
//! hand-picked matches that make the library look the same on every device
//! (see [`crate::profile`]).
//!
//! ## Layout
//!
@@ -17,7 +19,8 @@
//! nothing is overwritten and there is no write conflict to resolve.
//!
//! Each file carries the installation's whole watch table, and merging is per
//! episode: the newer `updated_at` wins. That is idempotent and order-free, so
//! episode: the newer `updated_at` wins. The profile's registers merge the same
//! way, per key. That is idempotent and order-free, so
//! it does not matter how many devices there are, which synced first, or that
//! a device writes back entries it learned from another. A device whose clock
//! runs ahead wins ties it should not, which is the price of not having a
@@ -25,11 +28,11 @@
//!
//! The keys travel: `share-{token}` is derived from the link and a folder's
//! `drive-…` id from its node, so the same library added on two devices has
//! the same `(share_id, link_id)` on both. Nothing secret is in a file — the
//! link token decrypts nothing without the fragment — and the file is
//! end-to-end encrypted by Drive regardless.
//! the same `(share_id, link_id)` on both. The watch entries carry nothing
//! secret; the profile's share registers do carry each link and its password,
//! which is why they live only here, end-to-end encrypted by Drive.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use parking_lot::Mutex;
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
@@ -40,6 +43,8 @@ use crate::account::Account;
use crate::catalog::{Catalog, WatchState};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
use crate::profile::{Profile, ProfileChanges, Registers};
use crate::shares::ShareStore;
/// The Drive device watch history is kept on.
pub const DEVICE_NAME: &str = "proton-stream";
@@ -48,7 +53,8 @@ pub const DEVICE_NAME: &str = "proton-stream";
const FOLDER: &str = "watch-history";
/// Bumped only for a change an older build would misread. A file of a newer
/// version is skipped rather than half-understood.
/// version is skipped rather than half-understood. The profile's registers
/// were added without a bump: a build that predates them ignores the field.
const FORMAT_VERSION: u32 = 1;
/// One installation's watch history, as stored in Drive.
@@ -57,6 +63,15 @@ struct HistoryFile {
version: u32,
installation: String,
entries: Vec<HistoryEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
registers: Registers,
}
/// What one history file holds.
#[derive(Debug, Clone, Default, PartialEq)]
struct Contents {
entries: Vec<HistoryEntry>,
registers: Registers,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -112,7 +127,7 @@ struct Identity {
}
/// What one [`WatchSync::sync`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SyncReport {
/// Episodes whose position another device had newer.
pub applied: usize,
@@ -123,6 +138,8 @@ pub struct SyncReport {
/// Files of installations gone quiet that were moved to the trash, their
/// history now carried in this installation's own file.
pub retired: usize,
/// What other devices' shares and settings changed here.
pub profile: ProfileChanges,
}
/// How long an installation's file may go unwritten before another
@@ -243,10 +260,10 @@ impl HistoryDrive for ProtonDriveClient {
}
/// A history file as last read, by the revision it was read at. `None`
/// entries for one this build could not read.
/// contents for one this build could not read.
struct Seen {
revision: Option<String>,
entries: Option<Vec<HistoryEntry>>,
contents: Option<Contents>,
}
/// Syncs the catalog's watch table with the account's Drive.
@@ -257,8 +274,9 @@ pub struct WatchSync {
seen: Mutex<HashMap<String, Seen>>,
/// What this installation's own file holds, once known: what was read
/// back at the first sync, then what was last written. Compared against
/// the catalog so an unchanged history is not uploaded as a new revision.
own: Mutex<Option<Vec<HistoryEntry>>>,
/// the catalog and the profile so an unchanged history is not uploaded as
/// a new revision.
own: Mutex<Option<Contents>>,
/// The history folders, resolved once per account. Usually one; see
/// [`HistoryDrive::history_folders`].
folders: Mutex<Option<Vec<NodeUid>>>,
@@ -303,25 +321,34 @@ impl WatchSync {
*self.folders.lock() = None;
}
/// Pull every other installation's history into `catalog`, write this
/// one's if it changed, then retire the files of installations gone quiet.
/// Pull every other installation's history, shares and settings in, write
/// this one's if it changed, then retire the files of installations gone
/// quiet.
///
/// The catalog is locked only around its own reads and writes, never
/// across a request, so playback can keep saving its position while this
/// runs.
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
/// runs. Shares that arrive are configured but not opened or crawled; see
/// [`SyncReport::profile`] for what the front end has to do about them.
pub async fn sync(
&self,
account: &Account,
catalog: &Mutex<Catalog>,
store: &ShareStore,
) -> Result<SyncReport> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
self.sync_with(account.drive(), catalog, now).await
let profile = Profile { store, catalog };
self.sync_with(account.drive(), &profile, now).await
}
pub(crate) async fn sync_with<D: HistoryDrive>(
&self,
drive: &D,
catalog: &Mutex<Catalog>,
profile: &Profile<'_>,
now: i64,
) -> Result<SyncReport> {
let catalog = profile.catalog;
let _running = self.running.lock().await;
let folders = self.folders(drive).await?;
let own_name = format!("{}.json", self.installation);
@@ -339,12 +366,14 @@ impl WatchSync {
}
let mut remote = Vec::new();
let mut registers = Vec::new();
// Files whose every entry has been read, and so can be retired once
// this installation's file carries them.
let mut readable = Vec::new();
for file in &others {
if let Some(entries) = self.read(drive, file).await {
remote.extend(entries);
if let Some(contents) = self.read(drive, file).await {
remote.extend(contents.entries);
registers.push(contents.registers);
readable.push(file);
}
}
@@ -355,19 +384,22 @@ impl WatchSync {
// less than it holds, and read again next time.
let mut own_unreadable = false;
if self.own.lock().is_none() {
let entries = match &own_file {
let contents = match &own_file {
Some(file) => self.read(drive, file).await,
None => Some(Vec::new()),
None => Some(Contents::default()),
};
match entries {
Some(entries) => {
remote.extend(entries.iter().cloned());
*self.own.lock() = Some(entries);
match contents {
Some(contents) => {
remote.extend(contents.entries.iter().cloned());
registers.push(contents.registers.clone());
*self.own.lock() = Some(contents);
}
None => own_unreadable = true,
}
}
let (registers, profile_changes) = profile.sync(&registers, now)?;
let incoming: Vec<(String, String, WatchState)> = remote
.iter()
.filter(|entry| entry.is_sane())
@@ -388,15 +420,17 @@ impl WatchSync {
// A file that is missing is written even when what it would hold has
// not changed: another installation retired it, or it was deleted by
// hand, and either way the history it carried lives only here now.
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries);
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
{
let contents = Contents { entries, registers };
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&contents);
let empty = contents.entries.is_empty() && contents.registers.is_empty();
let uploaded = if own_unreadable || unchanged || (empty && own_file.is_none()) {
false
} else {
let file = HistoryFile {
version: FORMAT_VERSION,
installation: self.installation.clone(),
entries: entries.clone(),
entries: contents.entries.clone(),
registers: contents.registers.clone(),
};
let bytes = serde_json::to_vec(&file)
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
@@ -404,7 +438,7 @@ impl WatchSync {
Some(file) => drive.replace(&file.uid, &bytes).await?,
None => drive.create(&folders[0], &own_name, &bytes).await?,
}
*self.own.lock() = Some(entries);
*self.own.lock() = Some(contents);
true
};
@@ -435,34 +469,31 @@ impl WatchSync {
uploaded,
devices: others.len(),
retired: retired.len(),
profile: profile_changes,
})
}
/// The entries of one history file, from the cache when its revision has
/// The contents of one history file, from the cache when its revision has
/// not moved, or `None` when it cannot be read.
///
/// A file that will not download or parse is logged and skipped: it is
/// another device's, and one bad file must not stop the rest syncing.
async fn read<D: HistoryDrive>(
&self,
drive: &D,
file: &RemoteFile,
) -> Option<Vec<HistoryEntry>> {
async fn read<D: HistoryDrive>(&self, drive: &D, file: &RemoteFile) -> Option<Contents> {
let key = file.uid.link_id.as_str().to_owned();
if let Some(seen) = self.seen.lock().get(&key)
&& file.revision.is_some()
&& seen.revision == file.revision
{
return seen.entries.clone();
return seen.contents.clone();
}
let uid = &file.uid;
let entries = match drive.download(uid).await {
let contents = match drive.download(uid).await {
Ok(bytes) => {
let entries = parse(&bytes);
if entries.is_none() {
let contents = parse(&bytes);
if contents.is_none() {
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
}
entries
contents
}
Err(error) => {
// Not cached: a download that failed is tried again next time.
@@ -474,10 +505,10 @@ impl WatchSync {
key,
Seen {
revision: file.revision.clone(),
entries: entries.clone(),
contents: contents.clone(),
},
);
entries
contents
}
/// The history folders, resolved once per account.
@@ -491,10 +522,13 @@ impl WatchSync {
}
}
/// A history file's entries, or `None` for one this build cannot read.
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
/// A history file's contents, or `None` for one this build cannot read.
fn parse(bytes: &[u8]) -> Option<Contents> {
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
(file.version <= FORMAT_VERSION).then_some(file.entries)
(file.version <= FORMAT_VERSION).then_some(Contents {
entries: file.entries,
registers: file.registers,
})
}
/// The kind of device to register, which is only the icon the web app draws.
@@ -518,6 +552,7 @@ fn new_installation_id() -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::tests::Device;
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
HistoryEntry {
@@ -655,11 +690,8 @@ mod tests {
}
/// An installation: its sync and its catalog.
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
(
WatchSync::with_installation(id.to_owned()),
Mutex::new(Catalog::in_memory().unwrap()),
)
fn device(id: &str) -> (WatchSync, Device) {
(WatchSync::with_installation(id.to_owned()), Device::new(id))
}
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
@@ -684,11 +716,14 @@ mod tests {
.unwrap();
let first = laptop
.sync_with(&drive, &laptop_catalog, 100)
.sync_with(&drive, &laptop_catalog.profile(), 100)
.await
.unwrap();
assert!(first.uploaded);
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
let second = phone
.sync_with(&drive, &phone_catalog.profile(), 110)
.await
.unwrap();
assert_eq!(second.applied, 1);
assert_eq!(second.devices, 1);
@@ -714,12 +749,15 @@ mod tests {
.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.sync_with(&drive, &laptop_catalog.profile(), 300)
.await
.unwrap();
phone
.sync_with(&drive, &phone_catalog.profile(), 300)
.await
.unwrap();
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.sync_with(&drive, &laptop_catalog.profile(), 300)
.await
.unwrap();
@@ -740,8 +778,14 @@ mod tests {
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let again = laptop
.sync_with(&drive, &catalog.profile(), 200)
.await
.unwrap();
assert!(!again.uploaded);
assert_eq!(drive.uploads(), 1);
@@ -751,7 +795,10 @@ mod tests {
async fn nothing_is_uploaded_before_anything_was_watched() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let report = laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
assert!(!report.uploaded);
assert!(drive.names().is_empty());
}
@@ -764,11 +811,17 @@ mod tests {
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
drive.trash(&own).await.unwrap();
let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
let report = laptop
.sync_with(&drive, &catalog.profile(), 200)
.await
.unwrap();
assert!(report.uploaded);
assert_eq!(drive.names(), ["laptop.json"]);
@@ -782,11 +835,17 @@ mod tests {
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
// The catalog was deleted; the config, and with it the id, was not.
let (relaunched, empty) = device("laptop");
let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap();
let report = relaunched
.sync_with(&drive, &empty.profile(), 200)
.await
.unwrap();
assert_eq!(report.applied, 1);
assert!(!report.uploaded);
@@ -804,7 +863,10 @@ mod tests {
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let (relaunched, other) = device("laptop");
other
@@ -814,13 +876,19 @@ mod tests {
drive
.offline
.store(true, std::sync::atomic::Ordering::SeqCst);
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
let report = relaunched
.sync_with(&drive, &other.profile(), 200)
.await
.unwrap();
assert!(!report.uploaded);
drive
.offline
.store(false, std::sync::atomic::Ordering::SeqCst);
relaunched.sync_with(&drive, &other, 300).await.unwrap();
relaunched
.sync_with(&drive, &other.profile(), 300)
.await
.unwrap();
let state = other.lock().all_watch_states().unwrap();
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
}
@@ -832,6 +900,7 @@ mod tests {
version: FORMAT_VERSION + 1,
installation: "future".to_owned(),
entries: vec![entry("ep1", 60.0, 100)],
registers: Registers::new(),
};
drive.put("future.json", serde_json::to_vec(&future).unwrap());
let (laptop, catalog) = device("laptop");
@@ -841,7 +910,7 @@ mod tests {
.unwrap();
let report = laptop
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
.sync_with(&drive, &catalog.profile(), RETIRE_AFTER_SECS + DAY)
.await
.unwrap();
@@ -860,21 +929,24 @@ mod tests {
.unwrap();
drive.at(100);
old_phone
.sync_with(&drive, &old_catalog, 100)
.sync_with(&drive, &old_catalog.profile(), 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let later = 100 + RETIRE_AFTER_SECS;
drive.at(later);
let report = phone.sync_with(&drive, &catalog, later).await.unwrap();
let report = phone
.sync_with(&drive, &catalog.profile(), later)
.await
.unwrap();
assert_eq!(report.retired, 1);
assert_eq!(drive.names(), ["phone.json"]);
// Nothing was lost: the newcomer's file carries it now.
let (fresh, fresh_catalog) = device("fresh");
fresh
.sync_with(&drive, &fresh_catalog, later)
.sync_with(&drive, &fresh_catalog.profile(), later)
.await
.unwrap();
assert_eq!(
@@ -893,13 +965,13 @@ mod tests {
.unwrap();
drive.at(100);
laptop
.sync_with(&drive, &laptop_catalog, 100)
.sync_with(&drive, &laptop_catalog.profile(), 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let report = phone
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
.sync_with(&drive, &catalog.profile(), 100 + RETIRE_AFTER_SECS - DAY)
.await
.unwrap();
@@ -925,9 +997,13 @@ mod tests {
version: FORMAT_VERSION,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
registers: Registers::new(),
};
let bytes = serde_json::to_vec(&file).unwrap();
assert_eq!(parse(&bytes), Some(file.entries));
assert_eq!(
parse(&bytes).map(|contents| contents.entries),
Some(file.entries)
);
}
#[test]
@@ -936,6 +1012,7 @@ mod tests {
version: FORMAT_VERSION + 1,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
registers: Registers::new(),
};
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
assert_eq!(parse(b"not json"), None);
+201 -7
View File
@@ -18,7 +18,8 @@ use std::collections::HashSet;
use pstr_core::library::{self, TitleKind};
use pstr_core::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
Trailer,
};
use serde::Deserialize;
@@ -27,6 +28,7 @@ use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
use crate::tmdb::{Tmdb, TmdbId};
const ENDPOINT: &str = "https://graphql.anilist.co";
@@ -59,7 +61,9 @@ const SCORE_SCALE: f32 = 10.0;
/// Everything one entry is asked for, by search and by id alike.
///
/// The relations are the franchise's — see `pstr_core::franchise` — and the
/// staff are only the most relevant few, which is where a director sits.
/// staff are only the most relevant few, which is where a director sits. The
/// characters are the first ten by role, main ones first, each with their
/// Japanese voice actor.
macro_rules! entry_fragment {
() => {
r#"
@@ -77,11 +81,20 @@ fragment entry on Media {
genres
tags { name rank isMediaSpoiler }
episodes
duration
format
status
siteUrl
studios(isMain: true) { nodes { name } }
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
characters(sort: [ROLE, RELEVANCE, ID], perPage: 10) {
edges {
role
node { name { full } image { medium } }
voiceActors(language: JAPANESE, sort: [RELEVANCE, ID]) { name { full } image { medium } }
}
}
trailer { id site }
nextAiringEpisode { episode airingAt }
relations { edges { relationType node { id type } } }
}
@@ -158,16 +171,20 @@ pub struct AniList {
/// Shared by every lookup in flight — see [`crate::limiter`].
limiter: RateLimiter,
anizip: AniZip,
/// Asked by id for the art TVDB has not got, when a TMDB key is stored.
tmdb: Option<Tmdb>,
}
impl AniList {
/// `language` is the one episode names are wanted in, where ani.zip has
/// them in it.
pub fn new(http: reqwest::Client, language: String) -> Self {
/// them in it. `tmdb` is asked for the art of entries TVDB has none for —
/// see [`Tmdb::art`].
pub fn new(http: reqwest::Client, language: String, tmdb: Option<Tmdb>) -> Self {
Self {
anizip: AniZip::new(http.clone(), language),
http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
tmdb,
}
}
}
@@ -203,7 +220,8 @@ impl Provider for AniList {
}
/// The episodes of every entry the library's files are spread across, and
/// the series' fanart.
/// the series' fanart and logo — from TMDB, by the id ani.zip gives, for
/// an entry TVDB has no art for.
///
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
/// four of them, Mushoku Tensei's first is two — so a title's match is
@@ -234,6 +252,8 @@ impl Provider for AniList {
// The same ids as `visited`, in the order they were read.
let mut chain = Vec::new();
let mut series_entries = 0;
// The match's own: a sequel's art is not the title's.
let mut tmdb_id: Option<TmdbId> = None;
let mut next = Some(Link {
id: first,
series: found.kind == TitleKind::Series,
@@ -246,9 +266,15 @@ impl Provider for AniList {
series_entries
});
let mapping = self.anizip.mapping(link.id, entry_season).await?;
if chain.len() == 1 {
tmdb_id = mapping.tmdb;
}
if enrichment.backdrop_url.is_none() {
enrichment.backdrop_url = mapping.fanart;
}
if enrichment.logo_url.is_none() {
enrichment.logo_url = mapping.logo;
}
// Where two entries both list an episode, the earlier one — the
// one closer to the match — keeps it.
enrichment.episodes.extend(
@@ -264,6 +290,14 @@ impl Provider for AniList {
next = self.sequel(link.id, &visited).await?;
}
if let (Some(tmdb), Some(id)) = (&self.tmdb, tmdb_id)
&& (enrichment.backdrop_url.is_none() || enrichment.logo_url.is_none())
{
let art = tmdb.art_or_nothing(id).await;
enrichment.backdrop_url = enrichment.backdrop_url.or(art.backdrop);
enrichment.logo_url = enrichment.logo_url.or(art.logo);
}
tracing::debug!(
"{}: {} episodes over {} entries",
found.name,
@@ -308,7 +342,6 @@ impl AniList {
Ok(next_in_chain(edges, visited))
}
/// One GraphQL requestimpl AniList {
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
/// status and an `errors` array under a 200 — folded into one.
///
@@ -379,6 +412,12 @@ impl AniList {
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only
/// after the name as written found nothing, which is what keeps it from
/// broadening a search that was already working.
///
/// A film's version number is the same kind of miss. `Evangelion - 1.11 You
/// Are (Not) Alone` is the file of a film AniList calls `Evangelion: 1.0 You
/// Are (Not) Alone`, and `1.11` is not a word of that name — so the last
/// fallback asks without it, and leaves telling the films apart to the scoring,
/// which still sees the whole name.
fn search_terms(name: &str) -> Vec<String> {
let mut terms = vec![name.to_string()];
@@ -386,9 +425,31 @@ fn search_terms(name: &str) -> Vec<String> {
if depossessed != name {
terms.push(depossessed);
}
let unversioned = without_version_numbers(name);
if !unversioned.is_empty() && !terms.contains(&unversioned) {
terms.push(unversioned);
}
terms
}
/// `Evangelion - 1.11 You Are Alone` → `Evangelion - You Are Alone`.
///
/// A version is digits with a dot in them (`1.11`, `3.0+1.01`); a bare number
/// is left alone, because in `Kaijuu 8-gou` or `86` it is the name.
fn without_version_numbers(name: &str) -> String {
name.split(' ')
.filter(|word| {
let versioned = word.contains('.')
&& word.chars().any(|c| c.is_ascii_digit())
&& word
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '+'));
!versioned
})
.collect::<Vec<_>>()
.join(" ")
}
/// `Heavens Feel` → `Heaven Feel`.
///
/// Only words long enough to still mean something without it, and only where
@@ -532,6 +593,8 @@ struct Media {
#[serde(default)]
tags: Vec<Tag>,
episodes: Option<u32>,
/// Minutes per episode, or the film's running time.
duration: Option<u32>,
format: Option<String>,
season: Option<String>,
status: Option<String>,
@@ -539,6 +602,8 @@ struct Media {
site_url: Option<String>,
studios: Option<Studios>,
staff: Option<Staff>,
characters: Option<Characters>,
trailer: Option<AniTrailer>,
#[serde(rename = "nextAiringEpisode")]
next_airing_episode: Option<NextAiring>,
relations: Option<Relations>,
@@ -584,6 +649,32 @@ struct StaffEdge {
#[derive(Deserialize)]
struct Person {
name: PersonName,
image: Option<Image>,
}
#[derive(Deserialize)]
struct Image {
medium: Option<String>,
}
#[derive(Deserialize)]
struct Characters {
#[serde(default)]
edges: Vec<CharacterEdge>,
}
#[derive(Deserialize)]
struct CharacterEdge {
role: Option<String>,
node: Option<Person>,
#[serde(rename = "voiceActors", default)]
voice_actors: Vec<Person>,
}
#[derive(Deserialize)]
struct AniTrailer {
id: Option<String>,
site: Option<String>,
}
#[derive(Deserialize)]
@@ -706,6 +797,37 @@ impl Media {
.collect(),
popularity: self.popularity,
status: self.status,
runtime: self.duration.filter(|minutes| *minutes > 0),
cast: self
.characters
.map(|characters| {
characters
.edges
.into_iter()
.filter_map(|edge| {
let character = edge.node?;
let actor = edge.voice_actors.into_iter().next();
Some(CastMember {
character: character.name.full?,
character_image: character.image.and_then(|image| image.medium),
role: edge.role,
actor_image: actor
.as_ref()
.and_then(|actor| actor.image.as_ref())
.and_then(|image| image.medium.clone()),
actor: actor.and_then(|actor| actor.name.full),
})
})
.collect()
})
.unwrap_or_default(),
trailer: self.trailer.and_then(|trailer| {
Some(Trailer {
site: trailer.site?,
id: trailer.id?,
})
}),
revision: TitleDetails::REVISION,
next_episode: self
.next_airing_episode
.as_ref()
@@ -746,6 +868,8 @@ impl Media {
// backdrop is ani.zip's fanart, found when the match is
// enriched.
backdrop_url: None,
// ani.zip's too, like the fanart.
logo_url: None,
rating: self.average_score.map(|score| score / SCORE_SCALE),
genres: self.genres,
episodes: self.episodes,
@@ -775,7 +899,7 @@ fn kind_of(format: Option<&str>) -> TitleKind {
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become
/// nothing and `<br>` becomes the line break it stands for.
fn strip_markup(text: &str) -> String {
pub(crate) fn strip_markup(text: &str) -> String {
let text = text
.replace("<br>", "\n")
.replace("<br/>", "\n")
@@ -836,6 +960,29 @@ mod tests {
);
}
/// **What three of the four Rebuild films matched as: nothing.** AniList
/// requires every word, and a film's version number is not one of its
/// title's.
#[test]
fn a_films_version_number_is_searched_for_again_without_it() {
assert_eq!(
search_terms("Evangelion - 1.11 You Are Alone"),
vec![
"Evangelion - 1.11 You Are Alone".to_string(),
"Evangelion - You Are Alone".to_string(),
]
);
assert_eq!(
search_terms("Evangelion - 3.0+1.01 Thrice Upon a Time")[1],
"Evangelion - Thrice Upon a Time"
);
assert_eq!(
search_terms("Kaijuu 8-gou"),
vec!["Kaijuu 8-gou"],
"a number that is part of the name stays"
);
}
/// Byte slicing, on names that are not ASCII.
#[test]
fn a_multi_byte_name_is_not_cut_inside_a_character() {
@@ -894,6 +1041,51 @@ mod tests {
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
}
#[test]
fn the_cast_comes_with_its_voice_actors_and_the_trailer_with_its_site() {
let media: Media = serde_json::from_str(
r#"{
"id": 154587,
"title": {"romaji": "Sousou no Frieren", "english": null, "native": null},
"characters": {"edges": [
{"role": "MAIN",
"node": {"name": {"full": "Frieren"}, "image": {"medium": "frieren.png"}},
"voiceActors": [{"name": {"full": "Atsumi Tanezaki"},
"image": {"medium": "tanezaki.png"}}]},
{"role": "SUPPORTING",
"node": {"name": {"full": "Himmel"}, "image": null},
"voiceActors": []},
{"role": "BACKGROUND", "node": {"name": {"full": null}}, "voiceActors": []}
]},
"trailer": {"id": "tR8YH0G67Rk", "site": "youtube"}
}"#,
)
.expect("parse");
let details = media.into_candidate().metadata.details.expect("details");
assert_eq!(
details.cast,
[
CastMember {
character: "Frieren".into(),
character_image: Some("frieren.png".into()),
role: Some("MAIN".into()),
actor: Some("Atsumi Tanezaki".into()),
actor_image: Some("tanezaki.png".into()),
},
// No voice actor yet is still a character; no name is not.
CastMember {
character: "Himmel".into(),
role: Some("SUPPORTING".into()),
..CastMember::default()
},
]
);
assert_eq!(
details.trailer.and_then(|trailer| trailer.url()).as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
}
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
RelationEdge {
relation_type: Some(relation.into()),
@@ -971,6 +1163,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
};
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more