Compare commits

37 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
rahab.mathieu 0868c2af69 fix(player): resolve GL entry points from OpenGL.framework on macOS 2026-10-15 21:35:23 +02:00
118 changed files with 14527 additions and 1539 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
+7 -7
View File
@@ -5279,7 +5279,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "1.6.1" version = "2.0.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"futures", "futures",
@@ -5299,7 +5299,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "1.6.1" version = "2.0.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5333,7 +5333,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "1.6.1" version = "2.0.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5352,7 +5352,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "1.6.1" version = "2.0.1"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -5372,7 +5372,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "1.6.1" version = "2.0.1"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -5385,7 +5385,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "1.6.1" version = "2.0.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -5400,7 +5400,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "1.6.1" version = "2.0.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.6.1" version = "2.0.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
+144 -109
View File
@@ -1,83 +1,145 @@
<div align="center">
<img src="images/logo.svg" alt="" width="112" height="112">
# proton-stream # proton-stream
A Netflix-style cross-platform client for **Proton Drive public links**. **Your Proton Drive, streaming.** A Netflix-style library for the videos in your Proton Drive and
the share links people send you, on Linux, Windows, Android, Android TV and iOS.
Paste a share URL and its password; get a browsable, streamable library. No **[proton-stream.narl.io](https://proton-stream.narl.io)**
Proton account, no server to host, no download step — episodes start playing in
seconds and the seek bar works.
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust [![CI](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml/badge.svg)](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
Proton Drive SDK. [![Release](https://img.shields.io/github/v/release/narrrl/proton-stream?sort=semver)](https://github.com/narrrl/proton-stream/releases/latest)
[![License: MIT / GPL-3.0](https://img.shields.io/badge/license-MIT%20%2F%20GPL--3.0-blue.svg)](#license)
[![Rust 1.96+](https://img.shields.io/badge/rust-1.96%2B-orange.svg)](https://www.rust-lang.org)
[![Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/narl)
<img src="images/desktop-home.png" alt="The library in the desktop app" width="820">
</div>
> [!IMPORTANT]
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
> to describe what the software talks to. It provides no content: it plays what you keep in
> Proton Drive or what is shared with you.
## Features
**Library**
- **Public share links or your own Drive.** Paste a share link, with its password if it has one,
or sign in and pick folders of your Drive. Any number of them merge into one library.
- **Seasons and episodes from your folders.** The folder names the show, the file name gives the
season and episode, and a file without numbers is a film.
- **Posters, synopses and shelves**, from AniList or TMDB, **off by default**: shelves such as
*Directed by* and *From MADHOUSE*, a franchise folded into one tile, and a hand-picked match
that is never overwritten.
- **Filters and sorting:** series, films, watching, not started, downloaded; by name, last watched,
last added, air date, rating or popularity.
**Player**
- **Streams, never downloads first.** Episodes start in seconds, and a seek fetches only what it
lands on.
- **libmpv inside the app's own window:** HEVC 10-bit, HDR and ASS subtitles with hardware
decoding, and no transcoding.
- **Skip the opening and the ending** by chapter, an **Up next** countdown, and next and previous
episode.
- **Subtitle files beside the video** (`.srt`, `.ass`, `.ssa`, `.vtt`), and **the dialogue track
over *Signs & Songs***. Audio and subtitle choices are remembered for each show.
**Everywhere**
- **Resume on any device.** Sign in, and your place in every episode, your shares and your
settings sync through your own Drive.
- **Downloads** of an episode, a season or a show, to play offline.
- **Android 12+ and Android TV** with Material 3, background audio and new-episode
notifications; **iOS 17+** as a SwiftUI port for SideStore and AltStore.
- **Themes:** a near-black default, four Catppuccin flavours and Persona 5, each with nine accents.
- **Full keyboard control** on the desktop, and the `pstr` command line.
<table>
<tr>
<td width="50%"><img src="images/desktop-library.png" alt="The whole library, filtered and sorted"></td>
<td width="50%">
<img src="images/android-home.png" alt="Android: home" width="24%">
<img src="images/android-library.png" alt="Android: all titles" width="24%">
<img src="images/android-title.png" alt="Android: a title page" width="24%">
<img src="images/android-episodes.png" alt="Android: episodes and downloads" width="24%">
</td>
</tr>
</table>
## Install
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has:
| Platform | Download |
|---|---|
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` |
| Fedora | `proton-stream-<version>-1.x86_64.rpm` |
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` |
| Windows | `proton-stream-<version>-x64.msi`, or the portable `…-x86_64-windows.zip` |
| Android, Android TV | `proton-stream-<version>-android.apk` |
| iOS, iPadOS | `proton-stream-<version>-ios.ipa`, for SideStore or AltStore |
```bash
sudo apt install ./proton-stream_*_amd64.deb # Debian, Ubuntu
sudo dnf install ./proton-stream-*.x86_64.rpm # Fedora
cd packaging && makepkg -si # Arch, from a checkout
```
Then open **Proton Stream**, paste a share link on the **Shares** page (or press **Ctrl+V**), and
the library fills in. [Getting started](docs/GETTING_STARTED.md) walks through it, and
[Installation](docs/INSTALL.md) covers every platform, updating and uninstalling.
## Documentation
The [wiki](https://github.com/narrrl/proton-stream/wiki) is generated from [`docs/`](docs/) on
every push to `main`:
- [Getting started](docs/GETTING_STARTED.md), [Installation](docs/INSTALL.md) and the
[FAQ](docs/FAQ.md)
- [User guide](docs/USER_GUIDE.md), [Account and sync](docs/ACCOUNT_AND_SYNC.md) and
[Android and iOS](docs/MOBILE.md)
- [Command-line reference](docs/CLI.md), [Privacy](docs/PRIVACY.md) and
[Troubleshooting](docs/TROUBLESHOOTING.md)
- For contributors: [Development](docs/DEVELOPMENT.md), [Architecture](docs/ARCHITECTURE.md),
[Testing](docs/TESTING.md), [Android](docs/ANDROID.md) and [iOS](docs/IOS.md)
## Why it works ## Why it works
A Proton Drive file's content blocks are each a self-contained PGP packet A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
decrypted under one shared session key. There is no sequential streaming session shared session key. There is no sequential streaming session to resume, so seeking costs exactly
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode the blocks the seek lands on: a 2 GB episode plays without downloading 2 GB of it.
is playable without downloading 2 GB of it.
## Status On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link: **2.3 s to first
frame, about 1.5 s to resume after dragging to an arbitrary point, and 61 MiB fetched for the
first 38 seconds of playback.** Watch it again and the disk cache serves it with **no network at
all**: 0.3 s to first frame.
Early, but it is an app now: the picture lands **inside the window**, not in an The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
mpv window of its own. The SDK streaming surface, the catalog, the streaming no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
layer and the player are done and verified against a real share, and the app has whole data flow.
a library wall, a page per title with seasons and episodes,
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
button for openings and endings, next/previous episode with an "up next"
countdown, subtitle files beside the video, optional metadata, themes and share
management. What is left is mostly running the macOS packaging on a real Mac. See
`docs/DEVELOPMENT.md`.
A native Android 12+ client is under `android/`, sharing the Rust catalog and Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
streaming implementation through UniFFI. It features a modern Material 3 interface, SDK.
an immersive fullscreen player with auto-hiding controls, and offline downloads.
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same ## Privacy
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
[`docs/IOS.md`](docs/IOS.md).
```bash - **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
cargo run --release -p pstr-app # the app posters and descriptions, which sends your title names to AniList or TMDB.
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link - **The part of a share link after `#` is its decryption key.** It, any link password and your
``` Proton session live in the system credential store (Secret Service, Credential Manager, the
Android Keystore, the iOS Keychain), never in a config file.
- **Sync is end-to-end encrypted.** Watch history, shares and settings travel through your own
Drive, one file per device.
- **The block cache and downloads hold decrypted video** in your cache and data folders. Size or
empty the cache in **Settings → Storage**.
One window at a time: a second launch brings the open window forward and [Privacy](docs/PRIVACY.md) lists every connection and every file.
hands it any share link it was given, then exits.
The `pstr` CLI does the same work headlessly, and is what tells you which layer
is broken:
```bash
cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment'
cargo run -p pstr-cli -- crawl
cargo run -p pstr-cli -- list
cargo run -p pstr-cli -- metadata # opt-in; off unless configured
cargo run --release -p pstr-cli -- play --file Frieren --disk-cache
cargo run --release -p pstr-cli -- bench --file Frieren --verify
```
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link:
**2.3 s to first frame, ~1.5 s to resume after dragging to an arbitrary point,
61 MiB fetched for the first 38 seconds of playback.** Watch it again and the
disk cache serves it with **no network at all** — 0.3 s to first frame.
`bench` measures the same file without a player, reporting cold seek latency,
sustained throughput and peak memory, and `--verify` cross-checks a mid-file read
against the SDK's own range download.
## Design
| | |
|---|---|
| Topology | Fat client. No server; each viewer's app talks to Proton directly. |
| Auth | Public links only — but several, merged into one library. |
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
| Transcoding | None. mpv demuxes and decodes natively. |
| UI | egui/eframe on glow; mpv renders into the same GL context. |
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback, and an iOS 17+ port of it for sideloading. |
## Building ## Building
@@ -85,53 +147,26 @@ Rust 2024, MSRV 1.96.
```bash ```bash
cargo build --release cargo build --release
cargo run --release -p pstr-app # the app
cargo run -p pstr-cli -- add --name anime 'URL' # the same work, headless
``` ```
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in. WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
On Windows the libmpv ones
come from the separate **mpv-dev** archive rather than mpv's player build, and
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
crates.io; nothing here builds against a sibling checkout.
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`, - **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
and a portable `.zip` plus an `.msi` on Windows — are produced by `scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the [`packaging/README.md`](packaging/README.md).
per-platform detail. - **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and - **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain; [`docs/IOS.md`](docs/IOS.md).
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust
target: `scripts/build-ios.sh all` writes `dist/proton-stream-<version>-ios.ipa`.
## Privacy
- The share URL fragment **is** the decryption password. It and any custom
password live in the OS credential store — Secret Service on Linux, Credential
Manager on Windows — never in a config file.
- Nothing leaves your machine except requests to Proton, unless you turn metadata
enrichment on. The player is fed through an in-process mpv stream callback, not
a local HTTP server: there is no port for another process on the machine to ask
for the plaintext, and mpv is handed an opaque token rather than a URL that
would put a share secret in its log and window title.
- The block cache holds decrypted content. It lives in your cache directory,
can be deleted at any time the app is not running, and can be sized or
emptied from Settings → Storage.
- A share link given as a command-line argument is visible to other users of
the machine in the process list while that launch runs, and may land in
shell history. Pasting it into the window (Ctrl+V) does neither.
- The desktop's media widget is handed the title, the episode and a `file://`
path to artwork already on disk — never a provider URL, so the shell never
contacts a metadata provider on its own.
## License ## License
The shared Rust crates and desktop application are MIT licensed. The Android and The shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
iOS applications and their combined native distributions are GPL-3.0-or-later; see and their combined native distributions are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and [`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and the bundled
the bundled-component notices. component notices. The desktop application embeds the Inter typeface, under the SIL Open Font
The desktop application embeds the Inter typeface, under the SIL Open Font
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)), License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
and the Phosphor icon font, under MIT. and the Phosphor icon font, under MIT.
@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
*/ */
@Test @Test
fun chapterPlanningCrossesTheBoundaryIntact() { fun chapterPlanningCrossesTheBoundaryIntact() {
val plan = chapterPlan(emptyList(), 1560.0) val plan = chapterPlan(emptyList(), 1560.0, emptyList())
assertNotNull(plan) assertNotNull(plan)
assertNull(skipOffer(plan, 12.0)) assertNull(skipOffer(plan, 12.0))
} }
+5 -2
View File
@@ -323,8 +323,11 @@ class Player {
// needs no window, and a load that cannot get one plays audio anyway — // needs no window, and a load that cannot get one plays audio anyway —
// waiting for a surface here is what made background playback fail after // waiting for a surface here is what made background playback fail after
// a five-second stall. // 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"); set_string("sid", subtitles ? "auto" : "no");
pending_start_.store(std::max(0.0, start)); pending_start_.store(std::max(0.0, start));
// Retire the outgoing file's position, duration and end flag *before* // Retire the outgoing file's position, duration and end flag *before*
@@ -1,5 +1,6 @@
package io.narl.protonstream.playback package io.narl.protonstream.playback
import android.os.SystemClock
import android.view.Surface import android.view.Surface
import io.narl.protonstream.ui.ThumbnailSource import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -19,6 +20,7 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SkipRangeRecord
import uniffi.pstr_android.SubtitleFilesRecord import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.SubtitleTrackRecord import uniffi.pstr_android.SubtitleTrackRecord
import uniffi.pstr_android.chapterPlan import uniffi.pstr_android.chapterPlan
@@ -104,6 +106,15 @@ data class NowPlaying(
val hasNext: Boolean, 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( data class MpvTrack(
val id: Long, val id: Long,
val type: String, val type: String,
@@ -130,11 +141,15 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null) private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false) private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(null) private val mutableProblem = MutableStateFlow<String?>(null)
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
private var sleepJob: Job? = null
private var poller: Job? = null private var poller: Job? = null
/** The episode key and load generation the native player currently holds. */ /** The episode key and load generation the native player currently holds. */
@Volatile private var currentMedia: String? = null @Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0 @Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null @Volatile private var plannedFor: Pair<Double, Long>? = null
/** 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. */ /** The load generation whose subtitle pick has been looked at. */
@Volatile private var subtitlesCheckedFor: Double = -1.0 @Volatile private var subtitlesCheckedFor: Double = -1.0
var onPlaybackStarted: (() -> Unit)? = null var onPlaybackStarted: (() -> Unit)? = null
@@ -175,6 +190,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
*/ */
val problem: StateFlow<String?> = mutableProblem.asStateFlow() 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]. */ /** Dismiss the current [problem]. */
fun clearProblem() { mutableProblem.value = null } fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
@@ -226,6 +260,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
mutableTracks.value = emptyList() mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null) mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null plannedFor = null
skipRanges = null
} }
} }
} ?: false } ?: false
@@ -268,6 +303,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
} }
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } } 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 setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } } fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
@@ -276,7 +322,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
fun selectTrack(track: MpvTrack?) { fun selectTrack(track: MpvTrack?) {
val audio = track?.type == "audio" val audio = track?.type == "audio"
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) } 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()
}
} }
/** /**
@@ -394,7 +450,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
} }
}.onSuccess { chapters -> }.onSuccess { chapters ->
plannedFor = wanted 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)
} }
} }
@@ -511,6 +568,9 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private const val CACHED_UNTIL = 13 private const val CACHED_UNTIL = 13
private const val LOADED = 14 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 { init {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
System.loadLibrary("pstr_mpv") System.loadLibrary("pstr_mpv")
@@ -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 = {},
)
}
}
@@ -2,6 +2,7 @@ package io.narl.protonstream.playback
import android.app.Activity import android.app.Activity
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.SystemClock
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager import android.view.WindowManager
@@ -35,7 +36,10 @@ import androidx.compose.material.icons.automirrored.filled.Toc
import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Forward30 import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Fullscreen
@@ -84,6 +88,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -97,6 +104,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.shownName
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentTrack import io.narl.protonstream.ui.theme.AccentTrack
import io.narl.protonstream.ui.theme.EdgedButton import io.narl.protonstream.ui.theme.EdgedButton
@@ -121,6 +129,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uniffi.pstr_android.ChapterEntry import uniffi.pstr_android.ChapterEntry
import uniffi.pstr_android.ChapterKind
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord import uniffi.pstr_android.TrackPreferencesRecord
@@ -140,6 +149,9 @@ private const val DOUBLE_TAP_SECONDS = 10
/** How long the double-tap seek mark stays up. */ /** How long the double-tap seek mark stays up. */
private const val NUDGE_MS = 700L private const val NUDGE_MS = 700L
/** How long the unlock button stays up after a tap on a locked player. */
private const val UNLOCK_SHOWN_MS = 3_000L
/** How close to the end the next episode's opening blocks are fetched. */ /** How close to the end the next episode's opening blocks are fetched. */
private const val WARM_NEXT_SECONDS = 120.0 private const val WARM_NEXT_SECONDS = 120.0
@@ -185,6 +197,7 @@ fun PlayerScreen(
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val activity = context as? Activity val activity = context as? Activity
val titleKey = title.key val titleKey = title.key
val episode = episodes.getOrNull(index) val episode = episodes.getOrNull(index)
@@ -365,8 +378,22 @@ fun PlayerScreen(
// own reading — never on a failure, and never on the outgoing file's. // own reading — never on a failure, and never on the outgoing file's.
// Cancelled from the end-of-file card: the viewer chose to stay. // Cancelled from the end-of-file card: the viewer chose to stay.
var stay by remember(key) { mutableStateOf(false) } var stay by remember(key) { mutableStateOf(false) }
// The sleep timer set for the end of this episode stops it there instead.
val sleep by nativeHost.sleep.collectAsState()
val stopsAfterThis = (sleep as? SleepTimer.EndOf)?.media == key
LaunchedEffect(state.endReason, state.media) { LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance() if (state.endReason != EndReason.Eof || state.media != key) return@LaunchedEffect
if (stopsAfterThis) {
nativeHost.setSleepTimer(null)
} else if (autoplay && !stay && next != null) {
advance()
}
}
// A timer for the end of an episode the viewer has since moved on from
// was about that episode.
LaunchedEffect(key) {
val timer = nativeHost.sleep.value
if (timer is SleepTimer.EndOf && timer.media != key) nativeHost.setSleepTimer(null)
} }
// A file mpv could not play ends like any other. Saying so is the whole // A file mpv could not play ends like any other. Saying so is the whole
// difference between a broken episode and an app that silently skipped one. // difference between a broken episode and an app that silently skipped one.
@@ -420,6 +447,20 @@ fun PlayerScreen(
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target) if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
} }
// A file whose chapters name no opening or ending asks AniSkip, once, when
// its length is known. Only with posters and descriptions on: anything
// else, an error included, leaves the file as its chapters made it.
val lengthKnown = playingThis && state.duration > 0.0
LaunchedEffect(key, lengthKnown) {
if (!lengthKnown) return@LaunchedEffect
val named = chapters.entries.any { it.kind == ChapterKind.OPENING || it.kind == ChapterKind.ENDING }
if (named) return@LaunchedEffect
val ranges = runCatching {
NativeRuntime.engine().skipTimes(titleKey, episode.shareId, episode.linkId, state.duration)
}.getOrNull()
if (!ranges.isNullOrEmpty()) nativeHost.setSkipRanges(key, ranges)
}
var landscapeLocked by remember { mutableStateOf(false) } var landscapeLocked by remember { mutableStateOf(false) }
// Set by the rotation button: from then on the viewer decides. // Set by the rotation button: from then on the viewer decides.
var rotationChosen by remember { mutableStateOf(false) } var rotationChosen by remember { mutableStateOf(false) }
@@ -436,8 +477,12 @@ fun PlayerScreen(
// four seconds after it is reached cannot be found again. // four seconds after it is reached cannot be found again.
val exploring = rememberTouchExploration() val exploring = rememberTouchExploration()
LaunchedEffect(exploring) { if (exploring) showControls = true } LaunchedEffect(exploring) { if (exploring) showControls = true }
LaunchedEffect(showControls, state.paused, exploring) { // A chooser open over the controls. They hold still under it: the
if (showControls && !state.paused && !exploring) { // choosers live inside the controls, so hiding them closed whatever was
// being picked from, four seconds after the controls came up.
var picking by remember { mutableStateOf(false) }
LaunchedEffect(showControls, state.paused, exploring, picking) {
if (showControls && !state.paused && !exploring && !picking) {
delay(CONTROLS_SECONDS * 1_000) delay(CONTROLS_SECONDS * 1_000)
showControls = false showControls = false
} }
@@ -452,6 +497,21 @@ fun PlayerScreen(
} }
} }
val current by rememberUpdatedState(state) val current by rememberUpdatedState(state)
// Locked, the picture takes no touches but one: the button that unlocks
// it, which a tap brings up for a moment. For a phone in a pocket, or in
// a child's hands.
var locked by remember { mutableStateOf(false) }
var unlockShown by remember { mutableIntStateOf(0) }
LaunchedEffect(unlockShown) {
if (unlockShown > 0) {
delay(UNLOCK_SHOWN_MS)
unlockShown = 0
}
}
BackHandler(enabled = locked && !minimized) { unlockShown++ }
var swipe by remember { mutableStateOf<Swipe?>(null) }
// Brightness set by a swipe is the player's, not the app's.
DisposableEffect(activity) { onDispose { activity?.releaseBrightness() } }
// Where a remote's keys land while the controls are down. Once they are // Where a remote's keys land while the controls are down. Once they are
// up, the D-pad moves between their buttons as it does anywhere else. // up, the D-pad moves between their buttons as it does anywhere else.
val keys = remember { FocusRequester() } val keys = remember { FocusRequester() }
@@ -495,7 +555,22 @@ fun PlayerScreen(
// Double-tap a side to seek ten seconds, as every phone video // Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap // player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both. // waits out the double-tap timeout, which is the price of both.
.pointerInput(host) { .playerSwipes(
enabled = !locked,
activity = activity,
position = { current.position },
duration = { current.duration },
onSwipe = { swipe = it },
onSeek = { target ->
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
host.seek(target)
},
)
.pointerInput(host, locked) {
if (locked) {
detectTapGestures(onTap = { unlockShown++ })
return@pointerInput
}
detectTapGestures( detectTapGestures(
onTap = { showControls = !showControls }, onTap = { showControls = !showControls },
onDoubleTap = { offset -> onDoubleTap = { offset ->
@@ -505,6 +580,9 @@ fun PlayerScreen(
offset.x > size.width - third -> DOUBLE_TAP_SECONDS offset.x > size.width - third -> DOUBLE_TAP_SECONDS
else -> 0 else -> 0
} }
// Felt as well as seen: the bubble is under the thumb
// that asked for it.
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (seconds == 0) { if (seconds == 0) {
host.setPaused(!current.paused) host.setPaused(!current.paused)
} else { } else {
@@ -536,17 +614,20 @@ fun PlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = showControls, visible = showControls && !locked,
modifier = Modifier.align(Alignment.TopStart), modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
// A gradient rather than a band: dark where the text is, and gone
// by the time it reaches the picture, so the top of the frame is
// dimmed rather than cut off by a grey bar.
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f)) .background(Brush.verticalGradient(listOf(Scrim.copy(alpha = 0.75f), Color.Transparent)))
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 28.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = onMinimize) { IconButton(onClick = onMinimize) {
@@ -567,8 +648,11 @@ fun PlayerScreen(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
// The episode's name, never its file name: a release
// group's tag is no caption, and it is what a screenshot
// of the player would carry away.
Text( Text(
listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank)) listOfNotNull(title.displayName, episode.shownName())
.joinToString(" • "), .joinToString(" • "),
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,
@@ -593,7 +677,7 @@ fun PlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = showControls, visible = showControls && !locked,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -616,9 +700,35 @@ fun PlayerScreen(
landscapeLocked = activity?.toggleLandscape() ?: false landscapeLocked = activity?.toggleLandscape() ?: false
}, },
landscapeLocked = landscapeLocked, landscapeLocked = landscapeLocked,
media = key,
sleep = sleep,
onPicking = { picking = it },
onLock = {
locked = true
showControls = false
unlockShown++
},
) )
} }
swipe?.let { SwipeBubble(it, ::formatTime, Modifier.align(Alignment.Center)) }
AnimatedVisibility(
visible = locked && unlockShown > 0,
modifier = Modifier.align(Alignment.Center),
enter = fadeIn(),
exit = fadeOut(),
) {
TonalButton(onClick = {
locked = false
unlockShown = 0
showControls = true
}) {
Icon(Icons.Default.LockOpen, contentDescription = null)
Text("Unlock", modifier = Modifier.padding(start = 8.dp))
}
}
// Drawn whether or not the rest of the controls are up: an opening lasts // Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody // ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time. // reaches in time.
@@ -628,7 +738,7 @@ fun PlayerScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp), .padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
) { Text(skip.label) } ) { Text(skip.label) }
} }
@@ -655,7 +765,7 @@ fun PlayerScreen(
} }
UpNextCard( UpNextCard(
next = next, next = next,
visible = autoplay && !stay && !state.paused && !state.ended && playingThis && visible = autoplay && !stay && !stopsAfterThis && !state.paused && !state.ended && playingThis &&
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true), (tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
secondsLeft = tailLeft?.let { ceil(it).toInt() }, secondsLeft = tailLeft?.let { ceil(it).toInt() },
onPlayNow = { advance() }, onPlayNow = { advance() },
@@ -663,12 +773,12 @@ fun PlayerScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp), .padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
) )
// Played to the end with nothing starting after it: the last episode, // Played to the end with nothing starting after it: the last episode,
// autoplay off, or the viewer stayed. A black screen said none of that. // autoplay off, or the viewer stayed. A black screen said none of that.
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) { if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay || stopsAfterThis)) {
EndCard( EndCard(
finished = episode.label, finished = episode.label,
show = title.displayName, show = title.displayName,
@@ -842,12 +952,21 @@ private fun PlayerControls(
onEpisode: (Int) -> Unit, onEpisode: (Int) -> Unit,
onRotate: () -> Unit, onRotate: () -> Unit,
landscapeLocked: Boolean, landscapeLocked: Boolean,
media: String,
sleep: SleepTimer?,
onPicking: (Boolean) -> Unit,
onLock: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showSleep by remember { mutableStateOf(false) }
var choosing by remember { mutableStateOf<String?>(null) } var choosing by remember { mutableStateOf<String?>(null) }
var showEpisodes by remember { mutableStateOf(false) } var showEpisodes by remember { mutableStateOf(false) }
var showChapters by remember { mutableStateOf(false) } var showChapters by remember { mutableStateOf(false) }
var showSpeeds by remember { mutableStateOf(false) } var showSpeeds by remember { mutableStateOf(false) }
var more by remember { mutableStateOf(false) }
val picking = more || showSleep || choosing != null || showEpisodes || showChapters || showSpeeds
LaunchedEffect(picking) { onPicking(picking) }
DisposableEffect(Unit) { onDispose { onPicking(false) } }
// What the store says, so the dialog opens on the rate that is playing. // What the store says, so the dialog opens on the rate that is playing.
var speed by remember { mutableStateOf(1.0) } var speed by remember { mutableStateOf(1.0) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -870,9 +989,9 @@ private fun PlayerControls(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f)) .background(Brush.verticalGradient(listOf(Color.Transparent, Scrim.copy(alpha = 0.85f))))
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 12.dp, vertical = 4.dp), .padding(start = 12.dp, end = 12.dp, top = 32.dp, bottom = 4.dp),
) { ) {
// Where the thumb is while a finger is on it. A seek per touch-move // Where the thumb is while a finger is on it. A seek per touch-move
// aborts every outstanding prefetch, so a one-second drag used to issue // aborts every outstanding prefetch, so a one-second drag used to issue
@@ -928,7 +1047,6 @@ private fun PlayerControls(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (!roomForAll) { if (!roomForAll) {
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" } CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
var more by remember { mutableStateOf(false) }
Box { Box {
CompactIcon(Icons.Default.MoreVert, "More") { more = true } CompactIcon(Icons.Default.MoreVert, "More") { more = true }
DropdownMenu(expanded = more, onDismissRequest = { more = false }) { DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
@@ -951,6 +1069,8 @@ private fun PlayerControls(
onRotate, onRotate,
), ),
) )
add(Triple(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true })
add(Triple(Icons.Default.Lock, "Lock touch", onLock))
} }
entries.forEach { (icon, label, action) -> entries.forEach { (icon, label, action) ->
DropdownMenuItem( DropdownMenuItem(
@@ -999,11 +1119,29 @@ private fun PlayerControls(
if (landscapeLocked) "Unlock rotation" else "Lock to landscape", if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate, onClick = onRotate,
) )
CompactIcon(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true }
CompactIcon(Icons.Default.Lock, "Lock touch", onClick = onLock)
} }
} }
} }
} }
if (showSleep) {
SleepChooser(
current = sleep,
onDismiss = { showSleep = false },
onSelect = { minutes ->
showSleep = false
host.setSleepTimer(
when (minutes) {
null -> null
0 -> SleepTimer.EndOf(media)
else -> SleepTimer.At(SystemClock.elapsedRealtime() + minutes * 60_000L)
},
)
},
)
}
if (showSpeeds) { if (showSpeeds) {
SpeedChooser( SpeedChooser(
current = speed, current = speed,
@@ -1128,6 +1266,59 @@ private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Doub
) )
} }
/** What the sleep timer button says: whether one is running, and how long it has. */
private fun sleepLabel(sleep: SleepTimer?): String = when (sleep) {
null -> "Sleep timer"
is SleepTimer.EndOf -> "Sleep timer: end of episode"
is SleepTimer.At -> {
val left = ((sleep.at - SystemClock.elapsedRealtime()) / 60_000L + 1).coerceAtLeast(1)
"Sleep timer: $left min"
}
}
/**
* Off, the end of this episode, or a number of minutes. [onSelect] gets
* `null` for off, `0` for the end of the episode, and the minutes otherwise.
*/
@Composable
private fun SleepChooser(current: SleepTimer?, onDismiss: () -> Unit, onSelect: (Int?) -> Unit) {
val choices = listOf<Pair<Int?, String>>(
null to "Off",
0 to "End of this episode",
30 to "30 minutes",
60 to "60 minutes",
)
val chosen: Int? = when (current) {
null -> null
is SleepTimer.EndOf -> 0
is SleepTimer.At -> -1
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Sleep timer") },
text = {
Column(Modifier.fillMaxWidth()) {
if (current is SleepTimer.At) {
Text(
sleepLabel(current).removePrefix("Sleep timer: ") + " left",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
}
choices.forEach { (minutes, label) ->
EdgedButton(
onClick = { onSelect(minutes) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text((if (minutes == chosen) "✓ " else "") + label)
}
}
}
},
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
)
}
/** Where each chapter begins, ticked onto the seek bar. */ /** Where each chapter begins, ticked onto the seek bar. */
@Composable @Composable
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) { private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
@@ -1456,3 +1647,9 @@ private fun formatTime(seconds: Double): String {
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60) return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
else "%d:%02d".format(total / 60, total % 60) else "%d:%02d".format(total / 60, total % 60)
} }
/**
* The player's scrims are black whatever the theme: what is under them is a
* video frame, not the page, and a light theme's scrim would wash it out.
*/
private val Scrim = Color.Black
@@ -28,6 +28,8 @@ import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.SearchExtrasRecord
import uniffi.pstr_android.LibraryFilter import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
@@ -51,7 +53,16 @@ data class AppUiState(
/** A crawl is running, which the banner offers to stop. */ /** A crawl is running, which the banner offers to stop. */
val crawling: Boolean = false, val crawling: Boolean = false,
val query: String = "", 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(), 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 * The grid: [titles] ordered, each franchise folded into one tile when
* [grouped], and the shelves above it. Null until the bridge has answered, * [grouped], and the shelves above it. Null until the bridge has answered,
@@ -168,6 +179,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch { viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) } 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 { viewModelScope.launch {
workManager.workInfosByTagFlow(DownloadCoordinator.TAG) workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() } .map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
@@ -205,6 +220,32 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
searchQuery.value = query 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() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
busy("Refreshing the library…") { busy("Refreshing the library…") {
@@ -591,7 +632,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
if (report.settingsChanged) AppearanceState.reload() if (report.settingsChanged) AppearanceState.reload()
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) { if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
report.sharesRemoved.isNotEmpty()
) {
reloadNow() reloadNow()
} }
if (report.sharesAdded.isNotEmpty()) { if (report.sharesAdded.isNotEmpty()) {
@@ -625,7 +668,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* title's positions are what this throws away, and a mis-tap on a tile's * title's positions are what this throws away, and a mis-tap on a tile's
* menu should not cost them. * menu should not cost them.
*/ */
fun setTitleWatched(title: TitleRecord, watched: Boolean) = /** 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) markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
/** /**
@@ -727,8 +790,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val stored = runCatching { val stored = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) { // TMDB's is the only key: the provider's with TMDB chosen, and
engine.setMetadataApiKey(provider, apiKey) // film art's with AniList.
if (apiKey.isNotBlank()) {
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
} }
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true)) engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
engine.metadataSettings() engine.metadataSettings()
@@ -864,7 +929,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val query = current.query.takeIf(String::isNotBlank) val query = current.query.takeIf(String::isNotBlank)
Reloaded( Reloaded(
engine.shares(), engine.shares(),
engine.library(query), engine.library(null),
engine.arrangement(query, current.filter, current.sort, current.grouped), engine.arrangement(query, current.filter, current.sort, current.grouped),
engine.offlineFiles(), engine.offlineFiles(),
engine.metadataSettings(), engine.metadataSettings(),
@@ -893,10 +958,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() val engine = NativeRuntime.engine()
val search = query.takeIf(String::isNotBlank) val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped) Triple(
engine.library(null),
engine.arrangement(search, current.filter, current.sort, current.grouped),
search?.let(engine::searchExtras),
)
} }
}.onSuccess { (titles, arrangement) -> }.onSuccess { (titles, arrangement, extras) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) } mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } } }.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
@@ -4,12 +4,29 @@ import android.net.ConnectivityManager
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
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.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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -137,6 +154,17 @@ internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = n
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 } ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 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 { internal fun formatBytes(bytes: ULong): String {
val value = bytes.toDouble() val value = bytes.toDouble()
return when { return when {
@@ -221,6 +249,13 @@ internal fun rememberQueueReason(key: Any?): String? {
} }
} }
/**
* 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". * What an episode row is headed with: "1. Mother and Children".
* *
@@ -229,7 +264,7 @@ internal fun rememberQueueReason(key: Any?): String? {
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters. * of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/ */
internal fun EpisodeRecord.heading(position: Int): String { internal fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name } val named = shownName()
val number = number?.toInt() val number = number?.toInt()
return when { return when {
named != null && number != null -> "$number. $named" named != null && number != null -> "$number. $named"
@@ -238,3 +273,86 @@ internal fun EpisodeRecord.heading(position: Int): String {
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" } 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")
}
}
}
@@ -1,6 +1,28 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import androidx.compose.foundation.background 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.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -25,6 +47,7 @@ import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
import java.time.LocalDate import java.time.LocalDate
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@@ -48,6 +71,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@@ -87,12 +111,21 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.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.EpisodeRecord
import uniffi.pstr_android.LibraryFilter import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
import uniffi.pstr_android.airingCountdown
import uniffi.pstr_android.facetKind
/** /**
* How many shows the continue-watching shelf holds. * How many shows the continue-watching shelf holds.
@@ -122,6 +155,7 @@ internal fun LibraryFilter.label(): String = when (this) {
LibraryFilter.WATCHING -> "Watching" LibraryFilter.WATCHING -> "Watching"
LibraryFilter.UNWATCHED -> "Not started" LibraryFilter.UNWATCHED -> "Not started"
LibraryFilter.DOWNLOADED -> "Downloaded" LibraryFilter.DOWNLOADED -> "Downloaded"
LibraryFilter.MY_LIST -> "My List"
} }
/** The offer of posters and synopses, and its two answers. */ /** The offer of posters and synopses, and its two answers. */
@@ -167,6 +201,10 @@ internal fun LibraryScreen(
onAddShare: () -> Unit = {}, onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> 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 download = rememberDownload()
val context = LocalContext.current val context = LocalContext.current
@@ -198,72 +236,93 @@ internal fun LibraryScreen(
onSearch("") onSearch("")
} }
BackHandler(enabled = searching, onBack = closeSearch) BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back // The banner turns through these. Worked out here rather than in the grid,
// only at the top: the page says what it is on arrival, then gets out of // because whether there is one decides the shape of the whole page.
// the way. 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() val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) { val grid = rememberLazyGridState()
if (searching) { val actions: @Composable RowScope.() -> Unit = {
SearchBar(state.query, onSearch, closeSearch) IconButton(onClick = { searching = true }) {
} else { Icon(Icons.Default.Search, contentDescription = "Search library")
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
} }
PullToRefreshBox( ArrangeMenu(state.sort, state.grouped, onArrange)
isRefreshing = state.refreshing, // Pull to refresh is the gesture; the icon stays for a viewer who
onRefresh = onRefresh, // does not know it.
modifier = Modifier.weight(1f).padding(padding), IconButton(onClick = onRefresh, enabled = !state.refreshing) {
) { Icon(Icons.Default.Refresh, contentDescription = "Refresh")
when { }
state.loading -> LibrarySkeleton() }
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState( Box(Modifier.fillMaxSize()) {
"Nothing matches", Column(Modifier.fillMaxSize().then(if (overBanner) Modifier else Modifier.nestedScroll(scroll.nestedScrollConnection))) {
"No title in the library is called “${state.query.trim()}”.", if (searching) {
) SearchBar(state.query, onSearch, closeSearch)
// A share just added: say it is being looked through, rather } else if (!overBanner) {
// than offer to add the one that is already filling the page. LargeTopAppBar(
state.titles.isEmpty() && state.activity != null -> EmptyState( title = { Text("Library") },
"Looking through the share", actions = actions,
"${state.activity} Its titles appear here once it has been listed.", colors = pageBarColors(),
) scrollBehavior = scroll,
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
}
},
) )
} }
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 -> menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes) val playlist = title.seasons.flatMap(SeasonRecord::episodes)
@@ -273,6 +332,10 @@ internal fun LibraryScreen(
onResume(title, nextUpIndex(playlist, title.continueIndex)) onResume(title, nextUpIndex(playlist, title.continueIndex))
} }
MenuRow(Icons.Default.Info, "Open") { onTitle(title) } MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
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(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
MenuRow( MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle, if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
@@ -457,6 +520,10 @@ private fun LibraryGrid(
onResumeMenu: (Resumable) -> Unit, onResumeMenu: (Resumable) -> Unit,
onFilter: (LibraryFilter) -> Unit = {}, onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null, 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 // The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid. // as they came are the grid.
@@ -475,10 +542,25 @@ private fun LibraryGrid(
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp), columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = grid,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.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 // 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 // opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf // through. Inside the grid rather than pinned over it, so the shelf
@@ -486,17 +568,6 @@ private fun LibraryGrid(
if (metadataOffer != null && state.query.isBlank()) { if (metadataOffer != null && state.query.isBlank()) {
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) } item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
} }
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
title,
resume = resumable.firstOrNull { it.title.key == title.key },
onPlay = { onResume(title, it) },
onOpen = { onTitle(title) },
)
}
}
if (state.query.isBlank() && resumable.isNotEmpty()) { if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) { item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column { Column {
@@ -535,6 +606,9 @@ private fun LibraryGrid(
onLongClick = { onTitleMenu(title) }, onLongClick = { onTitleMenu(title) },
modifier = Modifier.width(SHELF_TILE_WIDTH), modifier = Modifier.width(SHELF_TILE_WIDTH),
shared = false, shared = false,
// On a shelf, an airing show says when its
// next episode is out.
badge = title.badge ?: title.countdown(),
) )
} }
} }
@@ -567,6 +641,38 @@ private fun LibraryGrid(
} }
} }
} }
// 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)
}
}
}
items(tiles, key = { it.title.key }) { tile -> items(tiles, key = { it.title.key }) { tile ->
PosterTile( PosterTile(
tile.title, tile.title,
@@ -578,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 * 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. * lines of text as blocks, so the page does not jump when the titles land.
@@ -610,57 +765,200 @@ private val SHELF_TILE_WIDTH = 112.dp
/** Enough placeholder tiles to fill a tablet's first screen. */ /** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18 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 * The titles the banner turns through: the one watched last first, then titles
* watched, one of the titles the provider has a backdrop for, turning over * the provider has a backdrop for, starting from a different one each day —
* once a day — the desktop's `featured`. * the desktop's `featured`.
*/ */
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? { private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): List<TitleRecord> {
resumable.firstOrNull()?.let { return it.title } val picked = listOfNotNull(resumable.firstOrNull()?.title).toMutableList()
val pictured = titles.filter { it.backdropUrl != null } val pictured = titles.filter { it.backdropUrl != null }
if (pictured.isEmpty()) return null if (pictured.isEmpty()) return picked
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()] 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 @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) } val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) { var mood by remember(title.key) { mutableStateOf<Color?>(null) }
RemoteArtwork( val background = MaterialTheme.colorScheme.background
title.backdropUrl ?: title.posterUrl, val veil = moodVeil(mood)
title.displayName, val ink = MaterialTheme.colorScheme.onBackground
Modifier.fillMaxSize(), // A logo is lettering made for dark art, white as often as not; over a
fallback = title.thumbnailSource, // light palette's veil it is gone, so the light palette keeps the name.
labelled = false, val dark = background.luminance() < 0.5f
) Box(Modifier.fillMaxSize().clickable(onClickLabel = "Open ${title.displayName}", onClick = onOpen)) {
Backdrop(title, Modifier.fillMaxSize(), onMood = { mood = it })
Box( Box(
Modifier.fillMaxSize().background( 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)) { Column(
Text( Modifier.align(Alignment.BottomStart).widthIn(max = 560.dp).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
title.displayName, ) {
style = MaterialTheme.typography.headlineMedium, if (dark) {
color = Color.White, RemoteLogo(
maxLines = 2, title.logoUrl,
overflow = TextOverflow.Ellipsis, title.displayName,
) Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 8.dp),
) { HeroName(title.displayName, ink) }
} else {
HeroName(title.displayName, ink)
}
Text( Text(
listOfNotNull( listOfNotNull(
title.rating?.let { "★ %.1f".format(it) }, title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "), title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
).joinToString(" · ").ifEmpty { title.caption() }, ).joinToString(" · ").ifEmpty { title.caption() },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f), color = ink.copy(alpha = 0.8f),
) )
title.overview?.let { title.overview?.let {
Text( Text(
it.trim(), it.trim(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f), color = ink.copy(alpha = 0.8f),
maxLines = 3, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) )
@@ -669,14 +967,49 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) { AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null) Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play") Text(if (resume?.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. */ /** A part-watched episode: its still, where it was left, and what is left of it. */
@Composable @Composable
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) { private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
@@ -739,6 +1072,7 @@ private fun PosterTile(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
folded: Int = 0, folded: Int = 0,
shared: Boolean = true, shared: Boolean = true,
badge: String? = title.badge,
) { ) {
Column( Column(
modifier modifier
@@ -772,6 +1106,20 @@ private fun PosterTile(
.semantics { contentDescription = "and $folded more" }, .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),
)
}
} }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
@@ -813,3 +1161,79 @@ private fun EpisodeRecord.timeLeft(): String? {
val minutes = ((at / fraction - at) / 60).toInt() val minutes = ((at / fraction - at) / 60).toInt()
return if (minutes < 1) "under a minute left" else "$minutes min left" 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
}
@@ -62,6 +62,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -78,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
@@ -135,6 +137,28 @@ fun ProtonStreamApp(
// into the video they had just left. // into the video they had just left.
var playerMinimized by rememberSaveable { mutableStateOf(false) } var playerMinimized by rememberSaveable { mutableStateOf(false) }
var addingShare by remember { mutableStateOf(false) } 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 selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
val playing = playingTitleKey val playing = playingTitleKey
@@ -172,7 +196,7 @@ fun ProtonStreamApp(
Destination.entries.forEach { item -> Destination.entries.forEach { item ->
item( item(
selected = destination == item, selected = destination == item,
onClick = { destination = item; selectedTitleKey = null }, onClick = { destination = item; selectedTitleKey = null; browseKind = null },
icon = { Icon(item.icon, contentDescription = item.label) }, icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }, label = { Text(item.label) },
colors = navigation, colors = navigation,
@@ -211,16 +235,34 @@ fun ProtonStreamApp(
// library stays in view while a title is open, so moving // library stays in view while a title is open, so moving
// between titles is one tap rather than back and in again. // between titles is one tap rather than back and in again.
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) { 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 { 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, state,
model::search, model::search,
{ title, index -> { title, index ->
// A search that led somewhere is one
// worth offering again.
model.rememberSearch()
playingTitleKey = title.key playingTitleKey = title.key
playingIndex = index playingIndex = index
playerMinimized = false playerMinimized = false
}, },
{ selectedTitleKey = it.key }, { model.rememberSearch(); selectedTitleKey = it.key },
model::refresh, model::refresh,
model::setTitleWatched, model::setTitleWatched,
model::forgetPosition, model::forgetPosition,
@@ -234,9 +276,19 @@ fun ProtonStreamApp(
}, },
onOpenSettings = { destination = Destination.Settings }, onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter, onFilter = model::filter,
onSetOnList = model::setOnList,
onBrowse = { facet ->
model.rememberSearch()
browse(facet)
browseFrom = null
},
onForgetSearches = model::forgetSearches,
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
value = model.moreLike(selected.key)
}
TitleScreen( TitleScreen(
selected, selected,
playerHost != null, playerHost != null,
@@ -255,9 +307,18 @@ fun ProtonStreamApp(
state.titles.firstOrNull { it.key == key } state.titles.firstOrNull { it.key == key }
}, },
onOpenTitle = { selectedTitleKey = it.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()) { Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(0.45f)) { libraryPane() } Box(Modifier.weight(0.45f)) { libraryPane() }
VerticalDivider() VerticalDivider()
@@ -6,13 +6,16 @@ import android.graphics.BitmapFactory
import android.util.LruCache import android.util.LruCache
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -24,22 +27,30 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.core.graphics.scale
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord 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.HttpURLConnection
import java.net.URI import java.net.URI
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@@ -77,9 +88,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
* *
* [fallback] is used only when there is no provider artwork or it fails to * [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare * load, which — with metadata off — is the common case rather than the rare
* one. 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 * anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it. * 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 @Composable
internal fun RemoteArtwork( internal fun RemoteArtwork(
@@ -89,29 +104,200 @@ internal fun RemoteArtwork(
fallback: ThumbnailSource? = null, fallback: ThumbnailSource? = null,
labelled: Boolean = true, labelled: Boolean = true,
maxEdge: Dp? = null, maxEdge: Dp? = null,
onMood: ((Color) -> Unit)? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val enabled = LocalArtworkLoading.current val enabled = LocalArtworkLoading.current
// Decoded at the size it is drawn: a grid tile is a few hundred pixels, // 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. // 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 val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) } var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) }
LaunchedEffect(url, fallback, enabled) { LaunchedEffect(url, fallback, enabled) {
if (!enabled) { if (!enabled) {
image = ArtworkLoad.Missing image = ArtworkLoad.Missing
return@LaunchedEffect return@LaunchedEffect
} }
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)) val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
?: ArtworkLoad.Missing if (bitmap != null && onMood != null) {
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
}
} }
when (val loaded = image) { Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
ArtworkLoad.Pending -> Skeleton(modifier) when (loaded) {
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled) ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop) 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 { private sealed interface ArtworkLoad {
data object Pending : ArtworkLoad data object Pending : ArtworkLoad
data object Missing : ArtworkLoad data object Missing : ArtworkLoad
@@ -128,20 +314,38 @@ private sealed interface ArtworkLoad {
internal val LocalArtworkLoading = staticCompositionLocalOf { true } 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 * 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. * 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 @Composable
internal fun Skeleton(modifier: Modifier = Modifier) { internal fun Skeleton(modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat( val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.55f, initialValue = -1f,
targetValue = 1f, targetValue = 2f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
label = "skeleton alpha", 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))
} }
/** /**
@@ -438,30 +438,42 @@ private fun MetadataSettingsDialog(
title = { Text("Metadata enrichment") }, title = { Text("Metadata enrichment") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 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 } SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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()) } if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) } else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
} }
} }
Text( Text(
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required." if (provider == MetadataProvider.ANI_LIST) {
else "Film and television; requires a free TMDB API key.", "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, style = MaterialTheme.typography.bodySmall,
) )
if (provider == MetadataProvider.TMDB) { if (provider == MetadataProvider.TMDB) {
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true) 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) if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
// Said before Save rather than after: what is forgotten // Said before Save rather than after: what is forgotten
// includes matches chosen by hand, which no look-up redoes. // includes matches chosen by hand, which no look-up redoes.
@@ -497,6 +509,7 @@ private fun MetadataSettingsDialog(
internal fun MetadataProvider.displayName(): String = when (this) { internal fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList" MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB" MetadataProvider.TMDB -> "TMDB"
MetadataProvider.TV_MAZE -> "TVmaze"
} }
private data class LegalDocument(val title: String, val assetPath: String) private data class LegalDocument(val title: String, val assetPath: String)
@@ -5,6 +5,12 @@ import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer 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 kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
@@ -12,6 +18,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -43,6 +50,9 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download 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.Edit
import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
@@ -81,6 +91,9 @@ import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border 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.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
@@ -88,10 +101,13 @@ import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord import uniffi.pstr_android.MatchRecord
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.FilterChip 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.Replay
import androidx.compose.material.icons.filled.DownloadDone import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@Composable @Composable
@@ -109,6 +125,11 @@ internal fun TitleScreen(
/** Every title of its franchise, in release order, this one included. */ /** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(), franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {}, onOpenTitle: (TitleRecord) -> Unit = {},
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 context = LocalContext.current
val download = rememberDownload() val download = rememberDownload()
@@ -172,8 +193,12 @@ internal fun TitleScreen(
) { ) {
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp) val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f) 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = list,
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -181,37 +206,46 @@ internal fun TitleScreen(
// Edge to edge, fading into the page: the art is the first thing // 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 // the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it. // as one card among the many below it.
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) { Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight).clipToBounds()) {
RemoteArtwork( // At half the speed of the page, so the picture stays
title.backdropUrl ?: title.posterUrl, // behind the page rather than being part of it.
title.displayName, Backdrop(
Modifier.fillMaxSize(), title,
fallback = title.thumbnailSource, Modifier.fillMaxSize().graphicsLayer {
labelled = false, translationY = if (list.firstVisibleItemIndex == 0) list.firstVisibleItemScrollOffset * PARALLAX else 0f
},
onMood = { mood = it },
) )
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.verticalGradient( 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, 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") { item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) { 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 // The other names it goes by: the share's when the shown one
// is the provider's, and the original-language one. // is the provider's, and the original-language one.
listOfNotNull( listOfNotNull(
@@ -224,6 +258,8 @@ internal fun TitleScreen(
listOfNotNull( listOfNotNull(
title.formatLabel, title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(), 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.rating?.let { "★ %.1f".format(it) },
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL }, "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "), ).joinToString(" · "),
@@ -231,19 +267,20 @@ internal fun TitleScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) )
// Who made it, and whether more is coming — the desktop's // Where the title stands, then what the files are rather
// line of facts, on a line of its own so neither wraps. // than what the title is.
listOfNotNull( val status = statusText(title)
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "), if (title.quality.isNotEmpty() || status != null) {
title.directors.firstOrNull()?.let { "dir. $it" }, Row(
airingNote(title), Modifier.padding(top = 8.dp),
).joinToString(" · ").takeIf(String::isNotEmpty)?.let { horizontalArrangement = Arrangement.spacedBy(4.dp),
Text( verticalAlignment = Alignment.CenterVertically,
it, ) {
style = MaterialTheme.typography.bodySmall, status?.let { (text, live) ->
color = MaterialTheme.colorScheme.onSurfaceVariant, StatusChip(text, live, Modifier.padding(end = 4.dp))
modifier = Modifier.padding(top = 2.dp), }
) title.quality.forEach { SpecBadge(it) }
}
} }
// The one thing the page is for, full width and naming what it // The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which // will play: "Resume" alone left the viewer guessing which
@@ -272,6 +309,13 @@ internal fun TitleScreen(
// three button styles used to wrap over three lines and bury // three button styles used to wrap over three lines and bury
// the episodes under them. // the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) { 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 // Only where it would do something different: an unstarted
// show already starts at the beginning. // show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) { if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
@@ -309,15 +353,63 @@ internal fun TitleScreen(
.clickable { overviewOpen = !overviewOpen }, .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 // Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it // them: a tag is its community describing it, not what it
// is filed as. // 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( Text(
(title.genres + title.tags.take(4)).joinToString(" · "), title.tags.take(4).joinToString(" · "),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 // Said plainly, because it changes what a re-match will do: a
@@ -356,15 +448,21 @@ internal fun TitleScreen(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp), Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (title.seasons.size > SEASON_CHIPS) {
// By position: two seasons can share a label, and a // A long-running show's row of chips is a scroll of
// repeated key is a crash in a lazy list. // its own; past a handful it is a list to pick from.
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry -> SeasonList(title.seasons, seasonIndex, { seasonIndex = it }, Modifier.weight(1f))
FilterChip( } else {
selected = index == seasonIndex, LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onClick = { seasonIndex = index }, // By position: two seasons can share a label, and a
label = { Text(entry.label) }, // 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 { season?.let {
@@ -411,7 +509,49 @@ internal fun TitleScreen(
} }
} }
} }
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) { if (showMatch) {
ChangeMatchDialog( ChangeMatchDialog(
@@ -423,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 * The bar over the title page: only the back button while the backdrop is on
* is marked rather than left out, so the row says where it sits in the story. * 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 @Composable
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) { private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
@@ -472,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 * What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
* no date yet, else nothing. * 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 at = title.nextAiringAt
val episode = title.nextEpisode val episode = title.nextEpisode
if (at != null && episode != null) { val next = at != null && episode != null
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault()) val label = title.status ?: (if (next || title.airing) "Airing" else return null)
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) 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. */ /** How wide the page's text and controls run on a window wider than a phone. */
@@ -607,6 +880,21 @@ private fun EpisodeRow(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter), 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)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text( Text(
@@ -625,7 +913,9 @@ private fun EpisodeRow(
Text( Text(
listOfNotNull( listOfNotNull(
numbering, 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, episode.airDate,
).joinToString(" · "), ).joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -740,7 +1030,7 @@ internal fun ChangeMatchDialog(
title = { Text("Change match") }, title = { Text("Change match") },
text = { text = {
Column { 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) OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
AccentButton(enabled = term.isNotBlank() && !searching, onClick = { AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
searching = true searching = true
@@ -775,7 +1065,10 @@ internal fun ChangeMatchDialog(
) { ) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text(option.name, fontWeight = FontWeight.SemiBold) 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(" · "),
)
} }
} }
} }
@@ -24,6 +24,13 @@ internal val Inter = FontFamily(
Font(R.font.inter_semibold, FontWeight.SemiBold), 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. * 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 * were built to share one palette; sharing one scale is the other half of why
* they should read as one app. * 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 * 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 * 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. * [TypeRampTest] pins every one of these against the Rust ramp.
*/ */
internal val ProtonTypography = Typography( 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), displayMedium = role(DISPLAY, FontWeight.SemiBold),
displaySmall = role(TITLE, FontWeight.SemiBold), displaySmall = role(TITLE, FontWeight.SemiBold),
// The name of the title you opened — the desktop's display line. // 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), labelSmall = role(MICRO, FontWeight.Medium),
) )
internal const val HERO = 34f
internal const val DISPLAY = 26f internal const val DISPLAY = 26f
internal const val TITLE = 20f internal const val TITLE = 20f
internal const val HEADING = 18f internal const val HEADING = 18f
@@ -79,6 +91,9 @@ internal const val LABEL = 13f
internal const val CAPTION = 12f internal const val CAPTION = 12f
internal const val MICRO = 11f internal const val MICRO = 11f
internal const val LEADING = 1.35f
internal const val HERO_LEADING = 1.1f
/** /**
* One rung as a `TextStyle`. * 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 * enough for the two-line title names in the grid and tight enough that a
* label on a control does not float. * 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, fontSize = size.sp,
lineHeight = (size * 1.35f).sp, lineHeight = (size * leading).sp,
fontWeight = weight, fontWeight = weight,
fontFamily = Inter, fontFamily = family,
) )
Binary file not shown.
@@ -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())
}
}
@@ -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))
}
}
@@ -36,6 +36,7 @@ import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.CastRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
@@ -291,6 +292,11 @@ class ScreenshotTest {
directors = listOf("Daisuke Hiramaki"), directors = listOf("Daisuke Hiramaki"),
tags = listOf("Idol", "Reincarnation"), tags = listOf("Idol", "Reincarnation"),
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"), 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. */ /** The rest of [SERIES]'s franchise, for the title page's row of it. */
@@ -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 { class TypeRampTest {
/** `Role::size()`, in the order `theme.rs` declares it. */ /** `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( private val expected = mapOf(
"displayLarge" to 26f, "displayLarge" to 34f,
"displayMedium" to 26f, "displayMedium" to 26f,
"displaySmall" to 20f, "displaySmall" to 20f,
"headlineLarge" to 26f, "headlineLarge" to 26f,
@@ -62,12 +62,14 @@ class TypeRampTest {
/** /**
* Leading is derived from the size, so the only thing worth asserting is * 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 * 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 @Test
fun `line height follows the size it belongs to`() { fun `line height follows the size it belongs to`() {
styles().forEach { (name, style) -> 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: 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.

Before

Width:  |  Height:  |  Size: 672 KiB

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

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

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

+506 -28
View File
@@ -246,6 +246,9 @@ pub struct SyncRecord {
pub settings_changed: bool, pub settings_changed: bool,
/// Hand-picked matches and per-show track choices taken. /// Hand-picked matches and per-show track choices taken.
pub titles: u32, pub titles: u32,
/// Whether My List changed: reload the library.
#[uniffi(default = false)]
pub list_changed: bool,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
@@ -304,6 +307,9 @@ pub enum TitleType {
pub enum MetadataProvider { pub enum MetadataProvider {
AniList, AniList,
Tmdb, Tmdb,
/// Never chosen in the settings: what AniList hands a show it does not
/// have. Seen on a [`MatchRecord`] from a hand search.
TvMaze,
} }
/// A palette family. The shared `pstr_core::appearance::Flavor`. /// A palette family. The shared `pstr_core::appearance::Flavor`.
@@ -350,10 +356,69 @@ pub enum TitleNamesChoice {
Romaji, Romaji,
} }
/// What a genre, studio or director chip opens: the shared
/// `pstr_core::discover::Facet`.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum BrowseFacet {
Genre { name: String },
Studio { name: String },
Person { name: String },
}
/// One episode a search found by its provider name.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct EpisodeMatchRecord {
pub title_key: String,
pub link_id: String,
/// `S01E05`.
pub label: String,
pub name: String,
}
/// A genre, studio or person a search named, with how many titles carry it.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct FacetCountRecord {
pub facet: BrowseFacet,
pub count: u32,
}
/// What a search found besides titles; the shared
/// `pstr_core::discover::Found`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SearchExtrasRecord {
pub episodes: Vec<EpisodeMatchRecord>,
/// Directors and studios.
pub people: Vec<FacetCountRecord>,
pub genres: Vec<FacetCountRecord>,
}
impl From<pstr_core::discover::Facet> for BrowseFacet {
fn from(facet: pstr_core::discover::Facet) -> Self {
use pstr_core::discover::Facet;
match facet {
Facet::Genre(name) => Self::Genre { name },
Facet::Studio(name) => Self::Studio { name },
Facet::Person(name) => Self::Person { name },
}
}
}
impl BrowseFacet {
fn core(&self) -> pstr_core::discover::Facet {
use pstr_core::discover::Facet;
match self {
Self::Genre { name } => Facet::Genre(name.clone()),
Self::Studio { name } => Facet::Studio(name.clone()),
Self::Person { name } => Facet::Person(name.clone()),
}
}
}
/// The shared `pstr_core::browse::Filter`. /// The shared `pstr_core::browse::Filter`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter { pub enum LibraryFilter {
All, All,
MyList,
Series, Series,
Films, Films,
Watching, Watching,
@@ -454,6 +519,49 @@ pub fn stored_palette(paths: AndroidPaths) -> PaletteRecord {
palette_record(&appearance_record(appearance)) palette_record(&appearance_record(appearance))
} }
/// The colour a picture leaves behind, as `0xAARRGGBB`, opaque: the shared
/// `pstr_core::appearance::mood` over [`pixels`], which are `0xAARRGGBB` too —
/// what `Bitmap.getPixels` hands back. `None` for nothing opaque.
///
/// The client squeezes the picture to a few hundred pixels first; this is the
/// weighting, kept in one place so every client lands on the same colour.
#[uniffi::export]
pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
pstr_core::appearance::mood(pixels.into_iter().map(|argb| {
let channel = |shift: u32| ((argb >> shift) & 0xff) as u8;
(
pstr_core::appearance::Rgb::new(channel(16), channel(8), channel(0)),
channel(24),
)
}))
.map(pstr_core::appearance::Rgb::argb)
}
/// What an airing show's tile says about its next episode, `Ep 8 · in 2 d`;
/// see `pstr_core::news::countdown`.
#[uniffi::export]
pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
pstr_core::news::countdown(episode, at, now)
}
/// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`,
/// `Person` — for over the heading of its page.
#[uniffi::export]
pub fn facet_kind(facet: BrowseFacet) -> String {
facet.core().kind().to_owned()
}
/// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`.
#[uniffi::export]
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
let rgb = |argb: u32| {
pstr_core::appearance::Rgb::new((argb >> 16) as u8, (argb >> 8) as u8, argb as u8)
};
pstr_core::appearance::mood_veil(rgb(background), rgb(mood), light).argb()
}
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct MetadataSettingsRecord { pub struct MetadataSettingsRecord {
pub enabled: bool, pub enabled: bool,
@@ -517,6 +625,9 @@ pub struct EpisodeRecord {
/// What a continue-watching shelf orders by — "where I was last" is a /// What a continue-watching shelf orders by — "where I was last" is a
/// question about time, and progress alone cannot answer it. /// question about time, and progress alone cannot answer it.
pub last_played: i64, pub last_played: i64,
/// The provider's running time, in minutes.
#[uniffi(default = None)]
pub runtime: Option<u32>,
} }
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
@@ -574,6 +685,44 @@ pub struct TitleRecord {
/// episode finished last. `None` keeps it off the shelf. See /// episode finished last. `None` keeps it off the shelf. See
/// `pstr_core::library::Title::continue_with`. /// `pstr_core::library::Title::continue_with`.
pub continue_index: Option<u32>, pub continue_index: Option<u32>,
/// The title's name as lettering, a transparent PNG with its source's
/// padding still around it — trim it before drawing.
#[uniffi(default = None)]
pub logo_url: Option<String>,
/// Minutes: an episode's length for a series, the running time of a film.
#[uniffi(default = None)]
pub runtime: Option<u32>,
/// What most of its files' names say about them — `4K`, `HDR`, `10-bit`,
/// `Dual audio` — in that order. Never a release group's name.
#[uniffi(default = [])]
pub quality: Vec<String>,
/// `Airing`, `Finished`, `Upcoming`, `On hiatus` or `Cancelled`: the
/// shared `TitleDetails::status_label`.
#[uniffi(default = None)]
pub status: Option<String>,
/// On My List.
#[uniffi(default = false)]
pub on_list: bool,
/// `New`, or `3 new` on a show being watched; see `pstr_core::news`.
#[uniffi(default = None)]
pub badge: Option<String>,
/// The characters, main ones first, with who voices them.
#[uniffi(default = [])]
pub cast: Vec<CastRecord>,
/// The trailer's page, to open in the browser; see
/// `pstr_core::metadata::Trailer::url`.
#[uniffi(default = None)]
pub trailer_url: Option<String>,
}
/// One character and who voices them: the shared
/// `pstr_core::metadata::CastMember`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct CastRecord {
pub character: String,
pub character_image: Option<String>,
pub actor: Option<String>,
pub actor_image: Option<String>,
} }
/// Playback preferences that outlive one file, one title and one launch. /// Playback preferences that outlive one file, one title and one launch.
@@ -663,6 +812,16 @@ pub struct ChapterPlan {
pub credits_start: Option<f64>, pub credits_start: Option<f64>,
} }
/// A stretch AniSkip says is the opening or the ending of the open file.
/// See `pstr_core::chapters::with_skip_ranges`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SkipRangeRecord {
/// `Opening` or `Ending`.
pub kind: ChapterKind,
pub start: f64,
pub end: f64,
}
/// What the player may offer to skip right now. /// What the player may offer to skip right now.
#[derive(Debug, Clone, PartialEq, uniffi::Record)] #[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SkipOffer { pub struct SkipOffer {
@@ -674,8 +833,15 @@ pub struct SkipOffer {
/// Resolve a file's chapters through the same rules the desktop player uses /// Resolve a file's chapters through the same rules the desktop player uses
/// (`pstr_core::chapters`), so an opening called `Intro` is read identically on /// (`pstr_core::chapters`), so an opening called `Intro` is read identically on
/// both. Kotlin reads the list out of mpv; the verdict is reached here. /// both. Kotlin reads the list out of mpv; the verdict is reached here.
///
/// `skips` is what AniSkip said about the file, empty until it has: cut into
/// the chapters when the file's own name no opening or ending.
#[uniffi::export] #[uniffi::export]
pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> ChapterPlan { pub fn chapter_plan(
chapters: Vec<ChapterRecord>,
duration: Option<f64>,
skips: Vec<SkipRangeRecord>,
) -> ChapterPlan {
let duration = duration.filter(|duration| *duration > 0.0); let duration = duration.filter(|duration| *duration > 0.0);
let chapters: Vec<Chapter> = chapters let chapters: Vec<Chapter> = chapters
.into_iter() .into_iter()
@@ -688,6 +854,22 @@ pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> Chap
start: chapter.start, start: chapter.start,
}) })
.collect(); .collect();
let skips: Vec<pstr_core::chapters::SkipRange> = skips
.into_iter()
.filter_map(|skip| {
let role = match skip.kind {
ChapterKind::Opening => ChapterRole::Opening,
ChapterKind::Ending => ChapterRole::Ending,
ChapterKind::Preview | ChapterKind::Content => return None,
};
Some(pstr_core::chapters::SkipRange {
role,
start: skip.start,
end: skip.end,
})
})
.collect();
let chapters = pstr_core::chapters::with_skip_ranges(&chapters, &skips, duration);
let roles = pstr_core::chapters::roles(&chapters, duration); let roles = pstr_core::chapters::roles(&chapters, duration);
ChapterPlan { ChapterPlan {
credits_start: pstr_core::chapters::credits_start(&chapters, &roles), credits_start: pstr_core::chapters::credits_start(&chapters, &roles),
@@ -1278,6 +1460,7 @@ impl AndroidEngine {
shares_removed: profile.shares_removed, shares_removed: profile.shares_removed,
settings_changed: profile.settings, settings_changed: profile.settings,
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX), titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
list_changed: profile.list,
})) }))
} }
Err(error) if error.is_signed_out() => { Err(error) if error.is_signed_out() => {
@@ -1605,11 +1788,15 @@ impl AndroidEngine {
pstr_core::browse::search(&cached.library, &cached.metadata, &query) pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.into_iter() .into_iter()
.filter(|title| { .filter(|title| {
filter.admits(title, |node| { filter.admits(
cached title,
.offline |node| {
.contains(&(node.share_id.clone(), node.link_id.clone())) cached
}) .offline
.contains(&(node.share_id.clone(), node.link_id.clone()))
},
&cached.list,
)
}) })
.collect(); .collect();
let tiles = pstr_core::browse::arrange( let tiles = pstr_core::browse::arrange(
@@ -1625,13 +1812,21 @@ impl AndroidEngine {
// Shelves are the whole library's, and only when it is shown // Shelves are the whole library's, and only when it is shown
// whole. // whole.
let shelves = if query.trim().is_empty() && filter == Filter::All { let shelves = if query.trim().is_empty() && filter == Filter::All {
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES) pstr_core::shelves::home(
.into_iter() &cached.library.titles,
.map(|shelf| ShelfRecord { &cached.metadata,
label: shelf.label, cached.names,
keys: shelf.keys, &cached.list,
}) &cached.first_seen,
.collect() now(),
SHELVES,
)
.into_iter()
.map(|shelf| ShelfRecord {
label: shelf.label,
keys: shelf.keys,
})
.collect()
} else { } else {
Vec::new() Vec::new()
}; };
@@ -1648,6 +1843,92 @@ impl AndroidEngine {
}) })
} }
/// The keys of the titles most like `key`, best first: a title page's
/// "More like this" row. Worked out on asking rather than for every
/// record, since it compares the title with the whole library.
pub fn more_like(&self, key: String) -> Result<Vec<String>, BridgeError> {
self.with_library(|cached| {
pstr_core::discover::more_like(&key, &cached.library.titles, &cached.metadata, SIMILAR)
})
}
/// What `search` finds besides titles: episodes by name, and the people,
/// studios and genres it names.
pub fn search_extras(&self, search: String) -> Result<SearchExtrasRecord, BridgeError> {
self.with_library(|cached| {
let found = pstr_core::discover::find(
&cached.library,
&cached.metadata,
&cached.guides,
&search,
);
let facets = |facets: Vec<(pstr_core::discover::Facet, usize)>| {
facets
.into_iter()
.map(|(facet, count)| FacetCountRecord {
facet: BrowseFacet::from(facet),
count: count as u32,
})
.collect()
};
SearchExtrasRecord {
episodes: found
.episodes
.into_iter()
.map(|hit| EpisodeMatchRecord {
title_key: hit.title_key,
link_id: hit.link_id,
label: hit.label,
name: hit.name,
})
.collect(),
people: facets(found.people),
genres: facets(found.genres),
}
})
}
/// The last few searches on this device, newest first.
pub fn recent_searches(&self) -> Result<Vec<String>, BridgeError> {
pstr_core::recent::load(&self.dirs)
.map(|recent| recent.queries)
.map_err(BridgeError::from_display)
}
/// Put `query` at the front of the recent searches. Refused when the
/// file would not parse, rather than writing a new list over it.
pub fn remember_search(&self, query: String) -> Result<(), BridgeError> {
let mut recent = pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
if recent.remember(&query) {
pstr_core::recent::save(&self.dirs, &recent).map_err(BridgeError::from_display)?;
}
Ok(())
}
/// Forget every recent search.
pub fn forget_searches(&self) -> Result<(), BridgeError> {
pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
pstr_core::recent::save(&self.dirs, &Default::default()).map_err(BridgeError::from_display)
}
/// The keys of every title `facet` stands for, in library order.
pub fn browse(&self, facet: BrowseFacet) -> Result<Vec<String>, BridgeError> {
let facet = facet.core();
self.with_library(|cached| facet.titles(&cached.library.titles, &cached.metadata))
}
/// Put a title on My List or take it off. Refused when the list on disk
/// would not parse, rather than writing an empty one over it.
pub fn set_on_list(&self, key: String, on: bool) -> Result<(), BridgeError> {
let mut list = pstr_core::mylist::load(&self.dirs).map_err(BridgeError::from_display)?;
if on {
list.add(&key, now());
} else {
list.remove(&key);
}
pstr_core::mylist::save(&self.dirs, &list).map_err(BridgeError::from_display)
}
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> { pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?; let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?;
let ready = !config.provider.needs_api_key() let ready = !config.provider.needs_api_key()
@@ -1723,6 +2004,56 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)? .map_err(BridgeError::from_display)?
} }
/// Where AniSkip says the opening and ending of one episode are, in a
/// file `duration` seconds long. Empty when the episode's provider entry
/// has no MyAnimeList id or nobody has marked it; an error with enrichment
/// off, like every other metadata call.
pub async fn skip_times(
self: Arc<Self>,
title_key: String,
share_id: String,
link_id: String,
duration: f64,
) -> Result<Vec<SkipRangeRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let service = self.metadata_service()?;
let found = self.with_library(|cached| {
let title = cached
.library
.titles
.iter()
.find(|title| title.key == title_key)?;
let episode = title.episodes().find(|episode| {
episode.node.share_id == share_id && episode.node.link_id == link_id
})?;
let number = episode.node.parsed.episode?;
let found = cached
.guides
.get(&title.key)?
.get(episode.node.parsed.season, number)?;
Some((found.mal_id?, found.entry_number?))
})?;
let Some((mal_id, number)) = found else {
return Ok(Vec::new());
};
let ranges = service
.skip_times(mal_id, number, duration)
.await
.map_err(BridgeError::from_display)?;
Ok(ranges
.into_iter()
.map(|range| SkipRangeRecord {
kind: chapter_kind(range.role),
start: range.start,
end: range.end,
})
.collect())
})
.await
.map_err(BridgeError::from_display)?
}
/// Pin a title to the entry the viewer picked, then fetch that entry's /// Pin a title to the entry the viewer picked, then fetch that entry's
/// episodes, backdrop and franchise chain — the same enrichment a match run /// episodes, backdrop and franchise chain — the same enrichment a match run
/// does, so the title page shows the new match's episodes as soon as this /// does, so the title page shows the new match's episodes as soon as this
@@ -1736,7 +2067,7 @@ impl AndroidEngine {
spawned(&runtime, async move { spawned(&runtime, async move {
let service = self.metadata_service()?; let service = self.metadata_service()?;
let metadata = title_metadata(found); let metadata = title_metadata(found);
if metadata.provider != service.provider() { if !service.chain().contains(&metadata.provider) {
return Err(BridgeError::Failure { return Err(BridgeError::Failure {
reason: "match came from a different metadata provider".to_owned(), reason: "match came from a different metadata provider".to_owned(),
}); });
@@ -1752,7 +2083,7 @@ impl AndroidEngine {
if let Some(enrichment) = &outcome.enrichment { if let Some(enrichment) = &outcome.enrichment {
let stored = self.catalog.lock().set_enrichment( let stored = self.catalog.lock().set_enrichment(
&title_key, &title_key,
service.provider(), outcome.provider,
now(), now(),
enrichment, enrichment,
); );
@@ -1909,7 +2240,11 @@ impl AndroidEngine {
pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> { pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> {
// Sanitized rather than trusted: the volume arrives from a slider, and // Sanitized rather than trusted: the volume arrives from a slider, and
// a language from a text field that can hold spaces and nothing else. // a language from a text field that can hold spaces and nothing else.
let prefs = playback_prefs(prefs).sanitized(); // Onto what is stored, not the defaults: the record has no subtitle
// style yet, and saving one without it would reset the desktop's and,
// through sync, every other installation's.
let stored = pstr_core::prefs::load(&self.dirs).unwrap_or_default();
let prefs = playback_prefs(prefs, stored).sanitized();
pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display) pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display)
} }
@@ -2349,21 +2684,13 @@ impl AndroidEngine {
/// behaviour. /// behaviour.
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> { async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
let service = Arc::new(self.metadata_service()?); let service = Arc::new(self.metadata_service()?);
let provider = service.provider();
let (titles, stored) = self.titles_and_metadata()?; let (titles, stored) = self.titles_and_metadata()?;
let enriched = self let enriched = self
.catalog .catalog
.lock() .lock()
.enrichment_ages() .enrichment_ages()
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
let pending = pstr_meta::service::plan( let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)); let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
let mut lookups = tokio::task::JoinSet::new(); let mut lookups = tokio::task::JoinSet::new();
@@ -2395,7 +2722,7 @@ impl AndroidEngine {
summary.episodes += enrichment.episodes.len() as u32; summary.episodes += enrichment.episodes.len() as u32;
let stored = self.catalog.lock().set_enrichment( let stored = self.catalog.lock().set_enrichment(
&outcome.title_key, &outcome.title_key,
provider, outcome.provider,
now(), now(),
enrichment, enrichment,
); );
@@ -2423,7 +2750,7 @@ impl AndroidEngine {
reason: "turn on metadata enrichment first".to_owned(), reason: "turn on metadata enrichment first".to_owned(),
}); });
} }
let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), config.provider); let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), ProviderId::Tmdb);
pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display) pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display)
} }
@@ -2434,11 +2761,15 @@ impl AndroidEngine {
let names = pstr_core::appearance::load(&self.dirs) let names = pstr_core::appearance::load(&self.dirs)
.unwrap_or_default() .unwrap_or_default()
.names; .names;
// Read each time like the appearance: it is one small file, and the
// phone's list changes without the catalog being written.
let list = pstr_core::mylist::load(&self.dirs).unwrap_or_default();
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
let writes = catalog.writes(); let writes = catalog.writes();
if let Some(cached) = self.library_cache.lock().as_ref() if let Some(cached) = self.library_cache.lock().as_ref()
&& cached.writes == writes && cached.writes == writes
&& cached.names == names && cached.names == names
&& cached.list == list
{ {
return Ok(read(cached)); return Ok(read(cached));
} }
@@ -2472,6 +2803,7 @@ impl AndroidEngine {
}) })
.collect(); .collect();
let now = now(); let now = now();
let badges = pstr_core::news::badges(&library.titles, &first_seen, now);
let titles: Vec<TitleRecord> = library let titles: Vec<TitleRecord> = library
.titles .titles
.iter() .iter()
@@ -2485,6 +2817,8 @@ impl AndroidEngine {
record.display_name = record.display_name =
pstr_core::browse::display_name(title, &metadata, names).to_owned(); pstr_core::browse::display_name(title, &metadata, names).to_owned();
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default(); record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
record.on_list = list.contains(&title.key);
record.badge = badges.get(&title.key).map(|badge| badge.label());
if let Some(details) = metadata if let Some(details) = metadata
.get(&title.key) .get(&title.key)
.and_then(|record| record.metadata.as_ref()) .and_then(|record| record.metadata.as_ref())
@@ -2501,12 +2835,14 @@ impl AndroidEngine {
let cached = CachedLibrary { let cached = CachedLibrary {
writes, writes,
names, names,
list,
library, library,
metadata, metadata,
added, added,
titles, titles,
offline: offline.into_keys().collect(), offline: offline.into_keys().collect(),
first_seen, first_seen,
guides,
}; };
let answer = read(&cached); let answer = read(&cached);
*self.library_cache.lock() = Some(cached); *self.library_cache.lock() = Some(cached);
@@ -3379,6 +3715,7 @@ fn normalized_language(language: Option<String>) -> Option<String> {
struct CachedLibrary { struct CachedLibrary {
writes: u64, writes: u64,
names: TitleNames, names: TitleNames,
list: pstr_core::mylist::MyList,
library: Library, library: Library,
metadata: HashMap<String, MetadataRecord>, metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file. /// When each title last gained a file.
@@ -3388,6 +3725,9 @@ struct CachedLibrary {
offline: HashSet<(String, String)>, offline: HashSet<(String, String)>,
/// When each file first appeared in a crawl; see [`Catalog::first_seen`]. /// When each file first appeared in a crawl; see [`Catalog::first_seen`].
first_seen: HashMap<(String, String), i64>, first_seen: HashMap<(String, String), i64>,
/// What the providers said about each title's episodes, for searching
/// them by name.
guides: HashMap<String, EpisodeGuide>,
} }
/// The titles whose files first appeared at or after `since`, with how many /// The titles whose files first appeared at or after `since`, with how many
@@ -3489,6 +3829,7 @@ fn filter_choice(filter: LibraryFilter) -> Filter {
LibraryFilter::Watching => Filter::Watching, LibraryFilter::Watching => Filter::Watching,
LibraryFilter::Unwatched => Filter::Unwatched, LibraryFilter::Unwatched => Filter::Unwatched,
LibraryFilter::Downloaded => Filter::Downloaded, LibraryFilter::Downloaded => Filter::Downloaded,
LibraryFilter::MyList => Filter::MyList,
} }
} }
@@ -3507,6 +3848,9 @@ fn sort_choice(sort: LibrarySort) -> Sort {
/// desktop's four, because on a phone each is a screen's height. /// desktop's four, because on a phone each is a screen's height.
const SHELVES: usize = 3; const SHELVES: usize = 3;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
fn appearance_choice(record: AppearanceRecord) -> Appearance { fn appearance_choice(record: AppearanceRecord) -> Appearance {
Appearance { Appearance {
flavor: match record.flavor { flavor: match record.flavor {
@@ -3580,7 +3924,7 @@ fn playback_prefs_record(prefs: PlaybackPrefs) -> PlaybackPrefsRecord {
} }
} }
fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs { fn playback_prefs(record: PlaybackPrefsRecord, stored: PlaybackPrefs) -> PlaybackPrefs {
PlaybackPrefs { PlaybackPrefs {
volume: record.volume, volume: record.volume,
muted: record.muted, muted: record.muted,
@@ -3590,6 +3934,7 @@ fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
autoplay_next: record.autoplay_next, autoplay_next: record.autoplay_next,
auto_skip: record.auto_skip, auto_skip: record.auto_skip,
speed: record.speed, speed: record.speed,
..stored
} }
} }
@@ -3641,9 +3986,37 @@ fn title_record(
directors: details.map_or_else(Vec::new, |details| details.directors.clone()), directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
tags: details.map_or_else(Vec::new, |details| details.tags.clone()), tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
airing: details.is_some_and(|details| details.is_airing()), airing: details.is_some_and(|details| details.is_airing()),
status: details
.and_then(|details| details.status_label())
.map(str::to_owned),
next_episode: None, next_episode: None,
next_airing_at: None, next_airing_at: None,
on_list: false,
badge: None,
cast: details.map_or_else(Vec::new, |details| {
details
.cast
.iter()
.map(|member| CastRecord {
character: member.character.clone(),
character_image: member.character_image.clone(),
actor: member.actor.clone(),
actor_image: member.actor_image.clone(),
})
.collect()
}),
trailer_url: details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url()),
franchise: Vec::new(), franchise: Vec::new(),
logo_url: metadata.and_then(|found| found.logo_url.clone()),
runtime: details.and_then(|details| details.runtime),
quality: title
.quality()
.badges()
.into_iter()
.map(str::to_owned)
.collect(),
continue_index: title.continue_with().and_then(|episode| { continue_index: title.continue_with().and_then(|episode| {
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?; let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
u32::try_from(at).ok() u32::try_from(at).ok()
@@ -3668,6 +4041,7 @@ fn metadata_provider(provider: ProviderId) -> MetadataProvider {
match provider { match provider {
ProviderId::AniList => MetadataProvider::AniList, ProviderId::AniList => MetadataProvider::AniList,
ProviderId::Tmdb => MetadataProvider::Tmdb, ProviderId::Tmdb => MetadataProvider::Tmdb,
ProviderId::TvMaze => MetadataProvider::TvMaze,
} }
} }
@@ -3675,6 +4049,7 @@ fn provider_id(provider: MetadataProvider) -> ProviderId {
match provider { match provider {
MetadataProvider::AniList => ProviderId::AniList, MetadataProvider::AniList => ProviderId::AniList,
MetadataProvider::Tmdb => ProviderId::Tmdb, MetadataProvider::Tmdb => ProviderId::Tmdb,
MetadataProvider::TvMaze => ProviderId::TvMaze,
} }
} }
@@ -3725,6 +4100,8 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata {
kind: title_kind(record.kind), kind: title_kind(record.kind),
poster_url: record.poster_url, poster_url: record.poster_url,
backdrop_url: record.backdrop_url, backdrop_url: record.backdrop_url,
// A search answer carries none; enriching the choice finds it.
logo_url: None,
rating: record.rating.map(|rating| rating as f32), rating: record.rating.map(|rating| rating as f32),
genres: record.genres, genres: record.genres,
episodes: record.episode_count, episodes: record.episode_count,
@@ -3768,6 +4145,7 @@ fn episode_record(
still_url: named.and_then(|found| found.still_url.clone()), still_url: named.and_then(|found| found.still_url.clone()),
air_date: named.and_then(|found| found.air_date.clone()), air_date: named.and_then(|found| found.air_date.clone()),
last_played: episode.last_played(), last_played: episode.last_played(),
runtime: named.and_then(|found| found.runtime),
} }
} }
@@ -3822,6 +4200,22 @@ mod tests {
/// Material's containment ladder has five rungs where the palette names /// Material's containment ladder has five rungs where the palette names
/// three, and Kotlin lays them out in this order. A rung that is not past /// three, and Kotlin lays them out in this order. A rung that is not past
/// the one below it is a rung that reads as the same surface. /// the one below it is a rung that reads as the same surface.
#[test]
fn a_picture_handed_over_as_argb_pixels_keeps_its_colour_and_skips_the_clear_ones() {
let teal = 0xff14_8c96;
assert_eq!(super::picture_mood(vec![teal; 9]), Some(teal));
assert_eq!(super::picture_mood(vec![teal, 0x00ff_ffff]), Some(teal));
assert_eq!(super::picture_mood(vec![0x00ff_ffff]), None);
}
#[test]
fn the_veil_over_a_picture_is_the_shared_one_packed_as_argb() {
let background = 0xff0b_0b0f;
let veil = super::mood_veil(background, 0xffdc_1e1e, false);
assert_eq!(veil >> 24, 0xff);
assert!((veil >> 16) & 0xff > 0x0b, "{veil:08x}");
}
#[test] #[test]
fn the_derived_rung_continues_the_ladder_in_both_polarities() { fn the_derived_rung_continues_the_ladder_in_both_polarities() {
for flavor in FLAVORS { for flavor in FLAVORS {
@@ -4007,6 +4401,7 @@ mod tests {
chapter(3, "Cast", 4680.0), chapter(3, "Cast", 4680.0),
], ],
Some(4800.0), Some(4800.0),
Vec::new(),
); );
// Eleven minutes called `Intro` is the story, not a theme song. // Eleven minutes called `Intro` is the story, not a theme song.
@@ -4028,6 +4423,7 @@ mod tests {
chapter(1, "OP", 24.0), chapter(1, "OP", 24.0),
], ],
None, None,
Vec::new(),
); );
assert_eq!(plan.entries[0].label, "Chapter 1"); assert_eq!(plan.entries[0].label, "Chapter 1");
@@ -4037,6 +4433,25 @@ mod tests {
assert_eq!(skip_offer(&plan, 30.0), None); assert_eq!(skip_offer(&plan, 30.0), None);
} }
/// A file with no chapters of its own offers AniSkip's opening, through
/// the same skip as one that came with chapters.
#[test]
fn aniskips_opening_is_offered_in_a_file_without_chapters() {
let plan = chapter_plan(
Vec::new(),
Some(1440.0),
vec![super::SkipRangeRecord {
kind: ChapterKind::Opening,
start: 60.0,
end: 150.0,
}],
);
let offer = skip_offer(&plan, 70.0).expect("inside the opening");
assert_eq!(offer.label, "Skip opening");
assert_eq!(offer.target, 150.0);
assert_eq!(skip_offer(&plan, 10.0), None);
}
/// A skip is offered inside the opening and nowhere else — including at its /// A skip is offered inside the opening and nowhere else — including at its
/// very last second, where seeking would land the viewer where they already /// very last second, where seeking would land the viewer where they already
/// are, and after it, where the button would never go away. /// are, and after it, where the button would never go away.
@@ -4050,6 +4465,7 @@ mod tests {
chapter(3, "ED", 1320.0), chapter(3, "ED", 1320.0),
], ],
Some(1440.0), Some(1440.0),
Vec::new(),
); );
assert_eq!(skip_offer(&plan, 10.0), None, "content is not skippable"); assert_eq!(skip_offer(&plan, 10.0), None, "content is not skippable");
@@ -4105,6 +4521,7 @@ mod tests {
kind: TitleKind::Series, kind: TitleKind::Series,
poster_url: Some("https://example.test/poster.jpg".to_owned()), poster_url: Some("https://example.test/poster.jpg".to_owned()),
backdrop_url: Some("https://example.test/backdrop.jpg".to_owned()), backdrop_url: Some("https://example.test/backdrop.jpg".to_owned()),
logo_url: None,
rating: Some(8.7), rating: Some(8.7),
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()], genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
episodes: Some(26), episodes: Some(26),
@@ -4199,6 +4616,7 @@ mod tests {
kind, kind,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
@@ -4264,6 +4682,66 @@ mod tests {
let titles = engine.library(Some("frieren".into())).expect("library"); let titles = engine.library(Some("frieren".into())).expect("library");
assert_eq!(titles[0].display_name, "Sousou no Frieren"); assert_eq!(titles[0].display_name, "Sousou no Frieren");
// My List: the record says so straight away, the filter narrows to
// it, and it is a row above the grid.
engine
.set_on_list("sousou no frieren".into(), true)
.expect("list");
let titles = engine.library(Some("frieren".into())).expect("library");
assert!(titles[0].on_list);
let listed = engine
.arrangement(
None,
super::LibraryFilter::MyList,
super::LibrarySort::Name,
false,
)
.expect("arrange");
assert_eq!(listed.tiles.len(), 1);
let whole = engine
.arrangement(
None,
super::LibraryFilter::All,
super::LibrarySort::Name,
true,
)
.expect("arrange");
assert_eq!(whole.shelves[0].label, "My List");
engine
.set_on_list("sousou no frieren".into(), false)
.expect("list");
assert!(!engine.library(None).expect("library")[0].on_list);
// The film is Frieren's own franchise, so nothing else is like it.
assert!(
engine
.more_like("sousou no frieren".into())
.expect("similar")
.is_empty()
);
let genre = super::BrowseFacet::Genre {
name: "Drama".into(),
};
assert!(engine.browse(genre.clone()).expect("browse").is_empty());
assert_eq!(super::facet_kind(genre), "Genre");
// Recent searches: newest first, and forgotten on asking.
engine.remember_search("frieren".into()).expect("remember");
engine.remember_search("abyss".into()).expect("remember");
assert_eq!(
engine.recent_searches().expect("recent"),
["abyss", "frieren"]
);
engine.forget_searches().expect("forget");
assert!(engine.recent_searches().expect("recent").is_empty());
assert!(
engine
.search_extras("frieren".into())
.expect("extras")
.episodes
.is_empty()
);
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
} }
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+477 -65
View File
@@ -13,6 +13,7 @@ use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::Library; use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use pstr_core::mylist::MyList;
use crate::engine::{ use crate::engine::{
AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
@@ -49,6 +50,7 @@ impl FrameTimer {
Page::Library => "library", Page::Library => "library",
Page::History => "history", Page::History => "history",
Page::Title(_) => "title", Page::Title(_) => "title",
Page::Browse { .. } => "browse",
Page::Shares => "shares", Page::Shares => "shares",
Page::Downloads => "downloads", Page::Downloads => "downloads",
Page::Settings => "settings", Page::Settings => "settings",
@@ -96,6 +98,12 @@ pub enum Page {
History, History,
/// One title, by [`pstr_core::library::Title::key`]. /// One title, by [`pstr_core::library::Title::key`].
Title(String), Title(String),
/// The titles a genre, studio or person chip stands for, with the title
/// it was opened from, for the way back.
Browse {
facet: pstr_core::discover::Facet,
from: Option<String>,
},
Shares, Shares,
Downloads, Downloads,
/// How the app looks, how it plays, and what it looks up. /// How the app looks, how it plays, and what it looks up.
@@ -235,6 +243,14 @@ pub enum Action {
subtitles: bool, subtitles: bool,
subtitle: Option<String>, subtitle: Option<String>,
}, },
/// How subtitles look, for the file playing and every one after. Written
/// to the preferences once the mouse is let go; see
/// [`App::save_subtitle_style`].
SetSubtitleStyle(pstr_core::prefs::SubtitleStyle),
/// Shift the playing file's subtitles by this many seconds more.
NudgeSubtitleDelay(f64),
/// Put the playing file's subtitles back on the file's own timing.
ResetSubtitleDelay,
/// Play faster or slower, and keep doing so for the next file. /// Play faster or slower, and keep doing so for the next file.
SetSpeed(f64), SetSpeed(f64),
/// Seek past openings and credits without being asked. /// Seek past openings and credits without being asked.
@@ -265,8 +281,10 @@ pub enum Action {
share_id: String, share_id: String,
link_id: String, link_id: String,
}, },
/// Open or close the list of episodes beside the picture. /// Open or close one of the panels beside the picture.
ToggleEpisodes, TogglePanel(ui::player::PlayerPanel),
/// Pause when this runs out, or turn the timer off.
SetSleep(Option<ui::player::SleepChoice>),
/// Put watch states back as they were — what Undo does. /// Put watch states back as they were — what Undo does.
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>), RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
ClearStreamCache, ClearStreamCache,
@@ -285,6 +303,11 @@ pub enum Action {
SetShelf(Filter, Sort), SetShelf(Filter, Sort),
/// Fold each franchise into one tile, or show every title. /// Fold each franchise into one tile, or show every title.
SetGrouped(bool), SetGrouped(bool),
/// Put a title on My List, or take it off.
SetOnList {
key: String,
on: bool,
},
/// Open or close the list of keyboard shortcuts. /// Open or close the list of keyboard shortcuts.
ToggleShortcuts, ToggleShortcuts,
/// One page back: a title to the library, the library to nowhere. /// One page back: a title to the library, the library to nowhere.
@@ -402,6 +425,15 @@ pub struct LibraryView {
pub history: Vec<ui::history::Entry>, pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices. /// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>, pub resumable: Vec<usize>,
/// What each tile says about what arrived — `New`, `3 new` — by key.
pub badges: HashMap<String, String>,
/// The keys on My List.
pub listed: std::collections::HashSet<String>,
/// What the search found besides titles.
pub found: pstr_core::discover::Found,
/// "More like this" for the title page open, worked out once per title:
/// its key, and the keys of the titles like it.
pub similar: Option<(String, Vec<String>)>,
/// The library or what is known about it changed under the lists. /// The library or what is known about it changed under the lists.
stale: bool, stale: bool,
/// Only watch state changed: the shelves and franchises still stand, /// Only watch state changed: the shelves and franchises still stand,
@@ -437,6 +469,10 @@ impl Default for LibraryView {
shelves: Vec::new(), shelves: Vec::new(),
history: Vec::new(), history: Vec::new(),
resumable: Vec::new(), resumable: Vec::new(),
badges: HashMap::new(),
listed: std::collections::HashSet::new(),
similar: None,
found: pstr_core::discover::Found::default(),
stale: false, stale: false,
progressed: false, progressed: false,
} }
@@ -454,11 +490,21 @@ pub struct ViewInputs<'a> {
pub added: &'a HashMap<String, i64>, pub added: &'a HashMap<String, i64>,
/// The files kept on this computer, for the Downloaded filter. /// The files kept on this computer, for the Downloaded filter.
pub offline: &'a std::collections::HashSet<DownloadKey>, pub offline: &'a std::collections::HashSet<DownloadKey>,
pub list: &'a MyList,
/// When each file first appeared in a crawl, for what is new.
pub first_seen: &'a HashMap<(String, String), i64>,
/// What the providers said about each title's episodes, for searching
/// them by name.
pub guides: &'a HashMap<String, EpisodeGuide>,
pub now: i64,
} }
/// How many shelves the library shows above the grid. /// How many shelves the library shows above the grid.
const SHELVES: usize = 4; const SHELVES: usize = 4;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
impl LibraryView { impl LibraryView {
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) { fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
use pstr_core::library::Title; use pstr_core::library::Title;
@@ -473,6 +519,7 @@ impl LibraryView {
if !self.stale && self.progressed && unchanged && !watch_shapes_grid { if !self.stale && self.progressed && unchanged && !watch_shapes_grid {
self.history = ui::history::entries(library); self.history = ui::history::entries(library);
self.resumable = Self::resumable(library); self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
self.progressed = false; self.progressed = false;
return; return;
} }
@@ -481,6 +528,7 @@ impl LibraryView {
} }
let metadata = inputs.metadata; let metadata = inputs.metadata;
if self.stale { if self.stale {
self.similar = None;
let all: Vec<&Title> = library.titles.iter().collect(); let all: Vec<&Title> = library.titles.iter().collect();
self.franchises = pstr_core::franchise::group(&all, metadata) self.franchises = pstr_core::franchise::group(&all, metadata)
.into_iter() .into_iter()
@@ -500,28 +548,45 @@ impl LibraryView {
.enumerate() .enumerate()
.map(|(at, title)| (title.key.as_str(), at)) .map(|(at, title)| (title.key.as_str(), at))
.collect(); .collect();
self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES) self.listed = inputs
.into_iter() .list
.map(|shelf| { .entries()
let indices = shelf .map(|(key, _)| key.to_owned())
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
.collect(); .collect();
self.shelves = pstr_core::shelves::home(
&library.titles,
metadata,
inputs.names,
inputs.list,
inputs.first_seen,
inputs.now,
SHELVES,
)
.into_iter()
.map(|shelf| {
let indices = shelf
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
.collect();
} }
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query) let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
.into_iter() .into_iter()
.filter(|title| { .filter(|title| {
self.filter.admits(title, |node| { self.filter.admits(
inputs.offline.contains(&DownloadKey { title,
share_id: node.share_id.clone(), |node| {
link_id: node.link_id.clone(), inputs.offline.contains(&DownloadKey {
}) share_id: node.share_id.clone(),
}) link_id: node.link_id.clone(),
})
},
inputs.list,
)
}) })
.collect(); .collect();
let tiles = pstr_core::browse::arrange( let tiles = pstr_core::browse::arrange(
@@ -556,12 +621,23 @@ impl LibraryView {
if self.stale || self.progressed { if self.stale || self.progressed {
self.history = ui::history::entries(library); self.history = ui::history::entries(library);
self.resumable = Self::resumable(library); self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
}
if self.query != inputs.query || self.stale {
self.found = pstr_core::discover::find(library, metadata, inputs.guides, inputs.query);
} }
self.query = inputs.query.to_owned(); self.query = inputs.query.to_owned();
self.stale = false; self.stale = false;
self.progressed = false; self.progressed = false;
} }
fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap<String, String> {
pstr_core::news::badges(&library.titles, inputs.first_seen, inputs.now)
.into_iter()
.map(|(key, badge)| (key, badge.label()))
.collect()
}
/// The "Continue watching" shelf, as indices into the library. /// The "Continue watching" shelf, as indices into the library.
fn resumable(library: &Library) -> Vec<usize> { fn resumable(library: &Library) -> Vec<usize> {
library library
@@ -620,6 +696,9 @@ pub struct App {
pub metadata: HashMap<String, MetadataRecord>, pub metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file, by key — for "Recently added". /// When each title last gained a file, by key — for "Recently added".
pub added: HashMap<String, i64>, pub added: HashMap<String, i64>,
/// When each file first appeared in a crawl, for the New badges.
first_seen: HashMap<(String, String), i64>,
my_list: MyList,
/// What they said about the episodes under those titles. /// What they said about the episodes under those titles.
pub episodes: HashMap<String, EpisodeGuide>, pub episodes: HashMap<String, EpisodeGuide>,
pub settings: MetadataConfig, pub settings: MetadataConfig,
@@ -635,7 +714,7 @@ pub struct App {
/// Whether the list of keyboard shortcuts is open. /// Whether the list of keyboard shortcuts is open.
pub shortcuts_open: bool, pub shortcuts_open: bool,
/// What a key just did in the player, and when: "Volume 55%", "+30 s". /// What a key just did in the player, and when: "Volume 55%", "+30 s".
osd: Option<(String, f64)>, osd: Option<ui::player::Flash>,
pub view: LibraryView, pub view: LibraryView,
/// Whether the catalog has been read at all yet. Until it has, an empty /// Whether the catalog has been read at all yet. Until it has, an empty
/// library means "not loaded", not "nothing in it". /// library means "not loaded", not "nothing in it".
@@ -654,8 +733,12 @@ pub struct App {
/// Chapters already skipped on their own in this player, so seeking back /// Chapters already skipped on their own in this player, so seeking back
/// into an opening on purpose is not undone a frame later. /// into an opening on purpose is not undone a frame later.
auto_skipped: (u64, Vec<i64>), auto_skipped: (u64, Vec<i64>),
/// Whether the episode list is out on the player page. /// The subtitle style changed and has not been written yet.
pub episode_drawer: bool, subtitle_style_unsaved: bool,
/// Which panel is out beside the picture on the player page.
pub player_panel: Option<ui::player::PlayerPanel>,
/// The sleep timer, when one is set.
sleep: Option<Sleep>,
/// Set while the window is fullscreen, so `F` can toggle rather than only /// Set while the window is fullscreen, so `F` can toggle rather than only
/// ever entering. egui has no way to ask the platform. /// ever entering. egui has no way to ask the platform.
pub fullscreen: bool, pub fullscreen: bool,
@@ -713,6 +796,7 @@ impl App {
let window = crate::window::WindowMemory::new(dirs.window_file()); let window = crate::window::WindowMemory::new(dirs.window_file());
let notifier = crate::notify::Notifier::new(&dirs.cache); let notifier = crate::notify::Notifier::new(&dirs.cache);
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
let my_list = engine.my_list();
theme::install_fonts(&cc.egui_ctx); theme::install_fonts(&cc.egui_ctx);
theme::apply(&cc.egui_ctx, engine.appearance()); theme::apply(&cc.egui_ctx, engine.appearance());
cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom()); cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom());
@@ -742,6 +826,8 @@ impl App {
posters: ImageCache::default(), posters: ImageCache::default(),
metadata: HashMap::new(), metadata: HashMap::new(),
added: HashMap::new(), added: HashMap::new(),
first_seen: HashMap::new(),
my_list,
episodes: HashMap::new(), episodes: HashMap::new(),
settings, settings,
api_key: String::new(), api_key: String::new(),
@@ -760,8 +846,10 @@ impl App {
up_next: UpNext::default(), up_next: UpNext::default(),
warmed: 0, warmed: 0,
auto_skipped: (0, Vec::new()), auto_skipped: (0, Vec::new()),
subtitle_style_unsaved: false,
fullscreen: false, fullscreen: false,
episode_drawer: false, player_panel: None,
sleep: None,
opening: None, opening: None,
connecting: true, connecting: true,
crawling: false, crawling: false,
@@ -786,9 +874,26 @@ impl App {
/// Say over the picture what a control just did. Only on the player page; /// Say over the picture what a control just did. Only on the player page;
/// anywhere else the transport bar already shows it. /// anywhere else the transport bar already shows it.
fn flash(&mut self, ctx: &egui::Context, text: impl Into<String>) { fn flash(&mut self, ctx: &egui::Context, icon: &'static str, text: impl Into<String>) {
self.flash_level(ctx, icon, text, None);
}
/// The same, with a level drawn under it.
fn flash_level(
&mut self,
ctx: &egui::Context,
icon: &'static str,
text: impl Into<String>,
level: Option<f32>,
) {
if self.page == Page::Player { if self.page == Page::Player {
self.osd = Some((text.into(), ctx.input(|input| input.time))); self.osd = Some(ui::player::Flash {
icon,
text: text.into(),
level,
seek: None,
at: ctx.input(|input| input.time),
});
} }
} }
@@ -875,6 +980,11 @@ impl App {
self.connecting = false; self.connecting = false;
self.note(ctx, format!("could not open the shares: {error}"), true); self.note(ctx, format!("could not open the shares: {error}"), true);
} }
Event::FirstSeen(first_seen) => self.first_seen = first_seen,
Event::MyList(list) => {
self.my_list = list;
self.view.stale = true;
}
Event::LibraryLoaded(library, added) => { Event::LibraryLoaded(library, added) => {
self.library = library; self.library = library;
self.added = added; self.added = added;
@@ -1011,6 +1121,11 @@ impl App {
matcher.error = Some(error); matcher.error = Some(error);
} }
} }
Event::SkipTimes { player, ranges } => {
if let Some(playback) = self.playback.as_mut().filter(|p| p.id == player) {
playback.set_skip_ranges(ranges);
}
}
Event::PlaybackReady { target, stream } => { Event::PlaybackReady { target, stream } => {
let opening = self.opening.take(); let opening = self.opening.take();
// Dropped before the new one is built: two mpv cores would // Dropped before the new one is built: two mpv cores would
@@ -1266,7 +1381,7 @@ impl App {
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) -> Option<UpNextCard> { ) -> Option<UpNextCard> {
let has_next = self.neighbours().next; let has_next = self.neighbours().next;
let autoplay = self.engine.playback_prefs().autoplay_next; let autoplay = self.engine.playback_prefs().autoplay_next && !self.stops_after_this();
let next = self.adjacent(Adjacent::Next); let next = self.adjacent(Adjacent::Next);
let Some(playback) = &self.playback else { let Some(playback) = &self.playback else {
@@ -1352,6 +1467,62 @@ impl App {
}) })
} }
/// Whether the sleep timer is set to stop at the end of what is playing.
fn stops_after_this(&self) -> bool {
matches!(
(self.sleep, &self.playback),
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id == id
)
}
/// Pause once the sleep timer runs out. A timer for the end of an episode
/// that is no longer playing — the viewer moved on to another — is
/// dropped: it was about that episode.
fn tick_sleep(&mut self, ctx: &egui::Context) {
match (self.sleep, &self.playback) {
(Some(Sleep::At(at)), Some(playback)) if std::time::Instant::now() >= at => {
if !playback.paused {
playback.send(crate::playback::Command::TogglePause);
}
self.sleep = None;
self.note(ctx, "Sleep timer: paused", false);
}
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id != id => {
self.sleep = None;
}
(Some(_), None) => self.sleep = None,
_ => {}
}
}
/// Once a file has loaded with no chapters naming its opening or ending,
/// ask AniSkip, when the episode's provider entry has a MyAnimeList id.
fn ask_skip_times(&mut self) {
let Some(playback) = &mut self.playback else {
return;
};
let Some(length) = playback.wants_skip_times() else {
return;
};
playback.skips_asked = true;
let target = &playback.target;
let found = self.library.get(&target.title_key).and_then(|title| {
let episode = title.episodes().find(|episode| {
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
})?;
let number = episode.node.parsed.episode?;
self.episodes
.get(&title.key)?
.get(episode.node.parsed.season, number)
});
if let Some((mal_id, number)) =
found.and_then(|found| Some((found.mal_id?, found.entry_number?)))
{
self.engine
.request_skip_times(playback.id, mal_id, number, length);
}
}
/// Near the end of a file, fetch the opening blocks of the next one. /// Near the end of a file, fetch the opening blocks of the next one.
/// ///
/// The last two minutes, or the credits if the chapters say where they /// The last two minutes, or the credits if the chapters say where they
@@ -1417,6 +1588,11 @@ impl App {
/// end too, and auto-advancing through a broken share one episode at a /// end too, and auto-advancing through a broken share one episode at a
/// time is how a player earns being turned off. /// time is how a player earns being turned off.
fn autoplay_next(&mut self, ctx: &egui::Context) { fn autoplay_next(&mut self, ctx: &egui::Context) {
if self.stops_after_this() {
// The timer has done its job.
self.sleep = None;
return;
}
if !self.engine.playback_prefs().autoplay_next { if !self.engine.playback_prefs().autoplay_next {
return; return;
} }
@@ -1437,6 +1613,21 @@ impl App {
self.apply(ctx, Action::Play(target)); self.apply(ctx, Action::Play(target));
} }
/// Write the subtitle style, once whatever is changing it has let go.
///
/// A slider or a colour wheel changes the style every frame it is dragged,
/// and each change is shown on the playing file straight away. Writing
/// each one would be a file write per frame, so it waits for the mouse —
/// which also covers a menu closed mid-drag, where the control that would
/// have said "released" is no longer drawn.
fn save_subtitle_style(&mut self, ctx: &egui::Context) {
if self.subtitle_style_unsaved && !ctx.input(|input| input.pointer.any_down()) {
self.subtitle_style_unsaved = false;
self.engine
.set_playback_prefs(self.engine.playback_prefs(), true);
}
}
fn send_player(&self, command: crate::playback::Command) { fn send_player(&self, command: crate::playback::Command) {
if let Some(playback) = &self.playback { if let Some(playback) = &self.playback {
playback.send(command); playback.send(command);
@@ -1505,6 +1696,15 @@ impl App {
} }
fn apply(&mut self, ctx: &egui::Context, action: Action) { fn apply(&mut self, ctx: &egui::Context, action: Action) {
// A search that led somewhere is one worth offering again.
if self.page == Page::Library
&& matches!(
action,
Action::Goto(Page::Title(_) | Page::Browse { .. }) | Action::Play(_)
)
{
self.engine.remember_search(Some(&self.search));
}
match action { match action {
Action::Goto(page) => self.page = page, Action::Goto(page) => self.page = page,
Action::Play(mut target) => { Action::Play(mut target) => {
@@ -1644,7 +1844,10 @@ impl App {
} }
Action::WatchToEnd => self.up_next.dismissed = true, Action::WatchToEnd => self.up_next.dismissed = true,
Action::FocusSearch => { Action::FocusSearch => {
if !matches!(self.page, Page::Library | Page::Title(_)) { if !matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
) {
self.page = Page::Library; self.page = Page::Library;
} }
self.focus_search = true; self.focus_search = true;
@@ -1659,8 +1862,15 @@ impl App {
self.view.grouped = grouped; self.view.grouped = grouped;
self.remember_arrangement(); self.remember_arrangement();
} }
Action::Back => match self.page { Action::SetOnList { key, on } => {
self.my_list = self.engine.set_on_list(&key, on);
self.view.stale = true;
}
Action::Back => match &self.page {
Page::Title(_) => self.page = Page::Library, Page::Title(_) => self.page = Page::Library,
Page::Browse { from, .. } => {
self.page = from.clone().map_or(Page::Library, Page::Title)
}
// Back out of a search before out of anything else: it is the // Back out of a search before out of anything else: it is the
// last thing the viewer did. // last thing the viewer did.
Page::Library if !self.search.is_empty() => self.search.clear(), Page::Library if !self.search.is_empty() => self.search.clear(),
@@ -1699,7 +1909,7 @@ impl App {
playback.position, playback.position,
)); ));
self.send_player(crate::playback::Command::Screenshot(path)); self.send_player(crate::playback::Command::Screenshot(path));
self.flash(ctx, "screenshot saved".to_owned()); self.flash(ctx, egui_phosphor::regular::CAMERA, "Screenshot saved");
} }
Action::PasteShare(url) => { Action::PasteShare(url) => {
self.page = Page::Shares; self.page = Page::Shares;
@@ -1789,15 +1999,11 @@ impl App {
); );
} }
Action::Player(command) => { Action::Player(command) => {
if let crate::playback::Command::SeekBy(seconds) = command { if let crate::playback::Command::SeekBy(seconds) = command
self.flash( && self.page == Page::Player
ctx, {
if seconds < 0.0 { let now = ctx.input(|input| input.time);
format!("− {:.0} s", -seconds) self.osd = Some(ui::player::Flash::seek(self.osd.as_ref(), seconds, now));
} else {
format!("+ {seconds:.0} s")
},
);
} }
if let Some(playback) = &self.playback { if let Some(playback) = &self.playback {
playback.send(command); playback.send(command);
@@ -1825,7 +2031,7 @@ impl App {
} }
let text = format!("Speed {}", ui::format_speed(speed)); let text = format!("Speed {}", ui::format_speed(speed));
if self.page == Page::Player { if self.page == Page::Player {
self.flash(ctx, text); self.flash(ctx, egui_phosphor::regular::GAUGE, text);
} else { } else {
self.note(ctx, text, false); self.note(ctx, text, false);
} }
@@ -1910,7 +2116,32 @@ impl App {
Action::RemoveFromHistory { share_id, link_id } => { Action::RemoveFromHistory { share_id, link_id } => {
self.forget_watch(ctx, share_id, link_id, "history"); self.forget_watch(ctx, share_id, link_id, "history");
} }
Action::ToggleEpisodes => self.episode_drawer = !self.episode_drawer, Action::SetSleep(choice) => {
self.sleep = match choice {
None => None,
Some(ui::player::SleepChoice::EndOfEpisode) => self
.playback
.as_ref()
.map(|playback| Sleep::EndOfEpisode(playback.id)),
Some(ui::player::SleepChoice::Minutes(minutes)) => Some(Sleep::At(
std::time::Instant::now()
+ std::time::Duration::from_secs(u64::from(minutes) * 60),
)),
};
let text = match choice {
None => "Sleep timer off".to_owned(),
Some(ui::player::SleepChoice::EndOfEpisode) => {
"Stopping after this episode".to_owned()
}
Some(ui::player::SleepChoice::Minutes(minutes)) => {
format!("Pausing in {minutes} min")
}
};
self.flash(ctx, egui_phosphor::regular::MOON, text);
}
Action::TogglePanel(panel) => {
self.player_panel = (self.player_panel != Some(panel)).then_some(panel);
}
Action::RestoreWatch(states) => { Action::RestoreWatch(states) => {
for (share_id, link_id, state) in states { for (share_id, link_id, state) in states {
self.engine.save_watch_state(share_id, link_id, state); self.engine.save_watch_state(share_id, link_id, state);
@@ -1938,6 +2169,32 @@ impl App {
prefs.subtitle_language = subtitle; prefs.subtitle_language = subtitle;
self.engine.set_playback_prefs(prefs, true); self.engine.set_playback_prefs(prefs, true);
} }
Action::SetSubtitleStyle(style) => {
let mut prefs = self.engine.playback_prefs();
prefs.subtitle_style = style;
self.engine.set_playback_prefs(prefs, false);
self.subtitle_style_unsaved = true;
let style = self.engine.playback_prefs().subtitle_style;
if let Some(playback) = &mut self.playback {
playback.restyle_subtitles(&style);
}
}
Action::NudgeSubtitleDelay(delta) => {
if let Some(playback) = &mut self.playback {
playback.set_subtitle_delay(playback.subtitle_delay + delta);
let text = format!(
"Subtitle delay {}",
ui::format_delay(playback.subtitle_delay)
);
self.flash(ctx, egui_phosphor::regular::SUBTITLES, text);
}
}
Action::ResetSubtitleDelay => {
if let Some(playback) = &mut self.playback {
playback.set_subtitle_delay(0.0);
self.flash(ctx, egui_phosphor::regular::SUBTITLES, "Subtitle delay 0 s");
}
}
Action::SetAppearance(appearance) => { Action::SetAppearance(appearance) => {
self.engine.set_appearance(appearance); self.engine.set_appearance(appearance);
theme::apply(ctx, appearance); theme::apply(ctx, appearance);
@@ -1946,7 +2203,18 @@ impl App {
self.set_volume(volume, commit); self.set_volume(volume, commit);
// A slider shows its own value; a key press shows it here. // A slider shows its own value; a key press shows it here.
if commit { if commit {
self.flash(ctx, format!("Volume {volume:.0}%")); self.flash_level(
ctx,
if volume <= 0.0 {
egui_phosphor::regular::SPEAKER_SLASH
} else if volume < 50.0 {
egui_phosphor::regular::SPEAKER_LOW
} else {
egui_phosphor::regular::SPEAKER_HIGH
},
format!("Volume {volume:.0}%"),
Some((volume / pstr_player::MAX_VOLUME) as f32),
);
} }
} }
Action::ToggleMute => { Action::ToggleMute => {
@@ -1959,7 +2227,11 @@ impl App {
None => !prefs.muted, None => !prefs.muted,
}; };
self.send_player(crate::playback::Command::SetMuted(prefs.muted)); self.send_player(crate::playback::Command::SetMuted(prefs.muted));
self.flash(ctx, if prefs.muted { "Muted" } else { "Sound on" }); if prefs.muted {
self.flash(ctx, egui_phosphor::regular::SPEAKER_SLASH, "Muted");
} else {
self.flash(ctx, egui_phosphor::regular::SPEAKER_HIGH, "Sound on");
}
self.engine.set_playback_prefs(prefs, true); self.engine.set_playback_prefs(prefs, true);
} }
Action::SelectTrack { kind, id } => { Action::SelectTrack { kind, id } => {
@@ -2023,7 +2295,10 @@ impl App {
); );
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
let on_library = matches!(self.page, Page::Library | Page::Title(_)); let on_library = matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
);
let active = self let active = self
.downloads .downloads
.iter() .iter()
@@ -2088,7 +2363,9 @@ impl App {
if on_library && !self.library.is_empty() { if on_library && !self.library.is_empty() {
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
ui::search_field(ui, search, focus_search); if ui::search_field(ui, search, focus_search, &self.engine.recent_searches()) {
self.engine.remember_search(None);
}
} }
}); });
}); });
@@ -2128,7 +2405,10 @@ impl eframe::App for App {
} }
} }
self.warm_next(); self.warm_next();
self.ask_skip_times();
self.tick_sleep(ctx);
self.auto_skip(ctx); self.auto_skip(ctx);
self.save_subtitle_style(ctx);
self.window.observe(ctx); self.window.observe(ctx);
let mut actions: Vec<Action> = Vec::new(); let mut actions: Vec<Action> = Vec::new();
@@ -2169,13 +2449,7 @@ impl eframe::App for App {
speed: playback.speed, speed: playback.speed,
skip_to: playback.skippable().map(|(_, end)| end), skip_to: playback.skippable().map(|(_, end)| end),
}); });
shortcuts( shortcuts(ctx, self.fullscreen, self.player_panel, state, &mut actions);
ctx,
self.fullscreen,
self.episode_drawer,
state,
&mut actions,
);
// Before the draw, because drawing does not mutate — and it can // Before the draw, because drawing does not mutate — and it can
// push an action of its own, which is why it takes the same list. // push an action of its own, which is why it takes the same list.
@@ -2191,7 +2465,8 @@ impl eframe::App for App {
posters, posters,
metadata, metadata,
episodes, episodes,
episode_drawer, player_panel,
sleep,
.. ..
} = self; } = self;
// Before the list borrows the art caches for the rest of the frame. // Before the list borrows the art caches for the rest of the frame.
@@ -2215,6 +2490,45 @@ impl eframe::App for App {
next: opening.from == Page::Player, next: opening.from == Page::Player,
} }
}); });
// Only while paused, which is the only time the pause screen
// can show: the logo is a texture lookup, and could be a fetch.
let about = playback
.as_ref()
.filter(|playback| playback.paused)
.and_then(|playback| {
let title = library.get(&playback.target.title_key)?;
let mut art = ui::Art {
engine,
thumbs,
posters,
metadata,
episodes,
names: engine.appearance().names,
};
let target = &playback.target;
let episode = title
.seasons
.iter()
.flat_map(|season| &season.episodes)
.find(|episode| {
episode.node.share_id == target.share_id
&& episode.node.link_id == target.link_id
});
let synopsis = episode
.and_then(|episode| art.episode(&title.key, episode))
.and_then(|found| found.overview.clone())
.or_else(|| {
art.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.overview.clone())
});
Some(ui::player::About {
name: art.name(title).to_owned(),
logo: art.logo(title),
synopsis,
})
});
let list = playback let list = playback
.as_ref() .as_ref()
.and_then(|playback| library.get(&playback.target.title_key)) .and_then(|playback| library.get(&playback.target.title_key))
@@ -2229,7 +2543,6 @@ impl eframe::App for App {
episodes, episodes,
names: engine.appearance().names, names: engine.appearance().names,
}, },
open: *episode_drawer,
}); });
// No panels: the controls are drawn over the picture, and a nav bar // No panels: the controls are drawn over the picture, and a nav bar
// above a film is the one thing every player agrees not to do. // above a film is the one thing every player agrees not to do.
@@ -2246,12 +2559,15 @@ impl eframe::App for App {
up_next, up_next,
neighbours, neighbours,
episodes: list, episodes: list,
panel: *player_panel,
about,
sleep: sleep.map(Sleep::choice),
}, },
&mut actions, &mut actions,
); );
}); });
if let Some((text, at)) = &self.osd if let Some(flash) = &self.osd
&& !ui::player::osd(ctx, text, *at) && !flash.show(ctx, ctx.content_rect())
{ {
self.osd = None; self.osd = None;
} }
@@ -2338,8 +2654,24 @@ impl eframe::App for App {
names: self.engine.appearance().names, names: self.engine.appearance().names,
added: &self.added, added: &self.added,
offline: &self.offline_files, offline: &self.offline_files,
list: &self.my_list,
first_seen: &self.first_seen,
guides: &self.episodes,
now: crate::engine::unix_now(),
}, },
); );
// After the refresh, which forgets it when the library changed.
if let Page::Title(key) = &self.page
&& self.view.similar.as_ref().is_none_or(|(of, _)| of != key)
{
let keys = pstr_core::discover::more_like(
key,
&self.library.titles,
&self.metadata,
SIMILAR,
);
self.view.similar = Some((key.clone(), keys));
}
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress)); let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
let App { let App {
@@ -2418,7 +2750,19 @@ impl eframe::App for App {
.frame( .frame(
egui::Frame::new() egui::Frame::new()
.fill(theme::background()) .fill(theme::background())
.inner_margin(egui::Margin::symmetric(18, 12)), .inner_margin(egui::Margin {
left: ui::PAGE_MARGIN.x as i8,
right: ui::PAGE_MARGIN.x as i8,
// The library's banner starts right under the bar;
// the page keeps the margin itself when it has no
// banner to show.
top: if *page == Page::Library {
0
} else {
ui::PAGE_MARGIN.y as i8
},
bottom: ui::PAGE_MARGIN.y as i8,
}),
) )
.show(ui, |ui| { .show(ui, |ui| {
ui.multiply_opacity(fade); ui.multiply_opacity(fade);
@@ -2454,13 +2798,34 @@ impl eframe::App for App {
&mut art, &mut art,
library, library,
key, key,
view.franchises.get(key.as_str()).map(Vec::as_slice), ui::title::Context {
franchise: view.franchises.get(key.as_str()).map(Vec::as_slice),
similar: view
.similar
.as_ref()
.filter(|(of, _)| of == key)
.map(|(_, keys)| keys.as_slice())
.unwrap_or_default(),
on_list: view.listed.contains(key.as_str()),
},
ui::title::OfflineView { ui::title::OfflineView {
downloads, downloads,
files: offline_files, files: offline_files,
}, },
&mut actions, &mut actions,
), ),
Page::Browse { facet, from } => ui::library::browse(
ui,
&mut art,
library,
ui::library::Browsing {
facet,
from: from.as_deref(),
},
view,
engine.appearance().tiles,
&mut actions,
),
Page::Shares => ui::shares::show( Page::Shares => ui::shares::show(
ui, ui,
shares, shares,
@@ -2759,12 +3124,21 @@ fn changed_art(
.map(|(url, _)| url.to_owned()), .map(|(url, _)| url.to_owned()),
found.and_then(|metadata| metadata.poster_url.clone()), found.and_then(|metadata| metadata.poster_url.clone()),
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)), found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
found.and_then(|metadata| metadata.logo_url.clone()),
// The poster again: the blurred band is made from it.
found.and_then(|metadata| metadata.poster_url.clone()),
] ]
}; };
let mut keys: Vec<String> = Vec::new(); let mut keys: Vec<String> = Vec::new();
for key in before.keys().chain(after.keys()) { for key in before.keys().chain(after.keys()) {
let (old, new) = (urls(before, key), urls(after, key)); let (old, new) = (urls(before, key), urls(after, key));
let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)]; let texture_keys = [
key.clone(),
ui::poster_key(key),
ui::wide_key(key),
ui::logo_key(key),
ui::ambient_key(key),
];
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) { for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
if old != new { if old != new {
keys.push(texture); keys.push(texture);
@@ -2803,7 +3177,9 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
} }
if input.consume_key(Modifiers::ALT, Key::ArrowLeft) if input.consume_key(Modifiers::ALT, Key::ArrowLeft)
|| input.pointer.button_pressed(egui::PointerButton::Extra1) || input.pointer.button_pressed(egui::PointerButton::Extra1)
|| (!typing && matches!(page, Page::Title(_)) && input.key_pressed(Key::Escape)) || (!typing
&& matches!(page, Page::Title(_) | Page::Browse { .. })
&& input.key_pressed(Key::Escape))
|| (!typing && *page == Page::Library && input.key_pressed(Key::Backspace)) || (!typing && *page == Page::Library && input.key_pressed(Key::Backspace))
{ {
actions.push(Action::Back); actions.push(Action::Back);
@@ -2853,9 +3229,33 @@ struct PlayerKeys {
skip_to: Option<f64>, skip_to: Option<f64>,
} }
/// A sleep timer that is running.
#[derive(Debug, Clone, Copy)]
enum Sleep {
/// Stop when player `id`'s file ends, rather than going on to the next.
EndOfEpisode(u64),
/// Pause at this moment.
At(std::time::Instant),
}
impl Sleep {
/// What the panel shows as chosen.
fn choice(self) -> ui::player::SleepChoice {
match self {
Self::EndOfEpisode(_) => ui::player::SleepChoice::EndOfEpisode,
Self::At(at) => ui::player::SleepChoice::Minutes(
(at.saturating_duration_since(std::time::Instant::now())
.as_secs()
/ 60) as u32
+ 1,
),
}
}
}
/// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a /// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a
/// tenth, because 1.0 has to be somewhere a viewer can land on again. /// tenth, because 1.0 has to be somewhere a viewer can land on again.
const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0]; use crate::ui::transport::SPEEDS;
/// The next step from `speed` in `direction`, from wherever it is now. /// The next step from `speed` in `direction`, from wherever it is now.
fn step_speed(speed: f64, faster: bool) -> f64 { fn step_speed(speed: f64, faster: bool) -> f64 {
@@ -2878,7 +3278,7 @@ fn step_speed(speed: f64, faster: bool) -> f64 {
fn shortcuts( fn shortcuts(
ctx: &egui::Context, ctx: &egui::Context,
fullscreen: bool, fullscreen: bool,
drawer: bool, panel: Option<ui::player::PlayerPanel>,
playing: Option<PlayerKeys>, playing: Option<PlayerKeys>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
@@ -2909,6 +3309,8 @@ fn shortcuts(
Key::OpenBracket, Key::OpenBracket,
Key::CloseBracket, Key::CloseBracket,
Key::E, Key::E,
Key::Z,
Key::X,
Key::Escape, Key::Escape,
] ]
.into_iter() .into_iter()
@@ -2952,9 +3354,14 @@ fn shortcuts(
// Escape leaves fullscreen first and the page second, which is what // Escape leaves fullscreen first and the page second, which is what
// it does everywhere else and what stops one press from both // it does everywhere else and what stops one press from both
// un-maximising the window and hiding the film. // un-maximising the window and hiding the film.
Key::E => actions.push(Action::ToggleEpisodes), // mpv's own keys for it, and the step mpv uses.
// The episode list is the innermost thing open, so it goes first. Key::Z => actions.push(Action::NudgeSubtitleDelay(-0.1)),
Key::Escape if drawer => actions.push(Action::ToggleEpisodes), Key::X => actions.push(Action::NudgeSubtitleDelay(0.1)),
Key::E => actions.push(Action::TogglePanel(ui::player::PlayerPanel::Episodes)),
// A panel is the innermost thing open, so it goes first.
Key::Escape if panel.is_some() => {
actions.push(Action::TogglePanel(panel.expect("checked above")));
}
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen), Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
Key::Escape => actions.push(Action::LeavePlayer), Key::Escape => actions.push(Action::LeavePlayer),
_ => {} _ => {}
@@ -3015,6 +3422,7 @@ mod tests {
kind: TitleKind::Series, kind: TitleKind::Series,
poster_url: Some(url.into()), poster_url: Some(url.into()),
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
@@ -3088,12 +3496,16 @@ mod tests {
record("added", Some("w")), record("added", Some("w")),
] ]
.into(); .into();
// The tile and the cover are both drawn from the poster here; the // The tile, the cover and the blurred band are all drawn from the
// wide art has nothing to be. // poster here; the wide art has nothing to be.
assert_eq!( assert_eq!(
changed_art(&before, &after), changed_art(&before, &after),
vec![ vec![
"added", "added",
"ambient:added",
"ambient:dropped",
"ambient:found",
"ambient:moved",
"dropped", "dropped",
"found", "found",
"moved", "moved",
+337 -26
View File
@@ -33,6 +33,7 @@ use pstr_core::catalog::{
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::mylist::MyList;
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::profile::ProfileChanges; use pstr_core::profile::ProfileChanges;
use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_drive_rs::ThumbnailType;
@@ -170,6 +171,35 @@ const THUMBNAIL_MAX_EDGE: u32 = 640;
/// best there is. /// best there is.
pub const WIDE_MAX_EDGE: u32 = 1920; pub const WIDE_MAX_EDGE: u32 = 1920;
/// The longest edge of a title's logo once its padding is trimmed. Drawn at
/// most a few hundred pixels wide; this leaves room for a 2× display.
const LOGO_MAX_EDGE: u32 = 800;
/// The size a poster is squeezed to for [`Fit::Ambient`]: 16:9, and small
/// enough that what is left of it is colour and light rather than shapes.
const AMBIENT_SIZE: (u32, u32) = (64, 36);
/// How far the squeezed poster is blurred, in its own pixels. With linear
/// filtering stretching it across the window, this is what keeps the stretch
/// from showing its pixels.
const AMBIENT_BLUR: f32 = 3.0;
/// How a fetched picture is fitted before it becomes a texture.
#[derive(Clone, Copy)]
enum Fit {
/// Scaled down to this edge, nothing else.
Scaled(u32),
/// A logo: cropped to what is not transparent, then scaled to
/// [`LOGO_MAX_EDGE`]. TVDB pads every clear logo to a square, and the
/// padding would otherwise decide where it sits and how big it is drawn.
Logo,
/// A poster made into a wash of its own colours, for a band across the
/// window when a title has no wide art: squeezed to [`AMBIENT_SIZE`] and
/// blurred. A poster cropped to that shape was a blown-up slice of it,
/// which read as broken; this reads as the poster's light.
Ambient,
}
/// What the background side tells the UI. /// What the background side tells the UI.
pub enum Event { pub enum Event {
/// The share list changed on disk. /// The share list changed on disk.
@@ -192,6 +222,12 @@ pub enum Event {
/// The catalog was re-read: the library, and when each title last /// The catalog was re-read: the library, and when each title last
/// gained a file. /// gained a file.
LibraryLoaded(Library, HashMap<String, i64>), LibraryLoaded(Library, HashMap<String, i64>),
/// When each file first appeared in a crawl, for the New badges; see
/// [`pstr_core::catalog::Catalog::first_seen`]. Sent just before
/// [`Event::LibraryLoaded`].
FirstSeen(HashMap<(String, String), i64>),
/// My List changed on another device.
MyList(MyList),
/// A crawl finished for one share. /// A crawl finished for one share.
Crawled { Crawled {
share_id: String, share_id: String,
@@ -256,6 +292,12 @@ pub enum Event {
}, },
/// The artwork for this title could not be had. /// The artwork for this title could not be had.
PosterMissing { key: String }, PosterMissing { key: String },
/// Where AniSkip says the opening and ending of what player `player` has
/// open are. Empty when it does not know.
SkipTimes {
player: u64,
ranges: Vec<pstr_core::chapters::SkipRange>,
},
/// A stream opened and is ready to hand to mpv. /// A stream opened and is ready to hand to mpv.
PlaybackReady { PlaybackReady {
target: Box<PlaybackTarget>, target: Box<PlaybackTarget>,
@@ -386,6 +428,12 @@ pub struct Engine {
/// Which theme the window wears. Kept beside the playback preferences for /// Which theme the window wears. Kept beside the playback preferences for
/// the same reason: it is read from the UI and written to disk off it. /// the same reason: it is read from the UI and written to disk off it.
appearance: Arc<Mutex<Appearance>>, appearance: Arc<Mutex<Appearance>>,
/// My List, read once at launch. `None` when the file would not parse: it
/// is then never written over, and changing the list is refused.
my_list: Arc<Mutex<Option<MyList>>>,
/// The last few searches, newest first. `None`, and never written, when
/// the file would not parse.
recent: Arc<Mutex<Option<pstr_core::recent::RecentSearches>>>,
/// Preloaded so clicking Play never runs SQLite on egui's thread. /// Preloaded so clicking Play never runs SQLite on egui's thread.
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>, track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
downloads: Arc<Mutex<Downloads>>, downloads: Arc<Mutex<Downloads>>,
@@ -445,7 +493,7 @@ impl Enrichment {
/// Resolve a service for `config`, if one can be had. /// Resolve a service for `config`, if one can be had.
fn build(config: MetadataConfig) -> Self { fn build(config: MetadataConfig) -> Self {
let service = config.enabled.then(|| { let service = config.enabled.then(|| {
let key = pstr_meta::settings::api_key(config.provider); let key = pstr_meta::settings::api_key(ProviderId::Tmdb);
MetadataService::new(&config, key) MetadataService::new(&config, key)
.inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}")) .inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}"))
.ok() .ok()
@@ -493,12 +541,20 @@ impl Engine {
Appearance::default() Appearance::default()
}); });
// A provider with an API key needs it out of the OS credential store, let my_list = pstr_core::mylist::load(&dirs)
// and that can be a D-Bus round trip to a keyring that has to unlock .inspect_err(|error| tracing::warn!("read My List: {error}"))
// first — not something to hold the first frame for. Built on the .ok();
// blocking pool instead; until it lands, everything that needs the let recent = pstr_core::recent::load(&dirs)
// provider waits for it rather than concluding there is none. .inspect_err(|error| tracing::warn!("read recent searches: {error}"))
let deferred = config.enabled && config.provider.needs_api_key(); .ok();
// The TMDB key, which every provider looks for, comes out of the OS
// credential store, and that can be a D-Bus round trip to a keyring
// that has to unlock first — not something to hold the first frame
// for. Built on the blocking pool instead; until it lands, everything
// that needs the provider waits for it rather than concluding there is
// none.
let deferred = config.enabled;
let enrichment = if deferred { let enrichment = if deferred {
Enrichment { Enrichment {
config: config.clone(), config: config.clone(),
@@ -530,6 +586,8 @@ impl Engine {
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0), enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
prefs: Arc::new(Mutex::new(prefs)), prefs: Arc::new(Mutex::new(prefs)),
appearance: Arc::new(Mutex::new(appearance)), appearance: Arc::new(Mutex::new(appearance)),
my_list: Arc::new(Mutex::new(my_list)),
recent: Arc::new(Mutex::new(recent)),
track_prefs: Arc::new(Mutex::new(track_prefs)), track_prefs: Arc::new(Mutex::new(track_prefs)),
downloads: Arc::new(Mutex::new(Downloads::default())), downloads: Arc::new(Mutex::new(Downloads::default())),
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
@@ -594,6 +652,75 @@ impl Engine {
*self.appearance.lock() *self.appearance.lock()
} }
/// My List as it stands.
pub fn my_list(&self) -> MyList {
self.my_list.lock().clone().unwrap_or_default()
}
/// Put a title on My List or take it off, write the list, and return it
/// as it now is.
pub fn set_on_list(&self, key: &str, on: bool) -> MyList {
let mut guard = self.my_list.lock();
let Some(list) = guard.as_mut() else {
self.emit(Event::Error(
"My List could not be read, so it was left as it is.".to_owned(),
));
return MyList::default();
};
if on {
list.add(key, unix_now());
} else {
list.remove(key);
}
let saved = list.clone();
drop(guard);
let engine = self.clone();
self.runtime.spawn_blocking(move || {
// Whatever the list is by now, under the lock: two clicks in a row
// must not land on disk in the wrong order.
let guard = engine.my_list.lock();
if let Some(list) = guard.as_ref()
&& let Err(error) = pstr_core::mylist::save(&engine.dirs, list)
{
drop(guard);
engine.fail("save My List", error);
}
});
saved
}
/// The last few searches, newest first.
pub fn recent_searches(&self) -> Vec<String> {
self.recent
.lock()
.as_ref()
.map(|recent| recent.queries.clone())
.unwrap_or_default()
}
/// Put `query` at the front of the recent searches, or with `None`
/// forget them all, and write the list off the UI thread.
pub fn remember_search(&self, query: Option<&str>) {
let changed = match (self.recent.lock().as_mut(), query) {
(Some(recent), Some(query)) => recent.remember(query),
(Some(recent), None) => !std::mem::take(&mut recent.queries).is_empty(),
(None, _) => false,
};
if !changed {
return;
}
let engine = self.clone();
self.runtime.spawn_blocking(move || {
let guard = engine.recent.lock();
if let Some(recent) = guard.as_ref()
&& let Err(error) = pstr_core::recent::save(&engine.dirs, recent)
{
drop(guard);
engine.fail("save recent searches", error);
}
});
}
/// Change the theme and write it. /// Change the theme and write it.
/// ///
/// Always committed, unlike the volume: a theme is picked with a click and /// Always committed, unlike the volume: a theme is picked with a click and
@@ -994,6 +1121,15 @@ impl Engine {
Err(error) => tracing::warn!("reread the metadata settings: {error}"), Err(error) => tracing::warn!("reread the metadata settings: {error}"),
} }
} }
if profile.list {
match pstr_core::mylist::load(&self.dirs) {
Ok(list) => {
*self.my_list.lock() = Some(list.clone());
self.emit(Event::MyList(list));
}
Err(error) => tracing::warn!("reread My List: {error}"),
}
}
if profile.titles > 0 { if profile.titles > 0 {
match self.catalog.lock().all_title_track_prefs() { match self.catalog.lock().all_title_track_prefs() {
Ok(prefs) => *self.track_prefs.lock() = prefs, Ok(prefs) => *self.track_prefs.lock() = prefs,
@@ -1259,6 +1395,7 @@ impl Engine {
let library = Library::build(files, &watch); let library = Library::build(files, &watch);
let targets = download_targets(&library); let targets = download_targets(&library);
let added = library.added_at(&first_seen); let added = library.added_at(&first_seen);
engine.emit(Event::FirstSeen(first_seen));
engine.emit(Event::LibraryLoaded(library, added)); engine.emit(Event::LibraryLoaded(library, added));
engine.refresh_offline(Some(targets)); engine.refresh_offline(Some(targets));
} }
@@ -2590,8 +2727,6 @@ impl Engine {
} }
return self.emit(Event::Error(self.no_service_reason())); return self.emit(Event::Error(self.no_service_reason()));
}; };
let provider = service.provider();
let (stored, enriched) = { let (stored, enriched) = {
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
match catalog match catalog
@@ -2603,14 +2738,7 @@ impl Engine {
} }
}; };
let pending = pstr_meta::service::plan( let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
if pending.is_empty() { if pending.is_empty() {
if automatic { if automatic {
return; return;
@@ -2621,7 +2749,7 @@ impl Engine {
self.emit(Event::Status(format!( self.emit(Event::Status(format!(
"matching {} titles against {}…", "matching {} titles against {}…",
pending.len(), pending.len(),
provider.label() service.label()
))); )));
let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize); let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize);
@@ -2658,7 +2786,7 @@ impl Engine {
episodes_found += enrichment.episodes.len(); episodes_found += enrichment.episodes.len();
let stored = self.catalog.lock().set_enrichment( let stored = self.catalog.lock().set_enrichment(
&outcome.title_key, &outcome.title_key,
provider, outcome.provider,
now(), now(),
enrichment, enrichment,
); );
@@ -2740,7 +2868,7 @@ impl Engine {
if let Some(enrichment) = &outcome.enrichment { if let Some(enrichment) = &outcome.enrichment {
let stored = engine.catalog.lock().set_enrichment( let stored = engine.catalog.lock().set_enrichment(
&title.key, &title.key,
service.provider(), outcome.provider,
now(), now(),
enrichment, enrichment,
); );
@@ -2780,6 +2908,26 @@ impl Engine {
path.is_file().then_some(path) path.is_file().then_some(path)
} }
/// Ask AniSkip where episode `episode` of the MyAnimeList entry `mal_id`
/// has its opening and ending, for player `player`, whose file runs
/// `length` seconds. Only with enrichment on; a failure is logged and is
/// the same as no answer, which leaves the file as its chapters made it.
pub fn request_skip_times(&self, player: u64, mal_id: u32, episode: u32, length: f64) {
let engine = self.clone();
self.runtime.spawn(async move {
let Some(service) = engine.metadata_service().await else {
return;
};
match service.skip_times(mal_id, episode, length).await {
Ok(ranges) if !ranges.is_empty() => {
engine.emit(Event::SkipTimes { player, ranges });
}
Ok(_) => {}
Err(error) => tracing::debug!("AniSkip for {mal_id}/{episode}: {error}"),
}
});
}
/// Fetch, decode and post one title's artwork. /// Fetch, decode and post one title's artwork.
/// ///
/// Cached on disk under a hash of the URL, so a provider that moves its CDN /// Cached on disk under a hash of the URL, so a provider that moves its CDN
@@ -2794,18 +2942,35 @@ impl Engine {
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was /// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
/// most of why those looked soft. /// most of why those looked soft.
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) { pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
self.fetch_art(title_key, url, Fit::Scaled(max_edge));
}
/// [`Engine::request_poster`], for a title's logo: trimmed to its
/// lettering. See [`Fit::Logo`].
pub fn request_logo(&self, key: String, url: String) {
self.fetch_art(key, url, Fit::Logo);
}
/// [`Engine::request_poster`], blurred for a band. See [`Fit::Ambient`].
pub fn request_ambient(&self, key: String, url: String) {
self.fetch_art(key, url, Fit::Ambient);
}
fn fetch_art(&self, title_key: String, url: String, fit: Fit) {
let engine = self.clone(); let engine = self.clone();
// A size of its own on disk: the tile-sized copy of the same URL is // A size of its own on disk: the tile-sized copy of the same URL is
// already there under the plain name, scaled down. // already there under the plain name, scaled down.
let name = match max_edge { let name = match fit {
THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)), Fit::Scaled(THUMBNAIL_MAX_EDGE) => format!("{}.img", digest(&url)),
edge => format!("{}-{edge}.img", digest(&url)), Fit::Scaled(edge) => format!("{}-{edge}.img", digest(&url)),
Fit::Logo => format!("{}-logo.img", digest(&url)),
Fit::Ambient => format!("{}-ambient.img", digest(&url)),
}; };
let path = self.dirs.poster_cache().join(name); let path = self.dirs.poster_cache().join(name);
self.runtime.spawn(async move { self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await { if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_scaled(&bytes, max_edge) { match decode_fitted(&bytes, fit) {
Ok(decoded) => { Ok(decoded) => {
if let Some(smaller) = &decoded.smaller { if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await; store_image(&path, smaller).await;
@@ -2832,7 +2997,7 @@ impl Engine {
} }
}; };
match decode_scaled(&bytes, max_edge) { match decode_fitted(&bytes, fit) {
Ok(decoded) => { Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await; store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Poster { engine.emit(Event::Poster {
@@ -2984,11 +3149,43 @@ fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size. /// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> { fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
let decoded = image::load_from_memory(bytes)?; decode_fitted(bytes, Fit::Scaled(max_edge))
}
/// [`decode_scaled`], fitted as `fit` says. A trimmed logo is handed back
/// for the cache like a scaled picture is, so the trim is paid once.
fn decode_fitted(bytes: &[u8], fit: Fit) -> anyhow::Result<Decoded> {
let mut decoded = image::load_from_memory(bytes)?;
let mut changed = false;
let max_edge = match fit {
Fit::Scaled(edge) => edge,
Fit::Logo => {
if let Some(trimmed) = trim_alpha(&decoded) {
decoded = trimmed;
changed = true;
}
LOGO_MAX_EDGE
}
Fit::Ambient => {
// Already blurred when it came out of the cache: only a picture
// of another size is the poster itself.
let (width, height) = AMBIENT_SIZE;
if (decoded.width(), decoded.height()) != AMBIENT_SIZE {
decoded = decoded
.resize_exact(width, height, image::imageops::FilterType::Triangle)
.blur(AMBIENT_BLUR);
changed = true;
}
width
}
};
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge { let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
let scaled = decoded.thumbnail(max_edge, max_edge); let scaled = decoded.thumbnail(max_edge, max_edge);
let encoded = encode_for_cache(&scaled); let encoded = encode_for_cache(&scaled);
(scaled, encoded) (scaled, encoded)
} else if changed {
let encoded = encode_for_cache(&decoded);
(decoded, encoded)
} else { } else {
(decoded, None) (decoded, None)
}; };
@@ -3000,6 +3197,36 @@ fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
}) })
} }
/// `image` cropped to the pixels that are not (nearly) transparent, or
/// `None` when there is nothing to crop — no padding, no alpha, or nothing
/// but padding.
fn trim_alpha(image: &image::DynamicImage) -> Option<image::DynamicImage> {
/// Below this the pixel is anti-aliasing haze or compression noise, not
/// lettering.
const VISIBLE: u8 = 8;
if !image.color().has_alpha() {
return None;
}
let rgba = image.to_rgba8();
let (mut left, mut top, mut right, mut bottom) = (u32::MAX, u32::MAX, 0, 0);
for (x, y, pixel) in rgba.enumerate_pixels() {
if pixel[3] > VISIBLE {
left = left.min(x);
top = top.min(y);
right = right.max(x);
bottom = bottom.max(y);
}
}
if left > right {
return None;
}
let (width, height) = (right - left + 1, bottom - top + 1);
if (width, height) == (rgba.width(), rgba.height()) {
return None;
}
Some(image.crop_imm(left, top, width, height))
}
/// Encode a scaled picture for the cache: PNG when it has transparency to keep /// Encode a scaled picture for the cache: PNG when it has transparency to keep
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache /// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
/// holding the original — slower to load, never wrong. /// holding the original — slower to load, never wrong.
@@ -3076,6 +3303,24 @@ struct Entry {
texture: egui::TextureHandle, texture: egui::TextureHandle,
used: u64, used: u64,
bytes: usize, bytes: usize,
/// The picture's colour — see [`pstr_core::appearance::mood`]. Worked out
/// once, at upload, from the pixels on their way to the GPU.
mood: Option<egui::Color32>,
}
/// About how many pixels [`mood_of`] reads. A 64×36 wash is read whole; a
/// 1920 px backdrop is read at a stride that lands on about this many, which
/// is plenty for an average and costs nothing worth a frame.
const MOOD_SAMPLES: usize = 4096;
/// [`pstr_core::appearance::mood`] over a decoded picture.
fn mood_of(image: &egui::ColorImage) -> Option<egui::Color32> {
let stride = (image.pixels.len() / MOOD_SAMPLES).max(1);
pstr_core::appearance::mood(image.pixels.iter().step_by(stride).map(|pixel| {
let [r, g, b, a] = pixel.to_srgba_unmultiplied();
(pstr_core::appearance::Rgb::new(r, g, b), a)
}))
.map(|rgb| egui::Color32::from_rgb(rgb.r, rgb.g, rgb.b))
} }
/// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about /// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about
@@ -3143,12 +3388,14 @@ impl ImageCache {
break; break;
}; };
let bytes = image.pixels.len() * 4; let bytes = image.pixels.len() * 4;
let mood = mood_of(&image);
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR); let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
self.clock += 1; self.clock += 1;
let entry = Entry { let entry = Entry {
texture, texture,
used: self.clock, used: self.clock,
bytes, bytes,
mood,
}; };
if let Some(old) = self.textures.insert(key, entry) { if let Some(old) = self.textures.insert(key, entry) {
self.bytes -= old.bytes; self.bytes -= old.bytes;
@@ -3178,6 +3425,12 @@ impl ImageCache {
} }
} }
/// The colour of the picture under `key`, once it is a texture. A lookup
/// that does not count as asking for it: only drawing a picture keeps it.
pub fn mood(&self, key: &str) -> Option<egui::Color32> {
self.textures.get(key).and_then(|entry| entry.mood)
}
pub fn mark_missing(&mut self, key: String) { pub fn mark_missing(&mut self, key: String) {
self.missing.insert(key); self.missing.insert(key);
} }
@@ -3238,6 +3491,18 @@ mod image_cache_tests {
); );
} }
#[test]
fn an_uploaded_picture_keeps_its_colour_and_a_waiting_one_has_none() {
let ctx = egui::Context::default();
let mut cache = ImageCache::default();
let teal = egui::Color32::from_rgb(20, 140, 150);
cache.texture("a", || {});
cache.insert("a".into(), egui::ColorImage::filled([8, 8], teal));
assert_eq!(cache.mood("a"), None);
cache.upload(&ctx);
assert_eq!(cache.mood("a"), Some(teal));
}
#[test] #[test]
fn uploads_are_spread_over_frames() { fn uploads_are_spread_over_frames() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
@@ -3261,6 +3526,52 @@ mod image_cache_tests {
cache.upload(&ctx); cache.upload(&ctx);
assert!(cache.textures.is_empty()); assert!(cache.textures.is_empty());
} }
/// TVDB pads a clear logo to a square; trimmed, it is only its lettering.
#[test]
fn a_padded_logo_is_trimmed_to_its_lettering() {
let mut padded = image::RgbaImage::new(100, 100);
for x in 20..80 {
for y in 40..60 {
padded.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
}
}
// Haze below the visibility threshold does not count.
padded.put_pixel(2, 2, image::Rgba([255, 255, 255, 3]));
let trimmed = trim_alpha(&image::DynamicImage::ImageRgba8(padded)).expect("trimmed");
assert_eq!((trimmed.width(), trimmed.height()), (60, 20));
}
/// A poster for a band is squeezed and blurred once; what the cache keeps
/// is already the wash, and decoding it again leaves it as it is.
#[test]
fn a_poster_for_a_band_becomes_a_small_blurred_wash_once() {
let mut poster = image::RgbImage::new(200, 300);
for y in 0..150 {
for x in 0..200 {
poster.put_pixel(x, y, image::Rgb([255, 255, 255]));
}
}
let poster = encode_for_cache(&image::DynamicImage::ImageRgb8(poster)).expect("encode");
let decoded = decode_fitted(&poster, Fit::Ambient).expect("decode");
assert_eq!(decoded.image.size, [64, 36]);
// White above, black below, and a ramp between rather than an edge.
let middle = decoded.image.pixels[18 * 64 + 32];
assert!(middle.r() > 40 && middle.r() < 215, "{middle:?}");
let cached = decoded.smaller.expect("the wash is what is cached");
let again = decode_fitted(&cached, Fit::Ambient).expect("decode");
assert_eq!(again.image.size, [64, 36]);
assert!(again.smaller.is_none());
}
#[test]
fn a_picture_with_nothing_to_trim_is_left_alone() {
let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::new(10, 10));
assert!(trim_alpha(&opaque).is_none());
let blank = image::DynamicImage::ImageRgba8(image::RgbaImage::new(10, 10));
assert!(trim_alpha(&blank).is_none());
}
} }
#[cfg(test)] #[cfg(test)]
+125 -5
View File
@@ -147,6 +147,10 @@ pub enum Command {
/// Move subtitles, as a percentage of the window's height. See /// Move subtitles, as a percentage of the window's height. See
/// [`Playback::lift_subtitles`]. /// [`Playback::lift_subtitles`].
SetSubtitlePosition(f64), 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. /// 1.0 is normal speed.
SetSpeed(f64), SetSpeed(f64),
/// Save the current frame to this file. /// 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 /// Where subtitles were last put, so they are only moved when that changes
/// rather than once a frame. /// rather than once a frame.
subtitle_position: f64, 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. /// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>, 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>, 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 — /// What each of those chapters is, resolved against the rest of the file —
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed /// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
/// whenever either the chapters or the duration change. /// whenever either the chapters or the duration change.
@@ -259,10 +279,16 @@ impl Playback {
paused: false, paused: false,
volume: prefs.volume, volume: prefs.volume,
muted: prefs.muted, 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, speed: prefs.speed,
tracks: Vec::new(), tracks: Vec::new(),
chapters: Vec::new(), chapters: Vec::new(),
file_chapters: Vec::new(),
skip_ranges: Vec::new(),
skips_asked: false,
roles: Vec::new(), roles: Vec::new(),
credits_at: None, credits_at: None,
position: target.resume_at.unwrap_or(0.0), 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 /// the mouse is moving, which is exactly when someone is looking at the
/// screen. /// screen.
pub fn lift_subtitles(&mut self, covered: f32, height: f32) { 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() (100.0 * (1.0 - f64::from(covered / height))).round()
} else { } else {
100.0 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 { if position != self.subtitle_position {
self.subtitle_position = position; self.subtitle_position = position;
self.send(Command::SetSubtitlePosition(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. /// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool { pub fn is_embedded(&self) -> bool {
self.video.is_some() self.video.is_some()
@@ -362,6 +428,11 @@ impl Playback {
/// changed. Both are needed: the length of a chapter is what settles a name /// changed. Both are needed: the length of a chapter is what settles a name
/// that could mean two things. /// that could mean two things.
fn reclassify(&mut self) { 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.roles = pstr_player::roles(&self.chapters, self.duration);
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles); self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
} }
@@ -372,9 +443,13 @@ impl Playback {
PlayerEvent::FileLoaded => self.loaded = true, PlayerEvent::FileLoaded => self.loaded = true,
PlayerEvent::Volume(volume) => self.volume = *volume, PlayerEvent::Volume(volume) => self.volume = *volume,
PlayerEvent::Muted(muted) => self.muted = *muted, 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) => { PlayerEvent::Chapters(chapters) => {
self.chapters = chapters.clone(); self.file_chapters = chapters.clone();
self.reclassify(); self.reclassify();
} }
PlayerEvent::Position(position) => { PlayerEvent::Position(position) => {
@@ -407,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. /// Fraction played, for the seek bar. `None` until mpv reports a duration.
pub fn progress(&self) -> Option<f32> { pub fn progress(&self) -> Option<f32> {
let duration = self.duration.filter(|duration| *duration > 0.0)?; let duration = self.duration.filter(|duration| *duration > 0.0)?;
@@ -479,6 +574,11 @@ fn build_player(
Ok((Arc::new(player), None)) 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. /// The player defaults with the viewer's preferences folded in.
fn from_prefs( fn from_prefs(
prefs: &PlaybackPrefs, prefs: &PlaybackPrefs,
@@ -494,6 +594,7 @@ fn from_prefs(
.and_then(|p| p.subtitle_language.clone()) .and_then(|p| p.subtitle_language.clone())
.or_else(|| prefs.subtitle_language.clone()), .or_else(|| prefs.subtitle_language.clone()),
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles), 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 // Carried from file to file, like the volume: somebody who watches at
// 1.25 watches the next episode at 1.25 too. // 1.25 watches the next episode at 1.25 too.
options: vec![("speed".into(), format!("{:.2}", prefs.speed))], options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
@@ -658,6 +759,11 @@ fn apply_command(
Command::SetVolume(volume) => player.set_volume(volume), Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted), Command::SetMuted(muted) => player.set_muted(muted),
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent), 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::SetSpeed(speed) => player.set_speed(speed),
Command::Screenshot(path) => player.screenshot(&path), Command::Screenshot(path) => player.screenshot(&path),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track), Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
@@ -820,3 +926,17 @@ fn save(
watch_state(position, duration, watched), 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));
}
}
+390 -24
View File
@@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0;
pub const POSTER_WIDTH: f32 = 168.0; pub const POSTER_WIDTH: f32 = 168.0;
/// Covers are 2:3. /// Covers are 2:3.
pub const POSTER_ASPECT: f32 = 3.0 / 2.0; 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. /// 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`]. /// the one monospace style in the app is egui's own, set in [`apply`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role { 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. /// The name of the thing you are looking at, once per page.
Display, Display,
/// An empty state, or the heading of a page that has no display line. /// An empty state, or the heading of a page that has no display line.
@@ -78,6 +87,7 @@ pub enum Role {
impl Role { impl Role {
pub const fn size(self) -> f32 { pub const fn size(self) -> f32 {
match self { match self {
Self::Hero => 34.0,
Self::Display => 26.0, Self::Display => 26.0,
Self::Title => 20.0, Self::Title => 20.0,
Self::Heading => 18.0, Self::Heading => 18.0,
@@ -90,10 +100,11 @@ impl Role {
} }
} }
/// Headings are set in the semibold cut; everything below them in the /// The hero line is set in the display cut, headings in the semibold
/// regular one. /// one, and everything below them in the regular one.
pub fn family(self) -> egui::FontFamily { pub fn family(self) -> egui::FontFamily {
match self { match self {
Self::Hero => display(),
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(), Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
_ => egui::FontFamily::Proportional, _ => egui::FontFamily::Proportional,
} }
@@ -159,6 +170,9 @@ pub mod motion {
/// Artwork arriving over its placeholder. Slower, because it replaces /// Artwork arriving over its placeholder. Slower, because it replaces
/// something rather than adding to it, and a fast swap reads as a flicker. /// something rather than adding to it, and a fast swap reads as a flicker.
pub const ARRIVE: f32 = 0.24; 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. /// Fast out, slow in: the curve every hand-driven transition uses.
pub fn ease_out(t: f32) -> f32 { 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 /// sets the same on every desktop. Inter, under the OFL — the licence is
/// beside the files. /// 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 /// 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 /// 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 /// refresh arrow drew as an accented letter. The glyphs themselves are still
@@ -179,6 +193,9 @@ pub mod motion {
/// U+E000..=U+F8FF). /// U+E000..=U+F8FF).
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf"); const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.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 /// The family the headings are set in. egui has no notion of weight, so the
/// semibold cut is a family of its own. /// semibold cut is a family of its own.
@@ -186,9 +203,53 @@ pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into()) 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 /// 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. /// symbols Inter does not carry. The CJK fallback goes last, when there is
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions { /// one.
fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
use egui::FontFamily::{Monospace, Proportional}; use egui::FontFamily::{Monospace, Proportional};
let mut fonts = egui::FontDefinitions::default(); let mut fonts = egui::FontDefinitions::default();
@@ -199,6 +260,10 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
"inter-semibold".into(), "inter-semibold".into(),
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)), Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
); );
fonts.font_data.insert(
"inter-display".into(),
Arc::new(egui::FontData::from_static(INTER_DISPLAY)),
);
fonts fonts
.families .families
.entry(Proportional) .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 // Right behind Inter. The icons are in the private use area, which the
// bundled Inter no longer maps, so neither font shadows the other. // bundled Inter no longer maps, so neither font shadows the other.
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); 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(); 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] { for family in [Proportional, Monospace] {
fonts.families.entry(family).or_default().push(name.clone()); fonts.families.entry(family).or_default().push(name.clone());
} }
} }
let fallbacks = fonts.families[&Proportional] let fallbacks: Vec<String> = fonts.families[&Proportional]
.iter() .iter()
.filter(|name| *name != "inter") .filter(|name| *name != "inter")
.cloned(); .cloned()
let semibold_chain = std::iter::once("inter-semibold".to_owned())
.chain(fallbacks)
.collect(); .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 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, /// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
/// and native desktops already provide one. /// and native desktops already provide one.
pub fn install_fonts(ctx: &egui::Context) { 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 ctx = ctx.clone();
let spawned = std::thread::Builder::new() let spawned = std::thread::Builder::new()
.name("fonts".into()) .name("fonts".into())
@@ -251,20 +327,80 @@ fn install_cjk_fallback(ctx: &egui::Context) {
let Ok(bytes) = std::fs::read(&path) else { let Ok(bytes) = std::fs::read(&path) else {
return; return;
}; };
let Some(data) = cjk_font_data(bytes, index) else { let Some(data) = system_font_data(bytes, index) else {
tracing::debug!("CJK fallback {}#{index} does not parse", path.display()); tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
return; return;
}; };
ctx.set_fonts(font_definitions(Some(data))); let mut system = SYSTEM_FONTS.lock();
system.cjk = Some(Arc::new(data));
ctx.set_fonts(font_definitions(&system));
ctx.request_repaint(); ctx.request_repaint();
} }
/// The fallback font, if egui can read it. /// 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 /// 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, /// 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. /// and a font that fails is left out rather than taking the app down.
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> { fn system_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
skrifa::FontRef::from_index(&bytes, index).ok()?; skrifa::FontRef::from_index(&bytes, index).ok()?;
let mut data = egui::FontData::from_owned(bytes); let mut data = egui::FontData::from_owned(bytes);
data.index = index; data.index = index;
@@ -299,6 +435,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
path.is_file().then_some((path, index)) 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")] #[cfg(target_os = "windows")]
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> { fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc"); let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
@@ -316,6 +514,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
None 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. /// Every colour the app draws with, as egui wants them.
/// ///
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared /// The *choice* of colours is `pstr_core::appearance` — one resolution shared
@@ -563,6 +823,11 @@ pub fn ramp_fill(
/// scrim that starts opaque and lets the picture out, `true` for one that lets /// 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, /// the picture *into* the page — which is the one the title backdrop wants,
/// since the page is below the band. /// 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( pub fn fade_shape(
ctx: &egui::Context, ctx: &egui::Context,
rect: Rect, rect: Rect,
@@ -570,8 +835,29 @@ pub fn fade_shape(
direction: Direction, direction: Direction,
arriving: bool, arriving: bool,
) -> egui::Shape { ) -> egui::Shape {
let texture = alpha_ramp(ctx, color, direction, arriving); let texture = alpha_ramp(ctx, Color32::WHITE, direction, arriving);
textured(rect, CornerRadius::ZERO, &texture) 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 /// The one gradient that is not the accent: the top bar, which runs from the
@@ -637,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 /// 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. /// somebody else — the selected tab, which sits on the pill.
pub fn veil_over( pub fn veil_over(
@@ -881,6 +1204,15 @@ const RAMP_STOPS: usize = 64;
mod tests { mod tests {
use super::*; 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] #[test]
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() { fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
assert_eq!(fontconfig_face(262_144), 0); assert_eq!(fontconfig_face(262_144), 0);
@@ -889,17 +1221,51 @@ mod tests {
} }
#[test] #[test]
fn a_fallback_font_egui_cannot_read_is_left_out() { fn a_system_font_egui_cannot_read_is_left_out() {
assert!(cjk_font_data(INTER.to_vec(), 0).is_some()); assert!(system_font_data(INTER.to_vec(), 0).is_some());
assert!(cjk_font_data(INTER.to_vec(), 4).is_none()); assert!(system_font_data(INTER.to_vec(), 4).is_none());
assert!(cjk_font_data(b"not a font".to_vec(), 0).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] #[test]
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() { fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _; use skrifa::MetadataProvider as _;
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 font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
let shadowed: Vec<u32> = font let shadowed: Vec<u32> = font
.charmap() .charmap()
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -55,8 +55,8 @@ pub fn show(
.color(theme::text()), .color(theme::text()),
); );
ui.label(ui::muted(format!( ui.label(ui::muted(format!(
"Everything {} answers with, in its own order — nothing is scored here.", "Everything {} answer with, in their own order — nothing is scored here.",
provider.label() pstr_meta::service::chain_label(provider)
))); )));
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
@@ -279,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
.texture(&key, || engine.request_poster(key.clone(), url)) .texture(&key, || engine.request_poster(key.clone(), url))
} }
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out /// `Film · 2017 · 26 episodes · ★ 8.2 · AniList`, with whatever the provider
/// simply absent. /// 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 { fn facts(found: &TitleMetadata) -> String {
let mut parts: Vec<String> = vec![ let mut parts: Vec<String> = vec![
match found.kind { match found.kind {
@@ -298,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
} }
parts.push(found.provider.label().to_string());
parts.join(" · ") parts.join(" · ")
} }
+449 -7
View File
@@ -23,6 +23,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title}; use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames}; 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::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme; use crate::theme;
use widgets::Named; use widgets::Named;
@@ -80,6 +81,17 @@ impl Art<'_> {
.texture(&thumbnail_key(node), || engine.request_thumbnail(node)) .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`]. /// The name to show for `title`. See [`title_name`].
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str { pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
title_name(title, self.metadata, self.names) title_name(title, self.metadata, self.names)
@@ -107,7 +119,9 @@ impl Art<'_> {
} }
/// Art for a strip across the whole window, at full size: fanart, else /// 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 /// 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 /// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
@@ -128,10 +142,61 @@ impl Art<'_> {
{ {
return Some((texture, ArtShape::Landscape)); 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) 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. /// The picture for a title, and how to fit it.
/// ///
/// Provider artwork first, then Proton's still, then nothing. That order is /// Provider artwork first, then Proton's still, then nothing. That order is
@@ -162,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 /// The texture key of a title's cover, beside [`Art::of`]'s, which is the
/// title key itself. /// title key itself.
pub fn poster_key(title_key: &str) -> String { pub fn poster_key(title_key: &str) -> String {
@@ -173,6 +242,61 @@ pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}") 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` /// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
/// the Unix time `at` was. /// the Unix time `at` was.
pub fn ago(at: i64, now: i64) -> String { pub fn ago(at: i64, now: i64) -> String {
@@ -206,6 +330,18 @@ pub fn format_speed(speed: f64) -> String {
format!("{}×", text.trim_end_matches('0').trim_end_matches('.')) 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. /// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String { pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
@@ -244,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)) 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. /// 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 /// 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. /// 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 id = egui::Id::new("library-search");
let width = 240.0; let width = 240.0;
let height = ui.spacing().interact_size.y; let height = ui.spacing().interact_size.y;
@@ -287,7 +465,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
.id(id) .id(id)
.frame(egui::Frame::NONE) .frame(egui::Frame::NONE)
.hint_text(if has_focus { .hint_text(if has_focus {
"Titles and filenames" "Titles, episodes, people"
} else { } else {
"Search Ctrl+F" "Search Ctrl+F"
}) })
@@ -331,6 +509,56 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
search.clear(); 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. /// A thin bar in the accent, for how far something has got.
@@ -450,6 +678,10 @@ enum Fill {
Ink, Ink,
/// Nothing at rest, the hover colour under the pointer. /// Nothing at rest, the hover colour under the pointer.
None, 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. /// A row of tabs with one accent pill that slides between them.
@@ -570,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) 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; let position = rect.center() - galley.size() / 2.0;
@@ -642,18 +903,27 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
return 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 // 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. // 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 // egui drives this from wall-clock time, so it is the same speed whatever
// the frame rate — and it needs no state kept here. // the frame rate — and it needs no state kept here.
// Focus lights a tile the way the pointer does, so the keyboard can be // Focus lifts a tile the way the pointer does, and adds a ring of its own,
// followed across the grid. // so the keyboard can be followed across the grid.
let hover = ui.ctx().animate_bool_with_time( let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"), response.id.with("hover"),
response.hovered() || response.has_focus(), response.hovered() || response.has_focus(),
theme::motion::HOVER, 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 picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of // the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle. // letters popping into stills into one settle.
@@ -769,9 +1039,17 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
egui::StrokeKind::Inside, 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( 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), Vec2::new(width, text_height - 6.0),
); );
let painter = painter.with_clip_rect(text_rect); let painter = painter.with_clip_rect(text_rect);
@@ -802,6 +1080,146 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
response.on_hover_cursor(egui::CursorIcon::PointingHand) 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 largest centred rectangle inside `target` with `image`'s aspect ratio.
/// ///
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and /// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
@@ -917,8 +1335,32 @@ pub fn columns_of(available: f32, minimum: f32) -> Grid {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; 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] #[test]
fn times_are_formatted_by_length() { fn times_are_formatted_by_length() {
assert_eq!(format_time(0.0), "0:00"); assert_eq!(format_time(0.0), "0:00");
File diff suppressed because it is too large. Load diff
+392 -22
View File
@@ -7,7 +7,7 @@
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles}; use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames}; use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::{Colour, PlaybackPrefs, SubtitleBackdrop, SubtitleStyle};
use pstr_player::LANGUAGES; use pstr_player::LANGUAGES;
use crate::app::Action; use crate::app::Action;
@@ -58,6 +58,7 @@ pub fn show(
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
appearance_form(ui, prefs.appearance, prefs.desktop, actions); appearance_form(ui, prefs.appearance, prefs.desktop, actions);
playback_form(ui, prefs, actions); playback_form(ui, prefs, actions);
subtitle_form(ui, &prefs.playback.subtitle_style, actions);
storage_form(ui, prefs, actions); storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions); metadata_form(ui, settings, api_key, actions);
about(ui); about(ui);
@@ -428,6 +429,368 @@ fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Acti
); );
} }
/// 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. /// The streaming cache: how big it may get, and a way to empty it.
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) { fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
ui::widgets::settings_group( ui::widgets::settings_group(
@@ -511,8 +874,8 @@ fn metadata_form(
without this every tile is a pair of initials. Turning it on sends the titles \ without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \ in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears. With AniList, \ 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 \ a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \
wide artwork.", not the titles — go to ani.zip for episode names and wide artwork.",
)); ));
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
@@ -526,7 +889,7 @@ fn metadata_form(
if settings.enabled { if settings.enabled {
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
ui.label(ui::muted("Provider")); ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL { for provider in ProviderId::CHOOSABLE {
if ui if ui
.radio(settings.provider == provider, provider.label()) .radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description()) .on_hover_text(provider.description())
@@ -538,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() { if settings.provider.needs_api_key() {
ui.add_space(theme::space::L); ui.label(ui::muted("TMDB API key"));
ui.label(ui::muted(format!("{} API key", settings.provider.label()))); } else {
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( ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \ "TMDB API key, optional. With one, films get TMDB's backdrops and logos, \
empty and save to forget it.", 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.add_space(theme::space::L);
ui.horizontal(|ui| { ui.horizontal(|ui| {
+5 -1
View File
@@ -36,9 +36,13 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
("N / P", "Next and previous episode"), ("N / P", "Next and previous episode"),
("[ / ]", "Slower and faster"), ("[ / ]", "Slower and faster"),
("S", "Skip the opening or the credits"), ("S", "Skip the opening or the credits"),
("Z / X", "Subtitles 0.1 s earlier and later"),
("E", "The list of episodes"), ("E", "The list of episodes"),
("F or double-click", "Fullscreen"), ("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"), (
"Esc",
"Close a panel, leave fullscreen, then leave the player",
),
], ],
), ),
]; ];
+452 -113
View File
@@ -16,15 +16,29 @@ pub struct OfflineView<'a> {
pub files: &'a std::collections::HashSet<DownloadKey>, 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( pub fn show(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
library: &Library, library: &Library,
key: &str, key: &str,
franchise: Option<&[String]>, context: Context<'_>,
offline: OfflineView<'_>, offline: OfflineView<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let Context {
franchise,
similar,
on_list,
} = context;
let Some(title) = library.get(key) else { let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this // 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. // title. Nothing to show, so go back rather than draw an empty shell.
@@ -32,7 +46,7 @@ pub fn show(
return; return;
}; };
if back_link(ui).clicked() { if back_link(ui, "Library").clicked() {
actions.push(Action::Goto(Page::Library)); actions.push(Action::Goto(Page::Library));
} }
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
@@ -40,14 +54,21 @@ pub fn show(
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
header(ui, art, title, &offline, actions); header(ui, art, title, on_list, &offline, actions);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
if let Some(keys) = franchise { if let Some(keys) = franchise {
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect(); let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
if members.len() > 1 { if members.len() > 1 {
ui::section(ui, "In this franchise"); 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); ui.add_space(theme::space::XL);
} }
} }
@@ -85,7 +106,27 @@ pub fn show(
.enumerate() .enumerate()
.map(|(index, label)| (index, label.as_str())) .map(|(index, label)| (index, label.as_str()))
.collect(); .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; picked = index;
ui.data_mut(|data| data.insert_temp(picked_id, index)); ui.data_mut(|data| data.insert_temp(picked_id, index));
} }
@@ -109,61 +150,173 @@ pub fn show(
episode_row(ui, art, title, season, episode, &offline, actions); episode_row(ui, art, title, season, episode, &offline, actions);
} }
ui.add_space(theme::space::L); 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 /// The characters, each a round portrait over their name and who voices
/// marked rather than left out, so the row says where it sits in the story. /// them. A voice actor opens every title in the library they are in.
fn franchise_row( fn cast_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, 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], members: &[&Title],
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::horizontal() ui::shelf(ui, id, |ui| {
.id_salt(("franchise", &current.key)) ui.horizontal(|ui| {
.auto_shrink([false, true]) ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
.show(ui, |ui| { for member in members {
ui.horizontal(|ui| { let here = current.is_some_and(|current| member.key == current.key);
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; let picture = art.poster(member);
for member in members { let found = art
let here = member.key == current.key; .metadata
let picture = art.poster(member); .get(&member.key)
let found = art .and_then(|record| record.metadata.as_ref());
.metadata let what = found
.get(&member.key) .and_then(|found| found.details.as_ref())
.and_then(|record| record.metadata.as_ref()); .and_then(|details| details.format_label())
let what = found .unwrap_or(match member.kind {
.and_then(|found| found.details.as_ref()) TitleKind::Series => "Series",
.and_then(|details| details.format_label()) TitleKind::Film => "Film",
.unwrap_or(match member.kind { });
TitleKind::Series => "Series", let year = found
TitleKind::Film => "Film", .and_then(|found| found.year)
}); .or(member.year)
let year = found .map(|year| format!(" · {year}"))
.and_then(|found| found.year) .unwrap_or_default();
.or(member.year) let response = ui::card(
.map(|year| format!(" · {year}")) ui,
.unwrap_or_default(); Card {
let response = ui::card( art: picture,
ui, name: art.name(member),
Card { subtitle: format!("{what}{year}"),
art: picture, progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
name: art.name(member), badge: here.then(|| "Here".to_owned()),
subtitle: format!("{what}{year}"), width: 128.0,
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32), aspect: theme::POSTER_ASPECT,
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())));
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
} }
}); }
}); });
});
} }
/// A title's poster, at the poster's own shape, with how far into it the /// A title's poster, at the poster's own shape, with how far into it the
@@ -192,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 /// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them. /// filled button beside the page's real actions competed with them. `to`
fn back_link(ui: &mut egui::Ui) -> egui::Response { /// names where it goes.
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT)); 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())); let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() { if response.hovered() {
ui.painter().hline( ui.painter().hline(
@@ -218,7 +372,7 @@ fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &m
} else { } else {
"Mark season watched" "Mark season watched"
}; };
if ui.button(label).clicked() { if ui::tonal_button(ui, label).clicked() {
actions.push(Action::MarkEpisodes { actions.push(Action::MarkEpisodes {
key: title.key.clone(), key: title.key.clone(),
links: season links: season
@@ -272,6 +426,7 @@ fn header(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
title: &Title, title: &Title,
on_list: bool,
offline: &OfflineView<'_>, offline: &OfflineView<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
@@ -323,7 +478,9 @@ fn header(
// Text in a column a person can read, with the picture to the // Text in a column a person can read, with the picture to the
// right of it rather than under it. // right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0)); ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&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 // Whichever of the share's name and the provider's is not the one
// shown. Worth showing rather than only replacing: a viewer should // shown. Worth showing rather than only replacing: a viewer should
// be able to tell what the match actually matched, and find the // be able to tell what the match actually matched, and find the
@@ -346,6 +503,30 @@ fn header(
} }
ui.add_space(theme::space::XS); ui.add_space(theme::space::XS);
ui.label(ui::muted(meta_line(title, found.as_ref()))); 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.add_space(theme::space::L);
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -372,8 +553,7 @@ fn header(
} }
// Beside the button it is the alternative to. // Beside the button it is the alternative to.
if resuming if resuming
&& ui && ui::tonal_button(ui, "Start over")
.button("Start over")
.on_hover_text("Play from the beginning") .on_hover_text("Play from the beginning")
.clicked() .clicked()
{ {
@@ -383,18 +563,17 @@ fn header(
None, None,
))); )));
} }
ui::list_button(ui, &title.key, on_list, actions);
let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let title_keys: Vec<_> = title.episodes().map(key_of).collect();
let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
if all_offline { if all_offline {
if ui if ui::tonal_button(ui, "Delete show")
.button("Delete show")
.on_hover_text("Delete the offline copies; keep the online source") .on_hover_text("Delete the offline copies; keep the online source")
.clicked() .clicked()
{ {
actions.push(Action::DeleteOffline(title_keys, title.name.clone())); actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
} }
} else if ui } else if ui::tonal_button(ui, "Download show")
.button("Download show")
.on_hover_text("Download every episode for disconnected playback") .on_hover_text("Download every episode for disconnected playback")
.clicked() .clicked()
{ {
@@ -429,13 +608,15 @@ fn header(
// wrongly. Offered whether or not anything matched: the two // wrongly. Offered whether or not anything matched: the two
// cases it exists for are a title with no poster at all and a // cases it exists for are a title with no poster at all and a
// title wearing someone else's. // title wearing someone else's.
if ui if ui::tonal_button(
.button(match &found { ui,
match &found {
Some(_) => "Change match", Some(_) => "Change match",
None => "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())); actions.push(Action::OpenMatcher(title.key.clone()));
} }
@@ -443,14 +624,14 @@ fn header(
if let Some(found) = &found { if let Some(found) = &found {
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
description(ui, found); description(ui, found, &title.key, actions);
} }
}); });
}); });
if let Some((texture, index)) = backdrop { if let Some((texture, index)) = backdrop {
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
band(ui, &texture, index); band(ui, &texture, index, art.mood(title));
} }
} }
@@ -464,10 +645,15 @@ fn header(
/// page colour so text laid over it stays readable whatever the still happens to /// 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 /// be, and a fade into the page along the bottom so the band ends rather than
/// stops. /// 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 // 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. // 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 content = ui.min_rect();
let rect = egui::Rect::from_min_max( let rect = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left() - BLEED, content.top()), egui::pos2(ui.max_rect().left() - BLEED, content.top()),
@@ -501,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 // Heavier still when there is no fade coming, since then these are the only
// things standing between the text and the picture. // 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 ramps = theme::ramps_on();
let veil = theme::mood_veil(mood);
layers.push( layers.push(
egui::epaint::RectShape::filled( egui::epaint::RectShape::filled(
rect, rect,
@@ -509,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 // 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 // where the text is, so this coat only has to calm the picture's
// right side, not make it disappear. // 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(), .into(),
); );
@@ -521,7 +712,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
layers.push(theme::fade_shape( layers.push(theme::fade_shape(
ui.ctx(), ui.ctx(),
text_side, text_side,
theme::background(), veil,
theme::Direction::Horizontal, theme::Direction::Horizontal,
// Solid at the left, where the poster and the text are. // Solid at the left, where the poster and the text are.
false, false,
@@ -548,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. /// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) { ///
let tags = found /// Its genres, studios and director are chips that open every title in the
.details /// library sharing them.
.as_ref() 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()) .map(|details| details.tags.as_slice())
.unwrap_or_default(); .unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() { if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0; ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres { for genre in &found.genres {
ui::widgets::chip(ui, genre); 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 // After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as. // provider's community describing it, not what it is filed as.
@@ -568,6 +773,38 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
}); });
ui.add_space(theme::space::M); 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 { if let Some(overview) = &found.overview {
// Four lines, then "More". A provider's synopsis runs to three // Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes — // paragraphs on a long show, and all of them pushed the episodes —
@@ -605,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.add_space(theme::space::M);
ui.hyperlink_to( ui.horizontal(|ui| {
ui::muted(format!("More on {}", found.provider.label())), // In the browser: the app itself never requests the trailer.
url, 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,
);
}
});
} }
} }
@@ -629,6 +882,15 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(year) = title.year { if let Some(year) = title.year {
parts.push(year.to_string()); 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.kind == TitleKind::Series {
if title.seasons.len() > 1 { if title.seasons.len() > 1 {
parts.push(ui::library::plural(title.seasons.len(), "season")); parts.push(ui::library::plural(title.seasons.len(), "season"));
@@ -640,29 +902,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.push(format!("{watched} watched")); parts.push(format!("{watched} watched"));
} }
if let Some(details) = found.and_then(|found| found.details.as_ref()) { if let Some(season) = found
if let Some(season) = details.season_label() { .and_then(|found| found.details.as_ref())
parts.push(season); .and_then(|details| details.season_label())
} {
parts.extend(details.studios.iter().take(2).cloned()); parts.push(season);
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
if let Some((episode, at)) = details.next_airing(now) {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
parts.push(format!("episode {episode} airs {when}"));
} else if details.is_airing() {
parts.push("airing".to_owned());
}
} }
if let Some(found) = found { if let Some(found) = found {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
@@ -681,6 +925,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.join(" · ") 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. /// How big an episode's still is drawn.
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0); const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
@@ -701,6 +976,7 @@ fn episode_row(
found.name.clone(), found.name.clone(),
found.overview.clone(), found.overview.clone(),
found.air_date.clone(), found.air_date.clone(),
found.runtime,
) )
}); });
let watched = episode.is_watched(); let watched = episode.is_watched();
@@ -732,14 +1008,17 @@ fn episode_row(
.unwrap_or_default(); .unwrap_or_default();
let name = found let name = found
.as_ref() .as_ref()
.and_then(|(name, _, _)| name.clone()) .and_then(|(name, ..)| name.clone())
.unwrap_or_else(|| episode.detail().to_string()); .unwrap_or_else(|| episode.detail().to_string());
let mut job = egui::text::LayoutJob::default(); let mut job = egui::text::LayoutJob::default();
if !numbering.is_empty() { if !numbering.is_empty() {
job.append( job.append(
&format!("{numbering} "), &format!("{numbering} "),
0.0, 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( job.append(
@@ -762,9 +1041,10 @@ fn episode_row(
ui.label(ui::muted(details( ui.label(ui::muted(details(
episode, episode,
found.as_ref().and_then(|f| f.2.as_deref()), 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( let mut job = egui::text::LayoutJob::simple(
overview.clone(), overview.clone(),
theme::Role::Caption.font(), theme::Role::Caption.font(),
@@ -836,9 +1116,9 @@ fn episode_row(
ui.add_space(theme::space::XXS); ui.add_space(theme::space::XXS);
} }
/// The grey line under an episode's name: when it aired, how big it is, and /// The grey line under an episode's name: when it aired, how long it runs,
/// where it would resume. /// how big it is, and where it would resume.
fn details(episode: &Episode, air_date: Option<&str>) -> String { fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
let mut parts = Vec::new(); let mut parts = Vec::new();
if let Some(at) = episode.resume_at() { if let Some(at) = episode.resume_at() {
let left = episode let left = episode
@@ -853,6 +1133,9 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
if let Some(date) = air_date { if let Some(date) = air_date {
parts.push(date.to_owned()); parts.push(date.to_owned());
} }
if let Some(minutes) = runtime {
parts.push(ui::format_runtime(minutes));
}
match episode.node.size { match episode.node.size {
// A file the share says is empty is one that will not play — an upload // 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 // that never finished, usually. Worth saying on the row rather than
@@ -888,12 +1171,20 @@ pub(crate) fn still(
painter.rect_filled(rect, radius, theme::card_hover()); painter.rect_filled(rect, radius, theme::card_hover());
// Asked for only once the row is on screen: a season of twenty-five is // 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. // 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 { let tint = if watched {
egui::Color32::from_gray(150) egui::Color32::from_gray(150)
} else { } else {
egui::Color32::WHITE egui::Color32::WHITE
}; }
.gamma_multiply(art_in);
painter.add( painter.add(
egui::epaint::RectShape::filled(rect, radius, tint) egui::epaint::RectShape::filled(rect, radius, tint)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())), .with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
@@ -954,6 +1245,21 @@ pub(crate) fn still(
theme::accent_fill(&painter, played, radius, 0.0); 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 if response
.on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(if episode.resume_at().is_some() { .on_hover_text(if episode.resume_at().is_some() {
@@ -1037,3 +1343,36 @@ fn key_of(episode: &Episode) -> DownloadKey {
link_id: episode.node.link_id.clone(), 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
);
}
}
+120 -51
View File
@@ -11,12 +11,14 @@
//! and neither mutates: this module draws. //! and neither mutates: this module draws.
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2}; use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
use pstr_core::prefs::SubtitleStyle;
use pstr_player::{MAX_VOLUME, Track, TrackKind}; use pstr_player::{MAX_VOLUME, Track, TrackKind};
use crate::app::{Action, Adjacent, Page}; use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback}; use crate::playback::{Command, Playback};
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
use crate::ui::player::{PlayerPanel, SleepChoice};
use crate::ui::widgets::Named; use crate::ui::widgets::Named;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it /// Height of the clickable seek bar. Comfortably larger than the few pixels it
@@ -41,6 +43,9 @@ const ROW_HEIGHT_FULL: f32 = 44.0;
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`]. /// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
const ROW_HEIGHT_MINI: f32 = 34.0; 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. /// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours { pub struct Neighbours {
@@ -53,6 +58,8 @@ pub fn full(
ui: &mut egui::Ui, ui: &mut egui::Ui,
playback: &Playback, playback: &Playback,
neighbours: Neighbours, neighbours: Neighbours,
panel: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
seek_bar(ui, playback, actions, 6.0); seek_bar(ui, playback, actions, 6.0);
@@ -98,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| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions); panel_buttons(ui, playback, panel, sleep, actions);
ui.add_space(theme::space::M);
speed_menu(ui, playback, actions);
ui.add_space(theme::space::M);
chapter_menu(ui, playback, actions);
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
volume(ui, playback, actions); volume(ui, playback, actions);
}); });
@@ -470,6 +475,8 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
} }
ui.separator(); ui.separator();
track_options(ui, &subtitles, TrackKind::Subtitle, actions); track_options(ui, &subtitles, TrackKind::Subtitle, actions);
ui.separator();
subtitle_adjustments(ui, playback, actions);
}) })
.response .response
.on_hover_text("Subtitle track"); .on_hover_text("Subtitle track");
@@ -489,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>) { fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
for track in tracks { for track in tracks {
if ui.selectable_label(track.selected, track.label()).clicked() { if ui.selectable_label(track.selected, track.label()).clicked() {
@@ -501,54 +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, /// The buttons that open the player's panels, right to left: tracks, speed,
/// because that is the one case worth noticing at a glance. /// chapters, sleep. Each shows only when there is a choice to make, and says
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) { /// what is chosen when that is worth noticing at a glance.
let label = if (playback.speed - 1.0).abs() < 1e-6 { fn panel_buttons(
"Speed".to_owned() ui: &mut egui::Ui,
} else { playback: &Playback,
format!("Speed · {}", ui::format_speed(playback.speed)) 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| {
// The same steps `[` and `]` walk.
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
if ui
.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 let subtitles = playback.tracks_of(TrackKind::Subtitle).count();
/// most releases means anime and not much else. let audio = playback.tracks_of(TrackKind::Audio).count();
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) { if subtitles > 0 || audio > 1 {
if playback.chapters.len() < 2 { let shown = match playback.selected_track(TrackKind::Subtitle) {
return; 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); let speed = if (playback.speed - 1.0).abs() < 1e-6 {
ui.menu_button("Chapters", |ui| { egui_phosphor::regular::GAUGE.to_owned()
for chapter in &playback.chapters { } else {
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label()); format!(
if ui "{} {}",
.selectable_label(current == Some(chapter.index), label) egui_phosphor::regular::GAUGE,
.clicked() ui::format_speed(playback.speed)
{ )
actions.push(Action::Player(Command::SeekTo(chapter.start))); };
ui.close(); 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 {
.response button(
.on_hover_text("Chapters"); 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 /// A track in as few words as fit on a button: what distinguishes it from the
+72
View File
@@ -390,6 +390,52 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
response.on_hover_cursor(egui::CursorIcon::PointingHand) 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. /// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response { pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new() egui::Frame::new()
@@ -401,3 +447,29 @@ pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
}) })
.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
}
+39 -30
View File
@@ -546,15 +546,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
settings.provider.label(), settings.provider.label(),
settings.provider.description() settings.provider.description()
); );
if settings.provider.needs_api_key() { let stored_key = pstr_meta::settings::api_key(ProviderId::Tmdb).is_some();
println!( println!(
"api key: {}", "tmdb key: {}",
match pstr_meta::settings::api_key(settings.provider) { match (stored_key, settings.provider.needs_api_key()) {
Some(_) => "stored in the credential store", (true, true) => "stored in the credential store",
None => "not set — run `pstr metadata set --api-key`", (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 catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let stored = catalog.all_metadata()?; let stored = catalog.all_metadata()?;
@@ -575,12 +576,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
api_key, api_key,
} => { } => {
if let Some(name) = provider { if let Some(name) = provider {
settings.provider = ProviderId::parse(&name).ok_or_else(|| { settings.provider = ProviderId::parse(&name)
anyhow::anyhow!( .filter(|provider| ProviderId::CHOOSABLE.contains(provider))
"unknown provider {name:?}; try one of: {}", .ok_or_else(|| {
ProviderId::ALL.map(|provider| provider.as_str()).join(", ") anyhow::anyhow!(
) "unknown provider {name:?}; try one of: {}",
})?; ProviderId::CHOOSABLE
.map(|provider| provider.as_str())
.join(", ")
)
})?;
} }
if on { if on {
settings.enabled = true; settings.enabled = true;
@@ -590,12 +595,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
} }
if api_key { if api_key {
let key = rpassword::prompt_password(format!( // TMDB's is the only key there is: the provider with TMDB
"{} API key (empty to forget it): ", // chosen, and film art with AniList.
settings.provider.label() let key = rpassword::prompt_password("TMDB API key (empty to forget it): ")
)) .context("read the API key")?;
.context("read the API key")?; pstr_meta::settings::set_api_key(ProviderId::Tmdb, &key)
pstr_meta::settings::set_api_key(settings.provider, &key)
.context("store the API key")?; .context("store the API key")?;
} }
@@ -606,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// `Engine::set_metadata_config`. // `Engine::set_metadata_config`.
if off || settings.enabled { if off || settings.enabled {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?; let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let chain = pstr_meta::service::chain_of(settings.provider);
let stale = catalog let stale = catalog
.all_metadata()? .all_metadata()?
.values() .values()
.any(|record| record.provider != settings.provider); .any(|record| !chain.contains(&record.provider));
if off || stale { if off || stale {
catalog.clear_metadata()?; catalog.clear_metadata()?;
println!("cleared the stored answers"); println!("cleared the stored answers");
@@ -627,13 +632,13 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
anyhow::bail!( anyhow::bail!(
"lookups are off — run `pstr metadata set --on` first.\n\ "lookups are off — run `pstr metadata set --on` first.\n\
Matching sends the titles in your catalog to {}.", Matching sends the titles in your catalog to {}.",
settings.provider.label() pstr_meta::service::chain_label(settings.provider)
); );
} }
let service = pstr_meta::MetadataService::new( let service = pstr_meta::MetadataService::new(
&settings, &settings,
pstr_meta::settings::api_key(settings.provider), pstr_meta::settings::api_key(ProviderId::Tmdb),
) )
.context("build the metadata provider")?; .context("build the metadata provider")?;
@@ -646,8 +651,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
library.titles, library.titles,
&stored, &stored,
&enriched, &enriched,
settings.provider, &service.chain(),
service.has_details(),
force, force,
); );
if let Some(limit) = limit { if let Some(limit) = limit {
@@ -661,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
println!( println!(
"looking up {} titles against {}…", "looking up {} titles against {}…",
pending.len(), pending.len(),
settings.provider.label() service.label()
); );
// Serial, not concurrent: both providers rate-limit by the minute, // Serial, not concurrent: both providers rate-limit by the minute,
@@ -691,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
Some(found) => { Some(found) => {
matched += 1; matched += 1;
println!( println!(
" {name:<40} → {} ({})", " {name:<40} → {} ({}){}",
found.name, found.name,
found found
.year .year
.map(|year| year.to_string()) .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"), None => println!(" {name:<40} → no match"),
@@ -717,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
); );
catalog.set_enrichment( catalog.set_enrichment(
&outcome.title_key, &outcome.title_key,
settings.provider, outcome.provider,
unix_now(), unix_now(),
enrichment, enrichment,
)?; )?;
+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. /// a tab, a play glyph, never body copy.
pub const MIN_CONTRAST: f32 = 3.0; 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 /// Every colour a client draws with, resolved from a [`Flavor`] and an
/// [`Accent`]. /// [`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] #[test]
fn a_first_run_gets_the_palette_the_app_shipped_with() { fn a_first_run_gets_the_palette_the_app_shipped_with() {
let appearance = Appearance::default(); let appearance = Appearance::default();
+38 -9
View File
@@ -10,6 +10,7 @@ use std::collections::{HashMap, HashSet};
use crate::catalog::CatalogNode; use crate::catalog::CatalogNode;
use crate::library::{Library, Title, TitleKind, sort_name}; use crate::library::{Library, Title, TitleKind, sort_name};
use crate::metadata::{MetadataRecord, TitleNames}; use crate::metadata::{MetadataRecord, TitleNames};
use crate::mylist::MyList;
/// What order a grid is in. /// What order a grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
@@ -66,11 +67,14 @@ pub enum Filter {
Unwatched, Unwatched,
/// With at least one episode kept on this device. /// With at least one episode kept on this device.
Downloaded, Downloaded,
/// On My List.
MyList,
} }
impl Filter { impl Filter {
pub const ALL: [Self; 6] = [ pub const ALL: [Self; 7] = [
Self::All, Self::All,
Self::MyList,
Self::Series, Self::Series,
Self::Films, Self::Films,
Self::Watching, Self::Watching,
@@ -86,12 +90,18 @@ impl Filter {
Self::Watching => "Watching", Self::Watching => "Watching",
Self::Unwatched => "Not started", Self::Unwatched => "Not started",
Self::Downloaded => "Downloaded", Self::Downloaded => "Downloaded",
Self::MyList => "My List",
} }
} }
/// Whether the grid shows `title`. `offline` says whether a file is kept /// Whether the grid shows `title`. `offline` says whether a file is kept
/// on this device, which only the front end knows. /// on this device, which only the front end knows.
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool { pub fn admits(
self,
title: &Title,
offline: impl Fn(&CatalogNode) -> bool,
list: &MyList,
) -> bool {
let watched = title.watched_count(); let watched = title.watched_count();
match self { match self {
Self::All => true, Self::All => true,
@@ -102,6 +112,7 @@ impl Filter {
} }
Self::Unwatched => watched == 0 && title.resume().is_none(), Self::Unwatched => watched == 0 && title.resume().is_none(),
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)), Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
Self::MyList => list.contains(&title.key),
} }
} }
} }
@@ -312,6 +323,7 @@ mod tests {
kind: TitleKind::Series, kind: TitleKind::Series,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: Some(rating), rating: Some(rating),
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
@@ -454,12 +466,18 @@ mod tests {
let film = title("f", "Film", TitleKind::Film); let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series); let series = title("s", "Series", TitleKind::Series);
let none = |_: &CatalogNode| false; let none = |_: &CatalogNode| false;
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none)); let list = MyList::default();
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none)); assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list));
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none)); 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. // Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film, none)); assert!(Filter::Unwatched.admits(&film, none, &list));
assert!(!Filter::Watching.admits(&film, none)); assert!(!Filter::Watching.admits(&film, none, &list));
} }
#[test] #[test]
@@ -485,7 +503,18 @@ mod tests {
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new()); let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
let show = &library.titles[0]; let show = &library.titles[0];
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b")); let list = MyList::default();
assert!(!Filter::Downloaded.admits(show, |_| false)); 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));
} }
} }
+362 -19
View File
@@ -24,7 +24,7 @@ use crate::metadata::{
use crate::naming::{self, ParsedName}; use crate::naming::{self, ParsedName};
/// Bump when adding a migration. /// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 12; pub const SCHEMA_VERSION: i64 = 14;
const MIGRATION_V1: &str = r#" const MIGRATION_V1: &str = r#"
CREATE TABLE nodes ( CREATE TABLE nodes (
@@ -242,6 +242,29 @@ CREATE TABLE sidecars (
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id); CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
"#; "#;
/// Title logos and running times.
///
/// ani.zip sent both all along and neither was kept. Every AniList title is
/// marked un-enriched so the next match run asks for them; nothing is deleted,
/// and the stored episodes and art stay on screen until the answer replaces
/// them. The title runtime lives in `details`, which is refetched by
/// `TitleDetails::revision` rather than blanked here.
const MIGRATION_V13: &str = r#"
ALTER TABLE title_metadata ADD COLUMN logo_url TEXT;
ALTER TABLE episode_metadata ADD COLUMN runtime INTEGER;
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
"#;
/// Each episode's MyAnimeList id, for AniSkip.
///
/// ani.zip sends it with every entry and it was not kept. As with V13, every
/// AniList title is marked un-enriched so the next match run fills it in, and
/// nothing is deleted meanwhile.
const MIGRATION_V14: &str = r#"
ALTER TABLE episode_metadata ADD COLUMN mal_id INTEGER;
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
"#;
/// How old a share's last crawl may be before an app looks again on its own. /// How old a share's last crawl may be before an app looks again on its own.
/// Long enough that a launch rarely costs a crawl; short enough that a new /// Long enough that a launch rarely costs a crawl; short enough that a new
/// episode turns up the same day. /// episode turns up the same day.
@@ -497,6 +520,18 @@ impl Catalog {
tx.pragma_update(None, "user_version", 12)?; tx.pragma_update(None, "user_version", 12)?;
tx.commit()?; tx.commit()?;
} }
if version < 13 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V13)?;
tx.pragma_update(None, "user_version", 13)?;
tx.commit()?;
}
if version < 14 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V14)?;
tx.pragma_update(None, "user_version", 14)?;
tx.commit()?;
}
Ok(()) Ok(())
} }
@@ -1037,7 +1072,7 @@ impl Catalog {
let mut statement = self.conn.prepare( let mut statement = self.conn.prepare(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual, details, chain manual, details, chain, logo_url
FROM title_metadata", FROM title_metadata",
)?; )?;
let rows = statement.query_map([], row_to_metadata)?; let rows = statement.query_map([], row_to_metadata)?;
@@ -1058,7 +1093,7 @@ impl Catalog {
.query_row( .query_row(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
url, manual, details, chain url, manual, details, chain, logo_url
FROM title_metadata WHERE title_key = ?1", FROM title_metadata WHERE title_key = ?1",
params![title_key], params![title_key],
row_to_metadata, row_to_metadata,
@@ -1103,9 +1138,9 @@ impl Catalog {
&"INSERT INTO title_metadata ( &"INSERT INTO title_metadata (
title_key, provider, matched, fetched_at, remote_id, name, original_name, title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual, details manual, details, logo_url
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
?17, ?18) ?17, ?18, ?19)
ON CONFLICT (title_key) DO UPDATE SET ON CONFLICT (title_key) DO UPDATE SET
provider = excluded.provider, provider = excluded.provider,
matched = excluded.matched, matched = excluded.matched,
@@ -1120,6 +1155,9 @@ impl Catalog {
backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url, backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url,
title_metadata.backdrop_url) title_metadata.backdrop_url)
ELSE excluded.backdrop_url END, ELSE excluded.backdrop_url END,
logo_url = CASE WHEN same THEN COALESCE(excluded.logo_url,
title_metadata.logo_url)
ELSE excluded.logo_url END,
rating = excluded.rating, rating = excluded.rating,
genres = excluded.genres, genres = excluded.genres,
episodes = excluded.episodes, episodes = excluded.episodes,
@@ -1153,6 +1191,7 @@ impl Catalog {
data.and_then(|data| data.url.as_deref()), data.and_then(|data| data.url.as_deref()),
record.manual as i64, record.manual as i64,
details, details,
data.and_then(|data| data.logo_url.as_deref()),
], ],
)?; )?;
Ok(()) Ok(())
@@ -1220,7 +1259,8 @@ impl Catalog {
replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?; replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?;
tx.execute( tx.execute(
"UPDATE title_metadata "UPDATE title_metadata
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5 SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5,
logo_url = COALESCE(?6, logo_url)
WHERE title_key = ?1 AND provider = ?2", WHERE title_key = ?1 AND provider = ?2",
params![ params![
title_key, title_key,
@@ -1228,6 +1268,7 @@ impl Catalog {
fetched_at, fetched_at,
enrichment.backdrop_url.as_deref(), enrichment.backdrop_url.as_deref(),
enrichment.chain.join("\n"), enrichment.chain.join("\n"),
enrichment.logo_url.as_deref(),
], ],
)?; )?;
tx.commit()?; tx.commit()?;
@@ -1241,7 +1282,7 @@ impl Catalog {
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> { pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
let mut statement = self.conn.prepare( let mut statement = self.conn.prepare(
"SELECT title_key, season, number, name, overview, still_url, air_date, "SELECT title_key, season, number, name, overview, still_url, air_date,
absolute, entry_season, entry_number absolute, entry_season, entry_number, runtime, mal_id
FROM episode_metadata", FROM episode_metadata",
)?; )?;
let rows = statement.query_map([], |row| { let rows = statement.query_map([], |row| {
@@ -1259,6 +1300,8 @@ impl Catalog {
overview: row.get(4)?, overview: row.get(4)?,
still_url: row.get(5)?, still_url: row.get(5)?,
air_date: row.get(6)?, air_date: row.get(6)?,
runtime: row.get(10)?,
mal_id: row.get(11)?,
}, },
)) ))
})?; })?;
@@ -1365,8 +1408,8 @@ fn replace_episodes(
"INSERT OR REPLACE INTO episode_metadata ( "INSERT OR REPLACE INTO episode_metadata (
title_key, season, number, provider, fetched_at, title_key, season, number, provider, fetched_at,
name, overview, still_url, air_date, name, overview, still_url, air_date,
absolute, entry_season, entry_number absolute, entry_season, entry_number, runtime, mal_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
)?; )?;
for episode in episodes { for episode in episodes {
insert.execute(params![ insert.execute(params![
@@ -1382,6 +1425,8 @@ fn replace_episodes(
episode.absolute, episode.absolute,
episode.entry_season, episode.entry_season,
episode.entry_number, episode.entry_number,
episode.runtime,
episode.mal_id,
])?; ])?;
} }
Ok(()) Ok(())
@@ -1432,6 +1477,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
kind: parse_kind(row.get::<_, Option<String>>(9)?.as_deref()), kind: parse_kind(row.get::<_, Option<String>>(9)?.as_deref()),
poster_url: row.get(10)?, poster_url: row.get(10)?,
backdrop_url: row.get(11)?, backdrop_url: row.get(11)?,
logo_url: row.get(19)?,
rating: row.get(12)?, rating: row.get(12)?,
genres: genres genres: genres
.unwrap_or_default() .unwrap_or_default()
@@ -1549,7 +1595,9 @@ pub fn from_node(share_id: &str, node: &proton_drive_rs::Node) -> CatalogNode {
/// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their /// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their
/// contents group under the series above them; /// contents group under the series above them;
/// - whatever the filename claimed as a title, when it differs, is kept as the /// - whatever the filename claimed as a title, when it differs, is kept as the
/// episode's own name — with the redundant series prefix stripped. /// episode's own name — with the redundant series prefix stripped;
/// - **a folder of films is one title per film**, not one title: see
/// [`split_films`].
/// ///
/// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg` /// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg`
/// alongside the video, and a catalog that offers those as episodes is worse /// alongside the video, and a catalog that offers those as episodes is worse
@@ -1562,12 +1610,18 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
.map(|node| (node.uid.link_id.as_str(), node)) .map(|node| (node.uid.link_id.as_str(), node))
.collect(); .collect();
nodes let mut origins = Vec::new();
let mut rows: Vec<CatalogNode> = nodes
.iter() .iter()
.filter(|node| !node.is_folder() && naming::is_video_file(&node.name)) .filter(|node| !node.is_folder() && naming::is_video_file(&node.name))
.map(|node| { .map(|node| {
let mut row = from_node(share_id, node); let mut row = from_node(share_id, node);
let context = ancestry(node, &by_link); let context = ancestry(node, &by_link);
origins.push(Origin {
own: row.parsed.clone(),
folder: context.title_folder.clone(),
through_container: context.through_container,
});
if let Some(title) = context.title { if let Some(title) = context.title {
// The filename's own title, if it had one and it differs, is the // The filename's own title, if it had one and it differs, is the
@@ -1601,7 +1655,75 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
} }
row row
}) })
.collect() .collect();
split_films(&mut rows, &origins);
rows
}
/// Where a row's title came from, kept for [`split_films`].
struct Origin {
/// What the filename alone parsed as, before the folder overrode it.
own: ParsedName,
/// The link id of the folder that named the row.
folder: Option<String>,
through_container: bool,
}
/// Give each film in a folder of films its own title.
///
/// `Rebuild of Evangelion/` holding `Evangelion - 1.11 You Are (Not)
/// Alone.mkv` and three more is four films, each its own entry at every
/// provider. Titled by the folder they are one series of four unnumbered
/// files, matched as whichever single film the folder's name reads most like —
/// and no single film is right.
///
/// Only when every file in the folder says so, because the same shape with one
/// file out of place is a series:
///
/// - none is numbered, by its name or by a season folder;
/// - none is under a grouping folder, because `Neon Genesis Evangelion/Movies/`
/// is the series' films, and they belong on its page;
/// - each names itself, differently from the others, and not by the folder's
/// name — `Mushishi - The Green Seat.mkv` under `Mushishi/` is an episode
/// of that show whose number is missing, not a film.
fn split_films(rows: &mut [CatalogNode], origins: &[Origin]) {
use crate::library::title_key;
use std::collections::HashSet;
let mut by_folder: HashMap<&str, Vec<usize>> = HashMap::new();
for (index, origin) in origins.iter().enumerate() {
if let Some(folder) = &origin.folder {
by_folder.entry(folder.as_str()).or_default().push(index);
}
}
for members in by_folder.into_values() {
if members.len() < 2 {
continue;
}
let mut keys = HashSet::new();
let films = members.iter().all(|&index| {
let (row, origin) = (&rows[index], &origins[index]);
let own = &origin.own.title;
!origin.through_container
&& !origin.own.is_episode()
&& origin.own.season.is_none()
&& row.parsed.season.is_none()
&& !own.is_empty()
&& strip_prefix_title(own, &row.parsed.title) == *own
&& !own.eq_ignore_ascii_case(&row.parsed.title)
&& keys.insert(title_key(own))
});
if !films {
continue;
}
for index in members {
let row = &mut rows[index];
let own = &origins[index].own;
row.parsed.title = own.title.clone();
row.parsed.episode_title = own.episode_title.clone();
}
}
} }
/// Pair each subtitle file in a crawl with the video it is for. /// Pair each subtitle file in a crawl with the video it is for.
@@ -1784,6 +1906,10 @@ fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct Ancestry { struct Ancestry {
title: Option<String>, title: Option<String>,
/// The link id of the folder [`Ancestry::title`] came from.
title_folder: Option<String>,
/// Whether a grouping folder sat between the file and that one.
through_container: bool,
season: Option<u32>, season: Option<u32>,
year: Option<u32>, year: Option<u32>,
} }
@@ -1822,10 +1948,13 @@ fn ancestry(
match crate::naming::classify_folder(&ancestor.name) { match crate::naming::classify_folder(&ancestor.name) {
FolderRole::Season(season) => found.season = found.season.or(Some(season)), FolderRole::Season(season) => found.season = found.season.or(Some(season)),
FolderRole::Container => {} FolderRole::Container => {
found.through_container |= found.title.is_none();
}
FolderRole::Title(parsed) => { FolderRole::Title(parsed) => {
if found.title.is_none() && !parsed.title.is_empty() { if found.title.is_none() && !parsed.title.is_empty() {
found.title = Some(parsed.title.clone()); found.title = Some(parsed.title.clone());
found.title_folder = Some(link_id.clone());
found.year = found.year.or(parsed.year); found.year = found.year.or(parsed.year);
// `Game of Thrones - Season 1` names both; the season it // `Game of Thrones - Season 1` names both; the season it
// states belongs to the files under it. // states belongs to the files under it.
@@ -1898,6 +2027,7 @@ mod tests {
kind: TitleKind::Series, kind: TitleKind::Series,
poster_url: Some("https://example.invalid/p.jpg".into()), poster_url: Some("https://example.invalid/p.jpg".into()),
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: Some(8.4), rating: Some(8.4),
genres: vec!["Action".into(), "Drama".into()], genres: vec!["Action".into(), "Drama".into()],
episodes: Some(25), episodes: Some(25),
@@ -1993,6 +2123,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}], }],
) )
.expect("store episodes"); .expect("store episodes");
@@ -2085,6 +2217,8 @@ mod tests {
overview: Some("Ed and Al…".into()), overview: Some("Ed and Al…".into()),
still_url: None, still_url: None,
air_date: Some("2010-11-14".into()), air_date: Some("2010-11-14".into()),
runtime: None,
mal_id: None,
}, },
EpisodeMetadata { EpisodeMetadata {
season: Some(1), season: Some(1),
@@ -2096,6 +2230,8 @@ mod tests {
overview: None, overview: None,
still_url: Some("still.jpg".into()), still_url: Some("still.jpg".into()),
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}, },
]; ];
catalog catalog
@@ -2138,6 +2274,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: Some(24),
mal_id: None,
}; };
catalog catalog
.set_episode_metadata( .set_episode_metadata(
@@ -2181,6 +2319,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}], }],
) )
.expect("store episodes"); .expect("store episodes");
@@ -2237,6 +2377,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}], }],
) )
.expect("store episodes"); .expect("store episodes");
@@ -2253,16 +2395,18 @@ mod tests {
assert!(catalog.all_episode_metadata().expect("read").is_empty()); assert!(catalog.all_episode_metadata().expect("read").is_empty());
} }
/// Enrichment adds a backdrop when it found one and otherwise leaves the /// Enrichment adds a backdrop or a logo when it found one and otherwise
/// match's own alone; a new match makes the title un-enriched again. /// leaves the match's own alone; a new match makes the title un-enriched
/// again, and refreshing the same one keeps both.
#[test] #[test]
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() { fn enrichment_keeps_the_art_it_did_not_replace_and_a_different_match_resets_it() {
let mut catalog = Catalog::in_memory().expect("open"); let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord { let record = MetadataRecord {
title_key: "attack on titan".into(), title_key: "attack on titan".into(),
provider: ProviderId::AniList, provider: ProviderId::AniList,
metadata: Some(TitleMetadata { metadata: Some(TitleMetadata {
backdrop_url: Some("old.jpg".into()), backdrop_url: Some("old.jpg".into()),
logo_url: Some("old.png".into()),
..found() ..found()
}), }),
fetched_at: 1, fetched_at: 1,
@@ -2274,8 +2418,9 @@ mod tests {
.metadata("attack on titan") .metadata("attack on titan")
.expect("read") .expect("read")
.and_then(|record| record.metadata) .and_then(|record| record.metadata)
.and_then(|found| found.backdrop_url) .map(|found| (found.backdrop_url, found.logo_url))
}; };
let art = |backdrop: &str, logo: &str| Some((Some(backdrop.into()), Some(logo.into())));
catalog catalog
.set_enrichment( .set_enrichment(
@@ -2285,16 +2430,26 @@ mod tests {
&Enrichment::default(), &Enrichment::default(),
) )
.expect("enrich"); .expect("enrich");
assert_eq!(backdrop(&catalog).as_deref(), Some("old.jpg")); assert_eq!(backdrop(&catalog), art("old.jpg", "old.png"));
let enrichment = Enrichment { let enrichment = Enrichment {
backdrop_url: Some("fanart.jpg".into()), backdrop_url: Some("fanart.jpg".into()),
logo_url: Some("clearlogo.png".into()),
..Enrichment::default() ..Enrichment::default()
}; };
catalog catalog
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment) .set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
.expect("enrich again"); .expect("enrich again");
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg")); assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
// A refresh of the same entry answers with no art of its own.
catalog
.set_metadata(&MetadataRecord {
metadata: Some(found()),
..record.clone()
})
.expect("refresh");
assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
catalog catalog
.set_metadata(&MetadataRecord { .set_metadata(&MetadataRecord {
@@ -2331,8 +2486,11 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}], }],
backdrop_url: None, backdrop_url: None,
logo_url: None,
chain: Vec::new(), chain: Vec::new(),
}; };
catalog catalog
@@ -2390,6 +2548,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: Some(air_date.into()), air_date: Some(air_date.into()),
runtime: None,
mal_id: None,
}; };
let enrichment = Enrichment { let enrichment = Enrichment {
episodes: vec![ episodes: vec![
@@ -2397,6 +2557,7 @@ mod tests {
episode(2, None, "2026-10-09"), episode(2, None, "2026-10-09"),
], ],
backdrop_url: None, backdrop_url: None,
logo_url: None,
chain: Vec::new(), chain: Vec::new(),
}; };
catalog catalog
@@ -2455,8 +2616,11 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}], }],
backdrop_url: Some("fanart.jpg".into()), backdrop_url: Some("fanart.jpg".into()),
logo_url: None,
chain: vec!["457".into(), "20595".into()], chain: vec!["457".into(), "20595".into()],
}; };
catalog catalog
@@ -2616,6 +2780,63 @@ mod tests {
assert!(catalog.sidecars("s", "a").expect("read").is_empty()); assert!(catalog.sidecars("s", "a").expect("read").is_empty());
} }
/// Schema v13 asks every AniList title to be enriched again, for the
/// logos and running times it never kept — and deletes nothing: the
/// match, its details and its episode names stay on screen meanwhile.
#[test]
fn upgrading_to_v13_re_enriches_anilist_titles_and_keeps_everything_they_had() {
let conn = Connection::open_in_memory().expect("open");
for migration in [
MIGRATION_V1,
MIGRATION_V2,
MIGRATION_V3,
MIGRATION_V4,
MIGRATION_V5,
MIGRATION_V6,
MIGRATION_V7,
MIGRATION_V8,
MIGRATION_V9,
MIGRATION_V10,
MIGRATION_V11,
MIGRATION_V12,
] {
conn.execute_batch(migration).expect("migrate");
}
conn.pragma_update(None, "user_version", 12)
.expect("set version");
conn.execute_batch(
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, remote_id,
name, kind, backdrop_url, manual, details, enriched_at)
VALUES ('aot', 'anilist', 1, 1, '16498', 'Attack on Titan', 'series',
'fanart.jpg', 1, '{\"format\":\"TV\"}', 5),
('got', 'tmdb', 1, 1, '1399', 'Game of Thrones', 'series',
'backdrop.jpg', 0, NULL, 5);
INSERT INTO episode_metadata (title_key, season, number, provider, fetched_at, name)
VALUES ('aot', 1, 1, 'anilist', 1, 'To You, in 2000 Years');",
)
.expect("seed");
let catalog = Catalog::from_connection(conn).expect("upgrade");
let records = catalog.all_metadata().expect("read");
assert!(records["aot"].manual);
let aot = records["aot"].metadata.as_ref().expect("still matched");
assert_eq!(aot.backdrop_url.as_deref(), Some("fanart.jpg"));
assert_eq!(aot.logo_url, None);
let details = aot.details.as_ref().expect("details kept");
assert_eq!(details.format.as_deref(), Some("TV"));
assert!(!details.is_current());
let ages = catalog.enrichment_ages().expect("ages");
assert!(!ages.contains_key("aot"));
assert!(ages.contains_key("got"));
let episodes = catalog.all_episode_metadata().expect("read");
assert_eq!(
episodes["aot"].get(Some(1), 1).and_then(|e| e.runtime),
None
);
assert_eq!(episodes["aot"].len(), 1);
}
/// A file keeps the moment it first appeared across recrawls, and one /// A file keeps the moment it first appeared across recrawls, and one
/// that was there before this was recorded reads as 0. /// that was there before this was recorded reads as 0.
#[test] #[test]
@@ -2714,6 +2935,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}], }],
) )
.expect("store"); .expect("store");
@@ -3081,6 +3304,126 @@ mod tests {
assert_eq!(row(&film, "film").parsed.title, "Millennium Actress"); assert_eq!(row(&film, "film").parsed.title, "Millennium Actress");
} }
/// **What the Rebuild of Evangelion films were matched as.** A folder of
/// four films titled by the folder is one series, matched as a single film
/// that none of the four is. Each names itself, so each is its own title.
#[test]
fn a_folder_of_unnumbered_films_is_one_title_per_film() {
let nodes = vec![
root(),
folder("rebuild", Some("root"), "Rebuild of Evangelion"),
video(
"one",
"rebuild",
"Evangelion - 1.11 You Are (Not) Alone.mkv",
),
video(
"two",
"rebuild",
"Evangelion - 2.22 You Can (Not) Advance..mkv",
),
video(
"three",
"rebuild",
"Evangelion - 3.33 You Can (Not) Redo..mkv",
),
video(
"four",
"rebuild",
"Evangelion - 3.0+1.01 Thrice Upon a Time.mkv",
),
];
let rows = build_rows("share", &nodes);
assert_eq!(
row(&rows, "one").parsed.title,
"Evangelion - 1.11 You Are Alone"
);
assert_eq!(
row(&rows, "four").parsed.title,
"Evangelion - 3.0+1.01 Thrice Upon a Time"
);
for film in &rows {
assert_eq!(film.parsed.episode, None);
assert_eq!(film.parsed.episode_title, None, "a film is not an episode");
}
let library = crate::library::Library::build(rows, &HashMap::new());
assert_eq!(library.titles.len(), 4);
assert!(
library
.titles
.iter()
.all(|title| title.kind == TitleKind::Film)
);
}
/// One numbered file says the folder is a show, and the unnumbered ones are
/// its specials.
#[test]
fn a_folder_with_a_numbered_episode_in_it_is_not_split() {
let nodes = vec![
root(),
folder("show", Some("root"), "Promised Neverland"),
video("e1", "show", "[Group] Yakusoku no Neverland - 01.mkv"),
video("film", "show", "Some Recap Film.mkv"),
video("other", "show", "Another Special.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Promised Neverland");
}
}
/// Films under `Movies/` are the series' films, and stay on its page.
#[test]
fn films_under_a_grouping_folder_stay_with_the_series() {
let nodes = vec![
root(),
folder("eva", Some("root"), "Neon Genesis Evangelion"),
folder("movies", Some("eva"), "Movies"),
video("a", "movies", "Death and Rebirth.mkv"),
video("b", "movies", "The End of Evangelion.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Neon Genesis Evangelion");
}
}
/// Files that repeat the folder's name are that show's episodes with their
/// numbers missing, not films of their own.
#[test]
fn unnumbered_files_named_after_their_folder_stay_one_title() {
let nodes = vec![
root(),
folder("mushi", Some("root"), "Mushishi"),
video("a", "mushi", "Mushishi - The Green Seat.mkv"),
video("b", "mushi", "Mushishi - The Light of the Eyelid.mkv"),
];
let rows = build_rows("share", &nodes);
assert_eq!(row(&rows, "a").parsed.title, "Mushishi");
assert_eq!(
row(&rows, "a").parsed.episode_title.as_deref(),
Some("The Green Seat")
);
}
/// Two files that name themselves the same thing are not two films.
#[test]
fn files_whose_own_names_collide_are_not_split() {
let nodes = vec![
root(),
folder("film", Some("root"), "Akira"),
video("a", "film", "[GroupA] Neo Tokyo.mkv"),
video("b", "film", "Neo.Tokyo.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Akira");
}
}
/// The share root names the *share*, not a series — using it would label /// The share root names the *share*, not a series — using it would label
/// every top-level file identically. /// every top-level file identically.
#[test] #[test]
+229
View File
@@ -294,6 +294,116 @@ pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
.rposition(|chapter| position + f64::EPSILON >= chapter.start) .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -480,4 +590,123 @@ mod tests {
// and a button that seeks to zero would be worse than no button. // and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None); 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()
);
}
}
+1
View File
@@ -177,6 +177,7 @@ mod tests {
kind: TitleKind::Series, kind: TitleKind::Series,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
+4
View File
@@ -11,14 +11,18 @@ pub mod browse;
pub mod catalog; pub mod catalog;
pub mod chapters; pub mod chapters;
pub mod config; pub mod config;
pub mod discover;
pub mod error; pub mod error;
pub mod franchise; pub mod franchise;
pub mod language; pub mod language;
pub mod library; pub mod library;
pub mod metadata; pub mod metadata;
pub mod mylist;
pub mod naming; pub mod naming;
pub mod news;
pub mod prefs; pub mod prefs;
pub mod profile; pub mod profile;
pub mod recent;
pub mod shares; pub mod shares;
pub mod shelves; pub mod shelves;
pub mod subtitles; pub mod subtitles;
+59
View File
@@ -18,6 +18,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::catalog::{CatalogNode, WatchState}; use crate::catalog::{CatalogNode, WatchState};
use crate::naming;
/// A file has been watched once playback passed this fraction of it. Short of /// 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 /// 1.0 because nobody sits through the credits, and a title that never reaches
@@ -158,6 +159,34 @@ impl Title {
.sum() .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 { pub fn watched_count(&self) -> usize {
self.episodes().filter(|e| e.is_watched()).count() self.episodes().filter(|e| e.is_watched()).count()
} }
@@ -567,6 +596,36 @@ mod tests {
assert_eq!(title.preceding("s1", "a"), None); 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] #[test]
fn a_file_from_another_share_is_not_this_titles_neighbour() { fn a_file_from_another_share_is_not_this_titles_neighbour() {
let library = Library::build( let library = Library::build(
+208 -10
View File
@@ -39,15 +39,23 @@ pub enum ProviderId {
AniList, AniList,
/// The Movie Database. Film and television, needs a free API key. /// The Movie Database. Film and television, needs a free API key.
Tmdb, 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 { 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 { pub fn as_str(self) -> &'static str {
match self { match self {
Self::AniList => "anilist", Self::AniList => "anilist",
Self::Tmdb => "tmdb", Self::Tmdb => "tmdb",
Self::TvMaze => "tvmaze",
} }
} }
@@ -56,6 +64,7 @@ impl ProviderId {
match self { match self {
Self::AniList => "AniList", Self::AniList => "AniList",
Self::Tmdb => "TMDB", Self::Tmdb => "TMDB",
Self::TvMaze => "TVmaze",
} }
} }
@@ -64,9 +73,11 @@ impl ProviderId {
match self { match self {
Self::AniList => { Self::AniList => {
"Anime, with no API key and no account. Episode names and wide art come from \ "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::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 /// Landscape art, around 16:9. Much rarer than a poster, and the reason the
/// grid cannot simply prefer one shape. /// grid cannot simply prefer one shape.
pub backdrop_url: Option<String>, 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. /// Out of 10, however the provider scores.
pub rating: Option<f32>, pub rating: Option<f32>,
pub genres: Vec<String>, pub genres: Vec<String>,
@@ -161,6 +177,20 @@ pub struct TitleDetails {
/// side stories, recaps, retellings. What the library groups a franchise /// side stories, recaps, retellings. What the library groups a franchise
/// by; see `crate::franchise`. /// by; see `crate::franchise`.
pub related: Vec<String>, 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 — /// The entries enrichment walked through to find the title's episodes —
/// its own sequels, by provider id. Stored apart from the rest, because /// its own sequels, by provider id. Stored apart from the rest, because
/// it is enrichment's answer rather than the match's. /// it is enrichment's answer rather than the match's.
@@ -168,7 +198,63 @@ pub struct TitleDetails {
pub chain: Vec<String>, 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 { 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`. /// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
pub fn format_label(&self) -> Option<&'static str> { pub fn format_label(&self) -> Option<&'static str> {
Some(match self.format.as_deref()? { Some(match self.format.as_deref()? {
@@ -201,6 +287,20 @@ impl TitleDetails {
self.status.as_deref() == Some("RELEASING") 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`. /// The next episode and when it airs, when that is still after `now`.
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> { pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
let at = self.next_airing_at.filter(|at| *at > now)?; 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 /// sorted on, and a provider that answers `2009` should not be a parse
/// error. /// error.
pub air_date: Option<String>, 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 /// 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 /// A 16:9 picture of the whole title, when one was found. `None` leaves
/// whatever backdrop the match already had. /// whatever backdrop the match already had.
pub backdrop_url: Option<String>, 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 /// Every provider entry the episodes were taken from, the match's own
/// included. See [`TitleDetails::chain`]. /// included. See [`TitleDetails::chain`].
pub chain: Vec<String>, pub chain: Vec<String>,
@@ -496,8 +607,13 @@ pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
impl MetadataRecord { impl MetadataRecord {
/// Whether this answer is still worth using rather than asking again. /// 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; return false;
} }
// A hand-picked match does not go stale: nothing a re-ask could learn // 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() { let ttl = if self.metadata.is_some() {
MATCH_TTL_SECS MATCH_TTL_SECS
} else { } else if chain.last() == Some(&self.provider) {
MISS_TTL_SECS MISS_TTL_SECS
} else {
return false;
}; };
now.saturating_sub(self.fetched_at) < ttl now.saturating_sub(self.fetched_at) < ttl
} }
@@ -539,6 +657,57 @@ impl Default for MetadataConfig {
mod tests { mod tests {
use super::*; 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 { fn metadata() -> TitleMetadata {
TitleMetadata { TitleMetadata {
provider: ProviderId::AniList, provider: ProviderId::AniList,
@@ -550,6 +719,7 @@ mod tests {
kind: TitleKind::Series, kind: TitleKind::Series,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: Vec::new(), genres: Vec::new(),
episodes: Some(26), episodes: Some(26),
@@ -569,6 +739,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
} }
} }
@@ -752,13 +924,13 @@ mod tests {
fetched_at: now - MISS_TTL_SECS - 1, fetched_at: now - MISS_TTL_SECS - 1,
manual: false, manual: false,
}; };
assert!(!miss.is_fresh(now, ProviderId::AniList)); assert!(!miss.is_fresh(now, &[ProviderId::AniList]));
let matched = MetadataRecord { let matched = MetadataRecord {
metadata: Some(metadata()), metadata: Some(metadata()),
..miss.clone() ..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 /// What the viewer picked by hand outlives every TTL: it is not an answer
@@ -772,9 +944,9 @@ mod tests {
fetched_at: 0, fetched_at: 0,
manual: true, 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. // 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 — /// 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, fetched_at: 1_000_000_000,
manual: false, 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);
}
}
+240 -12
View File
@@ -22,8 +22,9 @@ pub struct ParsedName {
/// Release year, from a parenthesised or bracketed four-digit group. /// Release year, from a parenthesised or bracketed four-digit group.
pub year: Option<u32>, pub year: Option<u32>,
/// The episode's own name, when the filename states one after the /// The episode's own name, when the filename states one after the
/// numbering (`S01E01-Mother and Children`). Never the quality and codec /// numbering (`S01E01-Mother and Children`, `S01E01.Winter.Is.Coming.2160p`).
/// tags that follow numbering in scene releases — see [`episode_title`]. /// Never the quality and codec tags that follow numbering in scene
/// releases — see [`episode_title`].
pub episode_title: Option<String>, pub episode_title: Option<String>,
} }
@@ -98,17 +99,21 @@ struct NumberingMatch {
/// The episode's own name out of what followed the numbering. /// The episode's own name out of what followed the numbering.
/// ///
/// Only taken when a dash introduces it. Scene releases separate their quality, /// Taken when a dash introduces it, or from a scene release's dotted words up
/// source and codec tags with the same character as their title words (`.`), so /// to its first release tag. Scene releases separate their quality, source and
/// without that requirement `Show.Name.S02E05.1080p.WEB-DL.x265` would yield an /// codec tags with the same character as their title words (`.`), so the tag
/// "episode title" of `1080p WEB-DL x265`. /// 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> { fn episode_title(tail: &str) -> Option<String> {
let rest = tail.trim_start(); let rest = tail.trim_start();
let rest = rest let cleaned = match rest
.strip_prefix('-') .strip_prefix('-')
.or_else(|| rest.strip_prefix('\u{2013}'))?; .or_else(|| rest.strip_prefix('\u{2013}'))
{
let cleaned = clean_title(rest, None); Some(rest) => clean_title(rest, None),
None => dotted_title(rest.strip_prefix('.')?)?,
};
let cleaned = cleaned.trim().to_string(); let cleaned = cleaned.trim().to_string();
if cleaned.is_empty() { if cleaned.is_empty() {
None 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. /// 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 /// 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') { if is_token_char(tail) && tail != Some(b'v') {
continue; continue;
} }
if is_decimal_tail(text, after) {
continue;
}
found = Some(NumberingMatch { found = Some(NumberingMatch {
numbering: Numbering { numbering: Numbering {
season: None, season: None,
@@ -551,6 +663,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
found 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. /// 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 /// 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 /// Normalize the title span: separators to spaces, then trim the debris that
/// sits between the title and whatever followed it. /// sits between the title and whatever followed it.
fn clean_title(raw: &str, year: Option<u32>) -> String { 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 let mut text: String = raw
.chars() .char_indices()
.map(|c| match c { .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, other => other,
}) })
@@ -856,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. /// A version suffix still names the same episode.
#[test] #[test]
fn a_versioned_fansub_episode_keeps_its_number() { fn a_versioned_fansub_episode_keeps_its_number() {
@@ -1042,6 +1221,55 @@ mod tests {
assert_eq!(p.title, "Show Name"); 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. /// A library that names its files with nothing but the episode marker.
#[test] #[test]
fn a_bare_episode_marker_is_read_as_an_episode() { 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 /// everything at 1.25, and a speed that reset every episode would be a
/// setting nobody could use. /// setting nobody could use.
pub speed: f64, 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 { impl Default for PlaybackPrefs {
@@ -73,6 +278,7 @@ impl Default for PlaybackPrefs {
autoplay_next: true, autoplay_next: true,
auto_skip: false, auto_skip: false,
speed: 1.0, speed: 1.0,
subtitle_style: SubtitleStyle::default(),
} }
} }
} }
@@ -93,6 +299,7 @@ impl PlaybackPrefs {
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED); self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty()); 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_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_style = self.subtitle_style.sanitized();
self self
} }
} }
@@ -149,6 +356,16 @@ mod tests {
autoplay_next: false, autoplay_next: false,
auto_skip: true, auto_skip: true,
speed: 1.5, 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(); save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs); assert_eq!(load(&dirs).unwrap(), prefs);
@@ -156,6 +373,46 @@ mod tests {
std::fs::remove_dir_all(&temp).ok(); 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] #[test]
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() { fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
let prefs = PlaybackPrefs { let prefs = PlaybackPrefs {
+96 -6
View File
@@ -1,6 +1,6 @@
//! The rest of what makes the app look the same on every device: the shares, //! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches and the per-show track //! the settings, the hand-picked metadata matches, the per-show track choices
//! choices. Synced next to watch history by [`crate::sync`], in the same file. //! and My List. Synced next to watch history by [`crate::sync`], in the same file.
//! //!
//! ## Registers //! ## Registers
//! //!
@@ -8,8 +8,8 @@
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it //! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer //! 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 //! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share is a register with no value, so a //! number of devices. A removed share, or a title taken off My List, is a
//! removal travels like any other change. //! register with no value, so a removal travels like any other change.
//! //!
//! ## Noticing changes //! ## Noticing changes
//! //!
@@ -56,6 +56,7 @@ use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/"; const SHARE: &str = "share/";
const TRACKS: &str = "tracks/"; const TRACKS: &str = "tracks/";
const MATCH: &str = "match/"; const MATCH: &str = "match/";
const LIST: &str = "list/";
/// The settings files that travel, by register, and the fields in each that /// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them. /// stay on the device that wrote them.
@@ -98,6 +99,12 @@ struct SyncedShare {
folder: Option<AccountFolder>, 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 /// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway. /// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -123,6 +130,8 @@ pub struct ProfileChanges {
pub settings: bool, pub settings: bool,
/// Hand-picked matches and per-show track choices taken. /// Hand-picked matches and per-show track choices taken.
pub titles: usize, pub titles: usize,
/// Whether My List changed.
pub list: bool,
} }
impl ProfileChanges { impl ProfileChanges {
@@ -154,6 +163,8 @@ struct Observed {
/// Whether the share list itself could be read. Without it, a share /// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed. /// missing from `values` says nothing about whether it was removed.
shares_listed: bool, shares_listed: bool,
/// The same for My List.
list_read: bool,
} }
fn state_file(dirs: &AppDirs) -> PathBuf { fn state_file(dirs: &AppDirs) -> PathBuf {
@@ -248,6 +259,18 @@ impl Profile<'_> {
} }
} }
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(); let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() { if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks { for (title, prefs) in tracks {
@@ -320,6 +343,19 @@ impl Profile<'_> {
} }
return Ok(()); 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 { if key == TMDB_KEY {
match value.and_then(Value::as_str) { match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?, Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
@@ -428,9 +464,10 @@ impl Profile<'_> {
/// Record what changed on this device since the last sync, stamped `now`. /// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) { fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() { 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() if register.value.is_some()
&& key.starts_with(SHARE) && removable
&& observed.shares_listed
&& !observed.values.contains_key(key) && !observed.values.contains_key(key)
&& !observed.unknown.contains(key) && !observed.unknown.contains(key)
{ {
@@ -747,6 +784,7 @@ pub(crate) mod tests {
kind: crate::library::TitleKind::Series, kind: crate::library::TitleKind::Series,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
@@ -788,6 +826,58 @@ pub(crate) mod tests {
); );
} }
#[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] #[test]
fn a_synced_share_that_names_another_id_is_refused() { fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone"); let phone = Device::new("phone");
+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));
}
}
+62
View File
@@ -135,6 +135,46 @@ pub fn pick(
picked 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -168,6 +208,7 @@ mod tests {
kind: TitleKind::Film, kind: TitleKind::Film,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: genres.iter().map(|genre| genre.to_string()).collect(), genres: genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None, episodes: None,
@@ -258,4 +299,25 @@ mod tests {
assert_eq!(shelves[0].label, "Airing now"); assert_eq!(shelves[0].label, "Airing now");
assert_eq!(shelves[0].keys, vec!["a"]); 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"]);
}
} }
+201 -7
View File
@@ -18,7 +18,8 @@ use std::collections::HashSet;
use pstr_core::library::{self, TitleKind}; use pstr_core::library::{self, TitleKind};
use pstr_core::metadata::{ use pstr_core::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata, CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
Trailer,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -27,6 +28,7 @@ use crate::error::{Error, Result};
use crate::limiter::RateLimiter; use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query}; use crate::matching::{Candidate, Query};
use crate::provider::Provider; use crate::provider::Provider;
use crate::tmdb::{Tmdb, TmdbId};
const ENDPOINT: &str = "https://graphql.anilist.co"; 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. /// Everything one entry is asked for, by search and by id alike.
/// ///
/// The relations are the franchise's — see `pstr_core::franchise` — and the /// 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 { macro_rules! entry_fragment {
() => { () => {
r#" r#"
@@ -77,11 +81,20 @@ fragment entry on Media {
genres genres
tags { name rank isMediaSpoiler } tags { name rank isMediaSpoiler }
episodes episodes
duration
format format
status status
siteUrl siteUrl
studios(isMain: true) { nodes { name } } studios(isMain: true) { nodes { name } }
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } } 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 } nextAiringEpisode { episode airingAt }
relations { edges { relationType node { id type } } } relations { edges { relationType node { id type } } }
} }
@@ -158,16 +171,20 @@ pub struct AniList {
/// Shared by every lookup in flight — see [`crate::limiter`]. /// Shared by every lookup in flight — see [`crate::limiter`].
limiter: RateLimiter, limiter: RateLimiter,
anizip: AniZip, anizip: AniZip,
/// Asked by id for the art TVDB has not got, when a TMDB key is stored.
tmdb: Option<Tmdb>,
} }
impl AniList { impl AniList {
/// `language` is the one episode names are wanted in, where ani.zip has /// `language` is the one episode names are wanted in, where ani.zip has
/// them in it. /// them in it. `tmdb` is asked for the art of entries TVDB has none for —
pub fn new(http: reqwest::Client, language: String) -> Self { /// see [`Tmdb::art`].
pub fn new(http: reqwest::Client, language: String, tmdb: Option<Tmdb>) -> Self {
Self { Self {
anizip: AniZip::new(http.clone(), language), anizip: AniZip::new(http.clone(), language),
http, http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE), 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 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 /// 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 /// 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. // The same ids as `visited`, in the order they were read.
let mut chain = Vec::new(); let mut chain = Vec::new();
let mut series_entries = 0; 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 { let mut next = Some(Link {
id: first, id: first,
series: found.kind == TitleKind::Series, series: found.kind == TitleKind::Series,
@@ -246,9 +266,15 @@ impl Provider for AniList {
series_entries series_entries
}); });
let mapping = self.anizip.mapping(link.id, entry_season).await?; let mapping = self.anizip.mapping(link.id, entry_season).await?;
if chain.len() == 1 {
tmdb_id = mapping.tmdb;
}
if enrichment.backdrop_url.is_none() { if enrichment.backdrop_url.is_none() {
enrichment.backdrop_url = mapping.fanart; 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 // Where two entries both list an episode, the earlier one — the
// one closer to the match — keeps it. // one closer to the match — keeps it.
enrichment.episodes.extend( enrichment.episodes.extend(
@@ -264,6 +290,14 @@ impl Provider for AniList {
next = self.sequel(link.id, &visited).await?; 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!( tracing::debug!(
"{}: {} episodes over {} entries", "{}: {} episodes over {} entries",
found.name, found.name,
@@ -308,7 +342,6 @@ impl AniList {
Ok(next_in_chain(edges, visited)) Ok(next_in_chain(edges, visited))
} }
/// One GraphQL requestimpl AniList {
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP /// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
/// status and an `errors` array under a 200 — folded into one. /// 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 /// 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 /// after the name as written found nothing, which is what keeps it from
/// broadening a search that was already working. /// 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> { fn search_terms(name: &str) -> Vec<String> {
let mut terms = vec![name.to_string()]; let mut terms = vec![name.to_string()];
@@ -386,9 +425,31 @@ fn search_terms(name: &str) -> Vec<String> {
if depossessed != name { if depossessed != name {
terms.push(depossessed); terms.push(depossessed);
} }
let unversioned = without_version_numbers(name);
if !unversioned.is_empty() && !terms.contains(&unversioned) {
terms.push(unversioned);
}
terms 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`. /// `Heavens Feel` → `Heaven Feel`.
/// ///
/// Only words long enough to still mean something without it, and only where /// Only words long enough to still mean something without it, and only where
@@ -532,6 +593,8 @@ struct Media {
#[serde(default)] #[serde(default)]
tags: Vec<Tag>, tags: Vec<Tag>,
episodes: Option<u32>, episodes: Option<u32>,
/// Minutes per episode, or the film's running time.
duration: Option<u32>,
format: Option<String>, format: Option<String>,
season: Option<String>, season: Option<String>,
status: Option<String>, status: Option<String>,
@@ -539,6 +602,8 @@ struct Media {
site_url: Option<String>, site_url: Option<String>,
studios: Option<Studios>, studios: Option<Studios>,
staff: Option<Staff>, staff: Option<Staff>,
characters: Option<Characters>,
trailer: Option<AniTrailer>,
#[serde(rename = "nextAiringEpisode")] #[serde(rename = "nextAiringEpisode")]
next_airing_episode: Option<NextAiring>, next_airing_episode: Option<NextAiring>,
relations: Option<Relations>, relations: Option<Relations>,
@@ -584,6 +649,32 @@ struct StaffEdge {
#[derive(Deserialize)] #[derive(Deserialize)]
struct Person { struct Person {
name: PersonName, 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)] #[derive(Deserialize)]
@@ -706,6 +797,37 @@ impl Media {
.collect(), .collect(),
popularity: self.popularity, popularity: self.popularity,
status: self.status, 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_episode: self
.next_airing_episode .next_airing_episode
.as_ref() .as_ref()
@@ -746,6 +868,8 @@ impl Media {
// backdrop is ani.zip's fanart, found when the match is // backdrop is ani.zip's fanart, found when the match is
// enriched. // enriched.
backdrop_url: None, backdrop_url: None,
// ani.zip's too, like the fanart.
logo_url: None,
rating: self.average_score.map(|score| score / SCORE_SCALE), rating: self.average_score.map(|score| score / SCORE_SCALE),
genres: self.genres, genres: self.genres,
episodes: self.episodes, 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>` /// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become /// mostly, and the occasional `<i>`. egui draws no markup, so tags become
/// nothing and `<br>` becomes the line break it stands for. /// 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 let text = text
.replace("<br>", "\n") .replace("<br>", "\n")
.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. /// Byte slicing, on names that are not ASCII.
#[test] #[test]
fn a_multi_byte_name_is_not_cut_inside_a_character() { 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")); 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 { fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
RelationEdge { RelationEdge {
relation_type: Some(relation.into()), relation_type: Some(relation.into()),
@@ -971,6 +1163,8 @@ mod tests {
overview: None, overview: None,
still_url: None, still_url: None,
air_date: None, air_date: None,
runtime: None,
mal_id: None,
}; };
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)]; let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)])); assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
+139
View File
@@ -0,0 +1,139 @@
//! AniSkip: where an episode's opening and ending are, as its viewers marked
//! them.
//!
//! For the files that come with no chapters saying so — which is most of what
//! is not a fansub release. Asked once per episode, when it starts playing, by
//! MyAnimeList id and episode number, with the file's length so the answer is
//! one marked against a cut of the same length. Nothing is stored: the answer
//! is the player's for as long as the file is open. Like ani.zip it needs no
//! key and no account, and it learns nothing about the library that the match
//! did not already send AniList.
use pstr_core::chapters::{ChapterRole, SkipRange};
use serde::Deserialize;
use crate::error::{Error, Result};
const ENDPOINT: &str = "https://api.aniskip.com/v2/skip-times";
/// Where `episode` of the MyAnimeList entry `mal_id` has its opening and its
/// ending, in a cut `length` seconds long. Empty when nobody has marked it.
pub async fn skip_times(
http: &reqwest::Client,
mal_id: u32,
episode: u32,
length: f64,
) -> Result<Vec<SkipRange>> {
let response = http
.get(format!("{ENDPOINT}/{mal_id}/{episode}"))
.query(&[
("types", "op"),
("types", "ed"),
("episodeLength", &format!("{length:.0}")),
])
.send()
.await?;
let status = response.status();
// "No skip times found" comes back as a 404 with a body saying so.
if status.as_u16() == 404 {
return Ok(Vec::new());
}
if !status.is_success() {
return Err(Error::Http(format!("AniSkip answered {status}")));
}
let payload: Payload = response.json().await?;
Ok(payload.ranges())
}
#[derive(Deserialize, Default)]
struct Payload {
#[serde(default)]
found: bool,
#[serde(default)]
results: Vec<Skip>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Skip {
interval: Interval,
skip_type: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Interval {
start_time: f64,
end_time: f64,
}
impl Payload {
fn ranges(self) -> Vec<SkipRange> {
if !self.found {
return Vec::new();
}
self.results
.into_iter()
.filter_map(|skip| {
let role = match skip.skip_type.as_str() {
"op" => ChapterRole::Opening,
"ed" => ChapterRole::Ending,
_ => return None,
};
(skip.interval.end_time > skip.interval.start_time).then_some(SkipRange {
role,
start: skip.interval.start_time.max(0.0),
end: skip.interval.end_time,
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_answer_parses_into_an_opening_and_an_ending() {
let payload: Payload = serde_json::from_str(
r#"{"found":true,"results":[
{"interval":{"startTime":1.35,"endTime":91.35},"skipType":"op",
"skipId":"9a85542c","episodeLength":1469.979},
{"interval":{"startTime":1367,"endTime":1459},"skipType":"ed",
"skipId":"f6f905e7","episodeLength":1471}],
"message":"Successfully found skip times","statusCode":200}"#,
)
.expect("parse");
assert_eq!(
payload.ranges(),
vec![
SkipRange {
role: ChapterRole::Opening,
start: 1.35,
end: 91.35,
},
SkipRange {
role: ChapterRole::Ending,
start: 1367.0,
end: 1459.0,
},
]
);
}
#[test]
fn nothing_found_and_kinds_not_asked_for_parse_to_nothing() {
let none: Payload = serde_json::from_str(
r#"{"found":false,"results":[],"message":"No skip times found","statusCode":404}"#,
)
.expect("parse");
assert!(none.ranges().is_empty());
let recap: Payload = serde_json::from_str(
r#"{"found":true,"results":[{"interval":{"startTime":0,"endTime":60},"skipType":"recap"}]}"#,
)
.expect("parse");
assert!(recap.ranges().is_empty());
}
}
+112 -9
View File
@@ -20,6 +20,7 @@ use serde::Deserialize;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::limiter::RateLimiter; use crate::limiter::RateLimiter;
use crate::tmdb::TmdbId;
const ENDPOINT: &str = "https://api.ani.zip/mappings"; const ENDPOINT: &str = "https://api.ani.zip/mappings";
@@ -36,6 +37,10 @@ pub struct Mapping {
pub episodes: Vec<EpisodeMetadata>, pub episodes: Vec<EpisodeMetadata>,
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080. /// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
pub fanart: Option<String>, pub fanart: Option<String>,
/// TVDB's clear logo: the series' name as lettering, a transparent PNG.
pub logo: Option<String>,
/// The same entry on TMDB, where TVDB has none of the films.
pub tmdb: Option<TmdbId>,
} }
pub struct AniZip { pub struct AniZip {
@@ -90,6 +95,34 @@ struct Payload {
episodes: std::collections::BTreeMap<String, Episode>, episodes: std::collections::BTreeMap<String, Episode>,
#[serde(default)] #[serde(default)]
images: Vec<Image>, images: Vec<Image>,
mappings: Option<Ids>,
}
/// The entry's ids elsewhere.
#[derive(Deserialize)]
struct Ids {
/// AniList's format, upper-cased: `TV`, `MOVIE`, `OVA`…
#[serde(rename = "type")]
kind: Option<String>,
#[serde(default, deserialize_with = "lenient_number")]
themoviedb_id: Option<u32>,
/// The same entry on MyAnimeList, which AniSkip is keyed by.
#[serde(default, deserialize_with = "lenient_number")]
mal_id: Option<u32>,
}
impl Ids {
/// The TMDB entry, where its kind says which list the id is from. An OVA
/// or a special is filed either way on TMDB, so it is left alone rather
/// than guessed.
fn tmdb(&self) -> Option<TmdbId> {
let id = self.themoviedb_id?;
match self.kind.as_deref()? {
"MOVIE" => Some(TmdbId::Movie(id)),
"TV" | "TV_SHORT" | "ONA" => Some(TmdbId::Tv(id)),
_ => None,
}
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -108,6 +141,12 @@ struct Episode {
summary: Option<String>, summary: Option<String>,
image: Option<String>, image: Option<String>,
air_date: Option<String>, air_date: Option<String>,
/// TVDB's running time, in minutes.
#[serde(default, deserialize_with = "lenient_number")]
runtime: Option<u32>,
/// AniDB's, which is there when TVDB's is not.
#[serde(default, deserialize_with = "lenient_number")]
length: Option<u32>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -119,11 +158,16 @@ struct Image {
impl Payload { impl Payload {
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping { fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
let fanart = self let image = |kind: &str| {
.images self.images
.into_iter() .iter()
.find(|image| image.cover_type.as_deref() == Some("Fanart")) .find(|image| image.cover_type.as_deref() == Some(kind))
.and_then(|image| image.url); .and_then(|image| text(image.url.clone()))
};
let fanart = image("Fanart");
let logo = image("Clearlogo");
let tmdb = self.mappings.as_ref().and_then(Ids::tmdb);
let mal_id = self.mappings.as_ref().and_then(|ids| ids.mal_id);
let episodes = self let episodes = self
.episodes .episodes
@@ -150,11 +194,21 @@ impl Payload {
overview: text(episode.overview).or_else(|| text(episode.summary)), overview: text(episode.overview).or_else(|| text(episode.summary)),
still_url: text(episode.image), still_url: text(episode.image),
air_date: text(episode.air_date), air_date: text(episode.air_date),
runtime: episode
.runtime
.or(episode.length)
.filter(|minutes| *minutes > 0),
mal_id,
}) })
}) })
.collect(); .collect();
Mapping { episodes, fanart } Mapping {
episodes,
fanart,
logo,
tmdb,
}
} }
} }
@@ -206,15 +260,16 @@ mod tests {
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"}, "title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
"overview": "Titans appear inside Wall Rose.", "overview": "Titans appear inside Wall Rose.",
"image": "https://artworks.thetvdb.com/still.jpg", "image": "https://artworks.thetvdb.com/still.jpg",
"airDate": "2017-04-01"}, "airDate": "2017-04-01", "runtime": 24, "length": 25},
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1", "S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
"title": {"en": "TBA", "x-jat": "Ilse no Techou"}, "title": {"en": "TBA", "x-jat": "Ilse no Techou"},
"summary": "An OVA."}, "summary": "An OVA.", "length": "30"},
"2": {"episode": "2", "title": {"en": "Unplaced"}} "2": {"episode": "2", "title": {"en": "Unplaced"}}
}, },
"images": [ "images": [
{"coverType": "Banner", "url": "banner.jpg"}, {"coverType": "Banner", "url": "banner.jpg"},
{"coverType": "Fanart", "url": "fanart.jpg"} {"coverType": "Fanart", "url": "fanart.jpg"},
{"coverType": "Clearlogo", "url": "clearlogo.png"}
] ]
}"#; }"#;
@@ -241,6 +296,8 @@ mod tests {
Some("Titans appear inside Wall Rose.") Some("Titans appear inside Wall Rose.")
); );
assert_eq!(first.air_date.as_deref(), Some("2017-04-01")); assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
// TVDB's running time wins over AniDB's.
assert_eq!(first.runtime, Some(24));
} }
/// A special is numbered in strings and filed under season zero, and no /// A special is numbered in strings and filed under season zero, and no
@@ -260,6 +317,8 @@ mod tests {
assert_eq!(special.name.as_deref(), Some("Ilse no Techou")); assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
// AniDB's summary stands in for TVDB's missing overview. // AniDB's summary stands in for TVDB's missing overview.
assert_eq!(special.overview.as_deref(), Some("An OVA.")); assert_eq!(special.overview.as_deref(), Some("An OVA."));
// And AniDB's length for TVDB's missing running time.
assert_eq!(special.runtime, Some(30));
} }
#[test] #[test]
@@ -285,6 +344,11 @@ mod tests {
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg")); assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
} }
#[test]
fn the_clear_logo_is_kept() {
assert_eq!(mapping(None).logo.as_deref(), Some("clearlogo.png"));
}
#[test] #[test]
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() { fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
let mapping = serde_json::from_str::<Payload>("{}") let mapping = serde_json::from_str::<Payload>("{}")
@@ -292,5 +356,44 @@ mod tests {
.into_mapping("en", Some(1)); .into_mapping("en", Some(1));
assert!(mapping.episodes.is_empty()); assert!(mapping.episodes.is_empty());
assert_eq!(mapping.fanart, None); assert_eq!(mapping.fanart, None);
assert_eq!(mapping.logo, None);
assert_eq!(mapping.tmdb, None);
}
/// Paprika: a film with no TVDB art at all, and a TMDB id written as a
/// string.
#[test]
fn a_films_tmdb_id_is_kept_as_a_film() {
let mapping = serde_json::from_str::<Payload>(
r#"{"images": [], "mappings": {"type": "MOVIE", "themoviedb_id": "4977"}}"#,
)
.expect("parse")
.into_mapping("en", None);
assert_eq!(mapping.tmdb, Some(TmdbId::Movie(4977)));
}
#[test]
fn every_episode_carries_its_entrys_myanimelist_id() {
let mapping = serde_json::from_str::<Payload>(
r#"{"episodes": {"1": {"seasonNumber": 1, "episodeNumber": 1}},
"mappings": {"type": "TV", "mal_id": 52991}}"#,
)
.expect("parse")
.into_mapping("en", Some(1));
assert_eq!(mapping.episodes[0].mal_id, Some(52991));
}
#[test]
fn a_series_tmdb_id_is_kept_and_a_specials_is_not() {
let tmdb = |kind: &str| {
serde_json::from_str::<Payload>(&format!(
r#"{{"mappings": {{"type": "{kind}", "themoviedb_id": 209867}}}}"#
))
.expect("parse")
.into_mapping("en", Some(1))
.tmdb
};
assert_eq!(tmdb("TV"), Some(TmdbId::Tv(209867)));
assert_eq!(tmdb("OVA"), None);
} }
} }
+4 -2
View File
@@ -8,10 +8,10 @@
//! //!
//! ```text //! ```text
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best //! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
//! (anilist | tmdb) │ //! (anilist, then tvmaze | tmdb) │
//! ▼ //! ▼
//! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata //! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
//! (episodes, fanart) (anilist relations + ani.zip | tmdb) //! (episodes, fanart) (anilist relations + ani.zip | tvmaze | tmdb)
//! ``` //! ```
//! //!
//! Three things are worth knowing before touching any of it: //! Three things are worth knowing before touching any of it:
@@ -29,6 +29,7 @@
//! without either depending on this crate. //! without either depending on this crate.
pub mod anilist; pub mod anilist;
pub mod aniskip;
pub mod anizip; pub mod anizip;
pub mod error; pub mod error;
pub mod limiter; pub mod limiter;
@@ -37,6 +38,7 @@ pub mod provider;
pub mod service; pub mod service;
pub mod settings; pub mod settings;
pub mod tmdb; pub mod tmdb;
pub mod tvmaze;
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use matching::{Candidate, Query}; pub use matching::{Candidate, Query};
+1
View File
@@ -204,6 +204,7 @@ mod tests {
kind, kind,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
+357 -124
View File
@@ -5,8 +5,17 @@
//! │ miss / stale //! │ miss / stale
//! ▼ //! ▼
//! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord //! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord
//! │ no match
//! ▼
//! next provider in the chain, the same way
//! ``` //! ```
//! //!
//! The providers are asked in a chain: AniList, then TVmaze for what AniList
//! has not got. The first match wins and is stored under the provider that
//! gave it, so a library of anime with one live-action show in it is matched
//! against both without anyone choosing per title — and everything after a
//! match is asked of that match's own provider.
//!
//! Two rules govern what gets written back, and they are the difference between //! Two rules govern what gets written back, and they are the difference between
//! a library that settles down and one that hammers a third party forever: //! a library that settles down and one that hammers a third party forever:
//! //!
@@ -25,7 +34,8 @@ use std::sync::Arc;
use pstr_core::library::{Title, TitleKind, title_key}; use pstr_core::library::{Title, TitleKind, title_key};
use pstr_core::metadata::{ use pstr_core::metadata::{
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleDetails,
TitleMetadata,
}; };
use crate::anilist::AniList; use crate::anilist::AniList;
@@ -33,6 +43,7 @@ use crate::error::{Error, Result};
use crate::matching::{self, Query}; use crate::matching::{self, Query};
use crate::provider::Provider; use crate::provider::Provider;
use crate::tmdb::Tmdb; use crate::tmdb::Tmdb;
use crate::tvmaze::TvMaze;
/// How long to wait on a provider. /// How long to wait on a provider.
/// ///
@@ -43,10 +54,11 @@ const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Identifies this client to the providers, which both ask for it. /// Identifies this client to the providers, which both ask for it.
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The configured provider, resolved. /// One provider in the chain, resolved.
enum Source { enum Source {
AniList(AniList), AniList(AniList),
Tmdb(Tmdb), Tmdb(Tmdb),
TvMaze(TvMaze),
} }
impl Source { impl Source {
@@ -54,6 +66,7 @@ impl Source {
match self { match self {
Self::AniList(provider) => provider.id(), Self::AniList(provider) => provider.id(),
Self::Tmdb(provider) => provider.id(), Self::Tmdb(provider) => provider.id(),
Self::TvMaze(provider) => provider.id(),
} }
} }
@@ -61,78 +74,153 @@ impl Source {
match self { match self {
Self::AniList(provider) => provider.search(query).await, Self::AniList(provider) => provider.search(query).await,
Self::Tmdb(provider) => provider.search(query).await, Self::Tmdb(provider) => provider.search(query).await,
Self::TvMaze(provider) => provider.search(query).await,
} }
} }
/// The entry a stored match names, fetched again by its id. `None` from a /// The entry a stored match names, fetched again by its id. `None` from a
/// provider that has no details to add. /// provider that has no details to add — see [`gives_details`].
async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> { async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
match self { match self {
Self::AniList(provider) => provider.entry(id).await, Self::AniList(provider) => provider.entry(id).await,
Self::TvMaze(provider) => provider.entry(id).await,
Self::Tmdb(_) => Ok(None), Self::Tmdb(_) => Ok(None),
} }
} }
/// Whether [`Source::entry`] has anything to give.
fn has_details(&self) -> bool {
matches!(self, Self::AniList(_))
}
async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> { async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
match self { match self {
Self::AniList(provider) => provider.enrich(title, found).await, Self::AniList(provider) => provider.enrich(title, found).await,
Self::Tmdb(provider) => provider.enrich(title, found).await, Self::Tmdb(provider) => provider.enrich(title, found).await,
Self::TvMaze(provider) => provider.enrich(title, found).await,
} }
} }
} }
/// Metadata lookups, against one provider. /// The providers asked when the viewer chose `chosen`, in order.
///
/// AniList is followed by TVmaze, which has the television AniList does not
/// and needs no key either. TMDB has that television itself, so it is asked
/// alone. A stored answer from a provider outside the chain is the previous
/// choice's, and is re-asked.
pub fn chain_of(chosen: ProviderId) -> &'static [ProviderId] {
match chosen {
ProviderId::AniList => &[ProviderId::AniList, ProviderId::TvMaze],
ProviderId::Tmdb => &[ProviderId::Tmdb],
ProviderId::TvMaze => &[ProviderId::TvMaze],
}
}
/// [`chain_of`] `chosen`, for a sentence: `AniList and TVmaze`.
pub fn chain_label(chosen: ProviderId) -> String {
chain_of(chosen)
.iter()
.map(|provider| provider.label())
.collect::<Vec<_>>()
.join(" and ")
}
/// Whether `provider` fills in [`TitleMetadata::details`], and so whether a
/// match it gave without them, or with an older revision of them, is worth
/// fetching again.
fn gives_details(provider: ProviderId) -> bool {
matches!(provider, ProviderId::AniList | ProviderId::TvMaze)
}
/// Metadata lookups, against a chain of providers.
/// ///
/// Cheap to clone — the HTTP client inside is a connection pool that wants /// Cheap to clone — the HTTP client inside is a connection pool that wants
/// sharing, not duplicating. /// sharing, not duplicating.
#[derive(Clone)] #[derive(Clone)]
pub struct MetadataService { pub struct MetadataService {
source: Arc<Source>, /// In the order they are asked. Never empty.
sources: Arc<[Source]>,
http: reqwest::Client, http: reqwest::Client,
} }
impl MetadataService { impl MetadataService {
/// Build a service for `config`. /// Build a service for `config`, asking [`chain_of`] its provider.
/// ///
/// `api_key` is only consulted for a provider that needs one; for AniList it /// `tmdb_key` is the stored TMDB key, if there is one. TMDB as the provider
/// is ignored, and passing `None` there is not an error. /// cannot do without it; AniList uses one for the art of films TVDB has
pub fn new(config: &MetadataConfig, api_key: Option<String>) -> Result<Self> { /// none for, and does without it otherwise.
pub fn new(config: &MetadataConfig, tmdb_key: Option<String>) -> Result<Self> {
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.user_agent(USER_AGENT) .user_agent(USER_AGENT)
.timeout(TIMEOUT) .timeout(TIMEOUT)
.build() .build()
.map_err(Error::Network)?; .map_err(Error::Network)?;
let source = match config.provider { let tmdb_key = tmdb_key.filter(|key| !key.trim().is_empty());
ProviderId::AniList => { let tmdb = || {
Source::AniList(AniList::new(http.clone(), config.language.clone())) tmdb_key
} .clone()
ProviderId::Tmdb => Source::Tmdb(Tmdb::new( .map(|key| Tmdb::new(http.clone(), key, config.language.clone()))
http.clone(), .ok_or(Error::MissingApiKey(ProviderId::Tmdb))
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
config.language.clone(),
)),
}; };
let sources = chain_of(config.provider)
.iter()
.map(|provider| {
Ok(match provider {
ProviderId::AniList => Source::AniList(AniList::new(
http.clone(),
config.language.clone(),
tmdb().ok(),
)),
ProviderId::Tmdb => Source::Tmdb(tmdb()?),
ProviderId::TvMaze => Source::TvMaze(TvMaze::new(http.clone())),
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Self { Ok(Self {
source: Arc::new(source), sources: sources.into(),
http, http,
}) })
} }
pub fn provider(&self) -> ProviderId { /// Where AniSkip says an episode's opening and ending are. See
self.source.id() /// [`crate::aniskip`]; on the service so that it is only ever asked with
/// enrichment on, like every other source.
pub async fn skip_times(
&self,
mal_id: u32,
episode: u32,
length: f64,
) -> Result<Vec<pstr_core::chapters::SkipRange>> {
crate::aniskip::skip_times(&self.http, mal_id, episode, length).await
} }
/// Ask the provider about one title. /// The provider the viewer chose: the first one asked.
pub fn provider(&self) -> ProviderId {
self.sources[0].id()
}
/// Every provider asked, in order. What [`plan`] and
/// [`MetadataRecord::is_fresh`] judge a stored answer against.
pub fn chain(&self) -> Vec<ProviderId> {
self.sources.iter().map(Source::id).collect()
}
/// The chain, for a status line: `AniList and TVmaze`.
pub fn label(&self) -> String {
chain_label(self.provider())
}
fn source(&self, provider: ProviderId) -> Result<&Source> {
self.sources
.iter()
.find(|source| source.id() == provider)
.ok_or_else(|| Error::Config(format!("{} is not in use", provider.label())))
}
/// Ask the chain about one title, stopping at the first match.
/// ///
/// `Ok(None)` is a real answer — nothing matched well enough — and the /// `Ok(None)` is a real answer — nothing matched well enough anywhere —
/// caller should store it. An `Err` is not, and the caller should not. /// and the caller should store it. An `Err` is not, and the caller should
/// not: a provider that could not be asked has not said it lacks the
/// title, and settling for the next one's answer would store a guess in
/// its place.
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> { pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
let mut query = Query::new(title.name.clone(), title.year, title.kind); let mut query = Query::new(title.name.clone(), title.year, title.kind);
// Nothing in the files numbered itself, so the kind came from counting // Nothing in the files numbered itself, so the kind came from counting
@@ -140,34 +228,48 @@ impl MetadataService {
if !title.states_its_numbering() { if !title.states_its_numbering() {
query = query.with_guessed_kind(); query = query.with_guessed_kind();
} }
let candidates = self.source.search(&query).await?; for source in self.sources.iter() {
let found = candidates.len(); let candidates = source.search(&query).await?;
let best = matching::best(&query, candidates); let found = candidates.len();
let best = matching::best(&query, candidates);
tracing::debug!( tracing::debug!(
"{}: {found} candidates from {}, {}", "{}: {found} candidates from {}, {}",
title.name, title.name,
self.provider().label(), source.id().label(),
match &best { match &best {
Some(found) => format!("matched {:?}", found.name), Some(found) => format!("matched {:?}", found.name),
None => "no match".to_string(), None => "no match".to_string(),
}
);
if best.is_some() {
return Ok(best);
} }
); }
Ok(best) Ok(None)
} }
/// The same lookup, as a record ready to store — misses included. /// The same lookup, as a record ready to store — misses included.
///
/// A match is stored under the provider that gave it; a miss under the
/// last one asked, which is what makes it stand — see
/// [`MetadataRecord::is_fresh`].
pub async fn record(&self, title: &Title) -> Result<MetadataRecord> { pub async fn record(&self, title: &Title) -> Result<MetadataRecord> {
let metadata = self.lookup(title).await?; let metadata = self.lookup(title).await?;
Ok(MetadataRecord { Ok(MetadataRecord {
title_key: title_key(&title.name), title_key: title_key(&title.name),
provider: self.provider(), provider: metadata
.as_ref()
.map_or_else(|| self.last(), |found| found.provider),
metadata, metadata,
fetched_at: now(), fetched_at: now(),
manual: false, manual: false,
}) })
} }
fn last(&self) -> ProviderId {
self.sources[self.sources.len() - 1].id()
}
/// Everything the provider thinks `name` might be, unscored and in its own /// Everything the provider thinks `name` might be, unscored and in its own
/// order. /// order.
/// ///
@@ -182,39 +284,57 @@ impl MetadataService {
/// Takes a kind because the providers key their search on it, and no year: /// Takes a kind because the providers key their search on it, and no year:
/// the point of a hand search is that the library's own guesses are what /// the point of a hand search is that the library's own guesses are what
/// went wrong. /// went wrong.
///
/// Every provider in the chain is asked, the first one's answers first,
/// and each answer says which provider gave it. One that fails leaves its
/// answers out; only when all of them fail is that an error.
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> { pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> {
let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind(); let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
if query.name.is_empty() { if query.name.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let candidates = self.source.search(&query).await?; let mut options = Vec::new();
tracing::debug!( let mut failure = None;
"hand search {:?}: {} candidates from {}", for source in self.sources.iter() {
query.name, match source.search(&query).await {
candidates.len(), Ok(candidates) => {
self.provider().label() tracing::debug!(
); "hand search {:?}: {} candidates from {}",
Ok(candidates query.name,
.into_iter() candidates.len(),
.map(|candidate| candidate.metadata) source.id().label()
.collect()) );
options.extend(candidates.into_iter().map(|candidate| candidate.metadata));
}
Err(error) => {
tracing::warn!("hand search on {}: {error}", source.id().label());
failure.get_or_insert(error);
}
}
}
match failure {
Some(error) if options.is_empty() => Err(error),
_ => Ok(options),
}
} }
/// A record for an entry the viewer picked themselves. /// A record for an entry the viewer picked themselves, under the provider
/// it came from.
/// ///
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run /// Marked [`MetadataRecord::manual`], which is what keeps the next match run
/// — including a forced one — from undoing it. /// — including a forced one — from undoing it.
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord { pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
MetadataRecord { MetadataRecord {
title_key, title_key,
provider: self.provider(), provider: found.provider,
metadata: Some(found), metadata: Some(found),
fetched_at: now(), fetched_at: now(),
manual: true, manual: true,
} }
} }
/// The episodes of a matched title, and any art its match lacked. /// The episodes of a matched title, and any art its match lacked, from
/// the provider that matched it.
/// ///
/// Only ever called for a title that already has a match, because the /// Only ever called for a title that already has a match, because the
/// provider's own id is what it takes — there is no second search here, and /// provider's own id is what it takes — there is no second search here, and
@@ -224,12 +344,12 @@ impl MetadataService {
/// un-enriched and the next match run asks again. An empty answer is an /// un-enriched and the next match run asks again. An empty answer is an
/// answer, and is — see `Catalog::set_enrichment`. /// answer, and is — see `Catalog::set_enrichment`.
pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> { pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let enrichment = self.source.enrich(title, found).await?; let enrichment = self.source(found.provider)?.enrich(title, found).await?;
tracing::debug!( tracing::debug!(
"{}: {} episodes from {}{}", "{}: {} episodes from {}{}",
title.name, title.name,
enrichment.episodes.len(), enrichment.episodes.len(),
self.provider().label(), found.provider.label(),
if enrichment.backdrop_url.is_some() { if enrichment.backdrop_url.is_some() {
", with a backdrop" ", with a backdrop"
} else { } else {
@@ -261,8 +381,9 @@ pub enum Work {
Match(Title), Match(Title),
/// It is already matched; only its enrichment is missing or old. /// It is already matched; only its enrichment is missing or old.
Enrich(Title, Box<TitleMetadata>), Enrich(Title, Box<TitleMetadata>),
/// It is matched, but stored before [`TitleMetadata::details`] existed: /// It is matched, but stored before [`TitleMetadata::details`] existed or
/// fetch the same entry again by id — never searched for, so a match the /// by an older [`TitleDetails::REVISION`] of them: fetch the same entry
/// again by id — never searched for, so a match the
/// viewer picked by hand stays theirs — and enrich it too if `enrich`. /// viewer picked by hand stays theirs — and enrich it too if `enrich`.
Refresh { Refresh {
title: Title, title: Title,
@@ -283,6 +404,9 @@ impl Work {
#[derive(Debug)] #[derive(Debug)]
pub struct Outcome { pub struct Outcome {
pub title_key: String, pub title_key: String,
/// The provider the title is matched to, and so the one
/// [`Outcome::enrichment`] is stored under.
pub provider: ProviderId,
/// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it — /// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it —
/// misses included. /// misses included.
pub record: Option<MetadataRecord>, pub record: Option<MetadataRecord>,
@@ -298,44 +422,46 @@ pub struct Outcome {
/// already has a good match but has not been enriched — or was enriched longer /// already has a good match but has not been enriched — or was enriched longer
/// ago than a match is trusted — needs enriching only, and searching for it /// ago than a match is trusted — needs enriching only, and searching for it
/// again would ask the provider something it has already answered. One whose /// again would ask the provider something it has already answered. One whose
/// match predates its details is fetched again by id, which is one request /// match predates its details, or this build's revision of them, is fetched
/// and decides nothing. /// again by id, which is one request and decides nothing.
/// ///
/// A hand-picked entry is never searched for again, not even when `force`d — /// A hand-picked entry is never searched for again, not even when `force`d —
/// that means "the automatic answers are wrong", and re-deciding the one title /// that means "the automatic answers are wrong", and re-deciding the one title
/// the viewer already fixed by hand is the opposite of what they asked for. It /// the viewer already fixed by hand is the opposite of what they asked for. It
/// is still enriched and refreshed. /// is still enriched and refreshed, and `force` enriches it again.
/// ///
/// `details` is whether the provider has [`TitleMetadata::details`] to give /// `chain` is [`MetadataService::chain`]. Enriching and refreshing go to the
/// — see [`MetadataService::has_details`]. /// provider each title is matched to.
pub fn plan( pub fn plan(
titles: Vec<Title>, titles: Vec<Title>,
stored: &HashMap<String, MetadataRecord>, stored: &HashMap<String, MetadataRecord>,
enriched: &HashMap<String, (ProviderId, i64)>, enriched: &HashMap<String, (ProviderId, i64)>,
provider: ProviderId, chain: &[ProviderId],
details: bool,
force: bool, force: bool,
) -> Vec<Work> { ) -> Vec<Work> {
let now = now(); let now = now();
let mut work = Vec::new(); let mut work = Vec::new();
for title in titles { for title in titles {
let record = stored.get(&title.key); let record = stored.get(&title.key);
let pinned = record.is_some_and(|record| record.manual && record.provider == provider); let pinned = record.is_some_and(|record| record.manual && chain.contains(&record.provider));
if !pinned && (force || !is_usable(record, provider)) { if !pinned && (force || !is_usable(record, chain)) {
work.push(Work::Match(title)); work.push(Work::Match(title));
continue; continue;
} }
let Some(record) = record.filter(|record| record.metadata.is_some()) else { let Some(record) = record.filter(|record| record.metadata.is_some()) else {
continue; continue;
}; };
let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| { // Forced, a pinned title is enriched again even when it is fresh: art
*asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS // a newly saved key reaches is the usual reason to force.
}); let fresh = !force
let stale_details = details && enriched.get(&title.key).is_some_and(|(asked, at)| {
*asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS
});
let stale_details = gives_details(record.provider)
&& record && record
.metadata .metadata
.as_ref() .as_ref()
.is_some_and(|found| found.details.is_none()); .is_some_and(|found| !found.details.as_ref().is_some_and(TitleDetails::is_current));
if stale_details { if stale_details {
work.push(Work::Refresh { work.push(Work::Refresh {
title, title,
@@ -350,12 +476,6 @@ pub fn plan(
} }
impl MetadataService { impl MetadataService {
/// Whether this provider fills in [`TitleMetadata::details`], and so
/// whether a match stored without them is worth fetching again.
pub fn has_details(&self) -> bool {
self.source.has_details()
}
/// Do one piece of [`Work`]. /// Do one piece of [`Work`].
/// ///
/// A failed search is an `Err` and nothing is stored. A failed enrichment /// A failed search is an `Err` and nothing is stored. A failed enrichment
@@ -373,12 +493,14 @@ impl MetadataService {
}; };
Ok(Outcome { Ok(Outcome {
title_key: record.title_key.clone(), title_key: record.title_key.clone(),
provider: record.provider,
record: Some(record), record: Some(record),
enrichment, enrichment,
}) })
} }
Work::Enrich(title, found) => Ok(Outcome { Work::Enrich(title, found) => Ok(Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: found.provider,
record: None, record: None,
enrichment: Some(self.enrich(title, found).await?), enrichment: Some(self.enrich(title, found).await?),
}), }),
@@ -390,18 +512,22 @@ impl MetadataService {
let Some(stored) = &record.metadata else { let Some(stored) = &record.metadata else {
return Ok(Outcome { return Ok(Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: record.provider,
record: None, record: None,
enrichment: None, enrichment: None,
}); });
}; };
// An id the provider no longer has keeps what was stored, with // An id the provider no longer has keeps what was stored, its
// empty details so it is not asked about on every run. // details marked current so it is not asked about on every run.
let found = self let found = self
.source .source(stored.provider)?
.entry(&stored.remote_id) .entry(&stored.remote_id)
.await? .await?
.unwrap_or_else(|| TitleMetadata { .unwrap_or_else(|| TitleMetadata {
details: Some(Default::default()), details: Some(TitleDetails {
revision: TitleDetails::REVISION,
..stored.details.clone().unwrap_or_default()
}),
..stored.clone() ..stored.clone()
}); });
let enrichment = match enrich { let enrichment = match enrich {
@@ -410,6 +536,7 @@ impl MetadataService {
}; };
Ok(Outcome { Ok(Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: record.provider,
record: Some(MetadataRecord { record: Some(MetadataRecord {
metadata: Some(found), metadata: Some(found),
fetched_at: now(), fetched_at: now(),
@@ -432,6 +559,7 @@ impl MetadataService {
let enrichment = self.enrich_or_log(title, &found).await; let enrichment = self.enrich_or_log(title, &found).await;
Outcome { Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: found.provider,
record: Some(self.chosen(title.key.clone(), found)), record: Some(self.chosen(title.key.clone(), found)),
enrichment, enrichment,
} }
@@ -445,9 +573,9 @@ impl MetadataService {
} }
} }
/// Whether a stored answer can be used as-is. /// Whether a stored answer can be used as-is by `chain`.
pub fn is_usable(record: Option<&MetadataRecord>, provider: ProviderId) -> bool { pub fn is_usable(record: Option<&MetadataRecord>, chain: &[ProviderId]) -> bool {
record.is_some_and(|record| record.is_fresh(now(), provider)) record.is_some_and(|record| record.is_fresh(now(), chain))
} }
fn now() -> i64 { fn now() -> i64 {
@@ -469,6 +597,8 @@ mod tests {
..MetadataConfig::default() ..MetadataConfig::default()
}; };
assert!(MetadataService::new(&anilist, None).is_ok()); assert!(MetadataService::new(&anilist, None).is_ok());
// A stored TMDB key is used for art, not demanded.
assert!(MetadataService::new(&anilist, Some("key".into())).is_ok());
let tmdb = MetadataConfig { let tmdb = MetadataConfig {
provider: ProviderId::Tmdb, provider: ProviderId::Tmdb,
@@ -506,17 +636,34 @@ mod tests {
kind: TitleKind::Series, kind: TitleKind::Series,
poster_url: None, poster_url: None,
backdrop_url: None, backdrop_url: None,
logo_url: None,
rating: None, rating: None,
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
url: None, url: None,
details: None, details: Some(TitleDetails {
revision: TitleDetails::REVISION,
..TitleDetails::default()
}),
}), }),
fetched_at: now(), fetched_at: now(),
manual, manual,
} }
} }
/// [`matched`], stored before there were details.
fn without_details(key: &str, manual: bool, provider: ProviderId) -> MetadataRecord {
let mut record = matched(key, manual);
record.provider = provider;
if let Some(found) = record.metadata.as_mut() {
found.provider = provider;
found.details = None;
}
record
}
const ANILIST: &[ProviderId] = &[ProviderId::AniList, ProviderId::TvMaze];
fn kinds(work: &[Work]) -> Vec<(&str, &str)> { fn kinds(work: &[Work]) -> Vec<(&str, &str)> {
work.iter() work.iter()
.map(|work| match work { .map(|work| match work {
@@ -545,14 +692,7 @@ mod tests {
]); ]);
let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec(); let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec();
let work = plan( let work = plan(titles, &stored, &enriched, ANILIST, false);
titles,
&stored,
&enriched,
ProviderId::AniList,
false,
false,
);
assert_eq!( assert_eq!(
kinds(&work), kinds(&work),
vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")] vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
@@ -569,14 +709,7 @@ mod tests {
]); ]);
let titles = ["pinned", "automatic"].map(title).to_vec(); let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan( let work = plan(titles, &stored, &HashMap::new(), ANILIST, true);
titles,
&stored,
&HashMap::new(),
ProviderId::AniList,
false,
true,
);
assert_eq!( assert_eq!(
kinds(&work), kinds(&work),
vec![("pinned", "enrich"), ("automatic", "match")] vec![("pinned", "enrich"), ("automatic", "match")]
@@ -584,23 +717,30 @@ mod tests {
} }
/// A match stored before details existed is fetched again by id — a /// A match stored before details existed is fetched again by id — a
/// pinned one included, and without being searched for — and only by a /// pinned one included, and without being searched for — and only from a
/// provider that has details to give. /// provider that has details to give.
#[test] #[test]
fn a_match_without_details_is_refreshed_rather_than_searched_for() { fn a_match_without_details_is_refreshed_rather_than_searched_for() {
let stored = HashMap::from([ let records = |provider| {
("pinned".to_string(), matched("pinned", true)), HashMap::from([
("automatic".to_string(), matched("automatic", false)), (
]); "pinned".to_string(),
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]); without_details("pinned", true, provider),
),
(
"automatic".to_string(),
without_details("automatic", false, provider),
),
])
};
let enriched = |provider| HashMap::from([("pinned".to_string(), (provider, now()))]);
let titles = ["pinned", "automatic"].map(title).to_vec(); let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan( let work = plan(
titles.clone(), titles.clone(),
&stored, &records(ProviderId::AniList),
&enriched, &enriched(ProviderId::AniList),
ProviderId::AniList, ANILIST,
true,
false, false,
); );
assert_eq!( assert_eq!(
@@ -612,17 +752,89 @@ mod tests {
let work = plan( let work = plan(
titles, titles,
&stored, &records(ProviderId::Tmdb),
&enriched, &enriched(ProviderId::Tmdb),
ProviderId::AniList, &[ProviderId::Tmdb],
false,
false, false,
); );
assert_eq!(kinds(&work), vec![("automatic", "enrich")]); assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
} }
/// A record from the other provider is never usable — switching provider has /// A title AniList had nothing for is asked of TVmaze at once, rather than
/// to re-ask, including about titles the previous one found nothing for. /// waiting out AniList's stored miss; a match from TVmaze is enriched by
/// TVmaze, and a hand-picked one stays pinned — re-enriched when forced,
/// never searched for.
#[test]
fn titles_are_planned_against_the_provider_each_is_matched_to() {
let miss = MetadataRecord {
metadata: None,
..matched("anilist miss", false)
};
let tvmaze = |key: &str, manual| {
let mut record = matched(key, manual);
record.provider = ProviderId::TvMaze;
if let Some(found) = record.metadata.as_mut() {
found.provider = ProviderId::TvMaze;
}
record
};
let stored = HashMap::from([
("anilist miss".to_string(), miss),
("on tvmaze".to_string(), tvmaze("on tvmaze", false)),
("pinned".to_string(), tvmaze("pinned", true)),
]);
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::TvMaze, now()))]);
let titles = ["anilist miss", "on tvmaze", "pinned"].map(title).to_vec();
let work = plan(titles.clone(), &stored, &enriched, ANILIST, false);
assert_eq!(
kinds(&work),
vec![("anilist miss", "match"), ("on tvmaze", "enrich")]
);
// Forced, the pinned title is not searched for, but is enriched again
// although it is fresh.
let work = plan(titles, &stored, &enriched, ANILIST, true);
assert_eq!(
kinds(&work),
vec![
("anilist miss", "match"),
("on tvmaze", "match"),
("pinned", "enrich")
]
);
}
/// Details from an older build are refetched by id, and kept meanwhile;
/// details this build wrote are not asked for again.
#[test]
fn details_from_an_older_revision_are_refreshed_and_current_ones_are_not() {
let with = |revision| {
let mut record = matched("k", false);
if let Some(found) = record.metadata.as_mut() {
found.details = Some(TitleDetails {
revision,
..TitleDetails::default()
});
}
record
};
let enriched = HashMap::from([("k".to_string(), (ProviderId::AniList, now()))]);
let run = |record: MetadataRecord| {
let stored = HashMap::from([("k".to_string(), record)]);
let work = plan(vec![title("k")], &stored, &enriched, ANILIST, false);
kinds(&work)
.into_iter()
.map(|(_, kind)| kind.to_string())
.collect::<Vec<_>>()
};
assert_eq!(run(with(0)), vec!["refresh"]);
assert!(run(with(TitleDetails::REVISION)).is_empty());
}
/// A record from a provider outside the chain is never usable — switching
/// provider has to re-ask, including about titles the previous one found
/// nothing for.
#[test] #[test]
fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() { fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() {
let record = MetadataRecord { let record = MetadataRecord {
@@ -632,8 +844,29 @@ mod tests {
fetched_at: now(), fetched_at: now(),
manual: false, manual: false,
}; };
assert!(is_usable(Some(&record), ProviderId::AniList)); assert!(is_usable(Some(&record), &[ProviderId::AniList]));
assert!(!is_usable(Some(&record), ProviderId::Tmdb)); assert!(!is_usable(Some(&record), &[ProviderId::Tmdb]));
assert!(!is_usable(None, ProviderId::AniList)); assert!(!is_usable(None, &[ProviderId::AniList]));
}
/// AniList is followed by TVmaze, which needs no key either; TMDB, which
/// has television of its own, is asked alone.
#[test]
fn anilist_is_followed_by_tvmaze_and_tmdb_stands_alone() {
let config = MetadataConfig {
enabled: true,
..MetadataConfig::default()
};
let service = MetadataService::new(&config, None).expect("service");
assert_eq!(service.chain(), ANILIST);
assert_eq!(service.provider(), ProviderId::AniList);
assert_eq!(service.label(), "AniList and TVmaze");
let tmdb = MetadataConfig {
provider: ProviderId::Tmdb,
..config
};
let service = MetadataService::new(&tmdb, Some("key".into())).expect("service");
assert_eq!(service.chain(), vec![ProviderId::Tmdb]);
} }
} }
+205 -10
View File
@@ -9,6 +9,11 @@
//! not evidence enough to pick an endpoint. The `media_type` on each result is //! not evidence enough to pick an endpoint. The `media_type` on each result is
//! better evidence than the guess, and [`crate::matching`] only nudges on kind //! better evidence than the guess, and [`crate::matching`] only nudges on kind
//! anyway. //! anyway.
//!
//! A stored key is also used with AniList chosen, by id alone: ani.zip names
//! the TMDB entry an AniList match is, and [`Tmdb::art`] fetches its backdrop
//! and logo — what the films TVDB has no art for are missing. No title is
//! searched for that way.
use pstr_core::library::{Title, TitleKind}; use pstr_core::library::{Title, TitleKind};
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata}; use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
@@ -34,13 +39,16 @@ const STILL_SIZE: &str = "w300";
/// Where TMDB serves artwork from, and the sizes worth asking for. /// Where TMDB serves artwork from, and the sizes worth asking for.
/// ///
/// `w500` for a poster and `w780` for a backdrop are what the images are drawn /// `w500` for a poster and a logo and `w1280` for a backdrop are what the
/// at here, give or take; `original` is several megabytes of print-resolution /// images are drawn at here, give or take — a backdrop fills the width of the
/// artwork per title, which is bandwidth spent on pixels no tile can show. /// title page and the library's banner; `original` is several megabytes of
/// print-resolution artwork per title, which is bandwidth spent on pixels no
/// window shows.
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p"; const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
const POSTER_SIZE: &str = "w500"; const POSTER_SIZE: &str = "w500";
const BACKDROP_SIZE: &str = "w780"; const BACKDROP_SIZE: &str = "w1280";
const LOGO_SIZE: &str = "w500";
/// TMDB's published ceiling is around fifty requests a second and it no longer /// TMDB's published ceiling is around fifty requests a second and it no longer
/// enforces a daily one. This is well under it: the point of pacing here is a /// enforces a daily one. This is well under it: the point of pacing here is a
@@ -68,6 +76,30 @@ impl Tmdb {
} }
} }
/// A TMDB entry, by id. Films and television are numbered separately, so the
/// id alone does not name one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TmdbId {
Movie(u32),
Tv(u32),
}
impl TmdbId {
fn path(self) -> String {
match self {
Self::Movie(id) => format!("movie/{id}"),
Self::Tv(id) => format!("tv/{id}"),
}
}
}
/// An entry's wide picture and its logo.
#[derive(Debug, Default, PartialEq)]
pub struct Art {
pub backdrop: Option<String>,
pub logo: Option<String>,
}
impl Provider for Tmdb { impl Provider for Tmdb {
fn id(&self) -> ProviderId { fn id(&self) -> ProviderId {
ProviderId::Tmdb ProviderId::Tmdb
@@ -95,13 +127,25 @@ impl Provider for Tmdb {
.collect()) .collect())
} }
/// The episode list, and nothing else: the match already carried a proper /// The episode list and the logo. The match already carried a proper
/// backdrop, and TMDB files every season under one show, so the library's /// backdrop, and TMDB files every season under one show, so the library's
/// seasons need no walking. /// seasons need no walking.
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> { async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let logo_url = match found.remote_id.parse() {
Ok(id) => {
self.art_or_nothing(match found.kind {
TitleKind::Series => TmdbId::Tv(id),
TitleKind::Film => TmdbId::Movie(id),
})
.await
.logo
}
Err(_) => None,
};
Ok(Enrichment { Ok(Enrichment {
episodes: self.episodes(found).await?, episodes: self.episodes(found).await?,
backdrop_url: None, backdrop_url: None,
logo_url,
chain: Vec::new(), chain: Vec::new(),
}) })
} }
@@ -168,14 +212,47 @@ impl Tmdb {
still_url: episode still_url: episode
.still_path .still_path
.as_deref() .as_deref()
.map(|path| image(STILL_SIZE, path)), .map(|path| image_url(STILL_SIZE, path)),
air_date: episode.air_date.clone().filter(|date| !date.is_empty()), air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
runtime: episode.runtime.filter(|minutes| *minutes > 0),
mal_id: None,
} }
})); }));
} }
Ok(episodes) Ok(episodes)
} }
/// The best backdrop and logo TMDB has for `id`.
///
/// Artwork in the viewer's language, English or Japanese is asked for, and
/// artwork with no language at all, which for a backdrop is the one without
/// lettering on it. One request.
pub async fn art(&self, id: TmdbId) -> Result<Art> {
let languages = format!("{},en,ja,null", self.language_code());
let images: Images = self
.get(
&format!("{API_BASE}/{}/images", id.path()),
&[("include_image_language", languages.as_str())],
)
.await?;
Ok(images.into_art(self.language_code()))
}
/// [`Tmdb::art`], with a failure logged and taken as no art: the art is
/// decoration on an answer that is already complete, and a bad key or a
/// slow minute must not stop the episodes beside it being stored.
pub async fn art_or_nothing(&self, id: TmdbId) -> Art {
self.art(id)
.await
.inspect_err(|error| tracing::debug!("TMDB art for {id:?}: {error}"))
.unwrap_or_default()
}
/// `de` out of `de-DE`: images are tagged by language alone.
fn language_code(&self) -> &str {
self.language.split('-').next().unwrap_or("en")
}
/// One authenticated GET, paced, with TMDB's failures named. /// One authenticated GET, paced, with TMDB's failures named.
/// ///
/// TMDB is far more generous than AniList and a library-sized run rarely /// TMDB is far more generous than AniList and a library-sized run rarely
@@ -272,6 +349,66 @@ struct EpisodeRow {
overview: Option<String>, overview: Option<String>,
still_path: Option<String>, still_path: Option<String>,
air_date: Option<String>, air_date: Option<String>,
/// Minutes.
runtime: Option<u32>,
}
/// `/{movie,tv}/{id}/images`.
#[derive(Deserialize, Default)]
struct Images {
#[serde(default)]
backdrops: Vec<Image>,
#[serde(default)]
logos: Vec<Image>,
}
#[derive(Deserialize)]
struct Image {
file_path: String,
iso_639_1: Option<String>,
#[serde(default)]
vote_average: f32,
}
impl Images {
fn into_art(self, language: &str) -> Art {
// Bare is best for a backdrop: a logo or a title is drawn over it,
// and lettering already in the picture fights that.
let backdrop = best(&self.backdrops, |image| match image.iso_639_1.as_deref() {
None => 2,
Some(tag) if tag == language || tag == "en" => 1,
Some(_) => 0,
});
// A logo is lettering, so it is wanted in a language the viewer reads.
// Only PNGs: an SVG is nothing the art loaders here can draw.
let logos: Vec<&Image> = self
.logos
.iter()
.filter(|image| image.file_path.ends_with(".png"))
.collect();
let logo = best(&logos, |image| match image.iso_639_1.as_deref() {
Some(tag) if tag == language => 3,
Some("en") => 2,
None => 1,
Some(_) => 0,
});
Art {
backdrop: backdrop.map(|image| image_url(BACKDROP_SIZE, &image.file_path)),
logo: logo.map(|image| image_url(LOGO_SIZE, &image.file_path)),
}
}
}
/// The image `rank` likes most, the better-voted one among equals.
fn best<T: std::borrow::Borrow<Image>>(
images: &[T],
rank: impl Fn(&Image) -> u8,
) -> Option<&Image> {
images.iter().map(|image| image.borrow()).max_by(|a, b| {
rank(a)
.cmp(&rank(b))
.then(a.vote_average.total_cmp(&b.vote_average))
})
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -335,8 +472,11 @@ impl Result_ {
overview: self.overview.filter(|text| !text.is_empty()), overview: self.overview.filter(|text| !text.is_empty()),
year: year_of(self.first_air_date.or(self.release_date).as_deref()), year: year_of(self.first_air_date.or(self.release_date).as_deref()),
kind, kind,
poster_url: self.poster_path.map(|path| image(POSTER_SIZE, &path)), poster_url: self.poster_path.map(|path| image_url(POSTER_SIZE, &path)),
backdrop_url: self.backdrop_path.map(|path| image(BACKDROP_SIZE, &path)), backdrop_url: self
.backdrop_path
.map(|path| image_url(BACKDROP_SIZE, &path)),
logo_url: None,
rating: self.vote_average.filter(|rating| *rating > 0.0), rating: self.vote_average.filter(|rating| *rating > 0.0),
// `search/multi` carries neither, and a detail request per // `search/multi` carries neither, and a detail request per
// candidate would be a round-trip per *result* rather than per // candidate would be a round-trip per *result* rather than per
@@ -363,7 +503,7 @@ impl Result_ {
} }
} }
fn image(size: &str, path: &str) -> String { fn image_url(size: &str, path: &str) -> String {
format!("{IMAGE_BASE}/{size}{path}") format!("{IMAGE_BASE}/{size}{path}")
} }
@@ -437,7 +577,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
series.metadata.backdrop_url.as_deref(), series.metadata.backdrop_url.as_deref(),
Some("https://image.tmdb.org/t/p/w780/b.jpg") Some("https://image.tmdb.org/t/p/w1280/b.jpg")
); );
let film: Result_ = serde_json::from_str( let film: Result_ = serde_json::from_str(
@@ -451,6 +591,61 @@ mod tests {
assert_eq!(film.metadata.year, Some(1988)); assert_eq!(film.metadata.year, Some(1988));
} }
/// Paprika's images as TMDB answers them, trimmed: a bare backdrop beside
/// better-voted lettered ones, and logos in three languages and two
/// formats.
#[test]
fn the_bare_backdrop_and_a_png_logo_in_the_viewers_language_are_chosen() {
let images: Images = serde_json::from_str(
r#"{
"backdrops": [
{"file_path": "/en.jpg", "iso_639_1": "en", "vote_average": 5.5},
{"file_path": "/bare.jpg", "iso_639_1": null, "vote_average": 5.2},
{"file_path": "/bare-worse.jpg", "iso_639_1": null, "vote_average": 4.0},
{"file_path": "/ja.jpg", "iso_639_1": "ja", "vote_average": 6.0}
],
"logos": [
{"file_path": "/ja.png", "iso_639_1": "ja", "vote_average": 6.0},
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 5.0},
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 3.0},
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
]
}"#,
)
.expect("parse");
let art = images.into_art("en");
assert_eq!(
art.backdrop.as_deref(),
Some("https://image.tmdb.org/t/p/w1280/bare.jpg")
);
assert_eq!(
art.logo.as_deref(),
Some("https://image.tmdb.org/t/p/w500/en.png")
);
}
#[test]
fn a_logo_in_the_viewers_own_language_beats_an_english_one() {
let images: Images = serde_json::from_str(
r#"{"logos": [
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 9.0},
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 9.0},
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
]}"#,
)
.expect("parse");
assert_eq!(
images.into_art("de").logo.as_deref(),
Some("https://image.tmdb.org/t/p/w500/de.png")
);
}
#[test]
fn an_entry_with_no_images_has_no_art() {
let images: Images = serde_json::from_str("{}").expect("parse");
assert_eq!(images.into_art("en"), Art::default());
}
/// A rating of zero means "nobody has voted", not "everybody hated it", and /// A rating of zero means "nobody has voted", not "everybody hated it", and
/// showing it as 0.0/10 would be a lie about the title. /// showing it as 0.0/10 would be a lie about the title.
#[test] #[test]
+447
View File
@@ -0,0 +1,447 @@
//! TVmaze, over its public REST API.
//!
//! Television AniList does not have — which is everything that is not anime —
//! with no API key and no account. It is asked only about what AniList found
//! nothing for (see [`crate::service`]), so a library of anime sends it
//! nothing, and a library with one live-action show in it sends it that one
//! name.
//!
//! What it does not have is film: TVmaze indexes shows and nothing else, so a
//! title the library knows to be a film is not asked about at all. A show's
//! name is also a film's often enough that asking would mostly find the wrong
//! thing.
//!
//! Its art is good where it matters here: a 16:9 still for most episodes and a
//! full-HD background for most shows. Its "typography" image would be a logo
//! but is a JPEG on a solid ground, so there is no logo from here.
use pstr_core::library::{Title, TitleKind};
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata};
use serde::Deserialize;
use crate::anilist::strip_markup;
use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
const API_BASE: &str = "https://api.tvmaze.com";
/// TVmaze allows at least twenty calls every ten seconds per address. A little
/// under it, because the limit is counted on its side and a burst that lands
/// at the edge of its window gets a 429 for nothing.
const REQUESTS_PER_MINUTE: u32 = 100;
/// As [`crate::anilist`]'s.
const RETRIES: usize = 2;
/// The background width worth asking for. Shows often carry a 4K one beside
/// it, which is four times the bytes for a band no window draws that wide.
const BACKDROP_WIDTH: u32 = 1920;
pub struct TvMaze {
http: reqwest::Client,
limiter: RateLimiter,
}
impl TvMaze {
pub fn new(http: reqwest::Client) -> Self {
Self {
http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
}
}
}
impl Provider for TvMaze {
fn id(&self) -> ProviderId {
ProviderId::TvMaze
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
if !asks_about(query) {
return Ok(Vec::new());
}
let hits: Vec<SearchHit> = self
.get("/search/shows", &[("q", query.name.as_str())])
.await?
.unwrap_or_default();
Ok(hits
.into_iter()
.map(|hit| hit.show.into_candidate())
.collect())
}
/// Every episode and the show's background, in one request.
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let show: Option<Show> = self
.get(
&format!("/shows/{}", found.remote_id),
&[("embed[]", "episodes"), ("embed[]", "images")],
)
.await?;
Ok(show.map(Show::into_enrichment).unwrap_or_default())
}
}
impl TvMaze {
/// The show the TVmaze id `id` names, as search would have found it.
/// `None` for an id TVmaze no longer has.
pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
let show: Option<Show> = self.get(&format!("/shows/{id}"), &[]).await?;
Ok(show.map(|show| show.into_candidate().metadata))
}
/// One GET, paced. `None` for a 404, which TVmaze answers for an id it
/// does not have.
async fn get<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<Option<T>> {
let mut attempts = 0usize;
let response = loop {
self.limiter.acquire().await;
let response = self
.http
.get(format!("{API_BASE}{path}"))
.query(query)
.send()
.await?;
let status = response.status();
if status.as_u16() == 429 {
let waited = self.limiter.throttled(response.headers()).await;
attempts += 1;
if attempts > RETRIES {
return Err(Error::RateLimited);
}
tracing::debug!("TVmaze is throttling; waiting {waited:?}");
continue;
}
if status.as_u16() == 404 {
return Ok(None);
}
if !status.is_success() {
return Err(Error::Http(format!("TVmaze answered {status}")));
}
break response;
};
Ok(Some(response.json().await?))
}
}
/// Whether TVmaze could have `query` at all: not when the library knows it is
/// a film.
fn asks_about(query: &Query) -> bool {
!(query.kind_known && query.kind == TitleKind::Film)
}
#[derive(Deserialize)]
struct SearchHit {
show: Show,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Show {
id: i64,
name: String,
url: Option<String>,
#[serde(default)]
genres: Vec<String>,
status: Option<String>,
runtime: Option<u32>,
average_runtime: Option<u32>,
premiered: Option<String>,
rating: Option<Rating>,
network: Option<Channel>,
web_channel: Option<Channel>,
image: Option<Image>,
summary: Option<String>,
#[serde(rename = "_embedded")]
embedded: Option<Embedded>,
}
#[derive(Deserialize)]
struct Rating {
average: Option<f32>,
}
#[derive(Deserialize)]
struct Channel {
name: String,
}
#[derive(Deserialize)]
struct Image {
medium: Option<String>,
original: Option<String>,
}
#[derive(Deserialize, Default)]
struct Embedded {
#[serde(default)]
episodes: Vec<Episode>,
#[serde(default)]
images: Vec<Picture>,
}
#[derive(Deserialize)]
struct Episode {
name: Option<String>,
season: Option<u32>,
/// `null` on a special, which is numbered nowhere a file name would say.
number: Option<u32>,
airdate: Option<String>,
runtime: Option<u32>,
image: Option<Image>,
summary: Option<String>,
}
#[derive(Deserialize)]
struct Picture {
#[serde(rename = "type")]
kind: Option<String>,
#[serde(default)]
main: bool,
resolutions: Resolutions,
}
#[derive(Deserialize)]
struct Resolutions {
original: Option<Resolution>,
}
#[derive(Deserialize)]
struct Resolution {
url: String,
width: Option<u32>,
}
impl Show {
fn into_candidate(self) -> Candidate {
let status = match self.status.as_deref() {
Some("Running") => Some("RELEASING"),
Some("Ended") => Some("FINISHED"),
Some("To Be Determined") => Some("HIATUS"),
Some("In Development") => Some("NOT_YET_RELEASED"),
_ => None,
};
let channel = self.network.or(self.web_channel);
let details = TitleDetails {
start_date: self.premiered.clone().filter(|date| !date.is_empty()),
// Not a studio, but what the facts line names in its place: who
// made it, as a viewer would put it.
studios: channel.map(|channel| channel.name).into_iter().collect(),
status: status.map(str::to_string),
runtime: self
.runtime
.or(self.average_runtime)
.filter(|minutes| *minutes > 0),
revision: TitleDetails::REVISION,
..TitleDetails::default()
};
Candidate {
aliases: vec![self.name.clone()],
metadata: TitleMetadata {
provider: ProviderId::TvMaze,
remote_id: self.id.to_string(),
year: self
.premiered
.as_deref()
.and_then(|date| date.get(..4)?.parse().ok()),
name: self.name,
original_name: None,
overview: self.summary.as_deref().and_then(overview),
kind: TitleKind::Series,
poster_url: self.image.and_then(|image| image.original.or(image.medium)),
backdrop_url: None,
logo_url: None,
rating: self
.rating
.and_then(|rating| rating.average)
.filter(|rating| *rating > 0.0),
genres: self.genres,
episodes: None,
url: self.url,
details: Some(details),
},
// Always: TVmaze has nothing but shows.
kind_known: true,
}
}
fn into_enrichment(self) -> Enrichment {
let embedded = self.embedded.unwrap_or_default();
let episodes = embedded
.episodes
.into_iter()
.filter_map(|episode| {
Some(EpisodeMetadata {
season: episode.season,
number: episode.number?,
absolute: None,
entry_season: None,
entry_number: None,
name: episode.name.filter(|name| !name.is_empty()),
overview: episode.summary.as_deref().and_then(overview),
// The row draws a still about this wide; the original is
// a full-HD frame per episode.
still_url: episode
.image
.and_then(|image| image.medium.or(image.original)),
air_date: episode.airdate.filter(|date| !date.is_empty()),
runtime: episode.runtime.filter(|minutes| *minutes > 0),
mal_id: None,
})
})
.collect();
Enrichment {
episodes,
backdrop_url: backdrop(&embedded.images),
logo_url: None,
chain: Vec::new(),
}
}
}
/// The background to draw: the show's main one if it marked one, else the one
/// nearest [`BACKDROP_WIDTH`].
fn backdrop(images: &[Picture]) -> Option<String> {
images
.iter()
.filter(|picture| picture.kind.as_deref() == Some("background"))
.filter_map(|picture| Some((picture, picture.resolutions.original.as_ref()?)))
.min_by_key(|(picture, resolution)| {
(
!picture.main,
resolution.width.unwrap_or(0).abs_diff(BACKDROP_WIDTH),
)
})
.map(|(_, resolution)| resolution.url.clone())
}
/// TVmaze's summaries are HTML paragraphs. Each one keeps its own line.
fn overview(html: &str) -> Option<String> {
let text = strip_markup(&html.replace("</p>", "</p>\n"));
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
const SHOW: &str = r#"{
"id": 82, "url": "https://www.tvmaze.com/shows/82/game-of-thrones",
"name": "Game of Thrones", "type": "Scripted",
"genres": ["Drama", "Adventure", "Fantasy"], "status": "Ended",
"runtime": 60, "averageRuntime": 61, "premiered": "2011-04-17",
"rating": {"average": 8.9}, "network": {"id": 8, "name": "HBO"},
"webChannel": null,
"image": {"medium": "https://static.tvmaze.com/m/1.jpg",
"original": "https://static.tvmaze.com/o/1.jpg"},
"summary": "<p>Based on the novels.</p><p>Seven noble families fight.</p>",
"_embedded": {
"episodes": [
{"name": "Winter is Coming", "season": 1, "number": 1,
"type": "regular", "airdate": "2011-04-17", "runtime": 60,
"image": {"medium": "https://static.tvmaze.com/m/e1.jpg",
"original": "https://static.tvmaze.com/o/e1.jpg"},
"summary": "<p>Lord Stark is troubled.</p>"},
{"name": "The Kingsroad", "season": 1, "number": 2,
"type": "regular", "airdate": "2011-04-24", "runtime": null,
"image": null, "summary": null},
{"name": "Inside the Making", "season": 1, "number": null,
"type": "significant_special", "airdate": "2011-04-10"}
],
"images": [
{"type": "poster", "main": true, "resolutions":
{"original": {"url": "poster", "width": 2030}}},
{"type": "background", "main": false, "resolutions":
{"original": {"url": "uhd", "width": 3840}}},
{"type": "background", "main": false, "resolutions":
{"original": {"url": "hd", "width": 1920}}},
{"type": "typography", "main": false, "resolutions":
{"original": {"url": "lettering", "width": 840}}}
]
}
}"#;
#[test]
fn a_show_becomes_a_series_with_its_details() {
let show: Show = serde_json::from_str(SHOW).expect("parse");
let found = show.into_candidate().metadata;
assert_eq!(found.provider, ProviderId::TvMaze);
assert_eq!(found.remote_id, "82");
assert_eq!(found.kind, TitleKind::Series);
assert_eq!(found.year, Some(2011));
assert_eq!(found.rating, Some(8.9));
assert_eq!(
found.poster_url.as_deref(),
Some("https://static.tvmaze.com/o/1.jpg")
);
assert_eq!(
found.overview.as_deref(),
Some("Based on the novels.\nSeven noble families fight.")
);
let details = found.details.expect("details");
assert!(details.is_current());
assert_eq!(details.studios, vec!["HBO"]);
assert_eq!(details.status.as_deref(), Some("FINISHED"));
assert_eq!(details.runtime, Some(60));
assert_eq!(details.start_date.as_deref(), Some("2011-04-17"));
}
/// Specials are numbered nowhere a file would be, so they are left out
/// rather than colliding with the episode they share a season with.
#[test]
fn enrichment_keeps_numbered_episodes_and_the_full_hd_background() {
let show: Show = serde_json::from_str(SHOW).expect("parse");
let enrichment = show.into_enrichment();
assert_eq!(enrichment.episodes.len(), 2);
let first = &enrichment.episodes[0];
assert_eq!((first.season, first.number), (Some(1), 1));
assert_eq!(first.name.as_deref(), Some("Winter is Coming"));
assert_eq!(first.overview.as_deref(), Some("Lord Stark is troubled."));
assert_eq!(
first.still_url.as_deref(),
Some("https://static.tvmaze.com/m/e1.jpg")
);
assert_eq!(first.runtime, Some(60));
assert_eq!(enrichment.episodes[1].runtime, None);
assert_eq!(enrichment.backdrop_url.as_deref(), Some("hd"));
assert_eq!(enrichment.logo_url, None);
}
#[test]
fn a_background_the_show_marked_main_wins_over_a_better_sized_one() {
let images: Vec<Picture> = serde_json::from_str(
r#"[{"type": "background", "main": false, "resolutions":
{"original": {"url": "hd", "width": 1920}}},
{"type": "background", "main": true, "resolutions":
{"original": {"url": "chosen", "width": 1280}}}]"#,
)
.expect("parse");
assert_eq!(backdrop(&images).as_deref(), Some("chosen"));
assert_eq!(backdrop(&[]), None);
}
/// TVmaze has no films, and a show sharing a film's name is the wrong
/// answer far more often than it is an adaptation.
#[test]
fn a_known_film_is_not_asked_about_and_a_guessed_one_is() {
let film = Query::new("Paprika", None, TitleKind::Film);
assert!(!asks_about(&film));
assert!(asks_about(&film.with_guessed_kind()));
assert!(asks_about(&Query::new(
"Game of Thrones",
None,
TitleKind::Series
)));
}
}
+10 -1
View File
@@ -42,7 +42,7 @@ use libloading::Library;
/// ///
/// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin /// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin
/// will have picked — though with `libglvnd` in the way, either order works. /// will have picked — though with `libglvnd` in the way, either order works.
#[cfg(unix)] #[cfg(all(unix, not(target_os = "macos")))]
fn candidates() -> Vec<&'static str> { fn candidates() -> Vec<&'static str> {
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some(); let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
if wayland { if wayland {
@@ -52,6 +52,15 @@ fn candidates() -> Vec<&'static str> {
} }
} }
/// eframe's context on macOS is CGL, and the framework exports every GL
/// function it implements as a plain symbol — there is no `GetProcAddress` to
/// go through, so the direct lookup in [`GlLoader::resolve`] answers for all of
/// them.
#[cfg(target_os = "macos")]
fn candidates() -> Vec<&'static str> {
vec!["/System/Library/Frameworks/OpenGL.framework/OpenGL"]
}
#[cfg(windows)] #[cfg(windows)]
fn candidates() -> Vec<&'static str> { fn candidates() -> Vec<&'static str> {
vec!["opengl32.dll"] vec!["opengl32.dll"]
+1
View File
@@ -49,6 +49,7 @@ pub use chapters::{Chapter, ChapterRole, chapter_at, chapter_end, credits_start,
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use player::{ pub use player::{
EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput, EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput,
subtitle_options,
}; };
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry}; pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
pub use render::VideoRenderer; pub use render::VideoRenderer;
+166
View File
@@ -12,6 +12,7 @@ use std::sync::Arc;
use libmpv2::events::{Event, PropertyData}; use libmpv2::events::{Event, PropertyData};
use libmpv2::{Format, Mpv}; use libmpv2::{Format, Mpv};
use pstr_core::prefs::{SubtitleBackdrop, SubtitleStyle};
use pstr_stream::VideoStream; use pstr_stream::VideoStream;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
@@ -181,6 +182,8 @@ pub struct PlayerConfig {
/// Whether subtitles are shown at all. Off is a choice a viewer makes once /// Whether subtitles are shown at all. Off is a choice a viewer makes once
/// and expects to keep, and no language preference can express it. /// and expects to keep, and no language preference can express it.
pub subtitles: bool, pub subtitles: bool,
/// How subtitles look. See [`subtitle_options`].
pub subtitle_style: SubtitleStyle,
/// Raw mpv options, applied last, so anything here wins. /// Raw mpv options, applied last, so anything here wins.
pub options: Vec<(String, String)>, pub options: Vec<(String, String)>,
} }
@@ -206,6 +209,7 @@ impl Default for PlayerConfig {
audio_language: None, audio_language: None,
subtitle_language: None, subtitle_language: None,
subtitles: true, subtitles: true,
subtitle_style: SubtitleStyle::default(),
options: Vec::new(), options: Vec::new(),
} }
} }
@@ -293,6 +297,9 @@ impl Player {
if !config.subtitles { if !config.subtitles {
init.set_property("sid", "no")?; init.set_property("sid", "no")?;
} }
apply_subtitle_options(&config.subtitle_style, |name, value| {
init.set_property(name, value).map_err(Error::from)
});
for (name, value) in &config.options { for (name, value) in &config.options {
init.set_property(name.as_str(), value.as_str())?; init.set_property(name.as_str(), value.as_str())?;
} }
@@ -537,6 +544,18 @@ impl Player {
Ok(()) Ok(())
} }
/// Restyle subtitles now, on the line already showing.
pub fn apply_subtitle_style(&self, style: &SubtitleStyle) {
apply_subtitle_options(style, |name, value| self.set_option(name, value));
}
/// Show subtitles `seconds` later (or, negative, earlier) than the file
/// says. For a release whose subtitles were timed against another cut.
pub fn set_subtitle_delay(&self, seconds: f64) -> Result<()> {
self.mpv.set_property("sub-delay", seconds)?;
Ok(())
}
/// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's /// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's
/// default and what keeps a voice a voice at 1.5×. /// default and what keeps a voice a voice at 1.5×.
pub fn set_speed(&self, speed: f64) -> Result<()> { pub fn set_speed(&self, speed: f64) -> Result<()> {
@@ -593,6 +612,78 @@ impl Player {
} }
} }
/// The first option of the block that only makes sense on top of it. See
/// [`apply_subtitle_options`].
const BORDER_STYLE: &str = "sub-border-style";
/// The mpv options that draw `style`, every one of them every time, so that
/// changing a value back to its default while playing lands too.
///
/// `sub-ass-override` is `yes` unless the viewer asked otherwise, so a styled
/// track keeps its own size, fonts and colours. Not mpv's default, `scale`,
/// which hands it `sub-scale` as well. `force` repaints it whole. See
/// [`SubtitleStyle`] for why that is opt-in. `sub-pos` reaches a styled track
/// under `yes` too, so where a styled track sits is the caller's to decide:
/// see `pstr-app`'s `Playback::lift_subtitles`.
/// The border options come last, led by [`BORDER_STYLE`].
pub fn subtitle_options(style: &SubtitleStyle) -> Vec<(&'static str, String)> {
let flag = |on: bool| if on { "yes" } else { "no" }.to_owned();
let mut options = vec![
(
"sub-ass-override",
if style.restyle_styled { "force" } else { "yes" }.to_owned(),
),
("sub-scale", format!("{:.2}", style.scale)),
(
"sub-font",
style
.font
.clone()
.unwrap_or_else(|| SubtitleStyle::DEFAULT_FONT.to_owned()),
),
("sub-bold", flag(style.bold)),
("sub-italic", flag(style.italic)),
("sub-color", style.colour.to_mpv()),
// mpv's name for the shadow's colour, older than the shadow alias.
("sub-back-color", style.shadow_colour.to_mpv()),
("sub-shadow-offset", format!("{:.2}", style.shadow_offset)),
("sub-blur", format!("{:.2}", style.blur)),
("sub-pos", format!("{:.0}", style.position)),
];
match style.backdrop {
SubtitleBackdrop::Outline => options.extend([
(BORDER_STYLE, "outline-and-shadow".to_owned()),
("sub-outline-color", style.outline_colour.to_mpv()),
("sub-outline-size", format!("{:.2}", style.outline_size)),
]),
// `opaque-box` draws the outline as a box hugging each line, so the
// outline's colour and size become the box's colour and margin.
SubtitleBackdrop::Box => options.extend([
(BORDER_STYLE, "opaque-box".to_owned()),
("sub-outline-color", style.box_colour.to_mpv()),
("sub-outline-size", "6".to_owned()),
]),
}
options
}
/// Hand `style` to `set` option by option, best effort.
///
/// An option this libmpv refuses costs that option, not the player:
/// `sub-border-style` is mpv 0.38's, and Ubuntu 24.04 ships 0.37. A refused
/// border style ends the run, though, because what follows it only makes sense
/// on top of it: a box's colour and margin on a plain outline is a fat smear.
fn apply_subtitle_options(style: &SubtitleStyle, mut set: impl FnMut(&str, &str) -> Result<()>) {
for (name, value) in subtitle_options(style) {
if let Err(error) = set(name, &value) {
tracing::warn!("subtitle style {name}={value}: {error}");
if name == BORDER_STYLE {
break;
}
}
}
}
/// mpv's borrowed event to ours. /// mpv's borrowed event to ours.
fn translate(event: Event<'_>) -> PlayerEvent { fn translate(event: Event<'_>) -> PlayerEvent {
match event { match event {
@@ -620,6 +711,81 @@ fn translate(event: Event<'_>) -> PlayerEvent {
mod tests { mod tests {
use super::*; use super::*;
fn option<'a>(options: &'a [(&'static str, String)], name: &str) -> Option<&'a str> {
options
.iter()
.find(|(option, _)| *option == name)
.map(|(_, value)| value.as_str())
}
#[test]
fn the_default_subtitle_style_is_what_mpv_would_do_anyway() {
let options = subtitle_options(&SubtitleStyle::default());
// mpv 0.41's own defaults, from `mpv --list-options` — all but
// `sub-ass-override`, which mpv has at `scale`.
for (name, value) in [
("sub-ass-override", "yes"),
("sub-scale", "1.00"),
("sub-font", "sans-serif"),
("sub-bold", "no"),
("sub-italic", "no"),
("sub-color", "#FFFFFFFF"),
("sub-back-color", "#AF000000"),
("sub-shadow-offset", "0.00"),
("sub-blur", "0.00"),
("sub-pos", "100"),
("sub-border-style", "outline-and-shadow"),
("sub-outline-color", "#FF000000"),
("sub-outline-size", "1.65"),
] {
assert_eq!(option(&options, name), Some(value), "{name}");
}
}
#[test]
fn a_boxed_subtitle_style_turns_the_outline_into_a_box_of_its_colour() {
let style = SubtitleStyle {
backdrop: SubtitleBackdrop::Box,
box_colour: pstr_core::prefs::Colour::rgba(0x20, 0x10, 0x00, 0x80),
..SubtitleStyle::default()
};
let options = subtitle_options(&style);
assert_eq!(option(&options, "sub-border-style"), Some("opaque-box"));
assert_eq!(option(&options, "sub-outline-color"), Some("#80201000"));
}
#[test]
fn styled_subtitles_are_repainted_or_resized_only_when_asked() {
// `scale` would hand a styled track the size; `yes` hands it nothing
// but the `sub-ass-*` options, none of which this sets.
let kept = subtitle_options(&SubtitleStyle {
scale: 1.5,
..SubtitleStyle::default()
});
assert_eq!(option(&kept, "sub-ass-override"), Some("yes"));
let repainted = subtitle_options(&SubtitleStyle {
restyle_styled: true,
..SubtitleStyle::default()
});
assert_eq!(option(&repainted, "sub-ass-override"), Some("force"));
}
#[test]
fn a_refused_border_style_skips_what_depends_on_it_and_nothing_before() {
let mut set = Vec::new();
apply_subtitle_options(&SubtitleStyle::default(), |name, _| {
set.push(name.to_owned());
if name == BORDER_STYLE {
Err(Error::NulByte("test"))
} else {
Ok(())
}
});
assert_eq!(set.last().map(String::as_str), Some(BORDER_STYLE));
assert!(set.iter().any(|name| name == "sub-color"));
assert!(!set.iter().any(|name| name == "sub-outline-color"));
}
#[test] #[test]
fn every_end_reason_maps_to_something_the_ui_can_act_on() { fn every_end_reason_maps_to_something_the_ui_can_act_on() {
use libmpv2::mpv_end_file_reason as raw; use libmpv2::mpv_end_file_reason as raw;
+71
View File
@@ -0,0 +1,71 @@
# Account and sync
Signing in to Proton is optional. Without it, the app plays public share links and keeps
everything on one device. Signing in adds two things:
- **Folders of your own Drive** as shares, with no link to copy.
- **Sync** of your place in every episode, your shares, your settings and My List, across every
device you sign in on.
## Signing in
The **Proton account** section is at the top of the **Shares** page.
1. Enter your Proton address and password, then **Sign in**.
2. If your account has two-factor authentication, enter the code from your authenticator app.
3. If your account has a separate mailbox password, enter it and choose **Unlock**.
Proton sometimes asks to confirm you are human. The app then opens a small window with Proton's
own CAPTCHA page. Solve it, and sign-in continues.
The app keeps a session, not your password. The session goes into the system keyring. **Sign
out** ends it on Proton's side and deletes it here.
Not supported yet:
- **Security keys** (FIDO2). An account whose only second factor is a security key cannot sign
in. Add an authenticator app as a second method in your Proton settings.
- **Email or SMS verification** instead of a CAPTCHA.
## Adding a folder of your Drive
1. Sign in.
2. Under **Add from your Drive**, choose **Browse your Drive**.
3. Open folders until you reach the one that holds your videos.
4. Choose **Add this folder**.
The folder becomes a share like any link: it is crawled, shown in the library and streamed. It is
read through your account, so there is no link or password to store. The same folder has the
same identity on every device, so watch history follows it.
## Sync
When you are signed in, each device keeps a small file in your Drive. You find them under
**Computers** on the Proton Drive website, in a device called `proton-stream`, in the folder
`watch-history`.
**What syncs:**
- Where you stopped in each episode, and which episodes you watched
- Your shares, including their links and passwords (end-to-end encrypted, like every file in
your Drive)
- Settings: playback, appearance and posters and descriptions, including a TMDB key
- Matches you picked by hand, and your audio and subtitle choice for each show
- My List: a title you add on one device is on the list of every other, and one you remove is
removed from all of them
**What stays on each device:** volume and mute, the streaming cache size, text size, Wi-Fi only,
background audio, hardware decoding, window size, and the library's filter and sort order.
**When it syncs:** when the app starts, every 5 minutes while it is open, when you stop playing,
and when it closes. **Sync now** runs it at once. Android also syncs in the background shortly
after you leave the app.
**How conflicts are settled:** each device writes only its own file, and reads everyone else's.
For each episode, each setting and each title on My List, the newest change wins. A device that signs in for the first
time takes the others' state and does not overwrite it with its own defaults.
A device that has not synced for 90 days has its file moved to the Drive trash.
The status line under your address says how the last sync went, for example *Synced 3 min ago ·
2 positions from other devices*. To stop syncing, sign out.
+17 -10
View File
@@ -12,20 +12,27 @@ All of B14–B33 came out of a static review of the Android client
device** — there was no Android acceptance harness to reproduce them with, which device** — there was no Android acceptance harness to reproduce them with, which
is itself B42. Each states the reasoning that identifies it; treat the is itself B42. Each states the reasoning that identifies it; treat the
reproduction step as part of the fix. The remediation order is `plan.md` at the reproduction step as part of the fix. The remediation order is `plan.md` at the
repository root; Phases 1 to 5 of it have landed except for the tail of B41 and repository root; Phases 1 to 5 of it have landed except for one row of Phase
one row of Phase 5's table — everything from B14 to B39 is below in **Fixed**, 5's table — everything from B14 to B39 is below in **Fixed**, and B41's items
and B41 states which of its items are done. have all landed.
### B41 — assorted correctness and hygiene defects ### B41 — assorted correctness and hygiene defects
Individually small, grouped so they are not lost. The items marked **Landed** Individually small, grouped so they are not lost. Every item has now landed;
came with Phases 4 and 5; the rest are still open, which is why this entry is. the entry stays here, with B42, until a device run confirms them.
What remains is one item: the asynchronous track-list read-back.
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an - **Landed.** `NativeMpvHost.selectTrack` read the track list back straight
asynchronous property set, so the selection tick lags by up to a second; after setting `aid`/`sid`, before mpv had made the switch, so the tick in the
`alang`/`slang` are applied only at load (`pstr_mpv.cpp:225-227`), so a track chooser stayed on the old track until the next poll, up to a second
language change takes effect one episode late. later. The tick now moves when the track is chosen, and the list is read again
300 ms after. The second half of the report, a language change landing one
episode late, did not hold up: the choice is saved from the chosen track, and
the next episode loads with it. What was wrong nearby is that `load` set
`alang` and `slang` only when they were not empty, and mpv keeps both across
`loadfile`, so a file with no language preference took the previous file's.
Both are set on every load now. The iOS client had the same early read in
`MpvPlayer.select` and takes the same fix; it already set both languages on
every load.
- **Landed.** `NativeMpvHost.kt` polled on `Dispatchers.Main.immediate`, and - **Landed.** `NativeMpvHost.kt` polled on `Dispatchers.Main.immediate`, and
`tracks_json` issues five `mpv_get_property` calls per track — 51 synchronous `tracks_json` issues five `mpv_get_property` calls per track — 51 synchronous
property reads on the UI thread for a 10-track file, contending on the core property reads on the UI thread for a 10-track file, contending on the core
+96
View File
@@ -0,0 +1,96 @@
# Command-line reference
The desktop packages install two programs:
- `proton-stream`, the app
- `pstr`, a command-line tool that does the app's work without a window
Both use the same shares, library, settings and keyring, so a share added with `pstr` shows up in
the app.
## proton-stream
```bash
proton-stream # open the app
proton-stream 'https://drive.proton.me/urls/TOKEN#KEY' # open it with the Add form filled in
```
Only one window runs at a time. A second `proton-stream` brings the open window forward, hands it
any share link it was given, and exits.
A link given as an argument is visible to other users of the computer in the process list while
the command runs, and it may land in your shell history. Pasting it into the window with
**Ctrl+V** does neither.
## pstr
`pstr --help` lists every command, and `pstr <command> --help` explains one. `pstr` never takes a
password or key as an argument; it asks for them.
### Shares
| Command | Does |
|---|---|
| `pstr add --name NAME URL` | Add a public share link. Quote the URL, because of the `#`. |
| `pstr add --name NAME URL --custom-password` | The same, and ask for the link's own password |
| `pstr add-folder --name NAME VOLUME:LINK` | Add a folder of your Drive, by the id `pstr browse` prints |
| `pstr shares` | List the shares and their ids |
| `pstr remove ID` | Remove a share and delete its link and password from the keyring |
### Account
| Command | Does |
|---|---|
| `pstr login ADDRESS` | Sign in. Asks for the password, a two-factor code and a mailbox password as the account needs them. |
| `pstr logout` | Sign out and delete the stored session |
| `pstr account` | Show who is signed in |
| `pstr browse [VOLUME:LINK]` | List your Drive's top-level places, or one folder |
| `pstr sync` | Sync watch history now |
`pstr login` cannot show a CAPTCHA. If Proton asks for one, sign in once in the app instead.
### Library
| Command | Does |
|---|---|
| `pstr crawl [--share ID]` | Read every share, or one, into the library |
| `pstr list [--share ID]` | Print what the library holds |
### Posters and descriptions
| Command | Does |
|---|---|
| `pstr metadata status` | Show whether lookups are on, and which provider |
| `pstr metadata set --on --provider anilist` | Turn lookups on with AniList |
| `pstr metadata set --on --provider tmdb --api-key` | Turn lookups on with TMDB, and ask for the key |
| `pstr metadata set --off` | Turn lookups off, and delete every stored answer |
| `pstr metadata match [--force] [--limit N]` | Look up titles now. `--force` asks again about titles that already have an answer. |
### Testing playback
These two are for finding out which layer is slow or broken.
```bash
pstr bench --file Frieren --verify # measure seek time, throughput and memory, no player
pstr play --file Frieren --disk-cache # play in a plain mpv window
```
| Option | Does |
|---|---|
| `--file TEXT` | The first file whose name or title contains `TEXT` |
| `--share ID` | Look in one share only |
| `--disk-cache` | Keep decrypted blocks on disk, as the app does |
| `--readahead N` | Blocks to fetch ahead of the reader |
| `--ring-mib N` | Memory for blocks, in MiB (128 by default) |
| `--verify` | `bench` only: compare a read against the SDK's own download |
| `--throughput-mib N` | `bench` only: how much to read for the speed test (32 by default) |
| `--start SECONDS` | `play` only: start at this position |
| `--headless` | `play` only: decode with no window and no sound |
| `--for-seconds S` | `play` only: stop after this many seconds |
## Environment variables
| Variable | Does |
|---|---|
| `RUST_LOG` | How much both programs log to the terminal, for example `RUST_LOG=debug` |
| `PSTR_FRAME_CAP` | The app's frame rate limit on Wayland. 60 by default; `0` turns it off. |
+22 -4
View File
@@ -20,12 +20,13 @@ is the contract, and each side's copy is asserted against it by a test.
## Type ## Type
Nine rungs. The desktop names them; Android restates them because Material's own Ten rungs. The desktop names them; Android restates them because Material's own
scale is a different one (`displayLarge` is 57sp, which is a size for a lock scale is a different one (`displayLarge` is 57sp, which is a size for a lock
screen and not for anything in a library grid). screen and not for anything in a library grid).
| Role | Size | What it is | | Role | Size | What it is |
|---|---|---| |---|---|---|
| Hero | 34 | the banner's name and a title page's name, where no logo stands in |
| Display | 26 | the name of the thing you are looking at, once per page | | Display | 26 | the name of the thing you are looking at, once per page |
| Title | 20 | an empty state, or a page heading with no display line | | Title | 20 | an empty state, or a page heading with no display line |
| Heading | 18 | the brand in the nav bar; a dialog's heading | | Heading | 18 | the brand in the nav bar; a dialog's heading |
@@ -39,8 +40,11 @@ screen and not for anything in a library grid).
Weight and leading are each side's own. The desktop bundles Inter Weight and leading are each side's own. The desktop bundles Inter
(`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in (`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in
its semibold cut, which egui sees as a family of its own (`theme::semibold`); its semibold cut, which egui sees as a family of its own (`theme::semibold`);
everything below Section is the regular cut. Compose derives line height as everything below Section is the regular cut. Hero is set in Inter Display Bold,
1.35 × size rather than naming it nine more times. the optical size Inter draws for large text (`theme::display`); Android and iOS
map it to `displayLarge`. Compose derives line height as 1.35 × size rather
than naming it ten more times, except Hero at 1.1, where the text leading
pushes a two-line name apart.
Icons on the desktop come from the Phosphor icon font (`egui-phosphor`, Icons on the desktop come from the Phosphor icon font (`egui-phosphor`,
regular weight), installed right behind Inter so an icon can sit inside an regular weight), installed right behind Inter so an icon can sit inside an
@@ -59,9 +63,23 @@ this module, and keep Material's sizes. Nothing in the app draws with them.
buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
small control inside something else, and **12** (`LG`) for what floats — row small control inside something else, and **12** (`LG`) for what floats — row
cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has cards, dialogs, toasts. The library's banner is square: it runs edge to edge. **16** for an Android bottom sheet, which has
no desktop counterpart. no desktop counterpart.
## A picture's colour
The veils over a title's backdrop — the library's banner, the top of a title's
page — are the page colour tinted by the picture under them, so each title
carries its own light. All three clients get the same colour from the same
code: `pstr_core::appearance::mood` averages the picture, squeezed to a few
hundred pixels, counting a saturated pixel up to five times a grey one
(`MOOD_CHROMA_WEIGHT`), and `mood_veil` turns that into the veil. On a dark
palette the mood is darkened until its brightest channel is 60 (`MOOD_DEPTH`)
and the page moves 55% of the way to it (`MOOD_DARK`); a light palette moves
12% (`MOOD_LIGHT`). The phones reach both through the bridge, as
`pictureMood` and `moodVeil`. The fade at the bottom stays the page colour,
because that is what it ends in.
## Spacing ## Spacing
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16, The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
+1 -1
View File
@@ -10,7 +10,7 @@
| `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) | | `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) |
| `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context | | `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context |
| `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management | | `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management |
| Metadata providers (AniList / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default | | Metadata providers (AniList, then TVmaze / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default |
| Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac | | Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac |
## Build order from here ## Build order from here
+75
View File
@@ -0,0 +1,75 @@
# FAQ
## Is this an official Proton app?
No. It is an unofficial, open-source client written by one developer. Proton AG has not reviewed,
endorsed or supported it. "Proton" and "Proton Drive" are Proton AG's trademarks.
## Do I need a Proton account?
No, not to watch a public share link. You need one to add folders of your own Drive and to sync
your place across devices. See [Account and sync](ACCOUNT_AND_SYNC.md).
## Does it download the whole file before playing?
No. Proton Drive stores a file as blocks that each decrypt on their own, so the app fetches only
the blocks around the point you are watching. A seek fetches the block it lands on. On a
26-minute 1080p episode the first frame takes about 2.3 seconds.
## Does it transcode?
No. The video plays as it is stored, through libmpv. HEVC 10-bit, HDR and ASS subtitles all work,
with hardware decoding where the device has it.
## Subtitles are hard to read. Can I change them?
Yes. In **Settings → Subtitles** you can set the size, position, font, colours, outline, box,
shadow and blur. A **Box** background is easiest to read on bright scenes. Styled subtitles (ASS)
keep the look, size and place their makers gave them, unless you turn on **Restyle styled
subtitles**.
## The subtitles are early or late.
Press **Z** to show them 0.1 s earlier and **X** to show them later, or use **Delay** in the
player's subtitle menu. The delay holds for the file you are watching only.
## Does it provide any videos?
No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue,
no search of other people's shares and no content of its own.
## Why are there no posters?
Posters and descriptions are off by default, because looking them up sends your title names to
AniList or TMDB. Turn them on in **Settings → Posters and descriptions**. See
[Privacy](PRIVACY.md).
## A title is matched to the wrong show. How do I fix it?
Open the title and choose **Match…** (or **Change match**). Search for the right entry and pick
it. A match you pick is never replaced, and it syncs to your other devices.
## The episodes of one show appear as separate titles.
The app takes the show's name from its folder. Put every episode of a show in one folder, or in
season folders below it. [How the library reads your
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has examples.
## Can several people share one library?
Yes. Send them the same share link. Each person's app keeps its own watch history; sync only
connects devices that sign in to the same Proton account.
## Is there a macOS version?
Not as a release. The app builds and runs on macOS from source, and `scripts/build.sh` can make a
`.app` and a `.dmg` on a Mac, but no release ships one yet.
## Can I cast to a TV?
Not yet. On an Android TV, install the APK and run the app on the TV itself.
## Where do I report a bug?
Open an issue on [GitHub](https://github.com/narrrl/proton-stream/issues). Say which platform
and version you use, and what you did.
+66
View File
@@ -0,0 +1,66 @@
# Getting started
This page takes you from nothing to a playing episode. It takes a few minutes.
## 1. Install the app
Download the package for your system from the
[latest release](https://github.com/narrrl/proton-stream/releases/latest) and install it.
[Installation](INSTALL.md) has the commands for each platform.
## 2. Add a share
A *share* is a folder the app reads videos from. There are two kinds:
- **A public share link.** Someone sends you a Proton Drive link such as
`https://drive.proton.me/urls/ABC123#xyz`. You need no Proton account for this.
- **A folder of your own Drive.** Sign in to your Proton account, then pick a folder. See
[Account and sync](ACCOUNT_AND_SYNC.md).
To add a public link on the desktop:
1. Copy the whole link, including the part after `#`. That part is the key that decrypts the
share, so the link does not work without it.
2. Open the **Shares** page, or press **Ctrl+V** anywhere outside a text field.
3. Paste the link into **Link**.
4. If the person who shared it set a password of their own, turn on **The link asks for a
password** and type it.
5. Click **Add share**.
The app opens the link to check it before it saves anything. If the link is wrong, it tells you
why and stores nothing.
On Android and iOS, tap **Add share** on the **Shares** tab. On Android you can also share the
link to the app from your browser. [Android and iOS](MOBILE.md) has the details.
## 3. Wait for the library to fill
The app reads the share's folders. This is called a *crawl*. Titles appear in the **Library** as
they are found. A large share takes a minute or two.
The folder layout decides how titles are grouped. Each show should have its own folder:
```text
Sousou no Frieren/
Season 01/
S01E01.mkv
S01E02.mkv
Paprika (2006).mkv
```
A file without episode numbers is shown as a film. [How the library reads your
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has the full rules.
## 4. Press play
Open a title and click **Play**. The episode starts in a few seconds, and you can drag the seek
bar to any point. The app remembers where you stopped, so **Resume** takes you back.
## Next steps
- **Posters and descriptions.** Turn them on in **Settings → Posters and descriptions**. They are
off by default, because the app then sends your title names to AniList or TMDB. See
[Privacy](PRIVACY.md).
- **Your place on every device.** Sign in to Proton on each device, and your position in every
episode follows you. See [Account and sync](ACCOUNT_AND_SYNC.md).
- **Keyboard.** Press **?** on the desktop for the list of shortcuts.
+123
View File
@@ -0,0 +1,123 @@
# Installation
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has packages for Linux
and Windows, an APK for Android and an IPA for iOS, plus a `SHA256SUMS` file to check them
against. The version is part of each file name.
| Platform | File | Needs |
|---|---|---|
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` | x86_64 |
| Fedora | `proton-stream-<version>-1.x86_64.rpm` | x86_64 |
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` | x86_64, libmpv 2 |
| Arch Linux | Built from the repository with `makepkg` | |
| Windows | `proton-stream-<version>-x64.msi` or `…-x86_64-windows.zip` | x64 |
| Android, Android TV | `proton-stream-<version>-android.apk` | Android 12 or newer, arm64 or x86_64 |
| iOS, iPadOS | `proton-stream-<version>-ios.ipa` | iOS 17 or newer, SideStore or AltStore |
## Linux
The desktop app needs libmpv, WebKitGTK 4.1 (for the CAPTCHA window when you sign in) and a
Secret Service keyring such as GNOME Keyring or KWallet. The packages pull in everything except
the keyring, which most desktops already have.
### Debian and Ubuntu
Download the `.deb`, then:
```bash
sudo apt install ./proton-stream_*_amd64.deb
```
### Fedora
Download the `.rpm`, then:
```bash
sudo dnf install ./proton-stream-*.x86_64.rpm
```
### Arch Linux
The `PKGBUILD` in `packaging/` builds the checkout it sits in. It needs network access, so cargo
can fetch crates:
```bash
git clone https://github.com/narrrl/proton-stream.git
cd proton-stream/packaging
makepkg -si
```
### Other distributions
Unpack the tarball anywhere. It holds `proton-stream` (the app), `pstr` (the
[command line](CLI.md)) and the icon. Install libmpv 2 and WebKitGTK 4.1 from your
distribution first.
Both packages install:
- `/usr/bin/proton-stream`, the app, listed as **Proton Stream** in your applications menu
- `/usr/bin/pstr`, the command line
## Windows
Download either file from the release:
- **The `.msi` installer** installs to `Program Files\Proton Stream`, adds a Start menu entry
and puts `pstr` on the PATH.
- **The portable `.zip`** runs from any folder. Keep every file in it together: the app does not
start without `libmpv-2.dll` and `WebView2Loader.dll` beside it.
The binaries are not code-signed yet, so SmartScreen may warn the first time. Choose **More
info**, then **Run anyway**. The sign-in window uses the WebView2 runtime that comes with Windows
10 and 11.
## Android and Android TV
1. Download `proton-stream-<version>-android.apk` on the device.
2. Open it. Android asks once to allow installs from your browser or file manager.
3. Install.
The APK is signed, so later versions install over it and keep your library. On Android TV the app
appears in the launcher; installing needs a file manager or `adb install`.
## iOS and iPadOS
The `.ipa` is unsigned. [SideStore](https://sidestore.io) or [AltStore](https://altstore.io)
signs it with your own Apple ID when it installs:
1. Install SideStore or AltStore by following its own guide.
2. Download `proton-stream-<version>-ios.ipa`.
3. Open it with SideStore or AltStore.
A free Apple ID has to refresh the app every 7 days. SideStore and AltStore do that for you.
## Updating
Install the new release over the old one. Your shares, library, watch history and settings stay.
The app does not check for updates by itself; watch the
[releases](https://github.com/narrrl/proton-stream/releases) on GitHub.
## Uninstalling
Remove the package the way you installed it, for example `sudo apt remove proton-stream`.
This leaves your data behind. To remove that too:
1. In the app, remove each share on the **Shares** page. This deletes its link and password
from the keyring and its downloads from the disk.
2. Sign out on the **Shares** page, if you signed in.
3. Delete the app's folders, listed in [Privacy](PRIVACY.md#what-stays-on-your-device).
On Android and iOS, uninstalling the app deletes everything it stored.
## Building from source
Rust 1.96 or newer, plus the libmpv (≥ 2.0) development headers, and WebKitGTK 4.1's on Linux:
```bash
git clone https://github.com/narrrl/proton-stream.git
cd proton-stream
cargo build --release
```
[Development](DEVELOPMENT.md) and `packaging/README.md` have the details, including Windows, the
Android toolchain and iOS builds on macOS.
+6
View File
@@ -90,6 +90,12 @@ Each of these is the platform's doing, not a choice to diverge.
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`) a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
stand in for the ripple, and a settings page goes back by an edge strip of its stand in for the ripple, and a settings page goes back by an edge strip of its
own, as a title page does, where Android has the system back. own, as a title page does, where Android has the system back.
- **No zoom into a title page.** iOS 18's `.navigationTransition(.zoom)` only
runs inside a `NavigationStack`, and the title page is shown over the library
by hand, so it keeps its slide in from the edge — Android's shared poster has
no counterpart here.
- **The volume swipe turns the player's own volume**, the one its slider sets.
iOS lets no app set the device's media volume, which Android's swipe turns.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share - **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync arrive without them, and a restored `sync.json` would give two devices one sync
+139 -8
View File
@@ -45,8 +45,9 @@ good. What hung off it was not:
|---|---|---| |---|---|---|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. | | **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. | | **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. | | **AniSkip** `api.aniskip.com/v2/skip-times/` | none | Where an episode's opening and ending are, by MyAnimeList id and episode number, as viewers marked them. Asked at play time for files without such chapters; never stored. |
| **TVmaze** | none | Live-action TV episodes, keyless. | | **TMDB** | free key | Film backdrops and logos; live-action TV. Chosen as the provider, or with AniList chosen, asked by id for art alone. |
| **TVmaze** | none | Live-action TV: episodes with 16:9 stills and runtimes, network, status, full-HD backgrounds. No films; its "typography" art is a JPEG, so no logos. In use after AniList. |
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. | | **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. | | **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. | | **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
@@ -148,8 +149,30 @@ titles are one director's, and that is not a shelf — and a genre only under 40
of the library. Four at most, over the unfiltered library. Here: *Directed by of the library. Four at most, over the unfiltered library. Here: *Directed by
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*. Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
**The title page** adds the season, studios, director and next airing episode **The title page** adds the season and next airing episode to its line of
to its line of facts, and the tags beside the genres. facts, the tags beside the genres, and the studios and director as chips of
their own. Each genre, studio and director chip opens a page of every title
carrying it (`pstr_core::discover::Facet`).
**More like this.** `pstr_core::discover::more_like` scores every other title
by what it shares with this one — a director 4, a studio 3, a genre 2, one of
the first eight tags 1, the format 1 on top of anything else — and offers those
scoring 4 or more: a director, or two genres, but never one genre alone. The
title's own franchise is left out and each other franchise offers only its best
match. The same scoring, seeded by the title most recently watched to the end
and limited to titles not started, is the library's *Because you watched* row.
No request: it reads only what the match already stored.
**Cast and trailer.** The AniList query also asks for the first ten
characters by role, main ones first, each with their Japanese voice actor and
both portraits, and for the trailer's site and id. They are
`TitleDetails::cast` and `TitleDetails::trailer`, in the same JSON column — no
migration. `TitleDetails::REVISION` is 2, so a match stored before them is
fetched again by id on the next match run, as `runtime` was. A voice actor's
chip opens the same page as a director's (`Facet::Person`). The trailer is
only ever a link: `Trailer::url` builds the YouTube or Dailymotion address,
keeping only an id's own characters, and the browser opens it. Portraits are
fetched like posters, from AniList's image host.
**Android.** The same, through the bridge: `AndroidEngine::arrangement` **Android.** The same, through the bridge: `AndroidEngine::arrangement`
returns the grid's tiles and the shelves as keys into `library`'s titles, and returns the grid's tiles and the shelves as keys into `library`'s titles, and
@@ -160,15 +183,123 @@ same facts, tags and franchise row as the desktop's; *Title names* is in
Settings → Appearance. Android's grid is always covers, so it has no Settings → Appearance. Android's grid is always covers, so it has no
*Library tiles* choice. *Library tiles* choice.
**Folders of films.** `pstr_core::catalog::build_rows` gives each file in a
folder of films its own title, so the four Rebuild films in one folder are
four entries, matched one by one, and the franchise folds them back into one
tile. Only when no file is numbered, none sits under `Movies`/`OVA`, and each
names itself differently from the others and from the folder; anything less
is a series with its specials. A film's version number (`1.11`) is not part of its name on
AniList, so a search that finds nothing is asked again without it.
Not done: TMDB fills no details. Not done: TMDB fills no details.
**Logos and running times.** ani.zip sent both from the start and neither was
kept. Enrichment now keeps the series' TVDB *Clearlogo* — a transparent PNG —
in `title_metadata.logo_url`, kept across a refresh of the same entry like the
fanart, and each episode's running time (TVDB's, else AniDB's `length`) in
`episode_metadata.runtime`. AniList's `duration` lands in
`TitleDetails::runtime`: minutes per episode for a series, the whole running
time for a film. TMDB's episode runtimes are kept the same way.
A clear logo is padded to a square, so every client crops it to its visible
pixels before drawing it (`trim_alpha` on the desktop, `opaqueBounds` on
Android and iOS). It replaces the name over the library's banner and at the
top of the title page — except on a light theme's title page, where lettering
made for dark art could vanish. A film's running time joins its line of facts;
an episode row shows its own (on Android and iOS in place of the file size).
**Schema v13** adds the two columns and marks every AniList title un-enriched,
deleting nothing. `TitleDetails::revision` makes details written before
`runtime` existed refetch by id on the next run; they stay on screen until the
answer lands, rather than being blanked by the migration as schema v10 did.
**AniSkip.** A file whose chapters name no opening or ending — most files
that are not fansub releases — gets them from [AniSkip](https://aniskip.com),
where viewers mark where an episode's opening and ending are. ani.zip sends each
entry's MyAnimeList id with its mappings; enrichment now keeps it on every
episode row (`episode_metadata.mal_id`), next to the episode's number within
that entry. When such a file has loaded and its length is known, the player asks
`api.aniskip.com/v2/skip-times/{mal_id}/{number}?types=op&types=ed&episodeLength=`
once, with enrichment on only. The answer is kept in memory for as long as the
file is open and never stored.
`pstr_core::chapters::with_skip_ranges` cuts the answer into the file's
chapters as chapters named `Opening` and `Ending`, so the skip button, skipping
on its own, the up-next countdown and the marks on the seek bar all work through
the same code as for a file that came with them. A file whose own chapters
already name an opening or an ending keeps them: what a release says about its
own cut beats what a viewer of another release marked.
**Schema v14** adds the column and marks every AniList title un-enriched,
deleting nothing, as v13 did. Until the next match run fills it in, AniSkip is
not asked.
## Phase 3 — reach ## Phase 3 — reach
**Routing by title (done).** One provider for the whole library left Game of
Thrones unmatched in a share of anime, and switching the library to TMDB would
have thrown away every AniList match, chain and shelf. So the providers are now
asked in a chain (`pstr_meta::service::chain_of`): with AniList chosen, a title
AniList has no match for is searched on TVmaze, keyless, before it is given up
on. TMDB, which has television itself, is still asked alone.
- A match is stored under the provider that gave it, and everything after it
— enrichment, refreshing details by id — goes to that provider.
- A miss is stored under the *last* provider asked. `MetadataRecord::is_fresh`
takes the chain, and a miss only stands when the chain ends with the provider
that stored it. So an AniList miss from before TVmaze joined is not trusted,
and the next run asks TVmaze at once rather than after the miss expires.
- A provider that fails (a timeout, a 429) ends the lookup with an error rather
than falling through. The next provider's answer for a title AniList could
not be asked about is a guess about an anime, and a guess stored as a match
would stay.
- TVmaze is not asked about a title the library knows to be a film. It has
none, and a show sharing a film's name is far more often wrong than an
adaptation.
- A hand search asks every provider in the chain and labels each result with
the provider it came from; picking one pins the title to that provider.
TVmaze answers search with whole show records — genres, network, status,
premiere date, runtime, rating — so a match carries its details with no second
request. Enrichment is one more: `/shows/{id}` with the episodes and images
embedded. Specials, which TVmaze numbers nowhere a file would, are left out.
**Episode names from scene releases (done).** `Game.of.Thrones.S01E01.Winter.Is.Coming.2160p…`
names its episode in dotted words, and `naming::episode_title` now reads them,
stopping at the first release tag. A dotted run that no tag ends is not taken.
This is the fallback for a title no provider has.
**Film art from TMDB by id (done).** TVDB has no films, so ani.zip has no
fanart or logo for one, and seventeen of the library's titles — nearly all
films — had nothing wide to draw. ani.zip does give the TMDB id
(`mappings.themoviedb_id`, with `mappings.type` saying whether it is a film's
or a show's). 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 backdrop without lettering is preferred, and a
PNG logo in the viewer's language, then English. An OVA's or a special's id is
left alone, since TMDB files those either way. A failure there is logged and
the enrichment stored without the art, rather than lost over decoration.
With TMDB itself chosen, enrichment now makes the same request for the logo,
and backdrops are fetched at `w1280` rather than `w780`, since they fill the
width of the title page.
**A band that never looks broken (done).** A title with no wide art drew
its poster cropped to the band's shape: a blown-up slice of the middle of it.
Now the poster is squeezed to 64×36 and blurred once, when it is fetched
(`Fit::Ambient`; the cache keeps the wash, not the poster), and that is
stretched across the band — the poster's colours and light rather than a
crop. The library's banner then stands the poster itself at its right. Android
and iOS blur the poster the same way (Android 12 and later; older versions
decode it tiny, which softens it less).
**Quality from file names (done).** `naming::quality` reads `2160p`, `HDR`,
`10-bit` and `Dual-Audio` out of a release name, and a title's page shows what
most of its files say. No release group is read.
Still to do:
- **Local matching** against the offline database, so only ids leave the - **Local matching** against the offline database, so only ids leave the
machine. machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share.
- **Folders of films** matched per file: four Rebuild films in one folder are
four entries, not one.
- **Skip intro** from AniSkip for files without chapters, and filler flags from - **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan. Jikan.
+93
View File
@@ -0,0 +1,93 @@
# Android and iOS
The Android and iOS apps share the desktop app's engine, so a library, its watch history and its
settings behave the same everywhere. This page covers what is different on a phone, a tablet and
a TV. [Installation](INSTALL.md) explains how to install each one.
<table>
<tr>
<td><img src="../images/android-home.png" alt="Home" width="200"></td>
<td><img src="../images/android-library.png" alt="All titles" width="200"></td>
<td><img src="../images/android-title.png" alt="A title page" width="200"></td>
<td><img src="../images/android-episodes.png" alt="Episodes and downloads" width="200"></td>
</tr>
</table>
## Adding a share
Tap **Add share** on the **Shares** tab, paste the link, and add a custom password if the link has
one. The app checks the link before it saves it.
- **Android:** you can also share a link to the app from your browser or a chat app, or open a
`drive.proton.me/urls/…` link directly if you allow the app to open supported links in the
system settings.
- **iOS:** there is no share sheet. Copy the link, then tap **Paste link** in the dialog.
Folders of your own Drive work as on the desktop: sign in on the **Shares** tab, then **Add from
your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md).
## The player
- Tap to show or hide the controls.
- **Double-tap** the left or right third of the screen to go back or forward 10 seconds. Double-tap
the middle to pause.
- **Swipe** up and down on the left half for brightness and on the right half for volume. Swipe
sideways to seek: the bubble shows where you will land, and the seek happens when you let go.
Swipes that start at the very top or bottom of the screen are left to the system.
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks,
**Lock to landscape**, **Sleep timer** and **Lock touch**.
- **Sleep timer** pauses after 30 or 60 minutes, or stops at the end of this episode instead of
going on to the next. It keeps running with the screen off.
- **Lock touch** makes the picture ignore touches, for a phone in a pocket or a child's hands. Tap
to bring up **Unlock**.
- **Skip opening** and **Skip ending** work as on the desktop, AniSkip included: see
[The player](USER_GUIDE.md#the-player).
- Sound keeps playing with the screen off or the app in the background, with controls on the lock
screen. Turn off **Background audio** in Settings to stop that.
Picture-in-Picture is not available on either platform.
## Android TV
The app appears in the TV launcher. With the controls hidden:
| Remote | Does |
|---|---|
| Left / right | Back or forward 10 seconds |
| Centre, up or down | Show the controls |
| Play/pause | Pause and play |
There is no separate TV layout yet, and the app has not been tested on a real TV. Reports are
welcome.
## Downloads
Download an episode, a season or a show from its title page, and play it from the **Downloads**
tab without a connection.
- **Android** waits for Wi-Fi unless you turn off **Download on Wi-Fi only**. Downloads continue
with the app closed.
- **iOS** downloads only while the app is open, and for the short time iOS allows after you leave
it. A stopped download resumes where it stopped.
**Delete all offline episodes** in Settings frees the space in one step.
## New episodes
When a share gains episodes of a show, the app sends a notification.
- **Android** checks about every 6 hours, when the device has a network and the battery is not
low. Turn it off with **New episode notifications** in Settings.
- **iOS** asks the system to check every 6 hours or so; iOS decides when it actually runs.
## Where your secrets are kept
- **Android:** in the app's private storage, encrypted with a key in the Android Keystore. If you
change or remove the device's screen lock, Android may delete that key. The app then asks you
to **Re-enter link** for each share.
- **iOS:** in the Keychain, only on this device, and not in backups.
## Requirements
- **Android 12** or newer, on an arm64 or x86_64 device. 32-bit devices are not supported.
- **iOS 17** or newer, on iPhone or iPad.
+78
View File
@@ -0,0 +1,78 @@
# Privacy
proton-stream has no server and no account with its developer. Each app talks to Proton directly,
and to nothing else unless you turn on posters and descriptions. This page lists what goes where.
## What the app sends, and to whom
| To | What | When |
|---|---|---|
| Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always |
| AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on |
| TVmaze | With AniList chosen: the names of the titles AniList has no match for, and the ids of those TVmaze matched | Only with **Posters and descriptions** on |
| TMDB, with AniList chosen | The TMDB ids of matched titles that have no wide picture or logo yet. Never a title's name | Only with **Posters and descriptions** on and a TMDB key saved |
| AniSkip | The MyAnimeList id and episode number of what you play, and the file's length. Never a name | Only with **Posters and descriptions** on, when you play an episode whose file does not mark its opening and ending |
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
**Posters and descriptions** are off by default. When they are on, the provider learns the names
of the titles in your library. It does not learn your files, your watch history or your shares.
Turning them off deletes every stored answer.
**Trailer** on a title's page opens the trailer's YouTube or Dailymotion page in your web
browser. The app sends nothing there itself; your browser loads the page as it would any link.
## Secrets
- **The part of a share link after `#`** is the key that decrypts the share. The app treats it as
a password.
- **A link's own password**, if it has one.
- **Your Proton session**, if you signed in. The app keeps the session, not your password.
All of these go into the system's credential store, never into a config file:
| Platform | Store |
|---|---|
| Linux | Secret Service (GNOME Keyring, KWallet, KeePassXC) |
| Windows | Credential Manager |
| Android | App storage, encrypted with a key in the Android Keystore |
| iOS | Keychain, this device only |
If you sign in, your shares, with their links and passwords, also sync through your own Drive.
Like every file there, they are end-to-end encrypted. See [Account and sync](ACCOUNT_AND_SYNC.md).
## How the player gets the video
The player is fed through a callback inside the app's own process. There is no local web server,
so there is no port another program on the computer could ask for the decrypted video. The
player is handed an opaque token, not a link, so no secret ends up in its log or window title.
## What stays on your device
| What | Where on Linux |
|---|---|
| Settings, the share list (without secrets), My List and your last eight searches (`recent-searches.json`) | `~/.config/proton-stream/` |
| The library and watch history (`catalog.db`) and downloads (`offline/`) | `~/.local/share/proton-stream/` |
| Streamed video (`blocks/`), thumbnails and posters | `~/.cache/proton-stream/` |
On Windows the same folders are under `%APPDATA%\narl\proton-stream\` and
`%LOCALAPPDATA%\narl\proton-stream\`. On macOS they are under `~/Library/Application Support/`
and `~/Library/Caches/`, in `io.narl.proton-stream`.
**The streaming cache and downloads hold decrypted video.** Anyone who can read your user's files
can play them. Empty the cache in **Settings → Storage**, and delete downloads on the
**Downloads** page.
The library database holds file names and your watch history, in plain text. It holds no
secrets.
Recent searches are saved only when you open something a search found, are never synced, and
**Clear recent searches** under the empty search box deletes them.
## Other people on the same computer
- A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in
the process list while the command runs, and may land in your shell history. Paste links into
the window instead.
- Your desktop's media controls are given the title, the episode and a picture already on disk.
They are never given a provider link, so the desktop does not contact a provider by itself.
+41
View File
@@ -0,0 +1,41 @@
# proton-stream documentation
proton-stream turns the videos in your Proton Drive, and the share links people send you, into
a library you can browse and stream. It runs on Linux, Windows, Android, Android TV and iOS.
This is an unofficial client, not a Proton product.
<img src="../images/desktop-home.png" alt="The library in the desktop app" width="820">
## Start here
- [Getting started](GETTING_STARTED.md): install, add your first share, press play.
- [Installation](INSTALL.md): every platform, and how to update and uninstall.
- [FAQ](FAQ.md): short answers to the questions people ask first.
## Using it
- [User guide](USER_GUIDE.md): the library, title pages, the player, downloads, history and
settings.
- [Account and sync](ACCOUNT_AND_SYNC.md): signing in, folders of your own Drive, and keeping
your place on every device.
- [Android and iOS](MOBILE.md): what is different on a phone, a tablet and Android TV.
## Reference
- [Command-line reference](CLI.md): the `pstr` tool and the `proton-stream` launcher.
- [Privacy](PRIVACY.md): what goes where, and what stays on your device.
## When something goes wrong
- [Troubleshooting](TROUBLESHOOTING.md): links that will not add, sign-in problems, playback
problems, and where the logs are.
## Contributing
- [Development](DEVELOPMENT.md): the state of each layer, the build order and the traps.
- [Architecture](ARCHITECTURE.md): the crates, the data flow when an episode plays, and where
secrets live.
- [Testing](TESTING.md), [Metadata](METADATA.md), [Design tokens](DESIGN-TOKENS.md),
[Android development](ANDROID.md) and [iOS development](IOS.md).
Bugs are tracked in [BUGS.md](BUGS.md) in the repository.
+114
View File
@@ -0,0 +1,114 @@
# Troubleshooting
## Adding a share
### "Proton share URL has no #password fragment; copy the full share link"
The link is missing the part after `#`, which is its decryption key. Copy the link again from
where you got it, all of it.
### "This link has a password of its own. Enter it to add the link."
The person who shared it also set a password. Turn on **The link asks for a password** (on a
phone, fill in **Custom password**) and type it.
### "That share is already added."
The link, or the same folder of your Drive, is already on the **Shares** page.
### "Last crawl failed: …"
The share could not be read. The link may have expired or been revoked, or its password
changed. If the password changed, choose **Re-enter link**.
## The keyring
### "share … has no stored credentials; remove and re-add it"
The link and password are gone from the keyring. This happens when a keyring is reset, or on
Android when the screen lock changes. Choose **Re-enter link** on the **Shares** page. Your
library, history and downloads stay.
### "credential store: …" on Linux
The app found no Secret Service. Install and start one, such as GNOME Keyring, KWallet or
KeePassXC with its Secret Service integration, and unlock it.
## Signing in
### "Proton did not accept that address and password"
Check both. Sign in on proton.me to make sure the account works.
### "Proton has blocked sign-ins from this app for now"
Proton's abuse protection stopped the sign-in. Wait a while and try again, or sign in once on
proton.me first.
### "This account's only second factor is a security key"
The app cannot use security keys yet. Add an authenticator app as a second method in your
Proton account settings, then sign in with its code.
### The CAPTCHA window does not open on Linux
The window needs WebKitGTK 4.1. The `.deb`, `.rpm` and Arch packages pull it in; with the
tarball, install it yourself.
### `pstr login` asks for a CAPTCHA
The command line cannot show one. Sign in once in the app.
## Playback
### Green blocks, a black picture or torn frames
Turn off **Hardware decoding** in Settings. It applies from the next file.
### An episode takes long to start
The first seconds of a file are fetched over the network. On a slow connection, start the
episode and give it a moment, or download it first. A second viewing comes from the disk cache.
### No skip button for the opening
The skip button comes from the file's chapter names. A file without named chapters gets none.
### The wrong subtitle track plays
Choose the track in the player. The app remembers it for the rest of the show. Set the
language to start in under **Settings → Playback**.
## Windows
### The app does not start from the portable `.zip`
Keep every file from the archive in one folder. The app needs `libmpv-2.dll` and
`WebView2Loader.dll` next to `proton-stream.exe`.
### SmartScreen warns about the installer
The binaries are not code-signed yet. Choose **More info**, then **Run anyway**.
### A pinned taskbar icon shows a blank picture after an update
Unpin it and pin it again once.
## Linux
### "Not responding" when the window is on another workspace
Fixed. On Wayland the app limits its own frame rate instead of waiting for vsync, which a
compositor withholds from a window it is not showing. Update to the latest release.
## Logs
Start the app from a terminal to see its log:
```bash
RUST_LOG=debug proton-stream
```
The same works for `pstr`. When you
[open an issue](https://github.com/narrrl/proton-stream/issues), include the lines around the
error, but **remove any share link first**: the part after `#` is a key.
+294
View File
@@ -0,0 +1,294 @@
# User guide
This page describes the desktop app. The Android and iOS apps have the same pages as tabs at the
bottom of the screen; [Android and iOS](MOBILE.md) covers what is different there.
The app has five pages, in the bar at the top: **Library**, **History**, **Shares**,
**Downloads** and **Settings**. **Ctrl+1** to **Ctrl+5** open them.
## Library
<img src="../images/desktop-library.png" alt="The whole library, filtered and sorted" width="820">
The library shows every title from every share in one place. Two shares that hold the same show
show it once.
- **The banner** across the top shows the title you watched last first, with **Resume**, how far
into it you are, **More info** and **My List**. It then turns through up to four more titles that have a wide
picture, a different set each day, moving on every eight seconds. It holds still while the
pointer is over it; on a phone, swipe it or tap a dot under it to move on yourself. Its shading
takes on the colours of the picture behind it, and so does the top of a title's page.
- **Continue watching** has the episode you stopped in for each show, or the next one when you
finished an episode. To take a show off this row, right-click it and choose **Remove from
Continue watching**.
- **New episodes** has the shows you are watching that got episodes since you last watched them.
Their tiles say how many, for example **3 new**.
- **My List** has the titles you put aside to watch, the last one added first.
- **Recently added** has the titles that arrived in the last two weeks and that you have not
started. Their tiles say **New**. What was already there when you added a share is not new.
- **Because you watched Frieren** has titles like the last one you watched to the end, that you
have not started. It needs posters and descriptions.
- **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when
posters and descriptions are on. The app shows at most four, and only for a director, studio or
genre with at least three titles. On a shelf, a show that is still airing says when its next
episode is out, for example **Ep 8 · in 2 d**.
- **All titles** is the full grid. Choose a filter and a sort order above it.
Rows that run past the window fade out at the edge. Point at a row to show the arrows at its
ends, which move it along by a page.
| Filters | Sort orders |
|---|---|
| All, My List, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
**My List** is a list of titles to watch later. Add a title with **My List** on its page or on
the banner, or right-click its tile and choose **Add to My List**. Choose the button again, or
**Remove from My List**, to take it off. When you are signed in, the list syncs to your other
devices ([Account and sync](ACCOUNT_AND_SYNC.md)).
**Group franchises** folds a series, its sequels and its films into one tile with a **+N** badge.
It is on by default, and needs posters and descriptions to know what belongs together.
**Search** with **Ctrl+F** or **/**. It matches every name a title has: the English one, the
romaji one and the name of its folder. With posters and descriptions on, it also finds:
- **People and studios**: directors and studios whose names match. Choose one to see all their
titles.
- **Genres** whose names match.
- **Episodes** whose names match. Choose one to play it.
The titles it finds come after these. Click the empty search box to see your last eight
searches. A search is kept only when you open something it found. **Clear recent searches**
removes the list. The list stays on this device and does not sync.
**Refresh** (or **F5**) reads every share again. The app also does this by itself when a share
has not been read for 6 hours.
### How the library reads your folders
The folder decides which show a file belongs to. The file name gives the season and episode.
| Layout | Read as |
|---|---|
| `Frieren/Season 01/S01E05.mkv` | Frieren, season 1, episode 5 |
| `Frieren/S01E05 - The Hero's Statue.mkv` | The same, with the episode title |
| `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 |
| `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 |
| `Akira (1988).mkv` | A film, from 1988 |
| `Rebuild of Evangelion/Evangelion - 1.11 You Are (Not) Alone.mkv` and three more | Four films, one title each |
- Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`.
- Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name;
the folder above them is.
- An episode title is read only after a dash, as in `S01E01 - Name`.
- A file with no episode number is a film. A folder of such files, each with a different name, is
one title per film.
The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video
containers.
## A title's page
<img src="../images/desktop-title.png" alt="A title's page" width="820">
Click a title to open its page. **Esc** goes back. On a phone, the picture at the top slides
away more slowly than the page, and once it has gone a bar with the title's name stays at the
top.
- **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from
the beginning.
- **Download show** keeps every episode on this device. See [Downloads](#downloads).
- **Match…** opens the matcher, where you choose which AniList, TVmaze or TMDB entry the title
is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
at all. A match you pick is never replaced.
- **More on AniList** (or TVmaze, or TMDB) opens the title's page there.
- Each season has **Mark season watched** and **Download season**. A show with more than five
seasons lists them in a drop-down instead of a row of buttons.
- With posters and descriptions on, a chip under the name says where the show stands: **Airing**
with the day the next episode comes out, **Finished**, **Upcoming**, **On hiatus** or
**Cancelled**.
- Each episode shows a tick when you have watched it, at the end of its row and in the corner of
its picture. Right-click an episode and choose **Mark
watched up to here** to tick it and every episode before it.
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
- **More like this**, under the episodes, lists titles in your library that share the most with
this one: its director, studio, genres, tags and format. It leaves out the title's own
franchise, and shows one title from each other franchise. It needs posters and descriptions.
- The genre chips under the description open a page with every title of that genre in your
library. So do the **Studio** and **Director** chips. Back, or **Esc**, returns to the title.
- **Cast** lists the main characters and who voices them in Japanese. Click one to see every title
in your library with that voice actor. Search finds voice actors too.
- **Trailer** opens the title's trailer in your web browser. The app does not play it or load it
itself.
- Titles matched before this version get their cast and trailer by themselves the next time the
app looks up posters and descriptions.
- With posters and descriptions on, a show's logo takes the place of its name at the top of the
page and on the library's banner, and each episode says how long it runs. A film says how long
it runs in the line under its name.
- A title with no wide picture shows its poster blurred across the top of the page instead, and
on the library's banner the poster itself beside it.
- Small marks under the name say what most of the files are, when their names say it: **4K**,
**1080p**, **HDR**, **10-bit** or **Dual audio**.
An episode counts as watched once you have seen 90% of it.
## The player
The picture plays inside the app's window. Move the mouse to show the controls.
- **Skip opening** and **Skip ending** appear when the file's chapters mark an opening, an ending
or a preview. For an anime whose file marks neither, the app asks AniSkip where they are, with
**Posters and descriptions** on; they then show as chapters too. Turn on **Skip openings and
credits** in Settings to skip them without asking.
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
**Play now**, **Cancel** or **Watch till the end**.
- **Subtitles and audio**, **Speed**, **Chapters** and **Sleep timer** are buttons at the bottom
right. Each opens a panel down the right of the picture; click the button again, or press Esc,
to close it.
- **Subtitles and audio** remembers your choice for each show. The panel also has **Size** and
**Position**, which are saved like the ones in Settings and change styled subtitles only when
they are restyled, and **Delay**, which moves subtitles earlier or later for this file only.
- **Speed** goes from 0.5× to 3×, and stays for the next episode.
- **Sleep timer**, the moon, pauses after 30 or 60 minutes, or stops at the end of this episode
instead of going on to the next.
- **Seeking and volume** show what they did in the middle of the picture. Several presses of ← or
→ add up, so three presses of → say "+ 90 s".
- **Paused** for 5 seconds with the mouse still, the picture dims and shows what you are watching:
the show's logo or name, the episode, and what it is about. Move the mouse to get the controls
back.
### Keyboard
Press **?** anywhere to see this list in the app.
| Key | Anywhere |
|---|---|
| Ctrl+F or / | Search the library |
| F5 or Ctrl+R | Read every share again |
| Ctrl+1 – 5 | Library, History, Shares, Downloads, Settings |
| Ctrl+, | Settings |
| Ctrl+V | Paste a share link to add it |
| Tab or arrows | Move between titles and episodes |
| Enter | Open or play what is highlighted |
| Alt+← or mouse back | Back |
| Key | Playing |
|---|---|
| Space or K | Pause and play |
| ← / → | Back 10 s, forward 30 s |
| ↑ / ↓ | Volume |
| M | Mute |
| N / P | Next and previous episode |
| [ / ] | Slower and faster |
| S | Skip the opening or the credits |
| Z / X | Subtitles 0.1 s earlier and later |
| E | The list of episodes |
| F or double-click | Fullscreen |
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
| Esc | Close a panel, leave fullscreen, then leave the player |
Media keys and your desktop's media controls work too.
### Subtitle files beside the video
The app picks up `.srt`, `.ass`, `.ssa` and `.vtt` files next to a video:
```text
Frieren/Season 01/
S01E01.mkv
S01E01.en.srt English
S01E01.en.forced.ass English, "forced"
Subs/
S01E01/
2_English.srt English
```
A `Subs` folder beside the videos works, and so does any subtitle in a folder that holds a single
video. The language comes from the last word in the name that names one.
### Dialogue, not signs and songs
Many anime releases have two subtitle tracks in one language: the full dialogue, and a short
*Signs & Songs* track that only translates on-screen text. When a file would start on the short
track, the app switches to the full one in the same language.
## History
Everything you played, newest first, grouped by day. An entry says **Watched**, **Stopped at
12:04 of 24:00** or **Played**. Removing an entry marks a watched episode unwatched, or forgets
where you stopped in one you had not finished.
## Downloads
A download keeps a full copy of an episode on this device, to play without a connection. Start
one from a title's page: a whole show, a season or a single episode.
The **Downloads** page lists them with **Pause**, **Resume**, **Cancel**, **Retry**, **Play** and
**Delete**. Three run at a time. Downloads do not count against the streaming cache.
## Shares
The **Shares** page lists every share, with what it holds and when it was last read.
- **Crawl** reads the share again.
- **Rename** changes the name shown in the app.
- **Re-enter link** gives the app the link again, for a keyring that lost it or a password that
changed. The library, history and downloads stay.
- **Remove** takes the share's titles out of the library, deletes its downloads and removes its
link and password from the keyring.
The **Proton account** section on this page is where you sign in. See
[Account and sync](ACCOUNT_AND_SYNC.md).
## Settings
**Appearance**
- **Palette:** Proton (the near-black default), Catppuccin Latte, Frappé, Macchiato and Mocha, or
Persona 5.
- **Accent:** Mauve, Pink, Sky, Pink → Sky, Lavender, Blue, Teal, Peach or Red. **Gradient
accent** draws it as a gradient.
- **Text size:** 90% to 150%. This one stays on this computer.
- **Library tiles:** posters or stills. **Title names:** as in the share, English or romaji.
**Playback**
- **Audio language** and **Subtitles:** the language to start in, or the file's own default.
- **Play the next episode** (on by default) and **Skip openings and credits** (off).
- **Hardware decoding** (on). Turn it off if the picture shows green blocks or stays black.
**Subtitles**
How subtitles look. A sample line at the top shows the style on a bright and a dark picture, and
a video that is playing changes at once.
- **Size** and **Position**: how big the line is and how far up the picture it sits.
- **Font**, **Bold** and **Italic**: any font installed on this computer. On Linux the sample
line shows the font too; on Windows and macOS it is drawn in the app's own font, and only the
video uses the one you chose.
- **Text colour**, and a **Background** of **Outline** or **Box**, each with its own colour. A
colour's opacity sets how much of the picture shows through. Choose **Box** if text is hard to
read on bright scenes.
- **Shadow** and **Blur**.
- **Restyle styled subtitles** (off). Plain subtitles (SRT) always take the whole style. Styled
subtitles (ASS) keep the look, size and place their makers gave them unless this is on; they
only move up while the player's controls are showing. Turned on, signs and songs can come out
wrong.
- **Reset** goes back to the default look.
**Storage**
- **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed,
so watching again needs no network. **Clear the streaming cache** empties it.
**Posters and descriptions**
- Off by default. When it is on, the app sends the names of your titles to the provider you pick:
**AniList** (no key needed) or **TMDB** (needs a free API key). With AniList, a title it does
not have, such as a live-action show, is looked up on **TVmaze** next, which needs no key
either. See [Privacy](PRIVACY.md).
- **TMDB API key:** needed for TMDB, optional with AniList. With a key, films and shows that have
no wide picture or logo get TMDB's, asked for by their TMDB id alone. After saving a key,
**Match everything again** fetches them for titles already matched.
- **Match the library** looks up new titles now. **Match everything again** looks up every title,
except those you matched by hand.
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

+77 -6
View File
@@ -41,7 +41,14 @@ final class AppModel {
/// A crawl is running, which the banner offers to stop. /// A crawl is running, which the banner offers to stop.
private(set) var crawling = false private(set) var crawling = false
private(set) var query = "" private(set) var query = ""
/// The whole library, whatever is searched for: the grid narrows to the
/// search through `arrangement`, and a title opened from a search stays one
/// the app can show. Android's `AppUiState.titles`.
var titles: [TitleRecord] = [] var titles: [TitleRecord] = []
/// What the search found besides titles; nil with no search.
private(set) var searchExtras: SearchExtrasRecord?
/// The last few searches on this device, newest first.
private(set) var recentSearches: [String] = []
/// The grid: `titles` ordered, each franchise folded into one tile when /// The grid: `titles` ordered, each franchise folded into one tile when
/// `grouped`, and the shelves above it. Nil until the bridge has answered, /// `grouped`, and the shelves above it. Nil until the bridge has answered,
/// when the grid falls back to `titles` as they are. /// when the grid falls back to `titles` as they are.
@@ -85,6 +92,7 @@ final class AppModel {
started = true started = true
reload() reload()
enteredForeground() enteredForeground()
Task { recentSearches = (try? await run { try $0.recentSearches() }) ?? [] }
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() } DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
DownloadCoordinator.shared.resumeQueued() DownloadCoordinator.shared.resumeQueued()
Task { Task {
@@ -136,6 +144,32 @@ final class AppModel {
} }
} }
/// Keep the search being shown as one to offer again: called when a viewer
/// opens something it found. Nothing for an empty one.
func rememberSearch() {
let query = query.trimmingCharacters(in: .whitespaces)
if query.isEmpty { return }
Task {
if let recent = try? await run({ engine in
try engine.rememberSearch(query: query)
return try engine.recentSearches()
}) {
recentSearches = recent
}
}
}
func forgetSearches() {
Task {
do {
try await run { try $0.forgetSearches() }
recentSearches = []
} catch {
reportError(error)
}
}
}
func refresh() { func refresh() {
crawl(nil) crawl(nil)
} }
@@ -480,7 +514,7 @@ final class AppModel {
if report.settingsChanged { if report.settingsChanged {
await AppearanceState.shared.reload() await AppearanceState.shared.reload()
} }
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty { if report.applied > 0 || report.titles > 0 || report.settingsChanged || report.listChanged || !report.sharesRemoved.isEmpty {
await reloadNow() await reloadNow()
} }
// A crawl takes longer than the grace iOS gives a backgrounded app. // A crawl takes longer than the grace iOS gives a backgrounded app.
@@ -534,6 +568,36 @@ final class AppModel {
} }
} }
// MARK: - My List
/// Put a title on My List, or take it off.
func setOnList(_ title: TitleRecord, _ on: Bool) {
Task {
do {
try await run { try $0.setOnList(key: title.key, on: on) }
} catch {
reportError(error)
}
reload()
}
}
/// The keys of the titles most like `key`, best first; none if the engine
/// cannot say.
func moreLike(_ key: String) async -> [String] {
(try? await run { try $0.moreLike(key: key) }) ?? []
}
/// The keys of every title `facet` stands for, in library order.
func browse(_ facet: BrowseFacet) async -> [String] {
do {
return try await run { try $0.browse(facet: facet) }
} catch {
reportError(error)
return []
}
}
// MARK: - Watch state // MARK: - Watch state
/// Every episode of a title watched or unwatched, with an Undo: a whole /// Every episode of a title watched or unwatched, with an Undo: a whole
@@ -637,8 +701,10 @@ final class AppModel {
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? { func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
do { do {
metadataSettings = try await run { engine in metadataSettings = try await run { engine in
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty { // TMDB's is the only key: the provider's with TMDB chosen, and
try engine.setMetadataApiKey(provider: provider, key: apiKey) // film art's with AniList.
if !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
try engine.setMetadataApiKey(provider: .tmdb, key: apiKey)
} }
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true)) try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
return try engine.metadataSettings() return try engine.metadataSettings()
@@ -808,7 +874,7 @@ final class AppModel {
_ = try engine.pruneOfflineFiles() _ = try engine.pruneOfflineFiles()
return Reloaded( return Reloaded(
shares: try engine.shares(), shares: try engine.shares(),
titles: try engine.library(search: query), titles: try engine.library(search: nil),
arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped), arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
offline: try engine.offlineFiles(), offline: try engine.offlineFiles(),
metadataSettings: try engine.metadataSettings(), metadataSettings: try engine.metadataSettings(),
@@ -834,11 +900,16 @@ final class AppModel {
let sort = sort let sort = sort
let grouped = grouped let grouped = grouped
do { do {
let (titles, arrangement) = try await run { engine in let (titles, arrangement, extras) = try await run { engine in
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped)) (
try engine.library(search: nil),
try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped),
try search.map { try engine.searchExtras(search: $0) }
)
} }
self.titles = titles self.titles = titles
self.arrangement = arrangement self.arrangement = arrangement
self.searchExtras = extras
} catch { } catch {
message = errorMessage(error) message = errorMessage(error)
} }
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more