Compare commits

13 Commits
Author SHA1 Message Date
nvrl 6e09129d0c fixed font loading 2026-10-03 02:03:24 +02:00
nvrl 476fec8c04 chore(VER): bumped 0.5.0 2026-10-02 23:42:13 +02:00
nvrl 7af0969e38 fix(stream): fetch a block again after a retriable failure
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.
2026-10-02 23:42:12 +02:00
nvrl 3df0644bd8 chore(deps): proton-sdk and proton-drive-rs 0.7.2, lru 0.18
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.
2026-10-02 23:42:12 +02:00
nvrl 5d079b05c9 feat(ios): a sideloadable iOS client over the shared Rust engine
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.
2026-10-02 23:34:17 +02:00
nvrl 4d44db0949 feat(app): desktop notifications on Windows
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.
2026-10-02 23:27:25 +02:00
nvrl 4a54af3245 feat(app): one window at a time, share link as an argument
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.
2026-10-02 23:24:28 +02:00
nvrl 7e734dbf19 feat(app): an episode list beside the picture
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.
2026-10-02 23:20:28 +02:00
nvrl e7eeaa518f feat(core,app): a history page
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.
2026-10-02 23:17:36 +02:00
nvrl 39866bee8d perf(app): read the metadata API key and the share list off the UI thread
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.
2026-10-02 23:12:25 +02:00
nvrl 85092fa893 refactor(app): spacing, radius and motion tokens
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.
2026-10-02 23:10:39 +02:00
nvrl 2128a2d705 fix(app): the highest episode row on screen no longer stretches to the window bottom
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.
2026-10-02 23:08:30 +02:00
nvrl 558446b9e4 feat(app): bundled Inter and Phosphor icons, window icon
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.
2026-10-02 23:07:05 +02:00
45 changed files with 2845 additions and 350 deletions

No files matched your search

