pstr_mpv observes demuxer-cache-time and hands it over with the rest of
the state, and the seek bar shades from the start to where the demuxer
has read, lighter than the played part. A jump that lands in the shaded
run costs no fetch, as on the desktop.
- a long press on a title opens a sheet: Play or Resume, Open, Download
all, and Mark watched or unwatched for the whole title
- a long press on a Continue watching card offers Resume, Open, and
Remove from Continue watching, which forgets the position as the
desktop does
- marking a title and leaving Continue watching both say what they did
in a snackbar with Undo, which puts every changed watch state back
- the "next up" episode is one shared function, used by the title page
and the menu alike
Playback, Downloads and storage, Appearance, Metadata and About, each a
group of rows with one line of explanation, and the whole row as the
target. The paragraphs under the switches are gone; what they said is
either the row's supporting line or a code comment.
Storage shows the cache's use against its budget as a bar over the
budget chips. Clearing the cache and deleting offline episodes are rows
that say how much they free, and the delete still asks first. Licence
and notices are rows under About.
- each share is a row with its name and how it is unlocked; Refresh,
Re-enter link and Remove sit behind a menu instead of three differently
styled controls that squeezed the name into a column a word wide
- Remove asks first, saying that offline episodes and watch progress go
with it
- Add share is an extended floating button, where the share sheet and a
tapped link land too
- an empty page says what to do instead of showing a bare heading
- titles are 2:3 posters three across on a phone, named under the poster
instead of in a card body; cropped backdrops at full width fitted two
titles to a screen
- a title's caption says "Film" or "1 episode" rather than "1 episodes",
and wraps at large font scales instead of being cut to "2023 · 7"
- Continue watching cards carry the resume bar on the still and say how
much is left: "S03E11 · 12 min left"
- search is a filled pill with a clear button
- pull to refresh crawls the shares, as the bar's refresh icon does
- the backdrop runs edge to edge and fades into the page, with the back
button over it
- one full-width Play that names the episode it plays ("Resume S02E01")
- Start over, Download, Match and the provider link as one row of
labelled icons instead of four buttons in three styles over three lines
- the synopsis is clamped to three lines and opens on a tap; the watched
count joins the year, rating and genres
- seasons are chips showing one season at a time, opened on the season
Play lands in, instead of stacked accordions
- episode rows head with "1. Mother and Children", or "Episode 1", rather
than the filename; numbering, size and air date sit under it, and the
resume bar lies on the still's lower edge
- the per-title track form is gone: the player's track chooser already
stores each title's choice, and Settings holds the global default
The type ramp now draws in Inter Regular and SemiBold, the same two files
the desktop bundles, so the clients read as one app. Inter is declared
above the ramp: top-level properties initialise in file order, and the
ramp built first would have captured it as null and silently used the
system face. Its OFL text ships in the licences beside the notices.
Screenshots re-recorded.
Lint's AppLinkWarning failed the gate: a BROWSABLE https filter reads as an
App Link missing autoVerify. It can never be one, since drive.proton.me is
Proton's domain, so the attribute now says so.
Roborazzi draws Library (phone, tablet, font scale 1.5, empty), a title,
Shares, Settings and Downloads from fixed records under Robolectric, and
build-android.sh check compares them with the committed images in
app/src/test/screenshots. The redesign changes every screen; a layout
regression now fails the gate and shows up in review as a changed image.
- Settings > Storage chooses the streaming cache's budget, 1 to 16 GiB,
and shows what it holds against it. The bridge gains
set_stream_cache_budget, which the next connection is built with and
an open one evicts down to at once. Android keeps the choice in its own
settings, like the desktop keeps it in desktop.json, and tells the
engine as it is created. Unset stays at the shared 4 GiB default.
- Settings > Playback can turn hardware decoding off for a device whose
decoder shows green or torn frames. pstr_mpv sets hwdec before each
loadfile, so it applies from the next episode.
A drive.proton.me/urls/ link opened from a browser, or text containing
one shared to the app, switches to Shares and opens the Add form with
the link filled in, fragment included. A playing episode moves to the
mini transport instead of covering the form. A recreated activity does
not offer the same link again.
The view filter is not autoVerify, since the domain is Proton's, so
Android only routes browser links here once "Open supported links" is
allowed in the app's settings; sharing works without that.
- libmpv is built in its own step and saved with actions/cache/save right
after, so a failure later in the job no longer discards a 12 minute
build the way a post-job save did
- lint's OldTargetApi fails the gate whenever a new platform ships, like
the dependency freshness checks already marked informational; raising
targetSdk is a deliberate change with its own device pass
The icon follows proton-drive-linux's logo: a violet gradient tile with
a darker lower edge, and a white play mark with a soft shadow, cut into
the blocks a revision is fetched in. The SVG, the window PNG and the
Windows .ico are rendered from one source.
On Android it is an adaptive icon with a monochrome layer for themed
icons, and the notification glyph is the same mark. The app is named
"Proton Stream"; the launcher shows "Stream", since "proton-stream" was
cut to "proton-st…".
ProtonStreamApp.kt had grown to 1665 lines holding every screen, and
every planned UI change touches it. The shell, Library, Title, Shares,
Downloads and Settings screens now each have their own file, plus
Common.kt for the empty state and byte formatting they share. Code is
moved unchanged; declarations used across files went from private to
internal.
The fontconfig that the pinned mpv-android revision builds requires
meson >= 1.11, and ubuntu-24.04's apt meson is 1.3.2, so buildLibmpvAndroid
failed configuring fontconfig.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Android run since August failed in :app:buildRustHost: the host
build of pstr-android reaches libdbus-sys through keyring's Secret
Service store, and the runner had no libdbus-1-dev. With no successful
run, nothing was ever cached, so each run rebuilt from nothing.
- libdbus-1-dev in both jobs
- one shared, save-on-failure Rust cache for both jobs
- the staged libmpv tree cached on the build script's hash; the script
stamps its output with the revision and its own hash and skips the
libmpv build when the stamp matches
PLAN.md records the Android parity and UI work that follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signing secrets are now configured, so the job no longer needs to be
switched off. It runs for v* tags and for a manual dispatch with
signed_release set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK retries a request that fails before its response starts, but not a
block body cut off part way through. That error stopped playback or failed
a download, and the SDK's new keep-alive pings make it the way a dead
connection now ends: seconds in, rather than at the storage timeout.
A block is now fetched up to three times, 250 ms and then 500 ms apart,
while ProtonError::is_retriable says a replay could succeed. A permanent
refusal still fails on the first attempt.
The SDK now pings idle HTTP/2 connections and bounds the connect phase, so
a connection dropped by a NAT or a network switch is replaced within seconds
instead of stalling every request on it until the timeout. It also clamps a
server's Retry-After to two minutes, re-mints expired block URLs on the
whole-file download path as well as the reader, and exposes
ProtonError::is_retriable.
lru follows the SDK to 0.18, so one copy is built instead of two.
A SwiftUI app that drives the same UniFFI engine as Android: Rust owns the
Proton sessions, SQLite and every decrypted byte, and Swift asks it questions
off the main thread. Playback goes through MPVKit's libmpv, with the pstr://
stream protocol ported from the Android JNI half, and secrets live in the
Keychain.
scripts/build-ios.sh builds the static library for aarch64-apple-ios, writes
the Swift bindings (module PstrBridge), generates the Xcode project with
XcodeGen and packages an unsigned .ipa for SideStore or AltStore, which
re-sign it with the viewer's own Apple ID. The generated project, Info.plist
and build output are ignored.
Toasts through WinRT, under the app id io.narl.proton-stream. The id is
registered for the current user under HKCU\Software\Classes\AppUserModelId
with a display name and icon on the first notification of a session, which
is what lets an app with no package or installer-made shortcut show one. If
that write fails, the toast goes out under PowerShell's id rather than not
at all.
A second launch hands its request to the open window and exits: over a
Unix socket in the user's runtime directory, or a named pipe on Windows
whose name carries a random key from the user's config directory, so
another account cannot take the name first. The open window comes forward,
and a share link given as the first such argument opens the Add form with
it filled in. Setup failures are logged and the app runs without the lock.
A button in the player's title strip, or E, opens the title's episodes
down the right of the picture, a season at a time, starting on the
playing season and scrolled to the playing episode. Each row has its still
(which plays it), its name, and whether it is playing, watched or part
way. The controls stay up while the list is out, and Esc closes it before
it leaves fullscreen or the player.
Every played episode, newest first, under Today, Yesterday, a weekday or a
date. A row shows the still (which plays it), the title (which opens it),
the episode, and where it stopped or that it was finished. Removing a row
resets that file to never played, with an Undo. History sits second in the
top bar, so Ctrl+1 to Ctrl+5 now run Library, History, Shares, Downloads,
Settings.
A provider with an API key read it from the OS credential store inside
Engine::new, which is a D-Bus round trip to a keyring that may have to
unlock first. It is now built on the blocking pool. Until it lands, poster
fetches, match runs and match searches wait for it rather than concluding
there is no provider and marking posters missing. A settings change made
in the meantime wins over the launch-time build. The share list is read
on the blocking pool too.
theme::space, theme::radius and theme::motion name the steps the UI is
drawn with. Literal gaps, corner radii and animation durations across ui/
now come from them, which folds a few near-misses onto the scale (10 and 14
point gaps onto 12, 10 point radii onto 12, 0.10 s hovers onto 0.12 s).
Page fades and toasts share one ease_out curve.
The watched and download buttons were laid out with with_layout(right_to_left,
Center), which takes every point of height the scroll area has left and
centres the buttons in it. Give the cluster the still's height instead.
Headings are set in Inter SemiBold, everything else in Inter Regular, so a
title sets the same on every desktop. Media and navigation glyphs (⏭, 🔊, ⛶,
★) come from the Phosphor icon font instead of whichever fallback font had
them. The CJK fallback still loads on its own thread and now joins the
bundled fonts instead of replacing them. The window carries the app icon
on X11 and Windows.
- a page fades in over 140 ms when the page changes, rather than cutting
- Ctrl+S in the player saves the frame, without subtitles, to
Pictures/proton-stream as "Title S01E04 12-34.png"
Tiles and episode stills light up under focus as they do under the
pointer and scroll into view when the arrow keys or Tab reach them;
Enter opens or plays. Global keys now check for a focused text field
rather than any focused widget, so / and Esc still work over a tile.
A toast can carry a button. Marking a whole title watched or unwatched
from its tile menu, and taking it off Continue watching, both lose
state the viewer may have wanted; each now says what it did with an
Undo that puts every changed watch state back as it was.
- the streaming cache's budget (1-16 GiB) is chosen in Settings, shows
what it holds, and can be emptied; a lower budget evicts at once
- hardware decoding can be turned off for a driver that shows green
frames, from the next file
- both live in desktop.json rather than the playback prefs shared with
Android, whose bridge record would reset them on every save. A file
that will not parse is never overwritten
The film keeps playing small at the left of the transport bar while
browsing, and is the button back to the player page. It sits beside
both rows of controls, so the bar is no taller for it, and renders at
thumbnail size four times a second rather than at the film's rate.
The title watched last, or with nothing part-watched one of the titles a
provider has a backdrop for, turning over once a day. Large art behind
the name, rating, genres, three lines of overview, and Resume / More
info.
- a finished batch of downloads, and a crawl long enough to walk away
from, notify through org.freedesktop.Notifications when the window is
not focused; once per batch, not once per file
- Ctrl+V with a share link outside a text field opens the add form
with the link in it
- MPRIS on Linux and SMTC on Windows through souvlaki: play/pause, next,
previous, stop and seeking from media keys and the panel widget, with
the title, episode, duration and the cached cover as a file:// URL
- the screensaver is held off while a film plays on the player page,
through the desktop portal with org.freedesktop.ScreenSaver as the
fallback, and SetThreadExecutionState on Windows. mpv's own
stop-screensaver has no window to act for under the render API
The up-next card shows the next episode's still with the seconds left
running round it, and the still itself plays it. The window opens at
the size and maximized state it closed at; position is left to the
window manager.
- right-click a library tile to play or resume it, mark it watched or
unwatched, download it, change its match, or take it off Continue
watching
- mpv's demuxer-cache-time is shaded on the seek bar, so it shows
whether a jump lands in what is already read ahead
- paused-for-cache shows a spinner over the picture and in the controls
Each share is its own session against its own tree, so one large share
no longer holds the rest back. Stop ends every listing still running;
a share already past its listing finishes storing what it found, since
stopping halfway through replacing its rows would leave them disagreeing
with its downloads.
- ImageCache drops the picture asked for longest ago past 256 MiB and
uploads at most six textures a frame
- posters and thumbnails larger than a card are stored scaled down, so
a cached grid no longer decodes and resizes full backdrops every launch
- a saved watch state patches the library in place instead of rebuilding
it from the catalog; the offline index reuses the library just built
- download byte progress sends one row, not a sorted copy of every row
- the CJK fallback font is found and read off the UI thread
- the watch-state upsert uses a cached statement
- toasts replace the status line; keyboard shortcuts with a ? overlay
- library: filters, sort, row virtualisation, cached search, skeleton
tiles until the catalog loads, gradient placeholder art
- title page: season pills, episode rows with stills and one download
glyph, portrait poster, clamped overview, resume time in the button
- player: fading chrome, smooth scrims, subtitles lifted over the
controls, drag-following seek knob, OSD, speed menu, auto-skip
- settings as grouped cards with toggles and palette previews
- shares: confirm before removing, inline link validation, form kept
until the add succeeds; downloads scroll and show rate and ETA
- downloads pipeline four blocks; the next episode is warmed near the end
- a film behind another page repaints at 4 Hz, not its frame rate
Fixes B55-B60.
A block the player waits for no longer waits on its disk-cache write and
two fsyncs; the put runs in a spawned task and only the block itself is
synced before the rename. Opening a stream can fetch the first and last
block together, where MKV cues and the MP4 moov live. Downloads open a
copying stream that reads the disk cache but never fills it.
crawl_reporting reports the running file count after each listing.
A track picked on one episode now carries over to the next by its title as
well as its language, so e.g. "Signs & Songs" vs "Full" subtitles in the same
language stick. Catalog migration V8 adds the title columns; the Android
bridge keeps a stored title only while its language is unchanged.
B14-B42 record the Android review the theme and lifecycle work came out of.
DESIGN-TOKENS.md states what both clients agree on and where each keeps its
copy.
Player and library screens drop hardcoded blacks, whites and radii for
scheme roles and shape tokens, and use the shared button wrappers.
Continue watching was pinned above the poster grid, holding a fixed slice
of the screen on every scroll. It is now a full-span item at the top of the
grid, so it scrolls away with the rest.
storedPalette() reads and resolves the theme file without building the
engine, so onCreate can set the window background and system-bar icon
polarity before anything draws. Previously the background was a hex literal
in themes.xml and the bar icons were pinned dark, which meant every cold
start flashed one flavour's colour and Latte drew white icons on a
near-white page.
Adds the Material scheme mapping, type ramp and shape tokens the palette
feeds, plus unit tests for both.
Type/shape/motion tokens live in theme.rs and every ui/ module reads them
instead of scattering literal sizes and radii. Appearance and playback
preferences move out of the shares page into their own Settings page and
nav tab.
The title backdrop's veil and fades were painted after the header was laid
out, so they covered the name and overview as well as the still — the text
was being dimmed by the thing meant to make it readable. All band layers
now go into the shape slot reserved before the header, behind the text.