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.
- 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).
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.
- 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.
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.
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.
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.
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.
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.
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.
- 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".
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.
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.
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.
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.
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.
- 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.
- 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.
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.
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.
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.
- 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".
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.
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.
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.
- 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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
The iOS client is now a screen-for-screen port of the Android app over the
same pstr-android UniFFI surface, with no Rust changes: Library, title page,
History, Shares (public links and Drive folders), Downloads and Settings,
with the same strings, undo snackbars and two-pane layout on wide screens.
- Theme from the engine's palette (Material role names, Inter, the same
type ramp and corners), repainted live from the appearance settings.
- Account sign-in with two-factor code, mailbox password and the Proton
CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and
watch-history sync every five minutes, after sign-in, on player close and
when the app leaves the screen.
- Player over libmpv: resume, progress saved every 5 s, autoplay with the
up-next card, skip and auto-skip from the chapter plan, track, speed,
volume and episode pickers, landscape lock, mini transport, and
lock-screen controls with interruption and route-change handling.
- Offline downloads with the Android record format, free-space floor,
retries, transfer rate and the Wi-Fi-only policy; interrupted transfers
resume at next launch.
- Config, data and download state are excluded from backup, as Android's
allowBackup=false: a restore would bring shares back without their
Keychain secrets and give two devices one sync identity.
Picture-in-picture, the share sheet and background downloads are not
possible the same way on iOS; docs/IOS.md lists why and what replaces them.
Adds XCTest ports of the Android unit tests, a `test` command to
scripts/build-ios.sh, the app icon and its render script, and an iOS
workflow on macos-15 that runs the tests and builds the unsigned .ipa.
Moves to proton-sdk / proton-drive-rs 0.7.3.
The stored Proton session now holds, per account key, the passphrase derived
from the mailbox password at sign-in, and resumes through
`ProtonDriveClient::with_key_passphrases`. The mailbox password — on a
single-password account, the login password — and the key salts are no longer
stored. A session in the earlier shape, which only development builds of
1.2.0 wrote, reads as signed out: signing in again replaces it.
An account whose only second factor is a security key is refused at sign-in
with a reason, rather than asked for a code that can only fail as a wrong one.
The code prompts now say plainly that security keys are not supported.
- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview
in a child process of the app (`pstr-app::verify`), confined to
https://*.proton.me, hands the token back and the sign-in is retried. Linux
packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship
WebView2Loader.dll, without which a -gnu build does not start.
- The desktop keyring splits a secret too long for one entry across several,
so a Proton session with many key salts survives Windows' 1280-unit cap.
Short secrets stay one entry, so every existing secret still reads.
- `WatchSync` runs against a small `HistoryDrive` trait and is tested end to
end against an in-memory Drive: two devices, newer wins, no re-upload of an
unchanged history, a missing file written again, an unreadable own file
never overwritten, a newer-format file left alone.
- An installation's file unwritten for 90 days is retired by the next device
to sync, after its entries are merged and carried in that device's file.
- Removing a share keeps its watch state: sync would bring it back from the
other devices anyway, and re-adding the share picks up where it left off.
- Watch history syncs once more on desktop exit (bounded to 3 s) and, on
Android, through a WorkManager job enqueued when the app leaves the screen.
- The second-factor prompts say security keys are not supported.