+6
View File
@@ -20,3 +20,9 @@
/packaging/pkg/
/packaging/src/
/packaging/*.pkg.tar.zst
# iOS generated state: XcodeGen owns the project and the Info.plist, and
# scripts/build-ios.sh writes the bindings and DerivedData under ios/build.
/ios/build/
/ios/*.xcodeproj/
/ios/ProtonStream/Info.plist
Generated
+104 -43
View File
@@ -119,12 +119,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-activity"
version = "0.6.1"
@@ -1767,6 +1761,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "egui-phosphor"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4"
dependencies = [
"egui",
]
[[package]]
name = "egui-wgpu"
version = "0.35.0"
@@ -2061,12 +2064,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -2352,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
"foldhash",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -2528,24 +2525,13 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
"foldhash",
]
[[package]]
@@ -2554,7 +2540,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"foldhash",
]
[[package]]
@@ -3243,11 +3229,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
version = "0.12.5"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [
"hashbrown 0.15.5",
"hashbrown 0.17.1",
]
[[package]]
@@ -4313,12 +4299,12 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proton-drive-rs"
version = "0.6.3"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11faa95cf40bd87d4d48435cdf9b16fff5f14caf45b3a565a941fd4a6e18cefe"
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
dependencies = [
"async-trait",
"base64 0.22.1",
"base64 0.23.0",
"bytes",
"futures",
"getrandom 0.4.3",
@@ -4340,13 +4326,13 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.6.3"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7de99acbd776f7bad5e1e1f3c40d7df3e4ef7411984d02370008ba1ff270a047"
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
dependencies = [
"aes-gcm 0.11.0",
"async-trait",
"base64 0.22.1",
"base64 0.23.0",
"bcrypt",
"bytes",
"chrono",
@@ -4373,7 +4359,7 @@ dependencies = [
[[package]]
name = "pstr-android"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"android_logger",
"jni 0.21.1",
@@ -4392,13 +4378,15 @@ dependencies = [
[[package]]
name = "pstr-app"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"chrono",
"dbus",
"directories",
"eframe",
"egui",
"egui-phosphor",
"futures",
"image",
"parking_lot",
@@ -4410,7 +4398,9 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"skrifa",
"souvlaki",
"tauri-winrt-notification",
"tokio",
"tracing",
"tracing-subscriber",
@@ -4419,7 +4409,7 @@ dependencies = [
[[package]]
name = "pstr-cli"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"clap",
@@ -4437,7 +4427,7 @@ dependencies = [
[[package]]
name = "pstr-core"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"directories",
"futures",
@@ -4455,7 +4445,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"pstr-core",
"reqwest",
@@ -4468,7 +4458,7 @@ dependencies = [
[[package]]
name = "pstr-player"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"async-trait",
"libloading",
@@ -4483,7 +4473,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"async-trait",
"futures",
@@ -5533,7 +5523,7 @@ dependencies = [
"dispatch",
"objc",
"thiserror 1.0.69",
"windows",
"windows 0.44.0",
]
[[package]]
@@ -5624,6 +5614,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tauri-winrt-notification"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
dependencies = [
"thiserror 2.0.19",
"windows 0.62.2",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -6684,6 +6685,27 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -6697,6 +6719,17 @@ dependencies = [
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -6725,6 +6758,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -6860,6 +6903,24 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
+14 -4
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "0.4.2"
version = "0.5.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2
[workspace.dependencies]
proton-sdk = { version = "0.6.3" }
proton-drive-rs = { version = "0.6.3" }
proton-sdk = { version = "0.7.2" }
proton-drive-rs = { version = "0.7.2" }
# Internal crates.
pstr-core = { path = "crates/pstr-core" }
@@ -51,6 +51,8 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
# Local dates for the history page. Already in the tree through the SDK.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
sha2 = "0.10"
# `bundled` so a Windows build needs no system SQLite.
rusqlite = { version = "0.32", features = ["bundled"] }
@@ -77,6 +79,12 @@ eframe = { version = "0.35", default-features = false, features = [
"x11",
] }
egui = "0.35"
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
# font has them, at its own size and baseline; an icon font is one set.
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
# Tests only: reads the bundled fonts' character maps. The version epaint
# already builds, so it costs no extra compile.
skrifa = { version = "0.42", default-features = false, features = ["std"] }
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
# pulls in every exotic format is weight this app has no use for.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
@@ -93,9 +101,11 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"]
# hold it for.
dbus = "0.9"
windows-sys = "0.61"
# Toasts on Windows. Linux notifications go over the `dbus` above.
tauri-winrt-notification = "0.8"
rpassword = "7"
libloading = "0.8"
lru = "0.12"
lru = "0.18"
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
# be tested against a fake without a network or an account.
+10
View File
@@ -35,8 +35,12 @@ Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID
```bash
cargo run --release -p pstr-app # the app
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
```
One window at a time: a second launch brings the open window forward and
hands it any share link it was given, then exits.
The `pstr` CLI does the same work headlessly, and is what tells you which layer
is broken:
@@ -106,6 +110,9 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
- The block cache holds decrypted content. It lives in your cache directory,
can be deleted at any time the app is not running, and can be sized or
emptied from Settings → Storage.
- A share link given as a command-line argument is visible to other users of
the machine in the process list while that launch runs, and may land in
shell history. Pasting it into the window (Ctrl+V) does neither.
- The desktop's media widget is handed the title, the episode and a `file://`
path to artwork already on disk — never a provider URL, so the shell never
contacts a metadata provider on its own.
@@ -115,3 +122,6 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
The shared Rust crates and desktop application are MIT licensed. The Android
application and its combined native distribution are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
The desktop application embeds the Inter typeface, under the SIL Open Font
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
and the Phosphor icon font, under MIT.
@@ -29,7 +29,7 @@ internal val ProtonShapes = Shapes(
/** The radius `pstr-app` draws everything at. */
internal const val CORNER = 8f
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
internal const val CORNER_TIGHT = 4f
/** A bottom sheet, which has no desktop counterpart. */
+6
View File
@@ -2,3 +2,9 @@
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
disable_java_cleaner = true
[bindings.swift]
# The iOS client (`ios/`) compiles the generated file straight into the app
# target and reaches the C side through its bridging header, so the names are
# what that header and `ios/project.yml` expect.
module_name = "PstrBridge"
+9 -1
View File
@@ -24,17 +24,25 @@ tracing.workspace = true
tracing-subscriber.workspace = true
eframe.workspace = true
egui.workspace = true
egui-phosphor.workspace = true
image.workspace = true
sha2.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
directories.workspace = true
chrono.workspace = true
souvlaki.workspace = true
[dev-dependencies]
skrifa.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
dbus.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { workspace = true, features = ["Win32_System_Power"] }
# Named pipes, for handing a second launch's request to the open window.
tokio = { workspace = true, features = ["net"] }
windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] }
tauri-winrt-notification.workspace = true
raw-window-handle = "0.6"
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+135 -34
View File
@@ -46,6 +46,7 @@ impl FrameTimer {
started: std::time::Instant::now(),
page: match page {
Page::Library => "library",
Page::History => "history",
Page::Title(_) => "title",
Page::Shares => "shares",
Page::Downloads => "downloads",
@@ -80,6 +81,8 @@ const UP_NEXT_SECONDS: f64 = 10.0;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Page {
Library,
/// Everything played, newest first.
History,
/// One title, by [`pstr_core::library::Title::key`].
Title(String),
Shares,
@@ -91,6 +94,15 @@ pub enum Page {
Player,
}
/// The pages in the top bar, in order. Ctrl+1 to Ctrl+5 go by position here.
const NAV: [Page; 5] = [
Page::Library,
Page::History,
Page::Shares,
Page::Downloads,
Page::Settings,
];
/// Something a click asked for, applied once the frame is drawn.
pub enum Action {
Goto(Page),
@@ -177,6 +189,14 @@ pub enum Action {
share_id: String,
link_id: String,
},
/// Take one file off the history page: forget its position and its
/// watched mark, with an Undo.
RemoveFromHistory {
share_id: String,
link_id: String,
},
/// Open or close the list of episodes beside the picture.
ToggleEpisodes,
/// Put watch states back as they were — what Undo does.
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
ClearStreamCache,
@@ -293,6 +313,8 @@ pub struct LibraryView {
built_with: (Filter, Sort),
/// Indices into [`Library::titles`].
pub matches: Vec<usize>,
/// Every played episode, newest first, for the history page.
pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>,
/// The library changed under the lists.
@@ -366,6 +388,7 @@ impl LibraryView {
self.matches = matches.into_iter().filter_map(index_of).collect();
self.built_with = (self.filter, self.sort);
if self.stale {
self.history = ui::history::entries(library);
self.resumable = library
.continue_watching()
.into_iter()
@@ -394,6 +417,8 @@ pub struct ShareForm {
pub struct App {
engine: Engine,
events: Receiver<Event>,
/// What later launches asked for. See [`crate::instance`].
requests: Receiver<crate::instance::Request>,
pub page: Page,
pub library: Library,
pub shares: Vec<Share>,
@@ -435,6 +460,8 @@ pub struct App {
/// Chapters already skipped on their own in this player, so seeking back
/// into an opening on purpose is not undone a frame later.
auto_skipped: (u64, Vec<i64>),
/// Whether the episode list is out on the player page.
pub episode_drawer: bool,
/// Set while the window is fullscreen, so `F` can toggle rather than only
/// ever entering. egui has no way to ask the platform.
pub fullscreen: bool,
@@ -473,13 +500,17 @@ impl App {
cc: &eframe::CreationContext<'_>,
runtime: std::sync::Arc<tokio::runtime::Runtime>,
dirs: AppDirs,
instance: crate::instance::Listener,
request: crate::instance::Request,
) -> anyhow::Result<Self> {
let requests = instance.serve(&runtime, &cc.egui_ctx, request);
// After the engine, not before: the engine is what reads the stored
// theme, and a window that paints one frame in the default palette
// before switching is a window that flashes on every launch.
let window = crate::window::WindowMemory::new(dirs.window_file());
let notifier = crate::notify::Notifier::new(&dirs.cache);
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
theme::install_font_fallbacks(&cc.egui_ctx);
theme::install_fonts(&cc.egui_ctx);
theme::apply(&cc.egui_ctx, engine.appearance());
// Paint from the catalog immediately; the network catches up. A library
@@ -493,6 +524,7 @@ impl App {
Ok(Self {
engine,
events,
requests,
page: Page::Library,
library: Library::default(),
shares: Vec::new(),
@@ -517,6 +549,7 @@ impl App {
warmed: 0,
auto_skipped: (0, Vec::new()),
fullscreen: false,
episode_drawer: false,
opening: None,
connecting: true,
crawling: false,
@@ -526,7 +559,7 @@ impl App {
media_cover: None,
inhibitor: crate::inhibit::Inhibitor::new(),
shown_page: (None, 0.0),
notifier: crate::notify::Notifier::new(),
notifier,
downloads: Vec::new(),
offline_files: std::collections::HashSet::new(),
confirm_partial_delete: None,
@@ -1053,6 +1086,39 @@ impl App {
self.engine.set_playback_prefs(prefs, commit);
}
/// Reset one file to never played, with an Undo. What leaving Continue
/// watching and leaving the history both come down to: the two are read
/// off the same watch state.
fn forget_watch(
&mut self,
ctx: &egui::Context,
share_id: String,
link_id: String,
place: &str,
) {
let found = self.library.titles.iter().find_map(|title| {
let episode = title.episodes().find(|episode| {
episode.node.share_id == share_id && episode.node.link_id == link_id
})?;
Some((title.name.clone(), episode.watch))
});
let Some((name, Some(before))) = found else {
return;
};
self.engine.save_watch_state(
share_id.clone(),
link_id.clone(),
watch_state(0.0, before.duration_secs, false),
);
let now = ctx.input(|input| input.time);
self.toasts.push_with(
now,
format!("removed {name} from {place}"),
"Undo",
Action::RestoreWatch(vec![(share_id, link_id, before)]),
);
}
fn apply(&mut self, ctx: &egui::Context, action: Action) {
match action {
Action::Goto(page) => self.page = page,
@@ -1318,28 +1384,12 @@ impl App {
);
}
Action::ForgetPosition { share_id, link_id } => {
let found = self.library.titles.iter().find_map(|title| {
let episode = title.episodes().find(|episode| {
episode.node.share_id == share_id && episode.node.link_id == link_id
})?;
Some((title.name.clone(), episode.watch))
});
let Some((name, Some(before))) = found else {
return;
};
self.engine.save_watch_state(
share_id.clone(),
link_id.clone(),
watch_state(0.0, before.duration_secs, false),
);
let now = ctx.input(|input| input.time);
self.toasts.push_with(
now,
format!("removed {name} from Continue watching"),
"Undo",
Action::RestoreWatch(vec![(share_id, link_id, before)]),
);
self.forget_watch(ctx, share_id, link_id, "Continue watching");
}
Action::RemoveFromHistory { share_id, link_id } => {
self.forget_watch(ctx, share_id, link_id, "history");
}
Action::ToggleEpisodes => self.episode_drawer = !self.episode_drawer,
Action::RestoreWatch(states) => {
for (share_id, link_id, state) in states {
self.engine.save_watch_state(share_id, link_id, state);
@@ -1432,14 +1482,14 @@ impl App {
focus_search: bool,
) {
ui.horizontal(|ui| {
ui.add_space(4.0);
ui.add_space(theme::space::XS);
ui.label(
theme::Role::Heading
.rich("proton-stream")
.strong()
.color(theme::accent()),
);
ui.add_space(12.0);
ui.add_space(theme::space::L);
let on_library = matches!(self.page, Page::Library | Page::Title(_));
let active = self
@@ -1459,12 +1509,13 @@ impl App {
} else {
format!("Downloads ({active})")
};
let pages = [Page::Library, Page::Shares, Page::Downloads, Page::Settings];
let pages = NAV;
let clicked = ui::tabs(
ui,
ui.id().with("nav"),
&[
("Library", on_library),
("History", self.page == Page::History),
("Shares", self.page == Page::Shares),
(&downloads, self.page == Page::Downloads),
("Settings", self.page == Page::Settings),
@@ -1475,7 +1526,7 @@ impl App {
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
ui.add_space(theme::space::XS);
if self.crawling {
if ui
.small_button("Stop")
@@ -1493,7 +1544,10 @@ impl App {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted("connecting"));
} else if ui
.button("Refresh")
.button(format!(
"{} Refresh",
egui_phosphor::regular::ARROW_CLOCKWISE
))
.on_hover_text("Re-crawl every share")
.clicked()
{
@@ -1501,7 +1555,7 @@ impl App {
}
if on_library && !self.library.is_empty() {
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui::search_field(ui, search, focus_search);
}
});
@@ -1524,6 +1578,15 @@ impl eframe::App for App {
self.window.observe(ctx);
let mut actions: Vec<Action> = Vec::new();
while let Ok(request) = self.requests.try_recv() {
// A second launch is someone looking for this window, so it comes
// forward even when there is nothing else to do.
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
if let crate::instance::Request::AddShare(url) = request {
actions.push(Action::PasteShare(url));
}
}
self.media.drain(self.playback.as_ref(), &mut actions);
let cover = self.media_cover();
self.media.show(self.playback.as_ref(), cover.as_deref());
@@ -1552,7 +1615,13 @@ impl eframe::App for App {
speed: playback.speed,
skip_to: playback.skippable().map(|(_, end)| end),
});
shortcuts(ctx, self.fullscreen, state, &mut actions);
shortcuts(
ctx,
self.fullscreen,
self.episode_drawer,
state,
&mut actions,
);
// Before the draw, because drawing does not mutate — and it can
// push an action of its own, which is why it takes the same list.
@@ -1562,8 +1631,30 @@ impl eframe::App for App {
playback,
opening,
overlay,
engine,
library,
thumbs,
posters,
metadata,
episodes,
episode_drawer,
..
} = self;
let list = playback
.as_ref()
.and_then(|playback| library.get(&playback.target.title_key))
.filter(|title| title.episode_count() > 1)
.map(|title| ui::player::Episodes {
title,
art: ui::Art {
engine,
thumbs,
posters,
metadata,
episodes,
},
open: *episode_drawer,
});
// No panels: the controls are drawn over the picture, and a nav bar
// above a film is the one thing every player agrees not to do.
egui::CentralPanel::default()
@@ -1578,6 +1669,7 @@ impl eframe::App for App {
overlay,
up_next,
neighbours,
episodes: list,
},
&mut actions,
);
@@ -1773,6 +1865,9 @@ impl eframe::App for App {
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
}
Page::Downloads => ui::downloads::show(ui, downloads, &mut actions),
Page::History => {
ui::history::show(ui, &mut art, library, &view.history, &mut actions)
}
// Drawn above, without any of these panels.
Page::Player => {}
}
@@ -1911,7 +2006,7 @@ fn screenshot_name(title: &str, caption: &str, position: f64) -> String {
/// rather than in one: the cut between two pages of tiles reads as a flicker,
/// a short fade as a change of place.
fn page_fade(ctx: &egui::Context, shown: &mut (Option<Page>, f64), page: &Page) -> f32 {
const SECONDS: f64 = 0.14;
const SECONDS: f64 = theme::motion::STATE as f64;
let now = ctx.input(|input| input.time);
if shown.0.as_ref() != Some(page) {
// The first page of a launch is there at once; only changes fade.
@@ -1922,7 +2017,7 @@ fn page_fade(ctx: &egui::Context, shown: &mut (Option<Page>, f64), page: &Page)
if progress < 1.0 {
ctx.request_repaint();
}
1.0 - (1.0 - progress).powi(3)
theme::motion::ease_out(progress)
}
/// What the top bar says while shares are being listed: one share by name,
@@ -2005,13 +2100,14 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
(Key::Num2, 1),
(Key::Num3, 2),
(Key::Num4, 3),
(Key::Num5, 4),
] {
if input.consume_key(Modifiers::COMMAND, key) {
tab = Some(index);
}
}
if input.consume_key(Modifiers::COMMAND, Key::Comma) {
tab = Some(3);
tab = Some(4);
}
// A share link pasted anywhere outside a text field is someone adding
// a share, wherever they happen to be.
@@ -2026,7 +2122,7 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
}
});
if let Some(index) = tab {
let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone();
let page = NAV[index].clone();
actions.push(Action::Goto(page));
}
}
@@ -2069,6 +2165,7 @@ fn step_speed(speed: f64, faster: bool) -> f64 {
fn shortcuts(
ctx: &egui::Context,
fullscreen: bool,
drawer: bool,
playing: Option<PlayerKeys>,
actions: &mut Vec<Action>,
) {
@@ -2098,6 +2195,7 @@ fn shortcuts(
Key::S,
Key::OpenBracket,
Key::CloseBracket,
Key::E,
Key::Escape,
]
.into_iter()
@@ -2141,6 +2239,9 @@ fn shortcuts(
// Escape leaves fullscreen first and the page second, which is what
// it does everywhere else and what stops one press from both
// un-maximising the window and hiding the film.
Key::E => actions.push(Action::ToggleEpisodes),
// The episode list is the innermost thing open, so it goes first.
Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
Key::Escape => actions.push(Action::LeavePlayer),
_ => {}
+79 -32
View File
@@ -307,6 +307,10 @@ pub struct Engine {
/// whenever the settings change, so nothing holds a client for a provider
/// the viewer has switched away from.
metadata: Arc<Mutex<Enrichment>>,
/// False until [`Engine::metadata`] holds what the settings resolve to.
/// Only ever false at launch, while the API key is read off the UI
/// thread; see [`Engine::new`].
enrichment_settled: Arc<tokio::sync::watch::Sender<bool>>,
/// Volume, mute and language preferences. Held here rather than in the UI
/// because a new player is built from them, and the player is started from
/// a background event.
@@ -402,6 +406,21 @@ impl Engine {
Appearance::default()
});
// A provider with an API key needs it out of the OS credential store,
// and that can be a D-Bus round trip to a keyring that has to unlock
// first — not something to hold the first frame for. Built on the
// blocking pool instead; until it lands, everything that needs the
// provider waits for it rather than concluding there is none.
let deferred = config.enabled && config.provider.needs_api_key();
let enrichment = if deferred {
Enrichment {
config: config.clone(),
service: None,
}
} else {
Enrichment::build(config.clone())
};
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
tracing::warn!("read show track preferences: {error}");
HashMap::new()
@@ -415,7 +434,8 @@ impl Engine {
catalog: Arc::new(Mutex::new(catalog)),
thumbnails: Arc::new(tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY)),
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
metadata: Arc::new(Mutex::new(Enrichment::build(config))),
metadata: Arc::new(Mutex::new(enrichment)),
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
prefs: Arc::new(Mutex::new(prefs)),
appearance: Arc::new(Mutex::new(appearance)),
track_prefs: Arc::new(Mutex::new(track_prefs)),
@@ -428,6 +448,12 @@ impl Engine {
events,
ctx,
};
if deferred {
let deferred = engine.clone();
engine.runtime.spawn_blocking(move || {
deferred.install_enrichment(Enrichment::build(config), true);
});
}
Ok((engine, receiver))
}
@@ -537,12 +563,15 @@ impl Engine {
// ---------------------------------------------------------------- shares
/// Re-read the share list.
/// Re-read the share list, off the UI thread: it is a file read, and at
/// launch the first frame should not wait on the disk for it.
pub fn load_shares(&self) {
match self.store.list() {
Ok(shares) => self.emit(Event::Shares(shares)),
Err(error) => self.fail("read the share list", error),
}
let engine = self.clone();
self.runtime
.spawn_blocking(move || match engine.store.list() {
Ok(shares) => engine.emit(Event::Shares(shares)),
Err(error) => engine.fail("read the share list", error),
});
}
/// Add a share, then open and crawl it — which is what "add" means to
@@ -1742,6 +1771,38 @@ impl Engine {
});
}
/// Put `built` in place and mark the provider settled.
///
/// `only_if_unsettled` is for the launch-time build: a settings change
/// that landed while it was reading the keyring is newer, and wins. The
/// check and the write share the lock, so the two cannot interleave.
fn install_enrichment(&self, built: Enrichment, only_if_unsettled: bool) {
let mut slot = self.metadata.lock();
if only_if_unsettled && *self.enrichment_settled.borrow() {
return;
}
*slot = built;
self.enrichment_settled.send_replace(true);
}
/// The provider, once the launch-time build has settled. `None` when
/// enrichment is off or has no usable key.
async fn metadata_service(&self) -> Option<MetadataService> {
let mut settled = self.enrichment_settled.subscribe();
let _ = settled.wait_for(|settled| *settled).await;
self.metadata.lock().service.clone()
}
/// Why there is no provider, for the viewer who asked for one.
fn no_service_reason(&self) -> String {
let config = self.metadata_config();
if !config.enabled {
"turn on metadata enrichment first".into()
} else {
format!("{} needs an API key", config.provider.label())
}
}
/// The enrichment settings as they stand.
pub fn metadata_config(&self) -> MetadataConfig {
self.metadata.lock().config.clone()
@@ -1774,7 +1835,7 @@ impl Engine {
tracing::warn!("clear stored metadata: {error}");
}
*engine.metadata.lock() = Enrichment::build(config.clone());
engine.install_enrichment(Enrichment::build(config.clone()), false);
engine.emit(Event::MetadataConfig(config));
engine.load_metadata();
});
@@ -1788,7 +1849,7 @@ impl Engine {
return engine.fail("store the API key", error);
}
let config = engine.metadata_config();
*engine.metadata.lock() = Enrichment::build(config);
engine.install_enrichment(Enrichment::build(config), false);
engine.emit(Event::Status(if key.trim().is_empty() {
format!("cleared the {} key", provider.label())
} else {
@@ -1837,13 +1898,8 @@ impl Engine {
}
async fn run_match(&self, titles: Vec<Title>, force: bool) {
let Some(service) = self.metadata.lock().service.clone() else {
let config = self.metadata_config();
return self.emit(Event::Error(if !config.enabled {
"turn on metadata enrichment first".into()
} else {
format!("{} needs an API key", config.provider.label())
}));
let Some(service) = self.metadata_service().await else {
return self.emit(Event::Error(self.no_service_reason()));
};
let provider = service.provider();
@@ -1979,21 +2035,13 @@ impl Engine {
/// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is
/// stored: this is a look, and only [`Engine::choose_match`] writes.
pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) {
let Some(service) = self.metadata.lock().service.clone() else {
let config = self.metadata_config();
return self.emit(Event::MatchSearchFailed {
title_key,
error: if !config.enabled {
"turn on metadata enrichment first".into()
} else {
format!("{} needs an API key", config.provider.label())
},
});
};
let engine = self.clone();
let permits = Arc::clone(&self.lookups);
self.runtime.spawn(async move {
let Some(service) = engine.metadata_service().await else {
let error = engine.no_service_reason();
return engine.emit(Event::MatchSearchFailed { title_key, error });
};
// The same permit the batch run takes: a viewer typing in the search
// box while a match run is going must not be what earns the 429.
let Ok(_permit) = permits.acquire().await else {
@@ -2019,12 +2067,11 @@ impl Engine {
/// to the next match run, because the point of choosing an entry by hand is
/// usually that its episode names were wrong too.
pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) {
let Some(service) = self.metadata.lock().service.clone() else {
return self.emit(Event::Error("turn on metadata enrichment first".into()));
};
let engine = self.clone();
self.runtime.spawn(async move {
let Some(service) = engine.metadata_service().await else {
return engine.emit(Event::Error(engine.no_service_reason()));
};
let name = found.name.clone();
let record = service.chosen(title.key.clone(), found.clone());
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
@@ -2103,7 +2150,7 @@ impl Engine {
}
}
let Some(service) = engine.metadata.lock().service.clone() else {
let Some(service) = engine.metadata_service().await else {
// Enrichment was turned off between the request and now.
return engine.emit(Event::PosterMissing { key: title_key });
};
+350
View File
@@ -0,0 +1,350 @@
//! One window at a time.
//!
//! A second launch hands what it was started with — a share link, or nothing
//! — to the window already open, and exits. Without this, a second click on
//! the launcher opened a second window on the same catalog: two engines
//! writing the same watch state, two crawls of the same shares, and two films
//! playing over each other.
//!
//! The channel between the two is a Unix socket in the user's runtime
//! directory, or a named pipe on Windows. Anything that goes wrong setting it
//! up is logged and the app runs on without it: a second window is a nuisance,
//! and a launch that refuses to start because of this is a bug.
use std::sync::mpsc::{Receiver, Sender, channel};
use pstr_core::config::AppDirs;
/// What a later launch asks of the window already open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Request {
/// Come to the front.
Raise,
/// Come to the front, with this share link in the Add form.
AddShare(String),
}
impl Request {
/// What a launch was started with: the first argument that is a share
/// link, so `proton-stream 'https://drive.proton.me/urls/…#…'` adds it.
pub fn from_args(args: impl IntoIterator<Item = String>) -> Self {
args.into_iter()
.map(|arg| arg.trim().to_owned())
.find(|arg| pstr_core::shares::share_token(arg).is_ok())
.map_or(Self::Raise, Self::AddShare)
}
/// One line on the wire.
fn encode(&self) -> String {
match self {
Self::Raise => "raise\n".to_owned(),
Self::AddShare(url) => format!("add {url}\n"),
}
}
/// The other end of [`Request::encode`]. Anything else is ignored rather
/// than guessed at — and a link that is not a share link is not one to
/// put in the form.
fn decode(line: &str) -> Option<Self> {
let line = line.trim();
if line == "raise" {
return Some(Self::Raise);
}
let url = line.strip_prefix("add ")?.trim();
pstr_core::shares::share_token(url)
.is_ok()
.then(|| Self::AddShare(url.to_owned()))
}
}
/// How long a launch spends handing its request over before giving up on it.
const HAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// The most a request may be. A share link is a few hundred bytes.
const MOST: u64 = 8 * 1024;
/// The outcome of [`claim`].
pub enum Claim {
/// This is the window. The listener answers later launches.
First(Listener),
/// A window was already open and has the request. This launch is done.
Handed,
}
/// Become the one window, or hand `request` to the one there is.
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
platform::claim(runtime, dirs, request)
}
/// Requests from later launches, until the process ends.
pub struct Listener {
inner: Option<platform::Inner>,
}
impl Listener {
/// Start answering. `first` is this launch's own request, which arrives
/// through the same channel as the rest so there is one place to act on it.
pub fn serve(
self,
runtime: &tokio::runtime::Runtime,
ctx: &egui::Context,
first: Request,
) -> Receiver<Request> {
let (sender, receiver) = channel();
if first != Request::Raise {
let _ = sender.send(first);
}
if let Some(inner) = self.inner {
platform::serve(
inner,
runtime,
Deliver {
sender,
ctx: ctx.clone(),
},
);
}
receiver
}
}
/// Where a received request goes: the app's queue, and a repaint so it is
/// acted on now rather than when the pointer next moves.
struct Deliver {
sender: Sender<Request>,
ctx: egui::Context,
}
impl Deliver {
fn line(&self, text: &str) {
let Some(request) = Request::decode(text) else {
tracing::debug!("instance: ignored a request that did not parse");
return;
};
let _ = self.sender.send(request);
self.ctx.request_repaint();
}
}
#[cfg(unix)]
mod platform {
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
pub struct Inner(UnixListener);
/// In the runtime directory where there is one: it is the user's alone and
/// emptied at logout, which is exactly the life a socket should have.
fn socket(dirs: &AppDirs) -> PathBuf {
directories::BaseDirs::new()
.and_then(|base| base.runtime_dir().map(std::path::Path::to_path_buf))
.unwrap_or_else(|| dirs.cache.clone())
.join("io.narl.proton-stream.sock")
}
pub fn claim(_: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
let path = socket(dirs);
if let Ok(mut stream) = UnixStream::connect(&path) {
let _ = stream.set_write_timeout(Some(HAND_TIMEOUT));
match stream.write_all(request.encode().as_bytes()) {
Ok(()) => return Claim::Handed,
Err(error) => tracing::warn!("instance: hand over to the open window: {error}"),
}
}
// Nobody answered, so a socket file still there is left over from a
// window that did not exit cleanly.
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(listener) => listener,
Err(error) => {
tracing::warn!("instance: listen at {}: {error}", path.display());
return Claim::First(Listener { inner: None });
}
};
// The runtime directory is private already; the cache fallback is
// not necessarily, and a share link is a secret.
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
Claim::First(Listener {
inner: Some(Inner(listener)),
})
}
pub fn serve(inner: Inner, _: &tokio::runtime::Runtime, deliver: Deliver) {
let spawned = std::thread::Builder::new()
.name("instance".into())
.spawn(move || {
for stream in inner.0.incoming() {
let Ok(stream) = stream else { continue };
let _ = stream.set_read_timeout(Some(HAND_TIMEOUT));
let mut text = String::new();
if stream.take(MOST).read_to_string(&mut text).is_ok() {
deliver.line(&text);
}
}
});
if let Err(error) = spawned {
tracing::warn!("instance thread: {error}");
}
}
}
#[cfg(windows)]
mod platform {
use std::hash::{BuildHasher, Hasher};
use std::io::Write;
use tokio::io::AsyncReadExt;
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
pub struct Inner {
name: String,
first: NamedPipeServer,
}
/// The pipe's name. Pipe names are machine-wide, so a fixed one could be
/// taken first by another account on the same machine, which would then
/// be handed this user's share links. The name carries a random part kept
/// in this user's own config directory, which nobody else can read.
fn pipe(dirs: &AppDirs) -> String {
let path = dirs.config.join("instance.key");
let key = std::fs::read_to_string(&path)
.ok()
.map(|key| key.trim().to_owned())
.filter(|key| key.len() == 32 && key.bytes().all(|b| b.is_ascii_hexdigit()))
.unwrap_or_else(|| {
// `RandomState` is seeded from the OS's random source, once
// per process and stepped per instance after that.
let mut key = String::new();
for _ in 0..2 {
let word = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
key.push_str(&format!("{word:016x}"));
}
if let Err(error) = std::fs::write(&path, &key) {
tracing::warn!("instance: store the pipe key: {error}");
}
key
});
format!(r"\\.\pipe\io.narl.proton-stream.{key}")
}
fn server(name: &str, first: bool) -> std::io::Result<NamedPipeServer> {
ServerOptions::new()
.first_pipe_instance(first)
.reject_remote_clients(true)
.access_outbound(false)
.create(name)
}
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
let name = pipe(dirs);
// Registering a pipe with tokio needs its reactor.
let _entered = runtime.enter();
match server(&name, true) {
Ok(first) => Claim::First(Listener {
inner: Some(Inner { name, first }),
}),
// Taken: there is a window. Hand it the request.
Err(_) => {
let started = std::time::Instant::now();
loop {
match std::fs::OpenOptions::new().write(true).open(&name) {
Ok(mut pipe) => {
if let Err(error) = pipe.write_all(request.encode().as_bytes()) {
tracing::warn!("instance: hand over to the open window: {error}");
}
return Claim::Handed;
}
// Every instance busy with another launch: wait a turn.
Err(_) if started.elapsed() < HAND_TIMEOUT => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(error) => {
tracing::warn!("instance: reach the open window: {error}");
return Claim::First(Listener { inner: None });
}
}
}
}
}
}
pub fn serve(inner: Inner, runtime: &tokio::runtime::Runtime, deliver: Deliver) {
runtime.spawn(async move {
let Inner { name, mut first } = inner;
loop {
if first.connect().await.is_err() {
continue;
}
let connected = first;
// The next instance is up before this one is read, so a launch
// right behind this one finds a pipe to connect to.
first = match server(&name, false) {
Ok(next) => next,
Err(error) => {
tracing::warn!("instance: reopen the pipe: {error}");
return;
}
};
let mut text = String::new();
let mut limited = connected.take(MOST);
let read = limited.read_to_string(&mut text);
if let Ok(Ok(_)) = tokio::time::timeout(HAND_TIMEOUT, read).await {
deliver.line(&text);
}
}
});
}
}
#[cfg(not(any(unix, windows)))]
mod platform {
use super::{AppDirs, Claim, Deliver, Listener, Request};
pub struct Inner;
pub fn claim(_: &tokio::runtime::Runtime, _: &AppDirs, _: &Request) -> Claim {
Claim::First(Listener { inner: None })
}
pub fn serve(_: Inner, _: &tokio::runtime::Runtime, _: Deliver) {}
}
#[cfg(test)]
mod tests {
use super::*;
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#fragmentsecret";
#[test]
fn a_launch_with_a_share_link_asks_for_it_to_be_added() {
let args = ["--verbose".to_owned(), format!(" {LINK} ")];
assert_eq!(Request::from_args(args), Request::AddShare(LINK.to_owned()));
}
#[test]
fn a_launch_with_no_share_link_only_raises_the_window() {
let args = ["https://example.com/".to_owned()];
assert_eq!(Request::from_args(args), Request::Raise);
assert_eq!(Request::from_args(Vec::new()), Request::Raise);
}
#[test]
fn a_request_survives_the_wire() {
for request in [Request::Raise, Request::AddShare(LINK.to_owned())] {
assert_eq!(Request::decode(&request.encode()), Some(request));
}
}
#[test]
fn a_line_that_is_not_a_request_is_ignored() {
assert_eq!(Request::decode("add https://example.com/"), None);
assert_eq!(Request::decode("open the pod bay doors"), None);
}
}
+1
View File
@@ -23,6 +23,7 @@ pub mod app;
pub mod desktop_prefs;
pub mod engine;
pub mod inhibit;
pub mod instance;
pub mod media;
pub mod notify;
pub mod pacing;
+20 -2
View File
@@ -31,9 +31,27 @@ fn main() -> anyhow::Result<()> {
);
let dirs = AppDirs::ensure().context("resolve app directories")?;
// Before the window: a second launch hands its request to the first and
// never opens one.
let request = pstr_app::instance::Request::from_args(std::env::args().skip(1));
let instance = match pstr_app::instance::claim(&runtime, &dirs, &request) {
pstr_app::instance::Claim::First(listener) => listener,
pstr_app::instance::Claim::Handed => {
tracing::info!("handed over to the window already open");
return Ok(());
}
};
let window = pstr_app::window::WindowState::load(&dirs.window_file());
let mut viewport = egui::ViewportBuilder::default();
// X11 and Windows take the icon from the window. Wayland ignores this and
// looks the app id up in the installed desktop entry instead.
match eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon-256.png")) {
Ok(icon) => viewport = viewport.with_icon(icon),
Err(error) => tracing::debug!("window icon: {error}"),
}
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
viewport: viewport
.with_title("proton-stream")
.with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
.with_maximized(window.is_some_and(|state| state.maximized))
@@ -50,7 +68,7 @@ fn main() -> anyhow::Result<()> {
"proton-stream",
options,
Box::new(move |cc| {
pstr_app::app::App::new(cc, runtime, dirs)
pstr_app::app::App::new(cc, runtime, dirs, instance, request)
.map(|app| Box::new(app) as Box<dyn eframe::App>)
.map_err(|error| error.into())
}),
+108 -13
View File
@@ -4,41 +4,48 @@
//! app already has the toast, and a second copy of it from the panel is noise.
//! That rule is the caller's — see `App::notify`.
//!
//! Linux only, over `org.freedesktop.Notifications`. Windows wants an
//! application id registered by an installer before a toast will show, and a
//! notification that silently never appears is worse than none.
//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on
//! macOS yet.
//!
//! A Windows toast is shown under an application id, and Windows drops a toast
//! whose id it does not know without a word. The id is registered for the
//! current user under `HKCU\Software\Classes\AppUserModelId` — the same
//! registration the Windows App SDK makes for an app with no package — which
//! works for the MSI and the portable zip alike. If that write fails, the
//! toast goes out under PowerShell's id instead, which Windows always knows: a
//! notification that names the wrong program beats one that never appears.
use std::path::Path;
/// Sends notifications from a thread of its own, so a notification daemon
/// that is slow to answer never costs a frame.
pub struct Notifier {
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", windows))]
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
}
impl Notifier {
pub fn new() -> Self {
/// `cache` is where Windows is pointed for the toast's icon.
pub fn new(cache: &Path) -> Self {
let _ = cache;
Self {
#[cfg(target_os = "linux")]
orders: linux::spawn(),
#[cfg(windows)]
orders: windows::spawn(cache.join("notification-icon.png")),
}
}
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", windows))]
if let Some(orders) = &self.orders {
let _ = orders.send((summary.into(), body.into()));
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "linux", windows)))]
let _ = (summary.into(), body.into());
}
}
impl Default for Notifier {
fn default() -> Self {
Self::new()
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::sync::mpsc::{Sender, channel};
@@ -93,3 +100,91 @@ mod linux {
Some(orders)
}
}
#[cfg(windows)]
mod windows {
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Sender, channel};
use tauri_winrt_notification::Toast;
/// The id toasts are shown under, and the key it is registered at.
const APP_ID: &str = "io.narl.proton-stream";
const ICON: &[u8] = include_bytes!("../assets/icon-256.png");
pub fn spawn(icon: PathBuf) -> Option<Sender<(String, String)>> {
let (orders, receiver) = channel::<(String, String)>();
std::thread::Builder::new()
.name("notify".into())
.spawn(move || {
// Registered on first use rather than at startup: most
// sessions never send one.
let mut app_id: Option<&str> = None;
for (summary, body) in receiver {
let id = *app_id.get_or_insert_with(|| {
let _ = std::fs::write(&icon, ICON);
if register(&icon) {
APP_ID
} else {
Toast::POWERSHELL_APP_ID
}
});
let shown = Toast::new(id).title(&summary).text1(&body).show();
if let Err(error) = shown {
tracing::debug!("notification: {error}");
}
}
})
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
.ok()?;
Some(orders)
}
/// Tell Windows the app id's display name and icon. Per user, no rights
/// needed, and written again every session so a moved install points at
/// a live icon.
fn register(icon: &Path) -> bool {
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey,
RegCreateKeyExW, RegSetValueExW,
};
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(std::iter::once(0)).collect()
}
let path = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"));
let mut key: HKEY = std::ptr::null_mut();
// SAFETY: every pointer is to a live, NUL-terminated buffer or an out
// parameter on this stack frame, and the key is closed before return.
unsafe {
let created = RegCreateKeyExW(
HKEY_CURRENT_USER,
path.as_ptr(),
0,
std::ptr::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
);
if created != 0 {
tracing::debug!("register the notification id: error {created}");
return false;
}
let icon = icon.to_string_lossy();
let mut written = true;
for (name, value) in [("DisplayName", "Proton Stream"), ("IconUri", icon.as_ref())] {
let name = wide(name);
let value = wide(value);
let bytes = u32::try_from(value.len() * 2).unwrap_or(u32::MAX);
let set =
RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), bytes);
written &= set == 0;
}
RegCloseKey(key);
written
}
}
}
+157 -18
View File
@@ -84,26 +84,151 @@ impl Role {
}
}
/// Headings are set in the semibold cut; everything below them in the
/// regular one.
pub fn family(self) -> egui::FontFamily {
match self {
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
_ => egui::FontFamily::Proportional,
}
}
/// For the hand-painted parts, which lay text out through a `Painter` and
/// never see a `RichText`.
pub fn font(self) -> egui::FontId {
egui::FontId::proportional(self.size())
egui::FontId::new(self.size(), self.family())
}
/// For the parts that go through egui's widgets.
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).size(self.size())
egui::RichText::new(text)
.size(self.size())
.family(self.family())
}
}
/// Install a real CJK fallback before the default egui fonts. egui ships a
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
/// CJK families. Native desktops already provide one, so use it when present.
/// The spacing scale. Gaps between things come from here rather than being
/// picked per call site, so two neighbouring groups are 12 apart or 16 apart
/// and never 14 in one place and 15 in the next.
pub mod space {
/// Between lines that belong to one thing: a title and its caption.
pub const XXS: f32 = 2.0;
pub const XS: f32 = 4.0;
/// Between controls in one cluster.
pub const S: f32 = 6.0;
pub const M: f32 = 8.0;
/// Between clusters on one row, and between rows of a form.
pub const L: f32 = 12.0;
/// Between the groups on a page.
pub const XL: f32 = 16.0;
/// Between sections.
pub const XXL: f32 = 24.0;
}
/// Corner radii, as `egui::CornerRadius::same` takes them.
pub mod radius {
/// A progress bar, or a strip along an edge.
pub const BAR: u8 = 3;
/// A badge over artwork. Android's `Shape.kt` has the same number.
pub const BADGE: u8 = 4;
/// A small control inside something else: a menu entry, a chip.
pub const SM: u8 = 6;
/// Everything else either client paints: cards, buttons, the tab pill.
pub const MD: u8 = 8;
/// Something that floats: a row card, a dialog, a toast, the hero.
pub const LG: u8 = 12;
}
/// How long things take to change, in seconds, for `Context::animate_*`.
pub mod motion {
/// A press, which has to answer before the finger is off the button.
pub const PRESS: f32 = 0.05;
/// A hover arriving and leaving.
pub const HOVER: f32 = 0.12;
/// A control changing state: a switch flipping, the tab pill travelling,
/// one page giving way to the next.
pub const STATE: f32 = 0.15;
/// Chrome fading in and out: the player's controls, a toast.
pub const FADE: f32 = 0.18;
/// Artwork arriving over its placeholder. Slower, because it replaces
/// something rather than adding to it, and a fast swap reads as a flicker.
pub const ARRIVE: f32 = 0.24;
/// Fast out, slow in: the curve every hand-driven transition uses.
pub fn ease_out(t: f32) -> f32 {
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
}
}
/// The UI typeface, bundled rather than taken from the system so a title
/// sets the same on every desktop. Inter, under the OFL — the licence is
/// beside the files.
///
/// On a thread of its own: `fc-match` and reading a font file of tens of
/// megabytes held the first frame back by a visible moment, and every name
/// that needs the fallback is still loading from the catalog by then anyway.
pub fn install_font_fallbacks(ctx: &egui::Context) {
/// Both files have their private use area mappings removed, because the
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
/// own alternate glyphs, and as the first font in the family it won: the
/// refresh arrow drew as an accented letter. The glyphs themselves are still
/// in the files; only the character map entries are gone. Strip them again
/// after any Inter update (fontTools: drop every `cmap` subtable entry in
/// U+E000..=U+F8FF).
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
/// The family the headings are set in. egui has no notion of weight, so the
/// semibold cut is a family of its own.
pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into())
}
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
/// symbols Inter does not carry. `cjk` goes last, when there is one.
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
use egui::FontFamily::{Monospace, Proportional};
let mut fonts = egui::FontDefinitions::default();
fonts
.font_data
.insert("inter".into(), Arc::new(egui::FontData::from_static(INTER)));
fonts.font_data.insert(
"inter-semibold".into(),
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
);
fonts
.families
.entry(Proportional)
.or_default()
.insert(0, "inter".into());
// Right behind Inter. The icons are in the private use area, which the
// bundled Inter no longer maps, so neither font shadows the other.
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
if let Some(cjk) = cjk {
let name = "system-cjk-fallback".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(cjk));
for family in [Proportional, Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
}
let fallbacks = fonts.families[&Proportional]
.iter()
.filter(|name| *name != "inter")
.cloned();
let semibold_chain = std::iter::once("inter-semibold".to_owned())
.chain(fallbacks)
.collect();
fonts.families.insert(semibold(), semibold_chain);
fonts
}
/// Install the bundled fonts now, and a CJK fallback when the system has one.
///
/// The bundled ones are compiled in and cost nothing to hand over. The CJK
/// fallback is on a thread of its own: `fc-match` and reading a font file of
/// tens of megabytes held the first frame back by a visible moment, and every
/// name that needs the fallback is still loading from the catalog by then
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
/// and native desktops already provide one.
pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(None));
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("fonts".into())
@@ -120,16 +245,9 @@ fn install_cjk_fallback(ctx: &egui::Context) {
let Ok(bytes) = std::fs::read(path) else {
return;
};
let mut fonts = egui::FontDefinitions::default();
let mut data = egui::FontData::from_owned(bytes);
data.index = index;
let name = "system-cjk-fallback".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(data));
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
ctx.set_fonts(fonts);
ctx.set_fonts(font_definitions(Some(data)));
ctx.request_repaint();
}
@@ -569,7 +687,7 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
visuals.selection.stroke = Stroke::new(1.0, palette.text);
visuals.hyperlink_color = palette.accent;
let radius = CornerRadius::same(8);
let radius = CornerRadius::same(radius::MD);
visuals.widgets.noninteractive.bg_fill = palette.surface;
visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
@@ -731,6 +849,27 @@ const RAMP_STOPS: usize = 64;
mod tests {
use super::*;
#[test]
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _;
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
let shadowed: Vec<u32> = font
.charmap()
.mappings()
.map(|(codepoint, _)| codepoint)
.filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
.collect();
assert!(
shadowed.is_empty(),
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
shadowed.len(),
shadowed[0]
);
}
}
#[test]
fn the_shipped_default_is_the_palette_that_was_here_before() {
let resolved = Palette::resolve(Appearance::default());
+6 -6
View File
@@ -20,7 +20,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Act
.show(ui, |ui| {
ui.set_max_width(960.0);
summary(ui, downloads);
ui.add_space(12.0);
ui.add_space(theme::space::L);
let mut start = 0;
while start < downloads.len() {
@@ -30,7 +30,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Act
.position(|item| item.target.title_key != *title_key)
.map_or(downloads.len(), |offset| start + offset);
group(ui, &downloads[start..end], actions);
ui.add_space(14.0);
ui.add_space(theme::space::L);
start = end;
}
});
@@ -108,7 +108,7 @@ fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
if totals_known && group.len() > 1 {
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
}
ui.add_space(6.0);
ui.add_space(theme::space::S);
for item in group {
row(ui, item, actions);
}
@@ -117,7 +117,7 @@ fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(6))
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -142,7 +142,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
buttons(ui, item, actions);
if item.total > 0 && item.state != DownloadState::Completed {
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.label(ui::muted(format!(
"{} / {}",
size(item.downloaded),
@@ -154,7 +154,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
});
});
});
ui.add_space(4.0);
ui.add_space(theme::space::XS);
}
/// What a row says about itself, and in what colour.
+238
View File
@@ -0,0 +1,238 @@
//! Everything that has been played, newest first, under a heading per day.
//!
//! Continue watching answers "what was I in the middle of"; this answers
//! "what was that episode on Tuesday". It is read straight off the watch
//! state, so there is nothing to keep in step: an episode is here because it
//! has a position or is marked watched, and leaves when it has neither.
use chrono::{DateTime, Local, NaiveDate, TimeZone};
use pstr_core::library::{Episode, Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::theme;
use crate::ui::{self, Art};
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
/// How far back the page goes. Further than anyone scrolls; the cap is there
/// so a library with years of history does not lay out every row of it.
const MOST: usize = 400;
/// Where one entry lives in [`Library::titles`]: title, season, episode.
pub type Entry = (usize, usize, usize);
/// The entries of `library.history()`, as positions, so they can be kept
/// between frames and only rebuilt when the library changes.
pub fn entries(library: &Library) -> Vec<Entry> {
let mut position = std::collections::HashMap::new();
for (t, title) in library.titles.iter().enumerate() {
for (s, season) in title.seasons.iter().enumerate() {
for (e, episode) in season.episodes.iter().enumerate() {
position.insert(std::ptr::from_ref(episode), (t, s, e));
}
}
}
library
.history()
.into_iter()
.filter_map(|(_, episode)| position.get(&std::ptr::from_ref(episode)).copied())
.collect()
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
entries: &[Entry],
actions: &mut Vec<Action>,
) {
if entries.is_empty() {
ui::empty_state(
ui,
"Nothing watched yet",
"Episodes and films you play show up here, newest first, so the one from \
last week is easy to find again.",
);
return;
}
let today = Local::now().date_naive();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.set_max_width(960.0);
let mut day = None;
for &(t, s, e) in entries.iter().take(MOST) {
let Some(title) = library.titles.get(t) else {
continue;
};
let Some(episode) = title
.seasons
.get(s)
.and_then(|season| season.episodes.get(e))
else {
continue;
};
let Some(at) = Local.timestamp_opt(episode.last_played(), 0).single() else {
continue;
};
if day != Some(at.date_naive()) {
if day.is_some() {
ui.add_space(theme::space::L);
}
day = Some(at.date_naive());
ui::section(ui, &day_label(at.date_naive(), today));
}
row(ui, art, title, episode, at, actions);
}
});
}
fn row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
episode: &Episode,
at: DateTime<Local>,
actions: &mut Vec<Action>,
) {
let row_id = ui.id().with(("history", &episode.node.link_id));
let frame = egui::Frame::new()
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(8));
let mut prepared = frame.begin(ui);
{
let ui = &mut prepared.content_ui;
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
ui::title::still(
ui,
art,
title,
episode,
episode.is_watched(),
STILL,
actions,
);
ui.add_space(theme::space::M);
let remove = 40.0;
let text_width = (ui.available_width() - remove).max(120.0);
ui.vertical(|ui| {
ui.set_width(text_width);
ui.spacing_mut().item_spacing.y = theme::space::XXS;
ui.add_space(theme::space::XS);
let name = ui
.add(
egui::Label::new(theme::Role::Body.rich(&title.name).color(theme::text()))
.sense(egui::Sense::click())
.truncate(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Show this title");
if name.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
// A film's only "episode" is its file, and its filename says
// nothing the title above it does not.
if title.kind != TitleKind::Film {
let episode_name = art
.episode(&title.key, episode)
.and_then(|found| found.name.clone())
.unwrap_or_else(|| episode.detail().to_owned());
let line = match episode.numbering() {
Some(numbering) => format!("{numbering} · {episode_name}"),
None => episode_name,
};
ui.add(egui::Label::new(ui::muted(line)).truncate())
.on_hover_text(&episode.node.name);
}
ui.label(ui::muted(status(episode, at)));
});
let cluster = egui::vec2(ui.available_width(), STILL.y);
let layout = egui::Layout::right_to_left(egui::Align::Center);
ui.allocate_ui_with_layout(cluster, layout, |ui| {
let remove = ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.on_hover_text(if episode.is_watched() {
"Remove from history — marks it unwatched"
} else {
"Remove from history — forgets where you stopped"
});
if remove.clicked() {
actions.push(Action::RemoveFromHistory {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
});
}
});
});
}
let response = prepared.allocate_space(ui);
let lit =
ui.ctx()
.animate_bool_with_time(row_id, response.contains_pointer(), theme::motion::HOVER);
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
prepared.paint(ui);
ui.add_space(theme::space::XXS);
}
/// The grey line under the names: how far it got, and when.
fn status(episode: &Episode, at: DateTime<Local>) -> String {
let when = at.format("%H:%M");
if episode.is_watched() {
return format!("Watched · {when}");
}
match (
episode.resume_at(),
episode.watch.and_then(|watch| watch.duration_secs),
) {
(Some(position), Some(duration)) => format!(
"Stopped at {} of {} · {when}",
ui::format_time(position),
ui::format_time(duration)
),
(Some(position), None) => format!("Stopped at {} · {when}", ui::format_time(position)),
_ => format!("Played · {when}"),
}
}
/// The heading over a day's entries: "Today", "Yesterday", a weekday within
/// the last week, and a date before that — with the year only when it is not
/// this one.
fn day_label(day: NaiveDate, today: NaiveDate) -> String {
use chrono::Datelike;
match (today - day).num_days() {
0 => "Today".into(),
1 => "Yesterday".into(),
2..=6 => day.format("%A").to_string(),
_ if day.year() == today.year() => day.format("%-d %B").to_string(),
_ => day.format("%-d %B %Y").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn a_day_is_named_relative_to_today_for_the_last_week_and_dated_before_that() {
let today = date(2026, 10, 2);
assert_eq!(day_label(today, today), "Today");
assert_eq!(day_label(date(2026, 10, 1), today), "Yesterday");
assert_eq!(day_label(date(2026, 9, 28), today), "Monday");
assert_eq!(day_label(date(2026, 9, 25), today), "25 September");
assert_eq!(day_label(date(2025, 12, 24), today), "24 December 2025");
}
}
+28 -21
View File
@@ -47,13 +47,13 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
if !searching {
if let Some(title) = featured(library, view, art.metadata, today()) {
hero(ui, art, title, actions);
ui.add_space(18.0);
ui.add_space(theme::space::XL);
}
let resumable = pick(&view.resumable);
if !resumable.is_empty() {
ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions);
ui.add_space(16.0);
ui.add_space(theme::space::XL);
}
}
@@ -74,10 +74,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
shelf_controls(ui, view, actions);
});
});
ui.add_space(6.0);
ui.add_space(theme::space::S);
if matches.is_empty() {
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.label(ui::muted(if searching {
"Nothing here by that name."
} else {
@@ -143,7 +143,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(12);
let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.of(title);
let found = art
.metadata
@@ -152,9 +152,11 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
let painter = ui.painter().with_clip_rect(rect);
painter.rect_filled(rect, radius, theme::card());
let art_in = ui
.ctx()
.animate_bool_with_time(response.id.with("art"), picture.is_some(), 0.3);
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
picture.is_some(),
theme::motion::ARRIVE,
);
if art_in < 1.0 {
ui::placeholder(&painter, rect, "", 1.0 - art_in);
}
@@ -211,20 +213,22 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => format!("▶ Resume {numbering}"),
(Some(_), None) => "▶ Resume".to_owned(),
(None, _) => "▶ Play".to_owned(),
(Some(_), Some(numbering)) => {
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
}
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
ui.add_space(6.0);
ui.add_space(theme::space::S);
}
if ui.button("More info").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
});
ui.add_space(14.0);
ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
let mut job = egui::text::LayoutJob::simple(
overview.to_owned(),
@@ -234,17 +238,17 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
);
job.wrap.max_rows = 3;
ui.label(job);
ui.add_space(8.0);
ui.add_space(theme::space::M);
}
let mut facts = vec![subtitle(title)];
if let Some(found) = &found {
if let Some(rating) = found.rating {
facts.push(format!("★ {rating:.1}"));
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
facts.extend(found.genres.iter().take(3).cloned());
}
ui.label(ui::muted(facts.join(" · ")));
ui.add_space(2.0);
ui.add_space(theme::space::XXS);
ui.add(
egui::Label::new(
theme::Role::Display
@@ -288,7 +292,7 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
if let Some(Some(sort)) = sort.inner {
actions.push(Action::SetShelf(view.filter, sort));
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented(
ui,
view.filter,
@@ -525,8 +529,11 @@ fn placeholder(ui: &mut egui::Ui) {
rect.min,
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
);
ui.painter()
.rect_filled(image, egui::CornerRadius::same(8), theme::card());
ui.painter().rect_filled(
image,
egui::CornerRadius::same(theme::radius::MD),
theme::card(),
);
}
});
ui.add_space(theme::CARD_GAP);
@@ -541,11 +548,11 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
.rich("Nothing in the library yet")
.strong(),
);
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.label(ui::muted(
"Add a Proton Drive share link, and everything playable behind it shows up here.",
));
ui.add_space(18.0);
ui.add_space(theme::space::XL);
if ui::accent_button(ui, "Add a share").clicked() {
actions.push(Action::Goto(Page::Shares));
}
+12 -12
View File
@@ -43,7 +43,7 @@ pub fn show(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(egui::CornerRadius::same(10)),
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(620.0);
@@ -58,21 +58,21 @@ pub fn show(
"Everything {} answers with, in its own order — nothing is scored here.",
provider.label()
)));
ui.add_space(10.0);
ui.add_space(theme::space::L);
search_row(ui, matcher, actions);
ui.add_space(6.0);
ui.add_space(theme::space::S);
current_row(ui, matcher, current.as_ref(), actions);
ui.add_space(10.0);
ui.add_space(theme::space::L);
if let Some(error) = &matcher.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(6.0);
ui.add_space(theme::space::S);
}
results(ui, matcher, art, actions);
ui.add_space(12.0);
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
@@ -178,7 +178,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
if row(ui, art, found).clicked() {
actions.push(Action::ChooseMatch(Box::new(found.clone())));
}
ui.add_space(4.0);
ui.add_space(theme::space::XS);
}
});
}
@@ -188,7 +188,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
let response = egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(6))
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -204,12 +204,12 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
} else {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(4),
egui::CornerRadius::same(theme::radius::BADGE),
theme::card_hover(),
);
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.label(
theme::Role::Body
@@ -241,7 +241,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
if response.hovered() {
ui.painter().rect_stroke(
response.rect,
egui::CornerRadius::same(6),
egui::CornerRadius::same(theme::radius::SM),
egui::Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Inside,
);
@@ -279,7 +279,7 @@ fn facts(found: &TitleMetadata) -> String {
parts.push(ui::library::plural(episodes as usize, "episode"));
}
if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}"));
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
parts.join(" · ")
}
+30 -21
View File
@@ -4,6 +4,7 @@
//! applies them after the frame is drawn.
pub mod downloads;
pub mod history;
pub mod library;
pub mod matcher;
pub mod player;
@@ -141,14 +142,14 @@ pub fn format_size(bytes: i64) -> String {
/// A section title with a rule under it.
pub fn section(ui: &mut egui::Ui, text: &str) {
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.label(
theme::Role::Section
.rich(text)
.strong()
.color(theme::text()),
);
ui.add_space(2.0);
ui.add_space(theme::space::XXS);
}
/// Small grey text, for everything that is context rather than content.
@@ -173,7 +174,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
let has_focus = ui.memory(|memory| memory.has_focus(id));
let lit = ui
.ctx()
.animate_bool_with_time(id.with("lit"), has_focus, 0.12);
.animate_bool_with_time(id.with("lit"), has_focus, theme::motion::HOVER);
let radius = CornerRadius::same((height / 2.0) as u8);
ui.painter().rect_filled(outer, radius, theme::card());
@@ -258,7 +259,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover());
if ui.is_rect_visible(rect) {
let radius = CornerRadius::same(3);
let radius = CornerRadius::same(theme::radius::BAR);
ui.painter().rect_filled(rect, radius, theme::card_hover());
let mut done = rect;
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
@@ -275,7 +276,7 @@ pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) {
ui.vertical_centered(|ui| {
ui.add_space(96.0);
ui.label(theme::Role::Title.rich(heading).strong());
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.label(muted(body));
});
}
@@ -311,7 +312,7 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(CornerRadius::same(10)),
.corner_radius(CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(420.0);
@@ -321,9 +322,9 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
.strong()
.color(theme::text()),
);
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
ui.add_space(16.0);
ui.add_space(theme::space::XL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if danger_button(ui, verb).clicked() {
answer = Answer::Confirmed;
@@ -382,15 +383,17 @@ pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<u
// the pill from overshooting when it moves between tabs of different
// lengths — both edges arrive at the same time.
let ctx = ui.ctx();
let left = ctx.animate_value_with_time(id.with("left"), target.left(), 0.16);
let right = ctx.animate_value_with_time(id.with("right"), target.right(), 0.16);
let left =
ctx.animate_value_with_time(id.with("left"), target.left(), theme::motion::STATE);
let right =
ctx.animate_value_with_time(id.with("right"), target.right(), theme::motion::STATE);
let rect = Rect::from_min_max(
egui::pos2(left, target.top()),
egui::pos2(right, target.bottom()),
);
ui.painter().set(
pill,
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(8)),
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
);
}
@@ -431,17 +434,21 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
return response;
}
let radius = CornerRadius::same(8);
let radius = CornerRadius::same(theme::radius::MD);
// Eased rather than switched, so the veil arrives over a few frames instead
// of appearing whole the moment the pointer crosses the edge. The press is
// quicker than the hover: a click should feel like it registered, not like
// it was considered.
let ctx = ui.ctx().clone();
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let press = ctx.animate_bool_with_time(
response.id.with("press"),
response.is_pointer_button_down_on(),
0.05,
theme::motion::PRESS,
);
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
@@ -510,7 +517,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = card.width;
let image_height = (width * theme::CARD_ASPECT).round();
let text_height = CARD_TEXT_HEIGHT;
let radius = CornerRadius::same(8);
let radius = CornerRadius::same(theme::radius::MD);
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
@@ -533,14 +540,16 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered() || response.has_focus(),
0.12,
theme::motion::HOVER,
);
// The picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle.
let art_in = ui
.ctx()
.animate_bool_with_time(response.id.with("art"), card.art.is_some(), 0.22);
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
card.art.is_some(),
theme::motion::ARRIVE,
);
let painter = ui.painter();
if hover > 0.0 {
@@ -591,7 +600,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
);
painter.rect_filled(
background,
CornerRadius::same(4),
CornerRadius::same(theme::radius::BADGE),
Color32::from_black_alpha(180),
);
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
@@ -724,7 +733,7 @@ pub(crate) fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opaci
theme::ramp_fill(
painter,
rect,
CornerRadius::same(8),
CornerRadius::same(theme::radius::MD),
(from, to),
theme::Direction::Vertical,
opacity,
+289 -15
View File
@@ -66,6 +66,10 @@ const SKIP_PAD_X: f32 = 22.0;
/// on the full wash. See [`scrim_bottom`].
const SCRIM_ALPHA: f32 = 170.0;
const SCRIM_FADE: f32 = 88.0;
/// How wide the episode list is, at most. A narrow window gives it less.
const DRAWER_WIDTH: f32 = 380.0;
/// The stills in the episode list.
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
/// State the page keeps between frames.
///
@@ -109,6 +113,17 @@ pub struct Chrome<'a> {
pub up_next: Option<UpNextCard>,
/// Whether stepping to another episode is possible in either direction.
pub neighbours: Neighbours,
/// The title's episodes, when it has more than one — what the list down
/// the right of the picture shows.
pub episodes: Option<Episodes<'a>>,
}
/// The playing title's episodes, for the list beside the picture.
pub struct Episodes<'a> {
pub title: &'a pstr_core::library::Title,
pub art: ui::Art<'a>,
/// Whether the list is out.
pub open: bool,
}
/// What the end-of-episode card says, when there is one.
@@ -187,6 +202,7 @@ pub fn show(
overlay,
up_next,
neighbours,
mut episodes,
} = chrome_state;
let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap();
@@ -228,7 +244,10 @@ pub fn show(
actions.push(Action::ToggleFullscreen);
}
let visible = overlay.visible(&ctx, playback);
let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
// The controls stay up while the list is out: it hangs off the button in
// the title strip, and is read at leisure.
let visible = overlay.visible(&ctx, playback) || drawer_open;
// Above the chrome when it is up, at the bottom corner when it is not —
// and clear of it either way, which is what the measured height buys.
let float_above = if visible {
@@ -251,7 +270,7 @@ pub fn show(
// Faded rather than switched, both ways: controls that blink out under a
// reading eye are more distracting than the ones that were there.
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, 0.18);
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, theme::motion::FADE);
if fade > 0.0 {
overlay.chrome_height = ui
.scope(|ui| {
@@ -260,6 +279,7 @@ pub fn show(
ui,
playback,
neighbours,
episodes.as_ref().map(|list| list.open),
rect,
overlay.chrome_height,
actions,
@@ -267,6 +287,11 @@ pub fn show(
})
.inner;
}
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
} else {
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
}
if !visible {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
// The controls have to disappear on their own, and nothing else is
@@ -379,7 +404,7 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
if ui.button("Back").clicked() {
actions.push(Action::LeavePlayer);
}
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.add(egui::Spinner::new().size(14.0));
},
);
@@ -479,7 +504,7 @@ fn up_next_card(
egui::Frame::new()
.fill(Color32::from_black_alpha(215))
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
.corner_radius(CornerRadius::same(8))
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(12))
.show(ui, |ui| {
ui.set_width(size.x - 24.0);
@@ -488,7 +513,7 @@ fn up_next_card(
// coming and when, read in one glance.
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
let painter = ui.painter();
let radius = CornerRadius::same(6);
let radius = CornerRadius::same(theme::radius::SM);
match &card.still {
Some(texture) => {
painter.add(
@@ -518,7 +543,7 @@ fn up_next_card(
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.label(
theme::Role::Caption
@@ -535,12 +560,12 @@ fn up_next_card(
)
.truncate(),
);
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
ui.add_space(6.0);
ui.add_space(theme::space::S);
if ui
.button("Watch till the end")
.on_hover_text("Play this one out — nothing will be skipped")
@@ -618,6 +643,7 @@ fn chrome(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
drawer: Option<bool>,
rect: Rect,
previous_height: f32,
actions: &mut Vec<Action>,
@@ -633,7 +659,7 @@ fn chrome(
.add(
egui::Button::new(theme::Role::Label.rich("Library"))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(96.0, 32.0)),
)
.on_hover_text("Keep playing, and go back to the library (Esc)")
@@ -641,7 +667,7 @@ fn chrome(
{
actions.push(Action::LeavePlayer);
}
ui.add_space(14.0);
ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.label(
theme::Role::Heading
@@ -657,18 +683,56 @@ fn chrome(
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
if ui
.add(
egui::Button::new(theme::Role::Subhead.rich("⛶"))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.min_size(Vec2::new(38.0, 32.0)),
egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
egui_phosphor::regular::CORNERS_IN
} else {
egui_phosphor::regular::CORNERS_OUT
}))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Fullscreen (F)")
.on_hover_text(if fullscreen {
"Leave fullscreen (F)"
} else {
"Fullscreen (F)"
})
.clicked()
{
actions.push(Action::ToggleFullscreen);
}
if let Some(open) = drawer {
ui.add_space(theme::space::S);
let fill = if open {
theme::accent()
} else {
Color32::from_black_alpha(160)
};
let ink = if open {
theme::on_accent()
} else {
Color32::WHITE
};
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::LIST_BULLETS)
.color(ink),
)
.fill(fill)
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Episodes (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
}
}
});
},
);
@@ -707,6 +771,216 @@ fn chrome(
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
}
/// Which file the episode list last scrolled to, in egui's memory, so it
/// scrolls to the playing one once when it opens rather than every frame.
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
/// The title's episodes, down the right of the picture between the title
/// strip and the controls. A season at a time, opened on the playing one.
fn episode_drawer(
ctx: &egui::Context,
list: &mut Episodes<'_>,
playback: &Playback,
rect: Rect,
chrome_height: f32,
actions: &mut Vec<Action>,
) {
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
let area = Rect::from_min_max(
egui::pos2(rect.right() - width - 16.0, rect.top() + TITLE_HEIGHT),
egui::pos2(rect.right() - 16.0, rect.bottom() - chrome_height - 8.0),
);
if area.height() < 160.0 {
return;
}
let title = list.title;
let target = &playback.target;
let is_playing = |episode: &pstr_core::library::Episode| {
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
};
let playing_season = title
.seasons
.iter()
.position(|season| season.episodes.iter().any(is_playing))
.unwrap_or(0);
let season_id = egui::Id::new(("episode-drawer-season", &title.key));
let mut season = ctx
.data(|data| data.get_temp::<usize>(season_id))
.unwrap_or(playing_season)
.min(title.seasons.len().saturating_sub(1));
let margin = theme::space::L;
egui::Area::new(egui::Id::new("episode-drawer"))
.order(egui::Order::Foreground)
.fixed_pos(area.min)
.show(ctx, |ui| {
egui::Frame::new()
.fill(theme::surface().gamma_multiply(0.96))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(margin as i8))
.shadow(theme::tile_shadow(1.0))
.show(ui, |ui| {
ui.set_width(area.width() - 2.0 * margin);
ui.set_height(area.height() - 2.0 * margin);
ui.horizontal(|ui| {
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.on_hover_text("Close (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
}
});
});
if title.seasons.len() > 1 {
ui.add_space(theme::space::XS);
let labels: Vec<(usize, String)> = title
.seasons
.iter()
.enumerate()
.map(|(index, season)| (index, season.label()))
.collect();
let choices: Vec<(usize, &str)> = labels
.iter()
.map(|(index, label)| (*index, label.as_str()))
.collect();
egui::ScrollArea::horizontal()
.id_salt("drawer-seasons")
.show(ui, |ui| {
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
season = picked;
ctx.data_mut(|data| data.insert_temp(season_id, picked));
}
});
}
ui.add_space(theme::space::S);
let Some(shown) = title.seasons.get(season) else {
return;
};
egui::ScrollArea::vertical()
.id_salt("drawer-episodes")
.auto_shrink([false, false])
.show(ui, |ui| {
for episode in &shown.episodes {
let row = drawer_row(
ui,
&mut list.art,
title,
episode,
is_playing(episode),
actions,
);
if is_playing(episode) {
let scrolled = ctx.data(|data| {
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
});
if scrolled.as_deref() != Some(target.link_id.as_str()) {
row.scroll_to_me(Some(egui::Align::Center));
ctx.data_mut(|data| {
data.insert_temp(
egui::Id::new(DRAWER_SCROLLED),
target.link_id.clone(),
)
});
}
}
}
});
});
});
}
/// One episode in the list: its still, which plays it, and what it is.
fn drawer_row(
ui: &mut egui::Ui,
art: &mut ui::Art<'_>,
title: &pstr_core::library::Title,
episode: &pstr_core::library::Episode,
playing: bool,
actions: &mut Vec<Action>,
) -> egui::Response {
let frame = egui::Frame::new()
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(6))
.fill(if playing {
theme::accent_dim()
} else {
Color32::TRANSPARENT
});
let response = frame
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
ui::title::still(
ui,
art,
title,
episode,
episode.is_watched(),
DRAWER_STILL,
actions,
);
ui.add_space(theme::space::M);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = theme::space::XXS;
let name = art
.episode(&title.key, episode)
.and_then(|found| found.name.clone())
.unwrap_or_else(|| episode.detail().to_owned());
if let Some(numbering) = episode.numbering() {
ui.label(
egui::RichText::new(numbering)
.monospace()
.color(theme::muted()),
);
}
let mut job = egui::text::LayoutJob::simple(
name,
theme::Role::Label.font(),
if episode.is_watched() && !playing {
theme::muted()
} else {
theme::text()
},
ui.available_width(),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
let state = if playing {
Some("Playing".to_owned())
} else if episode.is_watched() {
Some("Watched".to_owned())
} else {
episode
.resume_at()
.map(|at| format!("Stopped at {}", ui::format_time(at)))
};
if let Some(state) = state {
ui.label(theme::Role::Caption.rich(state).color(if playing {
theme::accent()
} else {
theme::muted()
}));
}
});
});
})
.response;
ui.add_space(theme::space::XXS);
response
}
/// The gradient behind the title strip, fading downwards out of the top edge.
fn scrim_top(ui: &egui::Ui, rect: Rect) {
// Squared, so the fade is dark where the text is and gone well before the
+26 -17
View File
@@ -50,7 +50,7 @@ pub fn show(
ui.add_space(margin);
ui.vertical(|ui| {
ui.set_width(width);
ui.add_space(8.0);
ui.add_space(theme::space::M);
appearance_form(ui, prefs.appearance, actions);
playback_form(ui, prefs, actions);
storage_form(ui, prefs, actions);
@@ -68,9 +68,9 @@ pub fn show(
/// only changed a small square would be asking someone to imagine the result.
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
ui.add_space(12.0);
ui.add_space(theme::space::L);
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL {
@@ -86,7 +86,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
}
}
});
ui.add_space(14.0);
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 0.0;
ui::widgets::settings_row(ui, "Accent", None, |ui| {
@@ -132,11 +132,11 @@ fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> eg
..appearance
});
let selected = appearance.flavor == flavor;
let hover = ui
.ctx()
.animate_bool_with_time(response.id, response.hovered(), 0.10);
let hover =
ui.ctx()
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
let painter = ui.painter();
let radius = egui::CornerRadius::same(8);
let radius = egui::CornerRadius::same(theme::radius::MD);
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
painter.rect_filled(preview, radius, palette.background);
@@ -155,11 +155,20 @@ fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> eg
egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0))
};
for x in [8.0, 49.0, 90.0] {
painter.rect_filled(tile(x), egui::CornerRadius::same(3), palette.card);
painter.rect_filled(
tile(x),
egui::CornerRadius::same(theme::radius::BAR),
palette.card,
);
}
let pill =
egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0));
theme::swatch_fill(painter, pill, egui::CornerRadius::same(3), &palette);
theme::swatch_fill(
painter,
pill,
egui::CornerRadius::same(theme::radius::BAR),
&palette,
);
painter.hline(
(preview.left() + 8.0)..=(preview.left() + 60.0),
preview.top() + 50.0,
@@ -354,7 +363,7 @@ fn metadata_form(
actions: &mut Vec<Action>,
) {
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
ui.add_space(12.0);
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 6.0;
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
@@ -362,7 +371,7 @@ fn metadata_form(
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.",
));
ui.add_space(10.0);
ui.add_space(theme::space::L);
let mut changed = false;
let mut enabled = settings.enabled;
@@ -372,7 +381,7 @@ fn metadata_form(
}
if settings.enabled {
ui.add_space(10.0);
ui.add_space(theme::space::L);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
@@ -387,7 +396,7 @@ fn metadata_form(
}
if settings.provider.needs_api_key() {
ui.add_space(10.0);
ui.add_space(theme::space::L);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
@@ -409,7 +418,7 @@ fn metadata_form(
));
}
ui.add_space(12.0);
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
@@ -423,7 +432,7 @@ fn metadata_form(
}
});
} else {
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
@@ -433,6 +442,6 @@ fn metadata_form(
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
ui.add_space(12.0);
ui.add_space(theme::space::L);
});
}
+10 -10
View File
@@ -38,7 +38,7 @@ pub fn show(
share_row(ui, share, titles, actions);
}
ui.add_space(22.0);
ui.add_space(theme::space::XXL);
add_form(ui, shares, form, actions);
});
}
@@ -46,7 +46,7 @@ pub fn show(
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -76,7 +76,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
});
});
});
ui.add_space(6.0);
ui.add_space(theme::space::S);
}
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
@@ -85,11 +85,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
"Paste the whole link, including everything after the # — that part is the key that \
decrypts it, and it is stored in your system keyring rather than on disk.",
));
ui.add_space(10.0);
ui.add_space(theme::space::L);
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
@@ -100,7 +100,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
.hint_text("Anime")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.label(ui::muted("Link"));
ui.horizontal(|ui| {
@@ -134,11 +134,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
if let Some(problem) = &problem {
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
}
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
if form.has_password {
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.add(
egui::TextEdit::singleline(&mut form.password)
.password(true)
@@ -147,7 +147,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
);
}
ui.add_space(12.0);
ui.add_space(theme::space::L);
let ready = !form.name.trim().is_empty()
&& !form.url.trim().is_empty()
&& problem.is_none()
@@ -156,7 +156,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
if let Some(error) = &form.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(6.0);
ui.add_space(theme::space::S);
}
ui.horizontal(|ui| {
if ui
+8 -4
View File
@@ -13,7 +13,10 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
&[
("Ctrl F or /", "Search the library"),
("F5 or Ctrl R", "Crawl every share again"),
("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"),
(
"Ctrl 1 – 5",
"Library, History, Shares, Downloads, Settings",
),
("Ctrl ,", "Settings"),
("Ctrl V", "Paste a share link to add it"),
("Tab or arrows", "Move between titles and episodes"),
@@ -33,6 +36,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
("N / P", "Next and previous episode"),
("[ / ]", "Slower and faster"),
("S", "Skip the opening or the credits"),
("E", "The list of episodes"),
("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"),
],
@@ -47,7 +51,7 @@ pub fn show(ctx: &egui::Context) -> bool {
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(20))
.corner_radius(egui::CornerRadius::same(12)),
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(460.0);
@@ -60,9 +64,9 @@ pub fn show(ctx: &egui::Context) -> bool {
});
});
for (heading, keys) in GROUPS {
ui.add_space(12.0);
ui.add_space(theme::space::L);
ui.label(ui::muted(heading.to_uppercase()));
ui.add_space(4.0);
ui.add_space(theme::space::XS);
egui::Grid::new(*heading)
.num_columns(2)
.spacing([18.0, 8.0])
+34 -28
View File
@@ -33,13 +33,13 @@ pub fn show(
if back_link(ui).clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(6.0);
ui.add_space(theme::space::S);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, &offline, actions);
ui.add_space(18.0);
ui.add_space(theme::space::XL);
// One season at a time, picked from a row of pills, rather than
// every season stacked as a collapsing header: a four-season show
@@ -78,7 +78,7 @@ pub fn show(
picked = index;
ui.data_mut(|data| data.insert_temp(picked_id, index));
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
}
ui.label(ui::muted(ui::library::plural(
season.episodes.len(),
@@ -88,7 +88,7 @@ pub fn show(
season_download(ui, title, season, &offline, actions);
});
});
ui.add_space(10.0);
ui.add_space(theme::space::L);
let Some(season) = title.seasons.get(picked) else {
return;
@@ -96,7 +96,7 @@ pub fn show(
for episode in &season.episodes {
episode_row(ui, art, title, season, episode, &offline, actions);
}
ui.add_space(12.0);
ui.add_space(theme::space::L);
});
}
@@ -105,7 +105,7 @@ pub fn show(
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
let size = egui::vec2(180.0, 270.0);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let radius = egui::CornerRadius::same(10);
let radius = egui::CornerRadius::same(theme::radius::LG);
ui.painter()
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
ui.painter().add(
@@ -128,7 +128,7 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
/// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich("← Library");
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() {
ui.painter().hline(
@@ -201,7 +201,7 @@ fn header(
.as_ref()
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.horizontal_top(|ui| {
match &picture {
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
@@ -224,7 +224,7 @@ fn header(
}
}
ui.add_space(22.0);
ui.add_space(theme::space::XXL);
ui.vertical(|ui| {
// Text in a column a person can read, with the picture to the
// right of it rather than under it.
@@ -242,9 +242,9 @@ fn header(
if by_hand { " (chosen by hand)" } else { "" }
)));
}
ui.add_space(4.0);
ui.add_space(theme::space::XS);
ui.label(ui::muted(meta_line(title, found.as_ref())));
ui.add_space(14.0);
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
@@ -342,14 +342,14 @@ fn header(
});
if let Some(found) = &found {
ui.add_space(14.0);
ui.add_space(theme::space::L);
description(ui, found);
}
});
});
if let Some((texture, index)) = backdrop {
ui.add_space(10.0);
ui.add_space(theme::space::L);
band(ui, &texture, index);
}
}
@@ -456,7 +456,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
ui::widgets::chip(ui, genre);
}
});
ui.add_space(8.0);
ui.add_space(theme::space::M);
}
if let Some(overview) = &found.overview {
// Four lines, then "More". A provider's synopsis runs to three
@@ -496,7 +496,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
}
}
if let Some(url) = &found.url {
ui.add_space(8.0);
ui.add_space(theme::space::M);
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
@@ -532,7 +532,7 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(found) = found {
if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}"));
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
// Only when it disagrees with what is on disk: "25 episodes · 25
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
@@ -573,15 +573,15 @@ fn episode_row(
let row_id = ui.id().with(("episode", &episode.node.link_id));
let frame = egui::Frame::new()
.corner_radius(egui::CornerRadius::same(10))
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(8));
let mut prepared = frame.begin(ui);
{
let ui = &mut prepared.content_ui;
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
still(ui, art, title, episode, watched, actions);
ui.add_space(6.0);
still(ui, art, title, episode, watched, STILL, actions);
ui.add_space(theme::space::S);
let controls = 96.0;
let text_width = (ui.available_width() - controls).max(120.0);
@@ -643,8 +643,13 @@ fn episode_row(
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.set_min_height(STILL.y);
// A fixed height, the still's. `with_layout` would hand the
// buttons all the height left in the scroll area's viewport and
// centre them in it, so whichever row sat highest on screen
// stretched down to the bottom of the window.
let cluster = egui::vec2(ui.available_width(), STILL.y);
let layout = egui::Layout::right_to_left(egui::Align::Center);
ui.allocate_ui_with_layout(cluster, layout, |ui| {
if ui::widgets::watched_mark(ui, watched)
.on_hover_text(if watched {
"Mark unwatched"
@@ -660,7 +665,7 @@ fn episode_row(
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
ui.add_space(6.0);
ui.add_space(theme::space::S);
download(ui, title, episode, offline, actions);
});
});
@@ -671,11 +676,11 @@ fn episode_row(
let lit = ui.ctx().animate_bool_with_time(
row_id,
response.hovered() || response.contains_pointer(),
0.10,
theme::motion::HOVER,
);
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
prepared.paint(ui);
ui.add_space(2.0);
ui.add_space(theme::space::XXS);
}
/// The grey line under an episode's name: when it aired, how big it is, and
@@ -711,19 +716,20 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
/// An explicit target rather than a clickable row: the row also carries the
/// watched mark and the download button, and a click that meant either of
/// those must never be one that starts a 1.4 GiB stream instead.
fn still(
pub(crate) fn still(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
episode: &Episode,
watched: bool,
size: egui::Vec2,
actions: &mut Vec<Action>,
) {
let (rect, response) = ui.allocate_exact_size(STILL, egui::Sense::click());
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(8);
let radius = egui::CornerRadius::same(theme::radius::MD);
let painter = ui.painter();
painter.rect_filled(rect, radius, theme::card_hover());
// Asked for only once the row is on screen: a season of twenty-five is
@@ -746,7 +752,7 @@ fn still(
let hover = ui.ctx().animate_bool_with_time(
response.id,
response.hovered() || response.has_focus(),
0.12,
theme::motion::HOVER,
);
if hover > 0.0 {
painter.rect_filled(
+6 -9
View File
@@ -17,7 +17,7 @@ const ERROR_SECONDS: f64 = 9.0;
/// How many fit at once. A burst beyond that drops the oldest.
const MOST: usize = 4;
/// How long one takes to arrive and to leave.
const FADE: f64 = 0.18;
const FADE: f64 = theme::motion::FADE as f64;
const WIDTH: f32 = 340.0;
pub struct Toast {
@@ -98,7 +98,7 @@ impl Toasts {
let age = now - toast.at;
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32;
let opacity = ease(arriving.min(leaving));
let opacity = theme::motion::ease_out(arriving.min(leaving));
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
.order(egui::Order::Foreground)
@@ -150,10 +150,6 @@ fn lifetime(error: bool) -> f64 {
if error { ERROR_SECONDS } else { SECONDS }
}
fn ease(t: f32) -> f32 {
1.0 - (1.0 - t).powi(3)
}
/// Which of a toast's buttons was pressed.
enum Pressed {
Nothing,
@@ -172,7 +168,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
egui::Frame::new()
.fill(theme::surface())
.stroke(Stroke::new(1.0, theme::card_hover()))
.corner_radius(CornerRadius::same(10))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin {
left: 16,
right: 10,
@@ -211,7 +207,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
pressed = Pressed::Close;
}
if let Some((label, _)) = &toast.action {
ui.add_space(4.0);
ui.add_space(theme::space::XS);
if ui
.add(
egui::Button::new(
@@ -240,7 +236,8 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
);
ui.painter().rect_filled(strip, CornerRadius::same(2), edge);
ui.painter()
.rect_filled(strip, CornerRadius::same(theme::radius::BAR), edge);
});
pressed
}
+50 -30
View File
@@ -56,7 +56,7 @@ pub fn full(
) {
seek_bar(ui, playback, actions, 6.0);
times(ui, playback, true);
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_FULL);
@@ -65,7 +65,7 @@ pub fn full(
// hand reaches for it.
step_button(
ui,
"⏮",
egui_phosphor::regular::SKIP_BACK,
"Previous (P)",
neighbours.previous,
actions,
@@ -76,11 +76,18 @@ pub fn full(
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
play_pause(ui, playback, actions, 44.0);
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
});
step_button(
ui,
egui_phosphor::regular::SKIP_FORWARD,
"Next (N)",
neighbours.next,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
},
);
ui.add_space(12.0);
ui.add_space(theme::space::L);
if playback.seeking || playback.buffering || !playback.loaded {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted(if playback.loaded && playback.seeking {
@@ -93,11 +100,11 @@ pub fn full(
// Right cluster: everything that is a choice rather than a move.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(8.0);
ui.add_space(theme::space::M);
speed_menu(ui, playback, actions);
ui.add_space(8.0);
ui.add_space(theme::space::M);
chapter_menu(ui, playback, actions);
ui.add_space(8.0);
ui.add_space(theme::space::M);
volume(ui, playback, actions);
});
});
@@ -123,7 +130,7 @@ pub fn mini(
Some(texture) => {
let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
let painter = ui.painter();
let radius = CornerRadius::same(6);
let radius = CornerRadius::same(theme::radius::SM);
painter.rect_filled(rect, radius, Color32::BLACK);
painter.add(
egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
@@ -134,7 +141,7 @@ pub fn mini(
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
0.12,
theme::motion::HOVER,
);
if hover > 0.0 {
painter.rect_filled(
@@ -165,7 +172,7 @@ pub fn mini(
.add(
egui::Button::new(theme::Role::Label.rich("Back to video"))
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(8)),
.corner_radius(CornerRadius::same(theme::radius::MD)),
)
.on_hover_text("Show the picture again")
.clicked()
@@ -174,7 +181,7 @@ pub fn mini(
}
}
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_MINI);
@@ -201,14 +208,21 @@ pub fn mini(
if ui.button("Stop").clicked() {
actions.push(Action::Player(Command::Stop));
}
ui.add_space(6.0);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
});
ui.add_space(theme::space::S);
step_button(
ui,
egui_phosphor::regular::SKIP_FORWARD,
"Next (N)",
neighbours.next,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
},
);
play_pause(ui, playback, actions, 34.0);
step_button(
ui,
"⏮",
egui_phosphor::regular::SKIP_BACK,
"Previous (P)",
neighbours.previous,
actions,
@@ -217,18 +231,18 @@ pub fn mini(
},
);
ui.add_space(8.0);
ui.add_space(theme::space::M);
track_menus(ui, playback, actions);
ui.add_space(6.0);
ui.add_space(theme::space::S);
volume(ui, playback, actions);
if playback.seeking || playback.buffering || !playback.loaded {
ui.add_space(6.0);
ui.add_space(theme::space::S);
ui.add(egui::Spinner::new().size(14.0));
}
});
});
ui.add_space(4.0);
ui.add_space(theme::space::XS);
seek_bar(ui, playback, actions, 5.0);
times(ui, playback, false);
});
@@ -414,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
}
let icon = if playback.muted || playback.volume <= 0.0 {
"🔇"
egui_phosphor::regular::SPEAKER_SLASH
} else if playback.volume < 50.0 {
egui_phosphor::regular::SPEAKER_LOW
} else {
"🔊"
egui_phosphor::regular::SPEAKER_HIGH
};
if ui
.button(icon)
.button(theme::Role::Subhead.rich(icon))
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.clicked()
{
@@ -553,11 +569,11 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
let active = response.hovered() || response.dragged();
// Grows rather than jumps when the pointer arrives.
let grow = ui
.ctx()
.animate_bool_with_time(response.id.with("grow"), active, 0.12);
let grow =
ui.ctx()
.animate_bool_with_time(response.id.with("grow"), active, theme::motion::HOVER);
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
let radius = CornerRadius::same(3);
let radius = CornerRadius::same(theme::radius::BAR);
let painter = ui.painter();
// From the palette rather than a fixed grey: the same bar sits on the
// video's dark scrim and on a light theme's transport strip.
@@ -676,6 +692,10 @@ fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) {
let screen = ui.ctx().content_rect();
let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0);
let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size);
painter.rect_filled(frame, CornerRadius::same(6), Color32::from_black_alpha(215));
painter.rect_filled(
frame,
CornerRadius::same(theme::radius::SM),
Color32::from_black_alpha(215),
);
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
}
+25 -17
View File
@@ -40,8 +40,12 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, *on, 0.14);
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
let t = ctx.animate_bool_with_time(response.id, *on, theme::motion::STATE);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let track = egui::Rect::from_min_size(
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
TRACK,
@@ -96,8 +100,12 @@ pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, watched, 0.14);
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let painter = ui.painter();
let center = rect.center();
let radius = SIZE / 2.0 - 1.0;
@@ -147,7 +155,7 @@ pub fn settings_group<R>(
description: Option<&str>,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
ui.add_space(4.0);
ui.add_space(theme::space::XS);
ui.label(
theme::Role::Subhead
.rich(heading)
@@ -155,13 +163,13 @@ pub fn settings_group<R>(
.color(theme::text()),
);
if let Some(description) = description {
ui.add_space(2.0);
ui.add_space(theme::space::XXS);
ui.label(crate::ui::muted(description));
}
ui.add_space(8.0);
ui.add_space(theme::space::M);
let inner = egui::Frame::new()
.fill(theme::card())
.corner_radius(CornerRadius::same(10))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(16, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -169,7 +177,7 @@ pub fn settings_group<R>(
add(ui)
})
.inner;
ui.add_space(26.0);
ui.add_space(theme::space::XXL);
inner
}
@@ -192,7 +200,7 @@ pub fn settings_row<R>(
Stroke::new(1.0, theme::card_hover()),
);
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
let inner = ui
.horizontal(|ui| {
ui.set_min_height(28.0);
@@ -209,7 +217,7 @@ pub fn settings_row<R>(
.inner
})
.inner;
ui.add_space(10.0);
ui.add_space(theme::space::L);
inner
}
@@ -224,7 +232,7 @@ pub fn segmented<T: Copy + PartialEq>(
let mut picked = None;
egui::Frame::new()
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(8))
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(3))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
@@ -237,7 +245,7 @@ pub fn segmented<T: Copy + PartialEq>(
theme::text()
});
let button = egui::Button::new(text)
.corner_radius(CornerRadius::same(6))
.corner_radius(CornerRadius::same(theme::radius::SM))
.fill(if selected {
theme::accent()
} else {
@@ -273,9 +281,9 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
if !ui.is_rect_visible(rect) {
return response;
}
let hover = ui
.ctx()
.animate_bool_with_time(response.id, response.hovered(), 0.10);
let hover =
ui.ctx()
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
let painter = ui.painter();
let c = rect.center();
if hover > 0.0 {
@@ -352,7 +360,7 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
.fill(theme::card_hover().gamma_multiply(0.8))
.corner_radius(CornerRadius::same(10))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.show(ui, |ui| {
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
+55
View File
@@ -369,6 +369,26 @@ impl Library {
titles
}
/// Every episode that has been started or finished, most recently
/// touched first. The history page.
///
/// An episode marked unwatched keeps a row with position zero, which is
/// "never played" for every purpose here, so it is left out.
pub fn history(&self) -> Vec<(&Title, &Episode)> {
let mut played: Vec<(&Title, &Episode)> = self
.titles
.iter()
.flat_map(|title| title.episodes().map(move |episode| (title, episode)))
.filter(|(_, episode)| {
episode
.watch
.is_some_and(|watch| watch.watched || watch.position_secs > 0.0)
})
.collect();
played.sort_by_key(|(_, episode)| std::cmp::Reverse(episode.last_played()));
played
}
/// Substring match over the display name and every filename under it, so
/// searching for a release group or an episode name finds its title.
pub fn search(&self, query: &str) -> Vec<&Title> {
@@ -517,6 +537,41 @@ mod tests {
}
}
#[test]
fn history_is_every_played_episode_newest_first_across_titles() {
let watch_state = HashMap::from([
(
("s".to_string(), "a".to_string()),
watch(600.0, 1400.0, false, 10),
),
(
("s".to_string(), "c".to_string()),
watch(1400.0, 1400.0, true, 30),
),
// Marked unwatched again: a row at zero, which is not a play.
(
("s".to_string(), "b".to_string()),
watch(0.0, 1400.0, false, 40),
),
]);
let library = Library::build(
vec![
node("s", "a", "Show - S01E01", Some(1), Some(1)),
node("s", "b", "Show - S01E02", Some(1), Some(2)),
node("s", "c", "Film", None, None),
node("s", "d", "Other - S01E01", Some(1), Some(1)),
],
&watch_state,
);
let order: Vec<&str> = library
.history()
.iter()
.map(|(_, episode)| episode.node.link_id.as_str())
.collect();
assert_eq!(order, ["c", "a"]);
}
#[test]
fn groups_episodes_into_seasons() {
let nodes = vec![
+93 -1
View File
@@ -10,15 +10,23 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid;
use crate::block::{BlockMap, BlockSource, SharedBlocks};
use crate::error::{Error, Result};
use crate::source::RevisionOpener;
/// How many times one block is fetched before its failure reaches the reader.
const BLOCK_ATTEMPTS: u32 = 3;
/// The pause before a block's second fetch, doubled before each one after.
const BLOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
/// One open revision on a public link.
pub struct RevisionBlocks {
reader: proton_drive_rs::RevisionReader,
@@ -50,7 +58,7 @@ impl BlockSource for RevisionBlocks {
)));
};
let block = self.reader.read_at(start, size).await?;
let block = fetch_retrying(BLOCK_RETRY_DELAY, || self.reader.read_at(start, size)).await?;
// A short block here is not a tail — the map says how long it is. It
// means the revision changed underneath us or the block table lied, and
// serving it would silently shift every later byte.
@@ -64,6 +72,38 @@ impl BlockSource for RevisionBlocks {
}
}
/// Fetch a block, again if the failure is one a replay could get past.
///
/// The SDK retries a request that fails before its response starts, but not a
/// block body cut off part way through: that error reaches us as it happened,
/// and without this it stops playback or fails a download. Its keep-alive pings
/// turn a silently dead connection into exactly that error, seconds in rather
/// than at the storage timeout, and the next attempt goes out on a new
/// connection. [`ProtonError::is_retriable`] decides what counts, so a
/// permanent refusal still fails on the first attempt.
async fn fetch_retrying<T, F, Fut>(
first_delay: Duration,
mut fetch: F,
) -> std::result::Result<T, ProtonError>
where
F: FnMut() -> Fut,
Fut: Future<Output = std::result::Result<T, ProtonError>>,
{
let mut delay = first_delay;
let mut attempt = 1;
loop {
match fetch().await {
Err(error) if attempt < BLOCK_ATTEMPTS && error.is_retriable() => {
tracing::debug!(attempt, %error, "block fetch failed; fetching again");
tokio::time::sleep(delay).await;
delay *= 2;
attempt += 1;
}
result => return result,
}
}
}
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv
/// can seek normally without a second playback path.
pub struct FileBlocks {
@@ -136,8 +176,60 @@ impl RevisionOpener for LibraryOpener {
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use proton_sdk::api::ResponseCode;
use proton_sdk::error::ProtonApiError;
use super::*;
fn api_error(http_status: u16) -> ProtonError {
ProtonError::Api(ProtonApiError {
code: ResponseCode::Unknown,
http_status,
message: String::new(),
details: None,
})
}
/// Run [`fetch_retrying`] over a fetch that fails with `failures` in turn and
/// then succeeds, returning the result and how many fetches it took.
async fn fetch_through(
failures: Vec<ProtonError>,
) -> (std::result::Result<u8, ProtonError>, u32) {
let calls = AtomicU32::new(0);
let failures = std::sync::Mutex::new(failures.into_iter());
let result = fetch_retrying(Duration::ZERO, || {
calls.fetch_add(1, Ordering::Relaxed);
let next = failures.lock().unwrap().next();
async move { next.map_or(Ok(7), Err) }
})
.await;
(result, calls.into_inner())
}
#[tokio::test]
async fn a_block_that_fails_retriably_is_fetched_again() {
let (result, calls) = fetch_through(vec![api_error(503)]).await;
assert_eq!(result.expect("second fetch succeeds"), 7);
assert_eq!(calls, 2);
}
#[tokio::test]
async fn a_permanent_refusal_is_not_fetched_again() {
let (result, calls) = fetch_through(vec![api_error(403)]).await;
assert!(result.is_err());
assert_eq!(calls, 1);
}
#[tokio::test]
async fn a_block_that_keeps_failing_returns_its_last_error() {
let (result, calls) =
fetch_through((0..BLOCK_ATTEMPTS + 1).map(|_| api_error(503)).collect()).await;
assert!(result.is_err());
assert_eq!(calls, BLOCK_ATTEMPTS);
}
/// The player hands its stream to mpv's demuxer thread, so everything from
/// the opener down has to cross threads. A compile-time guard, because the
/// failure mode is an unhelpful `FnOnce is not general enough` error deep in
+2
View File
@@ -124,3 +124,5 @@ an edge case.
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
| Desktop-only settings | config dir, `desktop.json` | Defaults if missing — hardware decoding, cache budget; never overwritten when unparseable |
| Window size | config dir, `window.json` | Defaults if missing or implausible |
| Single-instance pipe key (Windows) | config dir, `instance.key` | Yes — regenerated if missing |
| Notification app id (Windows) | `HKCU\Software\Classes\AppUserModelId\io.narl.proton-stream`, icon in cache dir | Yes — written again the first time a session notifies |
+31 -10
View File
@@ -12,10 +12,11 @@ is the contract, and each side's copy is asserted against it by a test.
|---|---|---|---|
| Colour roles | `pstr_core::appearance::Palette` | `ui/theme/Theme.kt` `schemeOf` | `SchemeRolesTest` |
| Type ramp | `pstr_app::theme::Role` | `ui/theme/Type.kt` | `TypeRampTest` |
| Corner radius | `pstr-app` `ui/mod.rs` | `ui/theme/Shape.kt` | `TypeRampTest` |
| Corner radius | `pstr_app::theme::radius` | `ui/theme/Shape.kt` | `TypeRampTest` |
| Gradient rule | `Palette::resolve` | — (derived, see below) | `appearance.rs` tests |
| Depth | `pstr_app::theme::{tile_shadow, bar_shadow}` | Material elevation | — |
| Motion | `Context::animate_*` | Compose defaults | — |
| Spacing | `pstr_app::theme::space` | Compose `dp` literals | — |
| Motion | `pstr_app::theme::motion` | Compose defaults | — |
## Type
@@ -35,8 +36,16 @@ screen and not for anything in a library grid).
| Caption | 12 | context rather than content: counts, sizes, errors under a field |
| Micro | 11 | the smallest thing that stays legible — a badge over artwork |
Weight and leading are each side's own: egui has one weight, and Compose derives
line height as 1.35 × size rather than naming it nine more times.
Weight and leading are each side's own. The desktop bundles Inter
(`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in
its semibold cut, which egui sees as a family of its own (`theme::semibold`);
everything below Section is the regular cut. Compose derives line height as
1.35 × size rather than naming it nine more times.
Icons on the desktop come from the Phosphor icon font (`egui-phosphor`,
regular weight), installed right behind Inter so an icon can sit inside an
ordinary label. Do not use Unicode symbols such as ⏭ or 🔊 as icons: they come
out of whichever fallback font has them, at that font's size and baseline.
Both clients point their framework's *default* styles at the ramp — egui's five
`TextStyle`s, Compose's fifteen — so a stock widget nobody restyled still lands
@@ -46,9 +55,19 @@ this module, and keep Material's sizes. Nothing in the app draws with them.
## Shape
**Radius 8** for everything either client paints: cards, buttons, the tab pill,
progress bars, dialogs. **4** for a badge over artwork. **16** for an Android
bottom sheet, which has no desktop counterpart.
**Radius 8** (`radius::MD`) for everything either client paints: cards,
buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
small control inside something else, and **12** (`LG`) for what floats — row
cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has
no desktop counterpart.
## Spacing
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
24 (`XXS` to `XXL`). Use a step of the scale for a gap, not a literal. Sizes of
things — a still, a button, a column — are not spacing and stay where they are
defined.
Two traps, both Android's:
@@ -96,9 +115,11 @@ page is a smudge on a near-white one. Android gets Material's elevation, which
already adapts. Neither uses a tint to say "raised".
Transitions on the desktop go through `Context::animate_*`: wall-clock driven,
frame-rate independent, no state held by the caller. Durations in use are 0.05 s
for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.22 s
for artwork arriving. Compose's defaults are close enough that nothing here
frame-rate independent, no state held by the caller. Durations come from
`theme::motion`: `PRESS` 0.05 s, `HOVER` 0.12 s, `STATE` 0.15 s (a switch, the
tab pill's travel, a page change), `FADE` 0.18 s (player chrome, toasts) and
`ARRIVE` 0.24 s (artwork over its placeholder). A hand-driven transition eases
with `motion::ease_out`. Compose's defaults are close enough that nothing here
overrides them.
## Desktop widgets
+140
View File
@@ -0,0 +1,140 @@
import Foundation
import Observation
/// The app's one Rust engine and the screen state read from it.
///
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only
/// asks it questions off the main thread and keeps the answers for SwiftUI.
@MainActor
@Observable
final class AppModel {
let engine: AndroidEngine?
private(set) var startupError: String?
var titles: [TitleRecord] = []
var shares: [ShareRecord] = []
var search = ""
var crawling = false
var error: String?
init() {
do {
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
} catch {
engine = nil
startupError = String(describing: error)
}
}
/// Application Support survives updates and is backed up, which is what
/// the watch history wants. The block cache goes under Caches, which iOS
/// may purge and never backs up.
private static func paths() throws -> AndroidPaths {
let files = FileManager.default
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let config = support.appendingPathComponent("config", isDirectory: true)
let data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
struct NoEngine: Error, CustomStringConvertible {
var description: String { "the Rust engine did not start" }
}
/// Run a blocking bridge call off the main thread. The synchronous exports
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T {
guard let engine else { throw NoEngine() }
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value
}
func report(_ error: Error) {
self.error = String(describing: error)
}
func reload() async {
let needle = search.trimmingCharacters(in: .whitespaces)
do {
let (titles, shares) = try await run { engine in
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
}
self.titles = titles
self.shares = shares
} catch {
report(error)
}
}
func crawl(shareId: String? = nil) async {
guard let engine, !crawling else { return }
crawling = true
defer { crawling = false }
do {
try await engine.crawl(shareId: shareId)
} catch {
report(error)
}
await reload()
}
func addShare(name: String, url: String, password: String?) async {
do {
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
await crawl(shareId: share.id)
} catch {
report(error)
}
}
func repairShare(id: String, url: String, password: String?) async {
do {
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
await crawl(shareId: id)
} catch {
report(error)
}
}
func removeShare(id: String) async {
do {
try await run { try $0.removeShare(shareId: id) }
} catch {
report(error)
}
await reload()
}
/// Marking seen puts the position at the duration where one is known, and
/// unwatching rewinds, as on desktop and Android.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async {
do {
try await run { engine in
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId, linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration, watched: watched
)
}
} catch {
report(error)
}
await reload()
}
/// Episodes started but not finished, most recent first.
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] {
titles
.flatMap { title in
title.seasons.flatMap(\.episodes)
.filter { $0.resumeAt != nil && !$0.watched }
.map { (title: title, episode: $0) }
}
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
}
}
@@ -0,0 +1,63 @@
import Foundation
import Security
/// The share URL fragment, custom passwords and metadata API keys, held in the
/// iOS Keychain. The counterpart of Android's `KeystoreSecretStore`.
///
/// Anything thrown from here reaches Rust as `UnexpectedUniFFICallbackError`,
/// which the bridge turns into an ordinary `BridgeError` rather than a panic on
/// whichever tokio worker asked (B18).
final class KeychainSecretStore: AndroidSecretStore, @unchecked Sendable {
private let service = "io.narl.protonstream.secrets"
struct KeychainError: Error, CustomStringConvertible {
let status: OSStatus
let action: String
var description: String {
let message = SecCopyErrorMessageString(status, nil) as String? ?? "status \(status)"
return "keychain \(action): \(message)"
}
}
private func query(_ key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
}
func set(key: String, value: String) throws {
let data = Data(value.utf8)
let update = SecItemUpdate(query(key) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if update == errSecSuccess { return }
guard update == errSecItemNotFound else { throw KeychainError(status: update, action: "update") }
var item = query(key)
item[kSecValueData as String] = data
// Readable by the refresh that runs while the phone is locked in a
// pocket, and never restored onto another device from a backup.
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let add = SecItemAdd(item as CFDictionary, nil)
guard add == errSecSuccess else { throw KeychainError(status: add, action: "add") }
}
func get(key: String) throws -> String? {
var item = query(key)
item[kSecReturnData as String] = true
item[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(item as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError(status: status, action: "read")
}
return String(data: data, encoding: .utf8)
}
func delete(key: String) throws {
let status = SecItemDelete(query(key) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status, action: "delete")
}
}
}
@@ -0,0 +1,49 @@
import AVFoundation
import SwiftUI
@main
struct ProtonStreamApp: App {
@State private var model = AppModel()
init() {
// `.playback` keeps the audio going with the screen locked or the app
// in the background, which `UIBackgroundModes: audio` then permits.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
}
var body: some Scene {
WindowGroup {
RootView()
.environment(model)
.preferredColorScheme(.dark)
}
}
}
struct RootView: View {
@Environment(AppModel.self) private var model
var body: some View {
if let failure = model.startupError {
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure))
} else {
TabView {
NavigationStack { LibraryView() }
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") }
NavigationStack { SharesView() }
.tabItem { Label("Shares", systemImage: "link") }
NavigationStack { SettingsView() }
.tabItem { Label("Settings", systemImage: "gearshape") }
}
.task { await model.reload() }
.alert("Something went wrong", isPresented: .init(
get: { model.error != nil },
set: { if !$0 { model.error = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(model.error ?? "")
}
}
}
}
@@ -0,0 +1,4 @@
// C declarations Swift sees without a module import: the UniFFI scaffolding
// generated beside PstrBridge.swift, and the stream ABI UniFFI does not cover.
#include "PstrBridgeFFI.h"
#include "pstr_stream.h"
+21
View File
@@ -0,0 +1,21 @@
// The blocking stream ABI exported by crates/pstr-android (`lib.rs`,
// `pstr_android_stream_*`). It is not part of the UniFFI interface on purpose:
// decrypted bytes go straight from Rust into libmpv's buffer and never pass
// through Swift values.
#pragma once
#include <stddef.h>
#include <stdint.h>
/// Read up to `length` bytes at `offset` into `buffer`. Returns the byte count,
/// 0 at end of file, or -1 on failure or cancellation.
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
/// The stream's size in bytes, or -1 for an unknown handle.
int64_t pstr_android_stream_size(uint64_t handle);
/// Interrupt the read in flight for this stream, if any.
void pstr_android_stream_cancel(uint64_t handle);
/// Drop the registry's reference. Call exactly once per published handle.
void pstr_android_stream_release(uint64_t handle);
+298
View File
@@ -0,0 +1,298 @@
import Foundation
import Libmpv
import Observation
import UIKit
/// MoltenVK can set the drawable to 1x1 to force a presentation through, which
/// flickers and can stick. MPVKit's demo carries the same guard
/// (mpv-player/mpv#13651).
final class MpvMetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { super.drawableSize }
set {
if Int(newValue.width) > 1, Int(newValue.height) > 1 {
super.drawableSize = newValue
}
}
}
}
struct MpvTrack: Identifiable, Hashable {
let id: Int64
let type: String
let language: String
let title: String
let selected: Bool
var label: String {
let parts = [title, language.uppercased()].filter { !$0.isEmpty }
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ")
}
}
/// One libmpv core rendering into a Metal layer. Every public method runs on
/// the main thread; mpv's events are read on a thread of their own and applied
/// back on the main thread.
@MainActor
@Observable
final class MpvPlayer {
enum EndReason { case eof, stopped, failed }
private(set) var position: Double = 0
private(set) var duration: Double = 0
private(set) var paused = false
private(set) var buffering = false
private(set) var tracks: [MpvTrack] = []
private(set) var chapters: [ChapterRecord] = []
private(set) var failure: String?
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in
/// that window still describes the *outgoing* file, including the
/// `END_FILE(stop)` stopping it emits.
private(set) var loading = false
/// Called once per file that ends, never for the one a load replaced.
@ObservationIgnored var onEnded: ((EndReason) -> Void)?
@ObservationIgnored let layer = MpvMetalLayer()
@ObservationIgnored private var mpv: OpaquePointer?
init() {
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
guard let mpv = mpv_create() else {
failure = "libmpv did not start"
return
}
var wid = layer
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
for (name, value) in [
("config", "no"),
("vo", "gpu-next"),
("gpu-api", "vulkan"),
("gpu-context", "moltenvk"),
("hwdec", "videotoolbox"),
("video-rotate", "no"),
("keep-open", "no"),
("cache", "yes"),
("cache-secs", "30"),
("demuxer-readahead-secs", "30"),
("demuxer-max-bytes", "50331648"),
("subs-fallback", "yes"),
] {
mpv_set_option_string(mpv, name, value)
}
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
mpv_terminate_destroy(mpv)
failure = "libmpv did not initialize"
return
}
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64)
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
mpv_request_log_messages(mpv, "error")
self.mpv = mpv
let thread = Thread { [weak self] in Self.eventLoop(mpv) { event in self?.apply(event) } }
thread.name = "mpv-events"
thread.start()
}
// MARK: Commands
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller
/// still owns the handle's release.
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool {
guard mpv != nil else { return false }
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
setString("alang", audio ?? "")
setString("slang", subtitle ?? "")
setString("sid", subtitles ? "auto" : "no")
position = 0
duration = 0
tracks = []
chapters = []
failure = nil
loading = true
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
loading = false
return false
}
return true
}
func setPaused(_ value: Bool) { setFlag("pause", value) }
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) }
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) }
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
func setMuted(_ value: Bool) { setFlag("mute", value) }
func select(audio: Bool, track: MpvTrack?) {
let property = audio ? "aid" : "sid"
if let track { setString(property, String(track.id)) } else { setString(property, "no") }
refreshTracks()
}
/// Showing no picture while backgrounded keeps the audio playing and avoids
/// the black frame MoltenVK otherwise leaves on return.
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a
/// block fetch, and the event thread destroys the core once it is down.
func shutdown() {
guard let mpv else { return }
self.mpv = nil
mpv_command_string(mpv, "quit")
}
// MARK: Events
private enum Event {
case double(UInt64, Double)
case flag(UInt64, Bool)
case count(UInt64)
case loaded
case ended(Int32)
case log(String)
}
/// Runs until the core shuts down, then destroys it. Nothing else calls
/// `mpv_terminate_destroy`, so no main-thread call can race it.
private nonisolated static func eventLoop(_ mpv: OpaquePointer, _ deliver: @escaping @MainActor (Event) -> Void) {
while true {
guard let event = mpv_wait_event(mpv, -1)?.pointee else { continue }
var decoded: Event?
switch event.event_id {
case MPV_EVENT_SHUTDOWN:
mpv_terminate_destroy(mpv)
return
case MPV_EVENT_PROPERTY_CHANGE:
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee,
let data = property.data else { break }
switch property.format {
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata)
default: break
}
case MPV_EVENT_FILE_LOADED:
decoded = .loaded
case MPV_EVENT_END_FILE:
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
decoded = .ended(end.reason.rawValue)
}
case MPV_EVENT_LOG_MESSAGE:
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
let text = message.text {
decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines))
}
default:
break
}
if let decoded {
DispatchQueue.main.async { deliver(decoded) }
}
}
}
private func apply(_ event: Event) {
switch event {
case let .double(id, value):
if loading { return }
if id == 1 { position = value } else if id == 2 { duration = value }
case let .flag(id, value):
if id == 3 { paused = value } else if id == 4 { buffering = value }
case let .count(id):
if loading { return }
if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() }
case .loaded:
loading = false
refreshTracks()
refreshChapters()
case let .ended(reason):
// The outgoing file's END_FILE arrives after the next load began.
if loading { return }
switch reason {
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof)
case MPV_END_FILE_REASON_ERROR.rawValue:
failure = failure ?? "The file could not be played"
onEnded?(.failed)
default: onEnded?(.stopped)
}
case let .log(text):
if !text.isEmpty { failure = text }
}
}
private func refreshTracks() {
let count = getInt("track-list/count")
tracks = (0 ..< max(0, count)).map { index in
let base = "track-list/\(index)/"
return MpvTrack(
id: getInt(base + "id"),
type: getString(base + "type") ?? "",
language: getString(base + "lang") ?? "",
title: getString(base + "title") ?? "",
selected: getString(base + "selected") == "yes"
)
}
}
private func refreshChapters() {
let count = getInt("chapter-list/count")
chapters = (0 ..< max(0, count)).map { index in
let base = "chapter-list/\(index)/"
return ChapterRecord(index: index, title: getString(base + "title"), start: getDouble(base + "time"))
}
}
// MARK: Property plumbing
private func command(_ args: [String]) -> Int32 {
guard let mpv else { return -1 }
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
pointers.append(nil)
defer { pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) } }
return mpv_command(mpv, &pointers)
}
private func setString(_ name: String, _ value: String) {
guard let mpv else { return }
mpv_set_property_string(mpv, name, value)
}
private func setDouble(_ name: String, _ value: Double) {
guard let mpv else { return }
var value = value
mpv_set_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
}
private func setFlag(_ name: String, _ value: Bool) {
guard let mpv else { return }
var flag: Int32 = value ? 1 : 0
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &flag)
}
private func getInt(_ name: String) -> Int64 {
guard let mpv else { return 0 }
var value: Int64 = 0
mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
return value
}
private func getDouble(_ name: String) -> Double {
guard let mpv else { return 0 }
var value = 0.0
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
return value
}
private func getString(_ name: String) -> String? {
guard let mpv, let raw = mpv_get_property_string(mpv, name) else { return nil }
defer { mpv_free(raw) }
return String(cString: raw)
}
}
@@ -0,0 +1,86 @@
import Foundation
import Libmpv
// libmpv's `pstr://<handle>` protocol, backed by the Rust stream registry. The
// Swift port of the stream half of android/app/src/main/cpp/pstr_mpv.cpp.
/// One open file on mpv's demuxer thread. `position` is only ever touched from
/// that thread; `cancelled` is also written from whichever thread cancels.
private final class StreamCookie {
let handle: UInt64
let size: Int64
var position: Int64 = 0
private let lock = NSLock()
private var _cancelled = false
init(handle: UInt64, size: Int64) {
self.handle = handle
self.size = size
}
var cancelled: Bool {
get { lock.withLock { _cancelled } }
set { lock.withLock { _cancelled = newValue } }
}
}
private func cookie(_ opaque: UnsafeMutableRawPointer?) -> StreamCookie? {
opaque.map { Unmanaged<StreamCookie>.fromOpaque($0).takeUnretainedValue() }
}
/// Register the protocol on a core that has not been initialized yet.
func registerStreamProtocol(_ mpv: OpaquePointer) -> Bool {
mpv_stream_cb_add_ro(mpv, "pstr", nil, streamOpen) >= 0
}
private func streamOpen(
_: UnsafeMutableRawPointer?,
_ uri: UnsafeMutablePointer<CChar>?,
_ info: UnsafeMutablePointer<mpv_stream_cb_info>?
) -> Int32 {
let failed = MPV_ERROR_LOADING_FAILED.rawValue
guard let uri, let info else { return failed }
let value = String(cString: uri)
guard value.hasPrefix("pstr://"), let handle = UInt64(value.dropFirst("pstr://".count)) else { return failed }
let size = pstr_android_stream_size(handle)
guard size >= 0 else { return failed }
info.pointee.cookie = Unmanaged.passRetained(StreamCookie(handle: handle, size: size)).toOpaque()
info.pointee.read_fn = { opaque, buffer, length in
guard let stream = cookie(opaque), let buffer else { return -1 }
if stream.cancelled { return -1 }
let remaining = stream.size - stream.position
if remaining <= 0 { return 0 }
let requested = Int(min(length, UInt64(remaining)))
let read = pstr_android_stream_read(stream.handle, UInt64(stream.position), buffer, requested)
if read > 0 { stream.position += read }
return read
}
info.pointee.seek_fn = { opaque, offset in
guard let stream = cookie(opaque), offset >= 0, offset <= stream.size else {
return Int64(MPV_ERROR_GENERIC.rawValue)
}
stream.position = offset
stream.cancelled = false
return offset
}
info.pointee.size_fn = { opaque in
cookie(opaque)?.size ?? Int64(MPV_ERROR_GENERIC.rawValue)
}
// The flag stops the next read; the one already parked in a 4 MiB block
// fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits
// out the whole fetch while joining the demuxer thread (B16).
info.pointee.cancel_fn = { opaque in
guard let stream = cookie(opaque) else { return }
stream.cancelled = true
pstr_android_stream_cancel(stream.handle)
}
// The one moment defined to be after mpv's last read of this file, so the
// Rust stream is released here and nowhere else (B15/B28).
info.pointee.close_fn = { opaque in
guard let opaque else { return }
let stream = Unmanaged<StreamCookie>.fromOpaque(opaque).takeRetainedValue()
pstr_android_stream_release(stream.handle)
}
return 0
}
+57
View File
@@ -0,0 +1,57 @@
# XcodeGen spec for the iOS client. The .xcodeproj and Info.plist are generated
# from this by scripts/build-ios.sh and are not committed.
name: ProtonStream
options:
bundleIdPrefix: io.narl
deploymentTarget:
iOS: "17.0"
packages:
MPVKit:
url: https://github.com/mpvkit/MPVKit
exactVersion: 1.0.0
targets:
ProtonStream:
type: application
platform: iOS
sources:
- path: ProtonStream
# UniFFI output, written by `scripts/build-ios.sh bindings`.
- path: build/generated/PstrBridge.swift
dependencies:
# The GPL build is the one with libass and every demuxer; the app is
# sideloaded, never distributed through the App Store.
- package: MPVKit
product: MPVKit-GPL
- sdk: Security.framework
- sdk: SystemConfiguration.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream
MARKETING_VERSION: ${PSTR_VERSION}
CURRENT_PROJECT_VERSION: ${PSTR_BUILD}
SWIFT_VERSION: "5.0"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h
HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated
LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/../target/aarch64-apple-ios/release
OTHER_LDFLAGS: -lpstr_android
info:
path: ProtonStream/Info.plist
properties:
CFBundleDisplayName: Proton Stream
LSRequiresIPhoneOS: true
ITSAppUsesNonExemptEncryption: false
UIBackgroundModes: [audio]
UILaunchScreen: {}
UIRequiresFullScreen: false
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone.
pkgname=proton-stream
pkgver=0.4.2
pkgver=0.5.0
pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64')
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Build the iOS client as an unsigned .ipa for SideStore / AltStore.
#
# SideStore re-signs whatever it installs with the viewer's own Apple ID, so
# nothing here touches a certificate or a provisioning profile. Needs macOS with
# Xcode, XcodeGen and the aarch64-apple-ios Rust target.
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/.." && pwd)"
ios_dir="$repo_root/ios"
generated="$ios_dir/build/generated"
target=aarch64-apple-ios
usage() {
echo "usage: $0 {rust|bindings|ipa|all}"
echo
echo " rust static libpstr_android.a for $target"
echo " bindings Swift bindings into ios/build/generated"
echo " ipa xcodegen + xcodebuild, then dist/ProtonStream-<version>.ipa"
echo " all all three, in that order"
}
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
[[ "$(uname -s)" == Darwin ]] || die "the iOS build needs macOS and Xcode"
# Same guard as scripts/build.sh: without the sibling checkout the resolve fails
# a hundred lines deep in cargo output.
if grep -q '^\[patch\.crates-io\]' "$repo_root/Cargo.toml" && [[ ! -d "$repo_root/../proton-sdk-rs" ]]; then
die "Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing."
fi
version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} found && /^version = /{gsub(/[" ]/, "", $3); print $3; exit}' "$repo_root/Cargo.toml")"
[[ -n "$version" ]] || die "no [workspace.package] version in Cargo.toml"
build_rust() {
log "cargo rustc -p pstr-android --target $target (staticlib)"
# The crate stays `cdylib, rlib` for Android; the archive iOS links is asked
# for here instead of being built on every other platform too.
( cd "$repo_root" && IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo rustc -p pstr-android --lib \
--release --locked --target "$target" --crate-type staticlib )
}
build_bindings() {
log "uniffi-bindgen --language swift"
# Library mode reads the interface out of a built library, and the host
# dylib is the one the bindgen binary can open.
( cd "$repo_root" && cargo build -p pstr-android --lib --locked )
rm -rf "$generated"
( cd "$repo_root" && cargo run -p pstr-android --features bindgen --bin uniffi-bindgen --locked -- \
generate --library target/debug/libpstr_android.dylib --language swift \
--no-format --out-dir "$generated" )
[[ -f "$generated/PstrBridge.swift" ]] || die "bindgen wrote no PstrBridge.swift"
}
build_ipa() {
command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)"
[[ -f "$repo_root/target/$target/release/libpstr_android.a" ]] || die "run '$0 rust' first"
[[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first"
log "xcodegen ($version)"
( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet )
log "xcodebuild (unsigned)"
xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \
-configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \
-derivedDataPath "$ios_dir/build/DerivedData" \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= \
build
local app="$ios_dir/build/DerivedData/Build/Products/Release-iphoneos/ProtonStream.app"
[[ -d "$app" ]] || die "xcodebuild produced no ProtonStream.app"
local stage="$ios_dir/build/ipa"
rm -rf "$stage"
mkdir -p "$stage/Payload" "$repo_root/dist"
cp -R "$app" "$stage/Payload/"
local ipa="$repo_root/dist/ProtonStream-$version.ipa"
rm -f "$ipa"
( cd "$stage" && zip -qry "$ipa" Payload )
log "wrote $ipa"
}
case "${1:-}" in
rust) build_rust ;;
bindings) build_bindings ;;
ipa) build_ipa ;;
all) build_rust; build_bindings; build_ipa ;;
*) usage >&2; exit 2 ;;
esac