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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The desktop opens them while mpv opens the video and adds them with
sub-add through the stream registry once the file has loaded. One in the
preferred language is turned on when subtitles are on and the file has
none of its own playing; the rest wait in the subtitle menu.
2026-10-13 21:11:20 +02:00
nvrl 51454b5a6e refactor(core,player): move the language table into pstr-core
The crawl needs it to read a subtitle file's language from its name
(en, eng, English), and pstr-player re-exports it unchanged.
2026-10-13 21:11:20 +02:00
nvrl c44b04cf4a fix(ios): keep a crawl running when the app leaves the screen
Without a background task iOS suspends the app at once and drops its
connections, so a glance at another app failed a minutes-long crawl.
2026-10-13 20:45:14 +02:00
nvrl 85bc975f5f fix(core,android,app): say why a crawl failed, not only which URL
A transport failure's text is reqwest's "error sending request for url
(…)", and the cause sits further down its source chain. Error::describe
appends the causes and drops the URL, so the reason fits on a share row.
2026-10-13 20:45:14 +02:00
nvrl 4f130d1d6e fix(core): list a folder again when its connection drops mid-crawl
A crawl is hundreds of listings and the first failure ended it. The SDK
replays a timeout or a refused connection, not a request whose connection
dropped, which is what a phone sees after the OS tears down its sockets or
hands over between networks. A listing is read-only, so the crawl retries
it after 1, 3 and 9 seconds.
2026-10-13 20:45:14 +02:00
nvrl fb3a97b408 fix(ios): await the engine before stopping a crawl
NativeRuntime.engine() is async and throws on iOS, so the bare call in
stopCrawl failed the simulator build. Run it in a Task, as Android runs
it in a coroutine.
2026-10-13 18:37:47 +02:00
nvrl 04fc1f04fe chore(vers) bumped version to 1.5.0 2026-10-13 18:26:47 +02:00
nvrl aa0f4b1250 docs(android): mark B41's settings read as landed 2026-10-13 16:51:23 +02:00
nvrl c261f88c3f perf(android): decode posters at tile size, and keep disk reads off the main thread
- RemoteArtwork takes the edge it is drawn at; library tiles decode
  their posters at that size rather than at 1024 pixels, sampled down
  and then scaled so a poster never lands under its tile. The decoded
  cache's ceiling rises from 32 to 64 MiB, so a grid scrolled back over
  is still decoded.
- The title page decodes its download records on the IO dispatcher.
  WorkManager reports progress several times a second while a season
  downloads, and each report re-read every record on the main thread.
- The settings file is loaded on a background thread at startup, so the
  playback service's start callback no longer reads it from disk on the
  main thread (B41).
2026-10-13 16:51:16 +02:00
nvrl bf3fa19c70 feat(android,ios): read each library tile once, with its long press as an action
A poster's artwork carried the title's name and so did the text under
it, so a screen reader said the name twice. The artwork is now left out
where the text names it, and a folded franchise tile says how many more
it stands for. The long press that opens a tile's menu is offered as an
action, labelled on Android and as More on iOS, where tapAndHold also
makes the whole tile one button. On iOS the app behind a full-screen
player is hidden from VoiceOver, which walked the library behind the
picture.
2026-10-13 16:51:11 +02:00
nvrl 01eee78317 feat(android,ios): double-tap to seek, controls that fit upright, and a player TalkBack can use
- Double-tap the left or right third of the picture to seek ten seconds,
  with a mark showing the seek happened; the middle plays or pauses.
- A wide picture turns the phone's player to landscape by itself, once
  per episode, until the viewer uses the rotation button.
- Upright, the pickers past subtitles go behind a More menu instead of
  running off the edge. Controls are 48dp on Android and 44pt on iOS.
- With TalkBack or VoiceOver on, the controls stay up: a control that
  fades four seconds after it is reached cannot be found again. The tap
  layer is announced as Show or Hide controls.
2026-10-13 16:50:54 +02:00
nvrl 2546bab0a6 perf(android,ios): warm what an episode opens with, and the next one near the end
The bridge's open_stream started no fetch until mpv asked, so every
open was the head and then the tail, one after the other. It now warms
both edges as the desktop does, and the resume block too when the
episode resumes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No change to the Rust crates, the catalog schema or the desktop and Android
clients since 1.2.0.
2026-10-07 01:55:22 +02:00
nvrl a28135cd51 ci(release): publish the unsigned iOS .ipa
The release calls ios.yml after the version check, as it does android.yml,
and attaches proton-stream-<version>-ios.ipa next to the other artifacts and
in SHA256SUMS.
2026-10-07 01:55:22 +02:00
nvrl 4b9c01d52d fix(ios): clear the compiler warnings from the first CI build
previewPalette does not throw, and mpv's wid now gets the layer's address
as an explicit int64 instead of a pointer to a Swift reference.
2026-10-07 01:27:10 +02:00
nvrl 3356602951 fix(ios): give the test target the generated header path
`@testable import ProtonStream` imports the app's bridging header, which
includes PstrBridgeFFI.h from ios/build/generated.
2026-10-07 00:54:45 +02:00