Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658c620c82 | ||
|
|
c1487c31d0 | ||
|
|
a8a18be248 | ||
|
|
16c2b4c9d3 | ||
|
|
2871a13ba6 | ||
|
|
6e0be9e3c8 | ||
|
|
0868c2af69 | ||
|
|
4a3a7291b8 | ||
|
|
ab7f057afe | ||
|
|
b1ea076ea3 | ||
|
|
8d615579e2 | ||
|
|
cafd969567 | ||
|
|
69981e3b45 | ||
|
|
cbe0f7166b | ||
|
|
f0402e74f9 | ||
|
|
d4e9628199 | ||
|
|
4b9cbd26bc | ||
|
|
588a7fcb09 | ||
|
|
e3e855a02d | ||
|
|
7790ae9cbb | ||
|
|
f0fe90f0fe | ||
|
|
51454b5a6e | ||
|
|
c44b04cf4a | ||
|
|
85bc975f5f | ||
|
|
4f130d1d6e |
No files matched your search
@@ -0,0 +1,68 @@
|
|||||||
|
name: Wiki
|
||||||
|
|
||||||
|
# docs/ is the source of the GitHub wiki. Every change to the documentation
|
||||||
|
# checks its links; a push to main publishes the result, so the wiki follows
|
||||||
|
# main. Pull requests only check: nothing unmerged reaches the wiki. Run it from
|
||||||
|
# the Actions tab to publish another ref, such as a release tag.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'README.md'
|
||||||
|
- 'scripts/publish-wiki.sh'
|
||||||
|
- '.github/workflows/wiki.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'README.md'
|
||||||
|
- 'scripts/publish-wiki.sh'
|
||||||
|
- '.github/workflows/wiki.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: 'Tag or branch to publish'
|
||||||
|
required: false
|
||||||
|
default: 'main'
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check documentation links
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
|
- name: Check links
|
||||||
|
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish to the wiki
|
||||||
|
needs: check
|
||||||
|
# Pull requests never publish.
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
concurrency:
|
||||||
|
group: wiki-publish
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
env:
|
||||||
|
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'github-actions[bot]'
|
||||||
|
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||||
|
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
|
||||||
@@ -4844,9 +4844,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pgp"
|
name = "pgp"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead 0.5.2",
|
"aead 0.5.2",
|
||||||
"aes 0.8.4",
|
"aes 0.8.4",
|
||||||
@@ -5218,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.7.3"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f"
|
checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.0",
|
"base64 0.23.0",
|
||||||
@@ -5245,9 +5245,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.7.3"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0"
|
checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5274,11 +5274,12 @@ dependencies = [
|
|||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5298,7 +5299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5332,7 +5333,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5351,7 +5352,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5371,7 +5372,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -5384,7 +5385,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -5399,7 +5400,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.5.0"
|
version = "1.6.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.7.3" }
|
proton-sdk = { version = "0.9.0" }
|
||||||
proton-drive-rs = { version = "0.7.3" }
|
proton-drive-rs = { version = "0.9.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
|
|||||||
@@ -1,83 +1,145 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||||
|
|
||||||
# proton-stream
|
# proton-stream
|
||||||
|
|
||||||
A Netflix-style cross-platform client for **Proton Drive public links**.
|
**Your Proton Drive, streaming.** A Netflix-style library for the videos in your Proton Drive and
|
||||||
|
the share links people send you, on Linux, Windows, Android, Android TV and iOS.
|
||||||
|
|
||||||
Paste a share URL and its password; get a browsable, streamable library. No
|
**[proton-stream.narl.io](https://proton-stream.narl.io)**
|
||||||
Proton account, no server to host, no download step — episodes start playing in
|
|
||||||
seconds and the seek bar works.
|
|
||||||
|
|
||||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust
|
[](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
|
||||||
Proton Drive SDK.
|
[](https://github.com/narrrl/proton-stream/releases/latest)
|
||||||
|
[](#license)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://buymeacoffee.com/narl)
|
||||||
|
|
||||||
|
<img src="images/desktop-home.png" alt="The library in the desktop app" width="820">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||||
|
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||||
|
> to describe what the software talks to. It provides no content: it plays what you keep in
|
||||||
|
> Proton Drive or what is shared with you.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**Library**
|
||||||
|
|
||||||
|
- **Public share links or your own Drive.** Paste a share link, with its password if it has one,
|
||||||
|
or sign in and pick folders of your Drive. Any number of them merge into one library.
|
||||||
|
- **Seasons and episodes from your folders.** The folder names the show, the file name gives the
|
||||||
|
season and episode, and a file without numbers is a film.
|
||||||
|
- **Posters, synopses and shelves**, from AniList or TMDB, **off by default**: shelves such as
|
||||||
|
*Directed by* and *From MADHOUSE*, a franchise folded into one tile, and a hand-picked match
|
||||||
|
that is never overwritten.
|
||||||
|
- **Filters and sorting:** series, films, watching, not started, downloaded; by name, last watched,
|
||||||
|
last added, air date, rating or popularity.
|
||||||
|
|
||||||
|
**Player**
|
||||||
|
|
||||||
|
- **Streams, never downloads first.** Episodes start in seconds, and a seek fetches only what it
|
||||||
|
lands on.
|
||||||
|
- **libmpv inside the app's own window:** HEVC 10-bit, HDR and ASS subtitles with hardware
|
||||||
|
decoding, and no transcoding.
|
||||||
|
- **Skip the opening and the ending** by chapter, an **Up next** countdown, and next and previous
|
||||||
|
episode.
|
||||||
|
- **Subtitle files beside the video** (`.srt`, `.ass`, `.ssa`, `.vtt`), and **the dialogue track
|
||||||
|
over *Signs & Songs***. Audio and subtitle choices are remembered for each show.
|
||||||
|
|
||||||
|
**Everywhere**
|
||||||
|
|
||||||
|
- **Resume on any device.** Sign in, and your place in every episode, your shares and your
|
||||||
|
settings sync through your own Drive.
|
||||||
|
- **Downloads** of an episode, a season or a show, to play offline.
|
||||||
|
- **Android 12+ and Android TV** with Material 3, background audio and new-episode
|
||||||
|
notifications; **iOS 17+** as a SwiftUI port for SideStore and AltStore.
|
||||||
|
- **Themes:** a near-black default, four Catppuccin flavours and Persona 5, each with nine accents.
|
||||||
|
- **Full keyboard control** on the desktop, and the `pstr` command line.
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%"><img src="images/desktop-library.png" alt="The whole library, filtered and sorted"></td>
|
||||||
|
<td width="50%">
|
||||||
|
<img src="images/android-home.png" alt="Android: home" width="24%">
|
||||||
|
<img src="images/android-library.png" alt="Android: all titles" width="24%">
|
||||||
|
<img src="images/android-title.png" alt="Android: a title page" width="24%">
|
||||||
|
<img src="images/android-episodes.png" alt="Android: episodes and downloads" width="24%">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has:
|
||||||
|
|
||||||
|
| Platform | Download |
|
||||||
|
|---|---|
|
||||||
|
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` |
|
||||||
|
| Fedora | `proton-stream-<version>-1.x86_64.rpm` |
|
||||||
|
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` |
|
||||||
|
| Windows | `proton-stream-<version>-x64.msi`, or the portable `…-x86_64-windows.zip` |
|
||||||
|
| Android, Android TV | `proton-stream-<version>-android.apk` |
|
||||||
|
| iOS, iPadOS | `proton-stream-<version>-ios.ipa`, for SideStore or AltStore |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./proton-stream_*_amd64.deb # Debian, Ubuntu
|
||||||
|
sudo dnf install ./proton-stream-*.x86_64.rpm # Fedora
|
||||||
|
cd packaging && makepkg -si # Arch, from a checkout
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **Proton Stream**, paste a share link on the **Shares** page (or press **Ctrl+V**), and
|
||||||
|
the library fills in. [Getting started](docs/GETTING_STARTED.md) walks through it, and
|
||||||
|
[Installation](docs/INSTALL.md) covers every platform, updating and uninstalling.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The [wiki](https://github.com/narrrl/proton-stream/wiki) is generated from [`docs/`](docs/) on
|
||||||
|
every push to `main`:
|
||||||
|
|
||||||
|
- [Getting started](docs/GETTING_STARTED.md), [Installation](docs/INSTALL.md) and the
|
||||||
|
[FAQ](docs/FAQ.md)
|
||||||
|
- [User guide](docs/USER_GUIDE.md), [Account and sync](docs/ACCOUNT_AND_SYNC.md) and
|
||||||
|
[Android and iOS](docs/MOBILE.md)
|
||||||
|
- [Command-line reference](docs/CLI.md), [Privacy](docs/PRIVACY.md) and
|
||||||
|
[Troubleshooting](docs/TROUBLESHOOTING.md)
|
||||||
|
- For contributors: [Development](docs/DEVELOPMENT.md), [Architecture](docs/ARCHITECTURE.md),
|
||||||
|
[Testing](docs/TESTING.md), [Android](docs/ANDROID.md) and [iOS](docs/IOS.md)
|
||||||
|
|
||||||
## Why it works
|
## Why it works
|
||||||
|
|
||||||
A Proton Drive file's content blocks are each a self-contained PGP packet
|
A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
|
||||||
decrypted under one shared session key. There is no sequential streaming session
|
shared session key. There is no sequential streaming session to resume, so seeking costs exactly
|
||||||
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode
|
the blocks the seek lands on: a 2 GB episode plays without downloading 2 GB of it.
|
||||||
is playable without downloading 2 GB of it.
|
|
||||||
|
|
||||||
## Status
|
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link: **2.3 s to first
|
||||||
|
frame, about 1.5 s to resume after dragging to an arbitrary point, and 61 MiB fetched for the
|
||||||
|
first 38 seconds of playback.** Watch it again and the disk cache serves it with **no network at
|
||||||
|
all**: 0.3 s to first frame.
|
||||||
|
|
||||||
Early, but it is an app now: the picture lands **inside the window**, not in an
|
The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
|
||||||
mpv window of its own. The SDK streaming surface, the catalog, the streaming
|
no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
|
||||||
layer and the player are done and verified against a real share, and the app has
|
whole data flow.
|
||||||
a library wall, a page per title with seasons and episodes,
|
|
||||||
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
|
|
||||||
button for openings and endings, next/previous episode with an "up next"
|
|
||||||
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files
|
|
||||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
|
||||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
|
||||||
|
|
||||||
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
|
||||||
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
SDK.
|
||||||
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
|
||||||
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
|
||||||
|
|
||||||
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
|
## Privacy
|
||||||
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
|
|
||||||
[`docs/IOS.md`](docs/IOS.md).
|
|
||||||
|
|
||||||
```bash
|
- **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
|
||||||
cargo run --release -p pstr-app # the app
|
posters and descriptions, which sends your title names to AniList or TMDB.
|
||||||
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
- **The part of a share link after `#` is its decryption key.** It, any link password and your
|
||||||
```
|
Proton session live in the system credential store (Secret Service, Credential Manager, the
|
||||||
|
Android Keystore, the iOS Keychain), never in a config file.
|
||||||
|
- **Sync is end-to-end encrypted.** Watch history, shares and settings travel through your own
|
||||||
|
Drive, one file per device.
|
||||||
|
- **The block cache and downloads hold decrypted video** in your cache and data folders. Size or
|
||||||
|
empty the cache in **Settings → Storage**.
|
||||||
|
|
||||||
One window at a time: a second launch brings the open window forward and
|
[Privacy](docs/PRIVACY.md) lists every connection and every file.
|
||||||
hands it any share link it was given, then exits.
|
|
||||||
|
|
||||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
|
||||||
is broken:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment'
|
|
||||||
cargo run -p pstr-cli -- crawl
|
|
||||||
cargo run -p pstr-cli -- list
|
|
||||||
cargo run -p pstr-cli -- metadata # opt-in; off unless configured
|
|
||||||
cargo run --release -p pstr-cli -- play --file Frieren --disk-cache
|
|
||||||
cargo run --release -p pstr-cli -- bench --file Frieren --verify
|
|
||||||
```
|
|
||||||
|
|
||||||
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link:
|
|
||||||
**2.3 s to first frame, ~1.5 s to resume after dragging to an arbitrary point,
|
|
||||||
61 MiB fetched for the first 38 seconds of playback.** Watch it again and the
|
|
||||||
disk cache serves it with **no network at all** — 0.3 s to first frame.
|
|
||||||
|
|
||||||
`bench` measures the same file without a player, reporting cold seek latency,
|
|
||||||
sustained throughput and peak memory, and `--verify` cross-checks a mid-file read
|
|
||||||
against the SDK's own range download.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Topology | Fat client. No server; each viewer's app talks to Proton directly. |
|
|
||||||
| Auth | Public links only — but several, merged into one library. |
|
|
||||||
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
|
||||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
|
||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
|
||||||
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
|
||||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
|
|
||||||
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback, and an iOS 17+ port of it for sideloading. |
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -85,53 +147,26 @@ Rust 2024, MSRV 1.96.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
cargo run --release -p pstr-app # the app
|
||||||
|
cargo run -p pstr-cli -- add --name anime 'URL' # the same work, headless
|
||||||
```
|
```
|
||||||
|
|
||||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux
|
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
|
||||||
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
|
WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
|
||||||
On Windows the libmpv ones
|
|
||||||
come from the separate **mpv-dev** archive rather than mpv's player build, and
|
|
||||||
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
|
||||||
crates.io; nothing here builds against a sibling checkout.
|
|
||||||
|
|
||||||
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`,
|
- **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
|
||||||
and a portable `.zip` plus an `.msi` on Windows — are produced by
|
`scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
|
||||||
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
|
[`packaging/README.md`](packaging/README.md).
|
||||||
per-platform detail.
|
- **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
|
||||||
|
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||||
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
- **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
|
||||||
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
[`docs/IOS.md`](docs/IOS.md).
|
||||||
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|
||||||
|
|
||||||
iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust
|
|
||||||
target: `scripts/build-ios.sh all` writes `dist/proton-stream-<version>-ios.ipa`.
|
|
||||||
|
|
||||||
## Privacy
|
|
||||||
|
|
||||||
- The share URL fragment **is** the decryption password. It and any custom
|
|
||||||
password live in the OS credential store — Secret Service on Linux, Credential
|
|
||||||
Manager on Windows — never in a config file.
|
|
||||||
- Nothing leaves your machine except requests to Proton, unless you turn metadata
|
|
||||||
enrichment on. The player is fed through an in-process mpv stream callback, not
|
|
||||||
a local HTTP server: there is no port for another process on the machine to ask
|
|
||||||
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
|
||||||
would put a share secret in its log and window title.
|
|
||||||
- The block cache holds decrypted content. It lives in your cache directory,
|
|
||||||
can be deleted at any time the app is not running, and can be sized or
|
|
||||||
emptied from Settings → Storage.
|
|
||||||
- A share link given as a command-line argument is visible to other users of
|
|
||||||
the machine in the process list while that launch runs, and may land in
|
|
||||||
shell history. Pasting it into the window (Ctrl+V) does neither.
|
|
||||||
- The desktop's media widget is handed the title, the episode and a `file://`
|
|
||||||
path to artwork already on disk — never a provider URL, so the shell never
|
|
||||||
contacts a metadata provider on its own.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The shared Rust crates and desktop application are MIT licensed. The Android and
|
The shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
|
||||||
iOS applications and their combined native distributions are GPL-3.0-or-later; see
|
and their combined native distributions are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and
|
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and the bundled
|
||||||
the bundled-component notices.
|
component notices. The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||||
The desktop application embeds the Inter typeface, under the SIL Open Font
|
|
||||||
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||||
and the Phosphor icon font, under MIT.
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Android TV: listed in its launcher, and installable without a touch
|
||||||
|
screen. Neither is required, so phones are offered the same build.
|
||||||
|
-->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ProtonStreamApplication"
|
android:name=".ProtonStreamApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -14,6 +21,7 @@
|
|||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
tools:targetApi="33"
|
tools:targetApi="33"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ProtonStream">
|
android:theme="@style/Theme.ProtonStream">
|
||||||
@@ -39,6 +47,7 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!--
|
<!--
|
||||||
Proton Drive public links. autoVerify="false", said explicitly:
|
Proton Drive public links. autoVerify="false", said explicitly:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ extern "C" {
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -210,6 +211,17 @@ struct PlaybackState {
|
|||||||
/// episode one — which is the difference between resuming an episode where
|
/// episode one — which is the difference between resuming an episode where
|
||||||
/// the viewer left it and resuming it where the *previous* one ended.
|
/// the viewer left it and resuming it where the *previous* one ended.
|
||||||
int64_t generation = 0;
|
int64_t generation = 0;
|
||||||
|
/// mpv has loaded this generation's file and made its own track pick,
|
||||||
|
/// which is the first moment the pick can be second-guessed.
|
||||||
|
bool loaded = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A subtitle file published for mpv, and what to call it.
|
||||||
|
struct Subtitle {
|
||||||
|
uint64_t handle = 0;
|
||||||
|
std::string title;
|
||||||
|
std::string language;
|
||||||
|
bool select = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Player {
|
class Player {
|
||||||
@@ -338,7 +350,10 @@ class Player {
|
|||||||
state_.generation = generation;
|
state_.generation = generation;
|
||||||
state_.loading = true;
|
state_.loading = true;
|
||||||
error_.clear();
|
error_.clear();
|
||||||
|
for (const auto &subtitle : pending_subtitles_) released_.push_back(subtitle.handle);
|
||||||
|
pending_subtitles_.clear();
|
||||||
}
|
}
|
||||||
|
flush_released();
|
||||||
const std::string url = "pstr://" + std::to_string(handle);
|
const std::string url = "pstr://" + std::to_string(handle);
|
||||||
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||||
if (mpv_command(mpv_, command) < 0) {
|
if (mpv_command(mpv_, command) < 0) {
|
||||||
@@ -372,6 +387,27 @@ class Player {
|
|||||||
mpv_command(mpv_, command);
|
mpv_command(mpv_, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A subtitle file beside the file of load `generation`, added once mpv
|
||||||
|
/// has that file loaded: an external track belongs to the file open at
|
||||||
|
/// the time. Handed over for a file already replaced, it is released
|
||||||
|
/// instead. `select` turns it on, but only when the file has no subtitle
|
||||||
|
/// of its own playing.
|
||||||
|
void add_subtitle(int64_t generation, Subtitle subtitle) {
|
||||||
|
bool now = false;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
if (generation != state_.generation) {
|
||||||
|
released_.push_back(subtitle.handle);
|
||||||
|
} else if (loaded_generation_ == generation) {
|
||||||
|
now = true;
|
||||||
|
} else {
|
||||||
|
pending_subtitles_.push_back(std::move(subtitle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush_released();
|
||||||
|
if (now) attach_subtitle(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackState state() const {
|
PlaybackState state() const {
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
return state_;
|
return state_;
|
||||||
@@ -395,6 +431,8 @@ class Player {
|
|||||||
char *lang_raw = nullptr;
|
char *lang_raw = nullptr;
|
||||||
char *title_raw = nullptr;
|
char *title_raw = nullptr;
|
||||||
int selected = 0;
|
int selected = 0;
|
||||||
|
int is_default = 0;
|
||||||
|
int forced = 0;
|
||||||
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
||||||
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
||||||
MpvString type(type_raw);
|
MpvString type(type_raw);
|
||||||
@@ -403,10 +441,14 @@ class Player {
|
|||||||
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||||
MpvString title(title_raw);
|
MpvString title(title_raw);
|
||||||
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
||||||
|
mpv_get_property(mpv_, (base + "default").c_str(), MPV_FORMAT_FLAG, &is_default);
|
||||||
|
mpv_get_property(mpv_, (base + "forced").c_str(), MPV_FORMAT_FLAG, &forced);
|
||||||
if (i) json += ',';
|
if (i) json += ',';
|
||||||
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
||||||
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
||||||
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}";
|
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") +
|
||||||
|
",\"default\":" + (is_default ? "true" : "false") +
|
||||||
|
",\"forced\":" + (forced ? "true" : "false") + "}";
|
||||||
}
|
}
|
||||||
return json + ']';
|
return json + ']';
|
||||||
}
|
}
|
||||||
@@ -434,6 +476,35 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void attach_subtitle(const Subtitle &subtitle) {
|
||||||
|
bool showing = false;
|
||||||
|
if (char *sid = mpv_get_property_string(mpv_, "sid")) {
|
||||||
|
showing = std::strcmp(sid, "no") != 0;
|
||||||
|
mpv_free(sid);
|
||||||
|
}
|
||||||
|
const std::string url = "pstr://" + std::to_string(subtitle.handle);
|
||||||
|
std::vector<const char *> command{
|
||||||
|
"sub-add", url.c_str(), subtitle.select && !showing ? "select" : "auto",
|
||||||
|
subtitle.title.c_str()};
|
||||||
|
if (!subtitle.language.empty()) command.push_back(subtitle.language.c_str());
|
||||||
|
command.push_back(nullptr);
|
||||||
|
// On failure mpv may never have opened it, so nothing will close it.
|
||||||
|
// Releasing one it did open and close is a no-op.
|
||||||
|
if (mpv_command(mpv_, command.data()) < 0) pstr_android_stream_release(subtitle.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release the subtitle streams queued under `state_mutex_`, outside it:
|
||||||
|
/// a release runs Rust destructors, which must not run under a lock a
|
||||||
|
/// reader of the state may be waiting on.
|
||||||
|
void flush_released() {
|
||||||
|
std::vector<uint64_t> handles;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
handles.swap(released_);
|
||||||
|
}
|
||||||
|
for (const uint64_t handle : handles) pstr_android_stream_release(handle);
|
||||||
|
}
|
||||||
|
|
||||||
struct MpvFree {
|
struct MpvFree {
|
||||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||||
};
|
};
|
||||||
@@ -534,6 +605,17 @@ class Player {
|
|||||||
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||||
const double start = pending_start_.exchange(0.0);
|
const double start = pending_start_.exchange(0.0);
|
||||||
if (start > 0.0) set_double("time-pos", start);
|
if (start > 0.0) set_double("time-pos", start);
|
||||||
|
std::vector<Subtitle> ready;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
// Mid-load, this is the outgoing file finishing its load.
|
||||||
|
if (!state_.loading) {
|
||||||
|
loaded_generation_ = state_.generation;
|
||||||
|
state_.loaded = true;
|
||||||
|
ready.swap(pending_subtitles_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &subtitle : ready) attach_subtitle(subtitle);
|
||||||
}
|
}
|
||||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||||
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||||
@@ -757,6 +839,12 @@ class Player {
|
|||||||
bool surface_changed_ = false;
|
bool surface_changed_ = false;
|
||||||
std::atomic<bool> frame_ready_{false};
|
std::atomic<bool> frame_ready_{false};
|
||||||
std::atomic<double> pending_start_{0.0};
|
std::atomic<double> pending_start_{0.0};
|
||||||
|
/// The load generation whose file mpv has loaded, under `state_mutex_`.
|
||||||
|
int64_t loaded_generation_ = -1;
|
||||||
|
/// Subtitles handed over before their file loaded, under `state_mutex_`.
|
||||||
|
std::vector<Subtitle> pending_subtitles_;
|
||||||
|
/// Streams to release once `state_mutex_` is let go.
|
||||||
|
std::vector<uint64_t> released_;
|
||||||
};
|
};
|
||||||
|
|
||||||
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
||||||
@@ -850,6 +938,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
|||||||
: JNI_FALSE);
|
: JNI_FALSE);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAddSubtitle(JNIEnv *env, jobject, jlong handle, jlong generation, jlong stream, jstring title, jstring language, jboolean select) noexcept {
|
||||||
|
c_boundary([=] {
|
||||||
|
Subtitle subtitle{static_cast<uint64_t>(stream), {}, {}, select == JNI_TRUE};
|
||||||
|
auto *p = from(handle);
|
||||||
|
if (!p || !utf8(env, title, subtitle.title) || !utf8(env, language, subtitle.language)) {
|
||||||
|
pstr_android_stream_release(subtitle.handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p->add_subtitle(generation, std::move(subtitle));
|
||||||
|
});
|
||||||
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
||||||
}
|
}
|
||||||
@@ -881,8 +980,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
|||||||
state.video_width, state.video_height,
|
state.video_width, state.video_height,
|
||||||
static_cast<jdouble>(state.end_reason),
|
static_cast<jdouble>(state.end_reason),
|
||||||
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||||
state.seeking ? 1.0 : 0.0, state.cached_until};
|
state.seeking ? 1.0 : 0.0, state.cached_until,
|
||||||
constexpr jsize kFields = 14;
|
state.loaded ? 1.0 : 0.0};
|
||||||
|
constexpr jsize kFields = 15;
|
||||||
jdoubleArray result = env->NewDoubleArray(kFields);
|
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import io.narl.protonstream.native.NativeRuntime
|
|||||||
import io.narl.protonstream.playback.NativeMpvHost
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlaybackService
|
import io.narl.protonstream.playback.PlaybackService
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||||
import io.narl.protonstream.sync.WatchSyncWorker
|
import io.narl.protonstream.sync.WatchSyncWorker
|
||||||
import io.narl.protonstream.ui.ProtonStreamApp
|
import io.narl.protonstream.ui.ProtonStreamApp
|
||||||
import io.narl.protonstream.ui.theme.AppearanceState
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
@@ -79,6 +80,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
playbackConnection,
|
playbackConnection,
|
||||||
Context.BIND_AUTO_CREATE,
|
Context.BIND_AUTO_CREATE,
|
||||||
)
|
)
|
||||||
|
NewEpisodesWorker.schedule(this)
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import java.util.concurrent.locks.ReentrantLock
|
|||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
import uniffi.pstr_android.ChapterPlan
|
import uniffi.pstr_android.ChapterPlan
|
||||||
import uniffi.pstr_android.ChapterRecord
|
import uniffi.pstr_android.ChapterRecord
|
||||||
|
import uniffi.pstr_android.SubtitleFilesRecord
|
||||||
|
import uniffi.pstr_android.SubtitleTrackRecord
|
||||||
import uniffi.pstr_android.chapterPlan
|
import uniffi.pstr_android.chapterPlan
|
||||||
|
import uniffi.pstr_android.fullSubtitlesInstead
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||||
@@ -107,6 +110,10 @@ data class MpvTrack(
|
|||||||
val language: String,
|
val language: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
|
/** The container marks it as the default choice. */
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
/** The container marks it as forced: on-screen text only, by convention. */
|
||||||
|
val forced: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
||||||
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||||
@@ -128,6 +135,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
@Volatile private var currentMedia: String? = null
|
@Volatile private var currentMedia: String? = null
|
||||||
@Volatile private var currentGeneration: Double = -1.0
|
@Volatile private var currentGeneration: Double = -1.0
|
||||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||||
|
/** The load generation whose subtitle pick has been looked at. */
|
||||||
|
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||||
var onPlaybackStarted: (() -> Unit)? = null
|
var onPlaybackStarted: (() -> Unit)? = null
|
||||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||||
var onExplicitStop: (() -> Unit)? = null
|
var onExplicitStop: (() -> Unit)? = null
|
||||||
@@ -292,6 +301,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
|
|
||||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand mpv the subtitle files beside [media], the episode [play] loaded.
|
||||||
|
*
|
||||||
|
* Native code holds them until mpv has the file loaded, since an external
|
||||||
|
* track belongs to the file open at the time, and releases them instead
|
||||||
|
* if another episode has been loaded since. [SubtitleFilesRecord.preferred]
|
||||||
|
* is turned on only when the file has no subtitle of its own playing;
|
||||||
|
* pass [subtitles] false to turn none on.
|
||||||
|
*/
|
||||||
|
fun addSubtitles(media: String, files: SubtitleFilesRecord, subtitles: Boolean) {
|
||||||
|
val generation = currentGeneration.toLong()
|
||||||
|
val added = withOpenHandle { handle ->
|
||||||
|
files.files.forEachIndexed { index, file ->
|
||||||
|
if (currentMedia == media) {
|
||||||
|
val select = subtitles && files.preferred == index.toUInt()
|
||||||
|
nativeAddSubtitle(handle, generation, file.handle.toLong(), file.title, file.language, select)
|
||||||
|
} else {
|
||||||
|
pstrAndroidStreamRelease(file.handle.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added == null) files.files.forEach { pstrAndroidStreamRelease(it.handle.toLong()) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun startPolling() {
|
private fun startPolling() {
|
||||||
if (poller?.isActive == true) return
|
if (poller?.isActive == true) return
|
||||||
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||||
@@ -322,6 +355,12 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
// both of which are the main thread's.
|
// both of which are the main thread's.
|
||||||
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||||
refreshChapters(next)
|
refreshChapters(next)
|
||||||
|
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
|
||||||
|
subtitlesCheckedFor != currentGeneration
|
||||||
|
) {
|
||||||
|
subtitlesCheckedFor = currentGeneration
|
||||||
|
preferFullSubtitles()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||||
if (tick++ % 4 == 0) refreshTracks()
|
if (tick++ % 4 == 0) refreshTracks()
|
||||||
@@ -359,13 +398,32 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the dialogue rather than the signs and songs mpv picked by language
|
||||||
|
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
|
||||||
|
* have made, not the viewer's.
|
||||||
|
*/
|
||||||
|
private fun preferFullSubtitles() {
|
||||||
|
refreshTracks()
|
||||||
|
val subtitles = mutableTracks.value.filter { it.type == "sub" }
|
||||||
|
val id = fullSubtitlesInstead(
|
||||||
|
subtitles.map {
|
||||||
|
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
|
||||||
|
},
|
||||||
|
) ?: return
|
||||||
|
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshTracks() {
|
private fun refreshTracks() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||||
val values = JSONArray(encoded)
|
val values = JSONArray(encoded)
|
||||||
List(values.length()) { index ->
|
List(values.length()) { index ->
|
||||||
values.getJSONObject(index).run {
|
values.getJSONObject(index).run {
|
||||||
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
|
MpvTrack(
|
||||||
|
getLong("id"), getString("type"), getString("language"), getString("title"),
|
||||||
|
getBoolean("selected"), optBoolean("default"), optBoolean("forced"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onSuccess { mutableTracks.value = it }
|
}.onSuccess { mutableTracks.value = it }
|
||||||
@@ -414,6 +472,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
subtitles: Boolean,
|
subtitles: Boolean,
|
||||||
hardwareDecoding: Boolean,
|
hardwareDecoding: Boolean,
|
||||||
): Boolean
|
): Boolean
|
||||||
|
private external fun nativeAddSubtitle(
|
||||||
|
handle: Long,
|
||||||
|
generation: Long,
|
||||||
|
stream: Long,
|
||||||
|
title: String,
|
||||||
|
language: String?,
|
||||||
|
select: Boolean,
|
||||||
|
)
|
||||||
private external fun nativePause(handle: Long, paused: Boolean)
|
private external fun nativePause(handle: Long, paused: Boolean)
|
||||||
private external fun nativeSeek(handle: Long, position: Double)
|
private external fun nativeSeek(handle: Long, position: Double)
|
||||||
private external fun nativeVolume(handle: Long, volume: Double)
|
private external fun nativeVolume(handle: Long, volume: Double)
|
||||||
@@ -434,7 +500,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||||
private const val FIELDS = 14
|
private const val FIELDS = 15
|
||||||
private const val GENERATION = 6
|
private const val GENERATION = 6
|
||||||
private const val WIDTH = 7
|
private const val WIDTH = 7
|
||||||
private const val HEIGHT = 8
|
private const val HEIGHT = 8
|
||||||
@@ -443,6 +509,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private const val CACHE_PERCENT = 11
|
private const val CACHE_PERCENT = 11
|
||||||
private const val SEEKING = 12
|
private const val SEEKING = 12
|
||||||
private const val CACHED_UNTIL = 13
|
private const val CACHED_UNTIL = 13
|
||||||
|
private const val LOADED = 14
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.semantics.onClick
|
import androidx.compose.ui.semantics.onClick
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@@ -250,11 +258,19 @@ fun PlayerScreen(
|
|||||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||||
SettingsStore(context).hardwareDecoding,
|
SettingsStore(context).hardwareDecoding,
|
||||||
)
|
)
|
||||||
|
|
||||||
// After the load, because mpv keeps none of the three across one.
|
// After the load, because mpv keeps none of the three across one.
|
||||||
nativeHost.setVolume(startup.volume)
|
nativeHost.setVolume(startup.volume)
|
||||||
nativeHost.setMuted(startup.muted)
|
nativeHost.setMuted(startup.muted)
|
||||||
nativeHost.setSpeed(startup.speed)
|
nativeHost.setSpeed(startup.speed)
|
||||||
session.transferToPlayer()
|
session.transferToPlayer()
|
||||||
|
// While mpv opens the episode, and off the spinner's path: the host
|
||||||
|
// holds them until it has the file.
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
val files = NativeRuntime.engine()
|
||||||
|
.openSubtitles(episode.shareId, episode.linkId, startup.subtitleLanguage)
|
||||||
|
nativeHost.addSubtitles(key, files, startup.subtitles)
|
||||||
|
}
|
||||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||||
opening = false
|
opening = false
|
||||||
}
|
}
|
||||||
@@ -436,12 +452,46 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val current by rememberUpdatedState(state)
|
val current by rememberUpdatedState(state)
|
||||||
|
// Where a remote's keys land while the controls are down. Once they are
|
||||||
|
// up, the D-pad moves between their buttons as it does anywhere else.
|
||||||
|
val keys = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(showControls) { if (!showControls) runCatching { keys.requestFocus() } }
|
||||||
if (minimized) return
|
if (minimized) return
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
|
// A TV remote, a keyboard: left and right seek as a double tap
|
||||||
|
// does, the middle button brings the controls up, and play/pause
|
||||||
|
// and space do what they say.
|
||||||
|
.focusRequester(keys)
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionLeft, Key.DirectionRight -> {
|
||||||
|
if (showControls) return@onKeyEvent false
|
||||||
|
val seconds = if (event.key == Key.DirectionLeft) -DOUBLE_TAP_SECONDS else DOUBLE_TAP_SECONDS
|
||||||
|
host.seek((current.position + seconds).coerceAtLeast(0.0))
|
||||||
|
nudge = seconds to (nudge?.second ?: 0) + 1
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.DirectionUp, Key.DirectionDown -> {
|
||||||
|
if (showControls) return@onKeyEvent false
|
||||||
|
showControls = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.MediaPlayPause, Key.Spacebar -> {
|
||||||
|
host.setPaused(!current.paused)
|
||||||
|
showControls = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.MediaPlay -> { host.setPaused(false); true }
|
||||||
|
Key.MediaPause -> { host.setPaused(true); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
// Double-tap a side to seek ten seconds, as every phone video
|
// Double-tap a side to seek ten seconds, as every phone video
|
||||||
// player has taught; the middle plays or pauses. A single tap
|
// player has taught; the middle plays or pauses. A single tap
|
||||||
// waits out the double-tap timeout, which is the price of both.
|
// waits out the double-tap timeout, which is the price of both.
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class SettingsStore(context: Context) {
|
|||||||
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
|
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
|
||||||
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
|
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
|
||||||
|
|
||||||
|
/** Whether a background crawl may say that a show gained episodes. */
|
||||||
|
var newEpisodeNotifications: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
|
||||||
|
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, value) } }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The desktop's range, in the steps a phone's storage is bought in. */
|
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||||
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||||
@@ -78,6 +83,7 @@ class SettingsStore(context: Context) {
|
|||||||
private const val KEY_LIBRARY_SORT = "library_sort"
|
private const val KEY_LIBRARY_SORT = "library_sort"
|
||||||
private const val KEY_LIBRARY_GROUPED = "library_grouped"
|
private const val KEY_LIBRARY_GROUPED = "library_grouped"
|
||||||
private const val KEY_LIBRARY_FILTER = "library_filter"
|
private const val KEY_LIBRARY_FILTER = "library_filter"
|
||||||
|
private const val KEY_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
|
||||||
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
|
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package io.narl.protonstream.sync
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import io.narl.protonstream.MainActivity
|
||||||
|
import io.narl.protonstream.R
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import uniffi.pstr_android.NewEpisodesRecord
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look at the shares while the app is away, and say when a show gained episodes.
|
||||||
|
*
|
||||||
|
* The app crawls a share it has not looked at for six hours whenever it opens,
|
||||||
|
* which finds a new episode only once someone opens it to look. This runs the
|
||||||
|
* same crawl on WorkManager's schedule instead. It only reports files the
|
||||||
|
* crawl itself found, so a share the app crawled since says nothing twice.
|
||||||
|
*/
|
||||||
|
class NewEpisodesWorker(
|
||||||
|
appContext: Context,
|
||||||
|
parameters: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
|
||||||
|
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
|
||||||
|
if (news.isNotEmpty()) post(news)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(news: List<NewEpisodesRecord>) {
|
||||||
|
if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = "A show in your library gained episodes"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
Intent(applicationContext, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val compat = NotificationManagerCompat.from(applicationContext)
|
||||||
|
// One per show, keyed by it, so a second crawl's news for the same show
|
||||||
|
// replaces the first rather than stacking under it.
|
||||||
|
news.forEach { item ->
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
|
.setContentTitle(item.title)
|
||||||
|
.setContentText(describe(item))
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setGroup(GROUP)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
if (news.size > 1) {
|
||||||
|
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
|
.setContentTitle("New episodes")
|
||||||
|
.setContentText(news.joinToString { it.title })
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setGroup(GROUP)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "new-episodes"
|
||||||
|
private const val CHANNEL_ID = "new-episodes"
|
||||||
|
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
|
||||||
|
private const val SUMMARY_TAG = "new-episodes-summary"
|
||||||
|
private const val NOTIFICATION_ID = 7
|
||||||
|
private const val INTERVAL_HOURS = 6L
|
||||||
|
|
||||||
|
/** What one show's notification says: "S01E04 and 2 more". */
|
||||||
|
fun describe(news: NewEpisodesRecord): String {
|
||||||
|
val more = news.count.toInt() - 1
|
||||||
|
val first = news.first
|
||||||
|
return when {
|
||||||
|
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
|
||||||
|
more == 0 -> "$first is new"
|
||||||
|
more == 1 -> "$first and 1 more are new"
|
||||||
|
else -> "$first and $more more are new"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the periodic look scheduled, or cancel it when the viewer
|
||||||
|
* turned it off. Called on every launch; keeping an existing schedule
|
||||||
|
* means a launch does not push the next look back.
|
||||||
|
*/
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val work = WorkManager.getInstance(context.applicationContext)
|
||||||
|
if (!SettingsStore(context).newEpisodeNotifications) {
|
||||||
|
work.cancelUniqueWork(WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.compose.ui.window.SecureFlagPolicy
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -104,6 +105,7 @@ internal fun SettingsScreen(
|
|||||||
val settings = remember { SettingsStore(context) }
|
val settings = remember { SettingsStore(context) }
|
||||||
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||||
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||||
|
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
|
||||||
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||||
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||||
// Playback preferences are the shared store's, not this app's: they are the
|
// Playback preferences are the shared store's, not this app's: they are the
|
||||||
@@ -207,6 +209,15 @@ internal fun SettingsScreen(
|
|||||||
backgroundAudio = it
|
backgroundAudio = it
|
||||||
settings.backgroundAudio = it
|
settings.backgroundAudio = it
|
||||||
}
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"New episode notifications",
|
||||||
|
"Looks at your shares every few hours and says when a show gains episodes",
|
||||||
|
newEpisodes,
|
||||||
|
) {
|
||||||
|
newEpisodes = it
|
||||||
|
settings.newEpisodeNotifications = it
|
||||||
|
NewEpisodesWorker.schedule(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||||
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 68 KiB |
@@ -13,7 +13,9 @@ use parking_lot::Mutex;
|
|||||||
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
|
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||||
use pstr_core::browse::{Filter, Sort};
|
use pstr_core::browse::{Filter, Sort};
|
||||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
use pstr_core::catalog::{
|
||||||
|
Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
|
||||||
|
};
|
||||||
use pstr_core::chapters::{Chapter, ChapterRole};
|
use pstr_core::chapters::{Chapter, ChapterRole};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||||
@@ -25,6 +27,7 @@ use pstr_core::proton_drive_rs::ThumbnailType;
|
|||||||
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||||
use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
|
use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
use pstr_core::shares::AccountFolder;
|
use pstr_core::shares::AccountFolder;
|
||||||
|
use pstr_core::subtitles::SubtitleTrack;
|
||||||
use pstr_core::sync::WatchSync;
|
use pstr_core::sync::WatchSync;
|
||||||
use pstr_core::{
|
use pstr_core::{
|
||||||
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
|
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
|
||||||
@@ -102,6 +105,36 @@ pub struct AndroidPaths {
|
|||||||
pub cache: String,
|
pub cache: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A subtitle file beside an episode, opened and published for the native
|
||||||
|
/// player like the episode itself. The player releases `handle` when mpv
|
||||||
|
/// closes it; one never handed to mpv goes back through
|
||||||
|
/// `pstr_android_stream_release`.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SubtitleFileRecord {
|
||||||
|
pub handle: u64,
|
||||||
|
/// What else the file name says — "forced", "SDH" — or "External".
|
||||||
|
pub title: String,
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An episode's subtitle files, and which to turn on when the episode has
|
||||||
|
/// none of its own playing.
|
||||||
|
#[derive(Debug, Clone, Default, uniffi::Record)]
|
||||||
|
pub struct SubtitleFilesRecord {
|
||||||
|
pub files: Vec<SubtitleFileRecord>,
|
||||||
|
pub preferred: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Episodes of one title that a background crawl found, for a notification.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NewEpisodesRecord {
|
||||||
|
pub title_key: String,
|
||||||
|
pub title: String,
|
||||||
|
pub count: u32,
|
||||||
|
/// The first new episode's label — "S01E04" — when there is one.
|
||||||
|
pub first: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Android implements this with an AES-GCM key held by Android Keystore.
|
/// Android implements this with an AES-GCM key held by Android Keystore.
|
||||||
#[uniffi::export(callback_interface)]
|
#[uniffi::export(callback_interface)]
|
||||||
pub trait AndroidSecretStore: Send + Sync {
|
pub trait AndroidSecretStore: Send + Sync {
|
||||||
@@ -707,6 +740,38 @@ fn chapter_kind(role: ChapterRole) -> ChapterKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One subtitle track as the player read it out of mpv's `track-list`.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SubtitleTrackRecord {
|
||||||
|
pub id: i64,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub default: bool,
|
||||||
|
pub forced: bool,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dialogue track to play in place of the subtitle track mpv chose, when
|
||||||
|
/// mpv chose the signs and songs (`pstr_core::subtitles`). Asked once per file,
|
||||||
|
/// as soon as mpv has loaded it and made its own pick.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn full_subtitles_instead(tracks: Vec<SubtitleTrackRecord>) -> Option<i64> {
|
||||||
|
let tracks: Vec<SubtitleTrack> = tracks
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| SubtitleTrack {
|
||||||
|
id: track.id,
|
||||||
|
language: track
|
||||||
|
.language
|
||||||
|
.filter(|language| !language.trim().is_empty()),
|
||||||
|
title: track.title.filter(|title| !title.trim().is_empty()),
|
||||||
|
default: track.default,
|
||||||
|
forced: track.forced,
|
||||||
|
selected: track.selected,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
pstr_core::subtitles::full_instead(&tracks)
|
||||||
|
}
|
||||||
|
|
||||||
/// What the app is holding on disk. Offline episodes are the viewer's, the
|
/// What the app is holding on disk. Offline episodes are the viewer's, the
|
||||||
/// block cache is not: only the latter may be reclaimed without asking.
|
/// block cache is not: only the latter may be reclaimed without asking.
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
@@ -1313,6 +1378,49 @@ impl AndroidEngine {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Crawl the shares due a look and say which titles gained episodes, for
|
||||||
|
/// a notification from a background job.
|
||||||
|
///
|
||||||
|
/// Only shares crawled here before: the first crawl of one finds every
|
||||||
|
/// file new, and "312 new episodes" is not news. A share that fails is
|
||||||
|
/// skipped, so one revoked link does not hide another's episodes.
|
||||||
|
pub async fn crawl_for_news(self: Arc<Self>) -> Result<Vec<NewEpisodesRecord>, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let crawls = self
|
||||||
|
.catalog
|
||||||
|
.lock()
|
||||||
|
.crawls()
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
let due: Vec<String> = self
|
||||||
|
.uncrawled_shares()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| crawls.get(id).is_some_and(|record| record.crawled_at > 0))
|
||||||
|
.collect();
|
||||||
|
if due.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let since = now();
|
||||||
|
for id in due {
|
||||||
|
if let Err(error) = self.crawl_inner(Some(id)).await {
|
||||||
|
log::debug!("background crawl failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.with_library(|cached| {
|
||||||
|
let mut news = news_since(&cached.library, &cached.first_seen, since);
|
||||||
|
// By the name the library shows, which may be the provider's.
|
||||||
|
for item in &mut news {
|
||||||
|
if let Some(record) = cached.titles.iter().find(|t| t.key == item.title_key) {
|
||||||
|
item.title.clone_from(&record.display_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
news
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
/// Every configured share, with what its last crawl here came to.
|
/// Every configured share, with what its last crawl here came to.
|
||||||
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
||||||
let shares = self.store.list().map_err(BridgeError::from_display)?;
|
let shares = self.store.list().map_err(BridgeError::from_display)?;
|
||||||
@@ -1972,6 +2080,83 @@ impl AndroidEngine {
|
|||||||
.map_err(BridgeError::from_display)?
|
.map_err(BridgeError::from_display)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the subtitle files paired with an episode, for the native player
|
||||||
|
/// to add once it has the episode loaded. `language` is the subtitle
|
||||||
|
/// language in effect for it, which picks [`SubtitleFilesRecord::preferred`].
|
||||||
|
///
|
||||||
|
/// Through the session already open: a downloaded episode played without
|
||||||
|
/// one goes without its subtitles rather than waiting on a handshake. One
|
||||||
|
/// that fails to open is left out; the episode plays either way.
|
||||||
|
pub async fn open_subtitles(
|
||||||
|
self: Arc<Self>,
|
||||||
|
share_id: String,
|
||||||
|
link_id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> SubtitleFilesRecord {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
let opened = spawned(&runtime, async move {
|
||||||
|
let sidecars = self
|
||||||
|
.catalog
|
||||||
|
.lock()
|
||||||
|
.sidecars(&share_id, &link_id)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if sidecars.is_empty() {
|
||||||
|
return SubtitleFilesRecord::default();
|
||||||
|
}
|
||||||
|
let Some(connection) = self.cached_connection() else {
|
||||||
|
return SubtitleFilesRecord::default();
|
||||||
|
};
|
||||||
|
let (engine, connection) = (&self, &connection);
|
||||||
|
let opening = sidecars.into_iter().map(|sidecar| async move {
|
||||||
|
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
|
||||||
|
match engine
|
||||||
|
.open_from_source(connection, &sidecar.share_id, &uid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stream) => {
|
||||||
|
stream.warm_edges();
|
||||||
|
Some((sidecar, stream))
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!("opening subtitle {} failed: {error}", sidecar.name);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let opened: Vec<_> = futures::future::join_all(opening)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
let sidecars: Vec<_> = opened.iter().map(|(sidecar, _)| sidecar.clone()).collect();
|
||||||
|
let preferred = pstr_core::catalog::preferred_sidecar(&sidecars, language.as_deref())
|
||||||
|
.and_then(|index| u32::try_from(index).ok());
|
||||||
|
let files = opened
|
||||||
|
.into_iter()
|
||||||
|
.map(|(sidecar, stream)| {
|
||||||
|
let handle = Arc::new(AndroidStream {
|
||||||
|
runtime: Arc::clone(&self.runtime),
|
||||||
|
stream,
|
||||||
|
cancel: tokio::sync::Notify::new(),
|
||||||
|
native_id: Mutex::new(None),
|
||||||
|
})
|
||||||
|
.native_handle();
|
||||||
|
SubtitleFileRecord {
|
||||||
|
handle,
|
||||||
|
title: sidecar.title.unwrap_or_else(|| "External".to_owned()),
|
||||||
|
language: sidecar.language,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
SubtitleFilesRecord { files, preferred }
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
opened.unwrap_or_else(|error| {
|
||||||
|
log::debug!("opening subtitles stopped: {error}");
|
||||||
|
SubtitleFilesRecord::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the blocks an episode opens with into the cache, without playing
|
/// Fetch the blocks an episode opens with into the cache, without playing
|
||||||
/// it: the next episode, near the end of this one, so that autoplay or
|
/// it: the next episode, near the end of this one, so that autoplay or
|
||||||
/// "Play now" starts from the cache rather than from two round trips.
|
/// "Play now" starts from the cache rather than from two round trips.
|
||||||
@@ -2321,6 +2506,7 @@ impl AndroidEngine {
|
|||||||
added,
|
added,
|
||||||
titles,
|
titles,
|
||||||
offline: offline.into_keys().collect(),
|
offline: offline.into_keys().collect(),
|
||||||
|
first_seen,
|
||||||
};
|
};
|
||||||
let answer = read(&cached);
|
let answer = read(&cached);
|
||||||
*self.library_cache.lock() = Some(cached);
|
*self.library_cache.lock() = Some(cached);
|
||||||
@@ -2637,18 +2823,22 @@ impl AndroidEngine {
|
|||||||
if let Err(error) = self
|
if let Err(error) = self
|
||||||
.catalog
|
.catalog
|
||||||
.lock()
|
.lock()
|
||||||
.record_crawl_failure(&id, &error.to_string())
|
.record_crawl_failure(&id, &error.describe())
|
||||||
{
|
{
|
||||||
log::warn!("record a failed crawl: {error}");
|
log::warn!("record a failed crawl: {error}");
|
||||||
}
|
}
|
||||||
return Err(BridgeError::from_display(error));
|
return Err(BridgeError::from_display(error.describe()));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let rows = build_rows(&id, &nodes);
|
let rows = build_rows(&id, &nodes);
|
||||||
self.catalog
|
let sidecars = build_sidecars(&id, &nodes);
|
||||||
.lock()
|
{
|
||||||
.replace_share(&id, &rows)
|
let mut catalog = self.catalog.lock();
|
||||||
.map_err(BridgeError::from_display)?;
|
catalog
|
||||||
|
.replace_share(&id, &rows)
|
||||||
|
.and_then(|()| catalog.replace_sidecars(&id, &sidecars))
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
}
|
||||||
let after = self
|
let after = self
|
||||||
.catalog
|
.catalog
|
||||||
.lock()
|
.lock()
|
||||||
@@ -3196,6 +3386,40 @@ struct CachedLibrary {
|
|||||||
titles: Vec<TitleRecord>,
|
titles: Vec<TitleRecord>,
|
||||||
/// The files kept on this device, by share and link id.
|
/// The files kept on this device, by share and link id.
|
||||||
offline: HashSet<(String, String)>,
|
offline: HashSet<(String, String)>,
|
||||||
|
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
|
||||||
|
first_seen: HashMap<(String, String), i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The titles whose files first appeared at or after `since`, with how many
|
||||||
|
/// and the first of them, most new episodes first.
|
||||||
|
fn news_since(
|
||||||
|
library: &Library,
|
||||||
|
first_seen: &HashMap<(String, String), i64>,
|
||||||
|
since: i64,
|
||||||
|
) -> Vec<NewEpisodesRecord> {
|
||||||
|
let mut news: Vec<NewEpisodesRecord> = library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.filter_map(|title| {
|
||||||
|
let fresh: Vec<&pstr_core::library::Episode> = title
|
||||||
|
.episodes()
|
||||||
|
.filter(|episode| {
|
||||||
|
first_seen
|
||||||
|
.get(&(episode.node.share_id.clone(), episode.node.link_id.clone()))
|
||||||
|
.is_some_and(|seen| *seen >= since)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let first = fresh.first()?;
|
||||||
|
Some(NewEpisodesRecord {
|
||||||
|
title_key: title.key.clone(),
|
||||||
|
title: title.name.clone(),
|
||||||
|
count: u32::try_from(fresh.len()).unwrap_or(u32::MAX),
|
||||||
|
first: Some(first.label()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
news.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.title.cmp(&b.title)));
|
||||||
|
news
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many provider lookups may be in flight at once.
|
/// How many provider lookups may be in flight at once.
|
||||||
@@ -3561,7 +3785,7 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream,
|
AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream,
|
||||||
AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker,
|
AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker,
|
||||||
chapter_plan, match_record, node_uid, normalized_language, palette_record,
|
chapter_plan, match_record, news_since, node_uid, normalized_language, palette_record,
|
||||||
prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release,
|
prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release,
|
||||||
pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata,
|
pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata,
|
||||||
write_partial_marker,
|
write_partial_marker,
|
||||||
@@ -4116,6 +4340,54 @@ mod tests {
|
|||||||
drop(engine);
|
drop(engine);
|
||||||
std::fs::remove_dir_all(root).expect("remove test directory");
|
std::fs::remove_dir_all(root).expect("remove test directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn news_is_the_titles_that_gained_files_since_the_crawl_began_most_first() {
|
||||||
|
let file = |link: &str, name: &str| pstr_core::catalog::CatalogNode {
|
||||||
|
share_id: "s".into(),
|
||||||
|
link_id: link.into(),
|
||||||
|
volume_id: "v".into(),
|
||||||
|
parent_link_id: None,
|
||||||
|
name: name.into(),
|
||||||
|
is_folder: false,
|
||||||
|
media_type: None,
|
||||||
|
size: None,
|
||||||
|
active_revision_id: None,
|
||||||
|
parsed: pstr_core::naming::parse(name),
|
||||||
|
};
|
||||||
|
let library = pstr_core::library::Library::build(
|
||||||
|
vec![
|
||||||
|
file("f1", "Frieren S01E01.mkv"),
|
||||||
|
file("f2", "Frieren S01E02.mkv"),
|
||||||
|
file("f3", "Frieren S01E03.mkv"),
|
||||||
|
file("m1", "Mushishi S01E01.mkv"),
|
||||||
|
file("m2", "Mushishi S01E02.mkv"),
|
||||||
|
file("o1", "Monster S01E01.mkv"),
|
||||||
|
],
|
||||||
|
&HashMap::new(),
|
||||||
|
);
|
||||||
|
let seen = |link: &str, at: i64| (("s".to_string(), link.to_string()), at);
|
||||||
|
let first_seen: HashMap<_, _> = [
|
||||||
|
seen("f1", 10),
|
||||||
|
seen("f2", 100),
|
||||||
|
seen("f3", 100),
|
||||||
|
seen("m1", 10),
|
||||||
|
seen("m2", 100),
|
||||||
|
seen("o1", 10),
|
||||||
|
]
|
||||||
|
.into();
|
||||||
|
let news: Vec<(String, u32, Option<String>)> = news_since(&library, &first_seen, 100)
|
||||||
|
.into_iter()
|
||||||
|
.map(|news| (news.title, news.count, news.first))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
news,
|
||||||
|
vec![
|
||||||
|
("Frieren".to_string(), 2, Some("S01E02".to_string())),
|
||||||
|
("Mushishi".to_string(), 1, Some("S01E02".to_string())),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
@@ -25,6 +25,8 @@ tracing-subscriber.workspace = true
|
|||||||
eframe.workspace = true
|
eframe.workspace = true
|
||||||
egui.workspace = true
|
egui.workspace = true
|
||||||
egui-phosphor.workspace = true
|
egui-phosphor.workspace = true
|
||||||
|
# The parser egui reads fonts with, to check a system font before handing it over.
|
||||||
|
skrifa.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
@@ -39,8 +41,6 @@ tao.workspace = true
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
embed-resource.workspace = true
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
skrifa.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dbus.workspace = true
|
dbus.workspace = true
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -28,7 +28,7 @@ use parking_lot::Mutex;
|
|||||||
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
||||||
use pstr_core::appearance::Appearance;
|
use pstr_core::appearance::Appearance;
|
||||||
use pstr_core::catalog::{
|
use pstr_core::catalog::{
|
||||||
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
|
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
|
||||||
};
|
};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
@@ -1494,7 +1494,7 @@ impl Engine {
|
|||||||
if let Err(error) = self
|
if let Err(error) = self
|
||||||
.catalog
|
.catalog
|
||||||
.lock()
|
.lock()
|
||||||
.record_crawl_failure(&share_id, &error.to_string())
|
.record_crawl_failure(&share_id, &error.describe())
|
||||||
{
|
{
|
||||||
tracing::warn!(%error, "record a failed crawl");
|
tracing::warn!(%error, "record a failed crawl");
|
||||||
}
|
}
|
||||||
@@ -1513,6 +1513,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let rows = build_rows(&share_id, &nodes);
|
let rows = build_rows(&share_id, &nodes);
|
||||||
|
let sidecars = build_sidecars(&share_id, &nodes);
|
||||||
let files = rows.len();
|
let files = rows.len();
|
||||||
|
|
||||||
// A writer opened before the crawl may be producing the revision
|
// A writer opened before the crawl may be producing the revision
|
||||||
@@ -1574,7 +1575,7 @@ impl Engine {
|
|||||||
for (link_id, _) in &stale {
|
for (link_id, _) in &stale {
|
||||||
catalog.remove_offline_file(&share_id, link_id)?;
|
catalog.remove_offline_file(&share_id, link_id)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
catalog.replace_sidecars(&share_id, &sidecars)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
if let Err(error) = stored {
|
if let Err(error) = stored {
|
||||||
@@ -1718,6 +1719,55 @@ impl Engine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the subtitle files paired with `target`, for the player to add
|
||||||
|
/// once mpv has the video.
|
||||||
|
///
|
||||||
|
/// Through the session already open: a local copy played without one goes
|
||||||
|
/// without its subtitles rather than waiting on a handshake. Each is
|
||||||
|
/// warmed — most are one block — so mpv reads it from the cache. One that
|
||||||
|
/// fails is left out quietly; the video plays either way.
|
||||||
|
pub async fn open_sidecars(
|
||||||
|
&self,
|
||||||
|
target: &PlaybackTarget,
|
||||||
|
) -> Vec<(pstr_core::catalog::Sidecar, VideoStream)> {
|
||||||
|
let sidecars = self
|
||||||
|
.catalog
|
||||||
|
.lock()
|
||||||
|
.sidecars(&target.share_id, &target.link_id)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if sidecars.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Some((library, source)) = self.opened() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let opening = sidecars.into_iter().map(|sidecar| {
|
||||||
|
let library = Arc::clone(&library);
|
||||||
|
let source = source.clone();
|
||||||
|
async move {
|
||||||
|
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
|
||||||
|
match library
|
||||||
|
.reauthenticating(&sidecar.share_id, || source.open(&sidecar.share_id, &uid))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stream) => {
|
||||||
|
stream.warm_edges();
|
||||||
|
Some((sidecar, stream))
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "open subtitle {}", sidecar.name);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
futures::future::join_all(opening)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// How far into the file `target` resumes, as a fraction of its running
|
/// How far into the file `target` resumes, as a fraction of its running
|
||||||
/// time — known from the last time it played.
|
/// time — known from the last time it played.
|
||||||
fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> {
|
fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> {
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
|
|||||||
use pstr_core::library::{Episode, Title};
|
use pstr_core::library::{Episode, Title};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
use pstr_player::{
|
use pstr_player::{
|
||||||
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
|
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
|
||||||
VideoOutput,
|
TrackKind, VideoOutput,
|
||||||
};
|
};
|
||||||
use pstr_stream::VideoStream;
|
use pstr_stream::VideoStream;
|
||||||
|
|
||||||
@@ -530,6 +530,15 @@ fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Opened while mpv opens the video, and added once it has: an external
|
||||||
|
// track belongs to the file loaded at the time.
|
||||||
|
let mut sidecars = Some(engine.runtime().spawn({
|
||||||
|
let engine = engine.clone();
|
||||||
|
let target = target.clone();
|
||||||
|
async move { engine.open_sidecars(&target).await }
|
||||||
|
}));
|
||||||
|
let mut subtitles: Vec<StreamHandle> = Vec::new();
|
||||||
|
|
||||||
let mut position = target.resume_at.unwrap_or(0.0);
|
let mut position = target.resume_at.unwrap_or(0.0);
|
||||||
let mut duration: Option<f64> = None;
|
let mut duration: Option<f64> = None;
|
||||||
let mut watched = false;
|
let mut watched = false;
|
||||||
@@ -558,6 +567,10 @@ fn run(
|
|||||||
match &event {
|
match &event {
|
||||||
PlayerEvent::FileLoaded => {
|
PlayerEvent::FileLoaded => {
|
||||||
duration = player.duration();
|
duration = player.duration();
|
||||||
|
if let Some(opening) = sidecars.take() {
|
||||||
|
let opened = engine.runtime().block_on(opening).unwrap_or_default();
|
||||||
|
subtitles = add_sidecars(&engine, &target, &player, opened);
|
||||||
|
}
|
||||||
// The first moment there is a track list to read: mpv has
|
// The first moment there is a track list to read: mpv has
|
||||||
// demuxed the file and made its own default selection. Put the
|
// demuxed the file and made its own default selection. Put the
|
||||||
// title's own choice over it before the menus are told, so they
|
// title's own choice over it before the menus are told, so they
|
||||||
@@ -565,6 +578,15 @@ fn run(
|
|||||||
if let Some(show) = target.track_prefs.as_deref() {
|
if let Some(show) = target.track_prefs.as_deref() {
|
||||||
restore_tracks(&player, show);
|
restore_tracks(&player, show);
|
||||||
}
|
}
|
||||||
|
// A subtitle track picked by name stays; one mpv picked by
|
||||||
|
// language may be the signs and songs, not the dialogue.
|
||||||
|
let picked = target
|
||||||
|
.track_prefs
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|show| show.subtitle_title.is_some());
|
||||||
|
if !picked {
|
||||||
|
prefer_full_subtitles(&player);
|
||||||
|
}
|
||||||
emit_tracks(&engine, id, &player);
|
emit_tracks(&engine, id, &player);
|
||||||
}
|
}
|
||||||
PlayerEvent::Duration(value) => duration = Some(*value),
|
PlayerEvent::Duration(value) => duration = Some(*value),
|
||||||
@@ -607,6 +629,7 @@ fn run(
|
|||||||
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
||||||
// OpenGL context its render context was built against.
|
// OpenGL context its render context was built against.
|
||||||
drop(handle);
|
drop(handle);
|
||||||
|
drop(subtitles);
|
||||||
drop(player);
|
drop(player);
|
||||||
|
|
||||||
engine.release(
|
engine.release(
|
||||||
@@ -721,6 +744,57 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play the dialogue rather than the signs and songs mpv picked by language.
|
||||||
|
/// Not remembered: it is the choice mpv should have made, not the viewer's.
|
||||||
|
fn prefer_full_subtitles(player: &Player) {
|
||||||
|
if let Some(track) = pstr_player::full_subtitles_instead(&player.tracks())
|
||||||
|
&& let Err(error) = player.select_track(TrackKind::Subtitle, Some(track))
|
||||||
|
{
|
||||||
|
tracing::warn!("select dialogue subtitle {track}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand mpv the subtitle files paired with the video it has just loaded.
|
||||||
|
///
|
||||||
|
/// One is turned on only when subtitles are on and the video has none of its
|
||||||
|
/// own playing — a file's own track was chosen by name and language already,
|
||||||
|
/// and an external one is the fallback, not an override.
|
||||||
|
fn add_sidecars(
|
||||||
|
engine: &Engine,
|
||||||
|
target: &PlaybackTarget,
|
||||||
|
player: &Player,
|
||||||
|
opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>,
|
||||||
|
) -> Vec<StreamHandle> {
|
||||||
|
let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref());
|
||||||
|
let playing = player
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.any(|track| track.kind == TrackKind::Subtitle && track.selected);
|
||||||
|
let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
|
||||||
|
let select = (config.subtitles && !playing)
|
||||||
|
.then(|| {
|
||||||
|
pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref())
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
sidecars
|
||||||
|
.iter()
|
||||||
|
.zip(streams)
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, (sidecar, stream))| {
|
||||||
|
let title = sidecar.title.as_deref().unwrap_or("External");
|
||||||
|
player
|
||||||
|
.add_subtitle(
|
||||||
|
stream,
|
||||||
|
title,
|
||||||
|
sidecar.language.as_deref(),
|
||||||
|
select == Some(index),
|
||||||
|
)
|
||||||
|
.inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the UI what the file contains and what of it is playing.
|
/// Tell the UI what the file contains and what of it is playing.
|
||||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||||
engine.emit(Event::Player {
|
engine.emit(Event::Player {
|
||||||
|
|||||||
@@ -248,15 +248,40 @@ fn install_cjk_fallback(ctx: &egui::Context) {
|
|||||||
let Some((path, index)) = system_cjk_font() else {
|
let Some((path, index)) = system_cjk_font() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(bytes) = std::fs::read(path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(data) = cjk_font_data(bytes, index) else {
|
||||||
|
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut data = egui::FontData::from_owned(bytes);
|
|
||||||
data.index = index;
|
|
||||||
ctx.set_fonts(font_definitions(Some(data)));
|
ctx.set_fonts(font_definitions(Some(data)));
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fallback font, if egui can read it.
|
||||||
|
///
|
||||||
|
/// egui panics on a font it cannot parse, and this one comes from whatever the
|
||||||
|
/// system has installed. So it is parsed here first, the same way egui does,
|
||||||
|
/// and a font that fails is left out rather than taking the app down.
|
||||||
|
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
|
||||||
|
skrifa::FontRef::from_index(&bytes, index).ok()?;
|
||||||
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
|
data.index = index;
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face within a font collection that fontconfig's `%{index}` names.
|
||||||
|
///
|
||||||
|
/// Fontconfig packs a variable font's named instance into the high 16 bits —
|
||||||
|
/// Fedora's variable Noto Sans CJK answers 262144, instance 4 of face 0. The
|
||||||
|
/// instance is not a face, and passing it to egui as one stopped the app at
|
||||||
|
/// start.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn fontconfig_face(index: u32) -> u32 {
|
||||||
|
index & 0xFFFF
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
let output = std::process::Command::new("fc-match")
|
let output = std::process::Command::new("fc-match")
|
||||||
@@ -269,6 +294,7 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|||||||
let index = lines
|
let index = lines
|
||||||
.next()
|
.next()
|
||||||
.and_then(|value| value.parse().ok())
|
.and_then(|value| value.parse().ok())
|
||||||
|
.map(fontconfig_face)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
path.is_file().then_some((path, index))
|
path.is_file().then_some((path, index))
|
||||||
}
|
}
|
||||||
@@ -855,6 +881,20 @@ const RAMP_STOPS: usize = 64;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
|
||||||
|
assert_eq!(fontconfig_face(262_144), 0);
|
||||||
|
assert_eq!(fontconfig_face(0x0002_0001), 1);
|
||||||
|
assert_eq!(fontconfig_face(2), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fallback_font_egui_cannot_read_is_left_out() {
|
||||||
|
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
|
||||||
|
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
|
||||||
|
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||||
use skrifa::MetadataProvider as _;
|
use skrifa::MetadataProvider as _;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use anyhow::{Context, Result};
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use pstr_core::account::{PlaceKind, SignIn};
|
use pstr_core::account::{PlaceKind, SignIn};
|
||||||
use pstr_core::catalog::{Catalog, build_rows};
|
use pstr_core::catalog::{Catalog, build_rows, build_sidecars};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::shares::AccountFolder;
|
use pstr_core::shares::AccountFolder;
|
||||||
use pstr_core::sync::WatchSync;
|
use pstr_core::sync::WatchSync;
|
||||||
@@ -425,6 +425,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
|
|
||||||
catalog
|
catalog
|
||||||
.replace_share(&share_id, &rows)
|
.replace_share(&share_id, &rows)
|
||||||
|
.and_then(|()| {
|
||||||
|
catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes))
|
||||||
|
})
|
||||||
.with_context(|| format!("store {share_id}"))?;
|
.with_context(|| format!("store {share_id}"))?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use proton_drive_rs::{NodeKind, ProtonDriveClient};
|
use proton_drive_rs::{NodeKind, ProtonDriveClient};
|
||||||
use proton_sdk::api::HumanVerificationCredential;
|
use proton_sdk::account::KeyPassphrases;
|
||||||
|
use proton_sdk::api::{HumanVerificationCredential, ResponseCode};
|
||||||
use proton_sdk::error::ProtonError;
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::NodeUid;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
@@ -51,6 +52,12 @@ const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security
|
|||||||
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
|
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
|
||||||
with its code.";
|
with its code.";
|
||||||
|
|
||||||
|
/// Why a sign-in Proton answered with [`ResponseCode::Blocked`] failed. The
|
||||||
|
/// server's own text says the app version is no longer supported, which is
|
||||||
|
/// not true and sends the viewer looking for an update that does not exist.
|
||||||
|
const SIGN_IN_BLOCKED: &str = "Proton has blocked sign-ins from this app for now. Wait a while \
|
||||||
|
and try again, or sign in once on proton.me first.";
|
||||||
|
|
||||||
/// Blocks in flight for streaming from the account, as for a public link —
|
/// Blocks in flight for streaming from the account, as for a public link —
|
||||||
/// see `ShareStore::open`.
|
/// see `ShareStore::open`.
|
||||||
const MAX_INFLIGHT_BLOCKS: usize = 48;
|
const MAX_INFLIGHT_BLOCKS: usize = 48;
|
||||||
@@ -91,10 +98,10 @@ impl StoredSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unlock(&self) -> HashMap<String, Vec<u8>> {
|
fn unlock(&self) -> KeyPassphrases {
|
||||||
self.key_passphrases
|
self.key_passphrases
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
|
.map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,9 +431,9 @@ impl AccountStore {
|
|||||||
/// back the account.
|
/// back the account.
|
||||||
///
|
///
|
||||||
/// The key passphrases are derived here, while the token still has the scope
|
/// The key passphrases are derived here, while the token still has the scope
|
||||||
/// the key salts need. Loading the addresses is what proves the mailbox
|
/// the key salts need. Unlocking the user keys is what proves the mailbox
|
||||||
/// password: it unlocks the user keys, and a wrong password unlocks none of
|
/// password: a wrong one unlocks none of them. Only the passphrases are
|
||||||
/// them. Only the passphrases are stored; the password goes no further.
|
/// stored; the password goes no further.
|
||||||
async fn finish(
|
async fn finish(
|
||||||
secrets: Arc<dyn SecretStore>,
|
secrets: Arc<dyn SecretStore>,
|
||||||
session: ProtonApiSession,
|
session: ProtonApiSession,
|
||||||
@@ -435,10 +442,10 @@ async fn finish(
|
|||||||
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
||||||
probe
|
probe
|
||||||
.account()
|
.account()
|
||||||
.addresses()
|
.unlock()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| match error {
|
.map_err(|error| match error {
|
||||||
ProtonError::InvalidOperation(_) => {
|
ProtonError::KeysLocked => {
|
||||||
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
||||||
}
|
}
|
||||||
other => other.into(),
|
other => other.into(),
|
||||||
@@ -447,10 +454,10 @@ async fn finish(
|
|||||||
.account()
|
.account()
|
||||||
.key_passphrases()
|
.key_passphrases()
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|(id, passphrase)| {
|
.map(|(id, passphrase)| {
|
||||||
String::from_utf8(passphrase)
|
std::str::from_utf8(passphrase)
|
||||||
.map(|passphrase| (id, passphrase))
|
.map(|passphrase| (id.to_owned(), passphrase.to_owned()))
|
||||||
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
|
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
|
||||||
})
|
})
|
||||||
.collect::<Result<HashMap<_, _>>>()?;
|
.collect::<Result<HashMap<_, _>>>()?;
|
||||||
@@ -500,7 +507,8 @@ fn persist_refreshed_tokens(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
|
/// Lift a CAPTCHA gate, a blocked sign-in and a wrong password out of the
|
||||||
|
/// generic API error.
|
||||||
///
|
///
|
||||||
/// Only a gate that can be solved in an app becomes
|
/// Only a gate that can be solved in an app becomes
|
||||||
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
|
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
|
||||||
@@ -512,6 +520,9 @@ fn classify_sign_in_failure(error: ProtonError) -> Error {
|
|||||||
{
|
{
|
||||||
return Error::HumanVerification(Box::new(challenge));
|
return Error::HumanVerification(Box::new(challenge));
|
||||||
}
|
}
|
||||||
|
if api.code == ResponseCode::Blocked {
|
||||||
|
return Error::SignIn(SIGN_IN_BLOCKED.to_owned());
|
||||||
|
}
|
||||||
if api.http_status == 422 && !api.is_human_verification_required() {
|
if api.http_status == 422 && !api.is_human_verification_required() {
|
||||||
return Error::SignIn(if api.message.is_empty() {
|
return Error::SignIn(if api.message.is_empty() {
|
||||||
"Proton did not accept that address and password".to_owned()
|
"Proton did not accept that address and password".to_owned()
|
||||||
@@ -642,6 +653,27 @@ mod tests {
|
|||||||
assert!(store.resume().expect("resume").is_none());
|
assert!(store.resume().expect("resume").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_session_unlocks_with_its_passphrases() {
|
||||||
|
let unlock = stored().unlock();
|
||||||
|
assert_eq!(unlock.len(), 1);
|
||||||
|
assert_eq!(unlock.get("key-1"), Some(b"derived".as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blocked_sign_in_does_not_claim_the_app_is_outdated() {
|
||||||
|
let error = classify_sign_in_failure(ProtonError::Api(proton_sdk::error::ProtonApiError {
|
||||||
|
code: ResponseCode::Blocked,
|
||||||
|
http_status: 422,
|
||||||
|
message: "This version of the app is no longer supported".to_owned(),
|
||||||
|
details: None,
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
matches!(&error, Error::SignIn(text) if text == SIGN_IN_BLOCKED),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stored_session_keeps_its_password_mode() {
|
fn a_stored_session_keeps_its_password_mode() {
|
||||||
let params = stored().resume_parameters();
|
let params = stored().resume_parameters();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::metadata::{
|
|||||||
use crate::naming::{self, ParsedName};
|
use crate::naming::{self, ParsedName};
|
||||||
|
|
||||||
/// Bump when adding a migration.
|
/// Bump when adding a migration.
|
||||||
pub const SCHEMA_VERSION: i64 = 11;
|
pub const SCHEMA_VERSION: i64 = 12;
|
||||||
|
|
||||||
const MIGRATION_V1: &str = r#"
|
const MIGRATION_V1: &str = r#"
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
@@ -225,6 +225,23 @@ CREATE TABLE share_crawls (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Subtitle files beside a video, which the crawl used to drop with the
|
||||||
|
/// `.nfo` files. Rebuilt by every crawl, like `nodes`; a subtitle belongs to
|
||||||
|
/// one video, the one it was paired with by name.
|
||||||
|
const MIGRATION_V12: &str = r#"
|
||||||
|
CREATE TABLE sidecars (
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
link_id TEXT NOT NULL,
|
||||||
|
volume_id TEXT NOT NULL,
|
||||||
|
video_link_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
language TEXT,
|
||||||
|
title TEXT,
|
||||||
|
PRIMARY KEY (share_id, link_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
|
||||||
|
"#;
|
||||||
|
|
||||||
/// How old a share's last crawl may be before an app looks again on its own.
|
/// How old a share's last crawl may be before an app looks again on its own.
|
||||||
/// Long enough that a launch rarely costs a crawl; short enough that a new
|
/// Long enough that a launch rarely costs a crawl; short enough that a new
|
||||||
/// episode turns up the same day.
|
/// episode turns up the same day.
|
||||||
@@ -329,6 +346,21 @@ pub struct CatalogNode {
|
|||||||
pub parsed: ParsedName,
|
pub parsed: ParsedName,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A subtitle file paired with the video beside it. See [`build_sidecars`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Sidecar {
|
||||||
|
pub share_id: String,
|
||||||
|
pub link_id: String,
|
||||||
|
pub volume_id: String,
|
||||||
|
/// The video this subtitle is for.
|
||||||
|
pub video_link_id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// The [`crate::language::LANGUAGES`] tag its name states, if any.
|
||||||
|
pub language: Option<String>,
|
||||||
|
/// What else its name says about it — "forced", "SDH" — for the picker.
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Where playback left off.
|
/// Where playback left off.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct WatchState {
|
pub struct WatchState {
|
||||||
@@ -459,6 +491,12 @@ impl Catalog {
|
|||||||
tx.pragma_update(None, "user_version", 11)?;
|
tx.pragma_update(None, "user_version", 11)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
if version < 12 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V12)?;
|
||||||
|
tx.pragma_update(None, "user_version", 12)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -547,6 +585,59 @@ impl Catalog {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace one share's subtitle files with `sidecars`.
|
||||||
|
///
|
||||||
|
/// Apart from [`Self::replace_share`] rather than inside it: a subtitle
|
||||||
|
/// left over from the crawl before is one that fails to open, quietly,
|
||||||
|
/// which is no reason to make every caller of that pass these along.
|
||||||
|
pub fn replace_sidecars(&mut self, share_id: &str, sidecars: &[Sidecar]) -> Result<()> {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM sidecars WHERE share_id = ?1",
|
||||||
|
params![share_id],
|
||||||
|
)?;
|
||||||
|
{
|
||||||
|
let mut insert = tx.prepare(
|
||||||
|
"INSERT OR REPLACE INTO sidecars (
|
||||||
|
share_id, link_id, volume_id, video_link_id, name, language, title
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
)?;
|
||||||
|
for sidecar in sidecars {
|
||||||
|
insert.execute(params![
|
||||||
|
share_id,
|
||||||
|
sidecar.link_id,
|
||||||
|
sidecar.volume_id,
|
||||||
|
sidecar.video_link_id,
|
||||||
|
sidecar.name,
|
||||||
|
sidecar.language,
|
||||||
|
sidecar.title,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subtitle files paired with one video, in name order.
|
||||||
|
pub fn sidecars(&self, share_id: &str, video_link_id: &str) -> Result<Vec<Sidecar>> {
|
||||||
|
let mut select = self.conn.prepare_cached(
|
||||||
|
"SELECT share_id, link_id, volume_id, video_link_id, name, language, title
|
||||||
|
FROM sidecars WHERE share_id = ?1 AND video_link_id = ?2 ORDER BY name",
|
||||||
|
)?;
|
||||||
|
let rows = select.query_map(params![share_id, video_link_id], |row| {
|
||||||
|
Ok(Sidecar {
|
||||||
|
share_id: row.get(0)?,
|
||||||
|
link_id: row.get(1)?,
|
||||||
|
volume_id: row.get(2)?,
|
||||||
|
video_link_id: row.get(3)?,
|
||||||
|
name: row.get(4)?,
|
||||||
|
language: row.get(5)?,
|
||||||
|
title: row.get(6)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<rusqlite::Result<_>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record that a crawl of `share_id` failed, keeping what the last
|
/// Record that a crawl of `share_id` failed, keeping what the last
|
||||||
/// finished one stored.
|
/// finished one stored.
|
||||||
pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> {
|
pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> {
|
||||||
@@ -670,6 +761,10 @@ impl Catalog {
|
|||||||
params![share_id],
|
params![share_id],
|
||||||
)?;
|
)?;
|
||||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM sidecars WHERE share_id = ?1",
|
||||||
|
params![share_id],
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||||
params![share_id],
|
params![share_id],
|
||||||
@@ -1509,6 +1604,182 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pair each subtitle file in a crawl with the video it is for.
|
||||||
|
///
|
||||||
|
/// By name, the way every player looks for one, in the three layouts shares
|
||||||
|
/// actually have:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Show/Season 01/S01E01.mkv + S01E01.srt, S01E01.en.srt, S01E01.en.forced.ass
|
||||||
|
/// Show/Season 01/Subs/S01E01.en.srt a subtitles folder beside the videos
|
||||||
|
/// Film (2020)/Subs/Film (2020)/2_English.srt a folder per video inside that
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// A subtitle whose name starts with no video's is still paired when its
|
||||||
|
/// folder holds one video only — `Film/film.mkv` beside `English.srt`. One
|
||||||
|
/// that matches nothing is left out: offering it under the wrong episode is
|
||||||
|
/// worse than not offering it.
|
||||||
|
pub fn build_sidecars(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Sidecar> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let by_link: HashMap<&str, &proton_drive_rs::Node> = nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| (node.uid.link_id.as_str(), node))
|
||||||
|
.collect();
|
||||||
|
let parent_of = |node: &proton_drive_rs::Node| {
|
||||||
|
node.parent_uid
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|uid| by_link.get(uid.link_id.as_str()).copied())
|
||||||
|
};
|
||||||
|
let mut videos: HashMap<&str, Vec<(&proton_drive_rs::Node, &str)>> = HashMap::new();
|
||||||
|
for node in nodes {
|
||||||
|
if !node.is_folder()
|
||||||
|
&& naming::is_video_file(&node.name)
|
||||||
|
&& let Some(parent) = &node.parent_uid
|
||||||
|
{
|
||||||
|
videos
|
||||||
|
.entry(parent.link_id.as_str())
|
||||||
|
.or_default()
|
||||||
|
.push((node, stem(&node.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sidecars: Vec<Sidecar> = nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|node| !node.is_folder() && naming::is_subtitle_file(&node.name))
|
||||||
|
.filter_map(|node| {
|
||||||
|
let own = stem(&node.name);
|
||||||
|
let parent = parent_of(node)?;
|
||||||
|
let (video, rest) = if is_subtitles_folder(&parent.name) {
|
||||||
|
// `Subs/S01E01.en.srt`, for the videos beside `Subs`.
|
||||||
|
beside(
|
||||||
|
videos.get(parent.parent_uid.as_ref()?.link_id.as_str())?,
|
||||||
|
own,
|
||||||
|
)?
|
||||||
|
} else if let Some(holder) = parent_of(parent)
|
||||||
|
&& is_subtitles_folder(&holder.name)
|
||||||
|
{
|
||||||
|
// `Subs/<video>/2_English.srt`: the folder is named for the video.
|
||||||
|
let candidates = videos.get(holder.parent_uid.as_ref()?.link_id.as_str())?;
|
||||||
|
let (video, _) = candidates
|
||||||
|
.iter()
|
||||||
|
.find(|(_, video)| video.eq_ignore_ascii_case(&parent.name))?;
|
||||||
|
(*video, own)
|
||||||
|
} else {
|
||||||
|
beside(videos.get(node.parent_uid.as_ref()?.link_id.as_str())?, own)?
|
||||||
|
};
|
||||||
|
let (language, title) = describe_subtitle(rest);
|
||||||
|
Some(Sidecar {
|
||||||
|
share_id: share_id.to_owned(),
|
||||||
|
link_id: node.uid.link_id.to_string(),
|
||||||
|
volume_id: node.uid.volume_id.to_string(),
|
||||||
|
video_link_id: video.uid.link_id.to_string(),
|
||||||
|
name: node.name.clone(),
|
||||||
|
language,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
sidecars.sort_by(|a, b| (&a.video_link_id, &a.name).cmp(&(&b.video_link_id, &b.name)));
|
||||||
|
sidecars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of a video's subtitle files to turn on when the video itself has
|
||||||
|
/// no subtitle playing: the first in the preferred language, or with no
|
||||||
|
/// preference the first of all. One in no stated language is taken over
|
||||||
|
/// nothing, since it is most often the only one there is.
|
||||||
|
///
|
||||||
|
/// Never one in another language: a viewer who asked for English subtitles
|
||||||
|
/// and gets German ones has not been helped.
|
||||||
|
pub fn preferred_sidecar(sidecars: &[Sidecar], language: Option<&str>) -> Option<usize> {
|
||||||
|
let Some(wanted) = language else {
|
||||||
|
return (!sidecars.is_empty()).then_some(0);
|
||||||
|
};
|
||||||
|
sidecars
|
||||||
|
.iter()
|
||||||
|
.position(|sidecar| {
|
||||||
|
sidecar
|
||||||
|
.language
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|have| crate::language::same_language(wanted, have))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
sidecars
|
||||||
|
.iter()
|
||||||
|
.position(|sidecar| sidecar.language.is_none())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file name without its extension.
|
||||||
|
fn stem(name: &str) -> &str {
|
||||||
|
name.rsplit_once('.').map_or(name, |(stem, _)| stem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_subtitles_folder(name: &str) -> bool {
|
||||||
|
["subs", "sub", "subtitles", "subtitle"]
|
||||||
|
.iter()
|
||||||
|
.any(|known| known.eq_ignore_ascii_case(name.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the videos in one folder a subtitle named `own` is for, and what
|
||||||
|
/// its name says past the video's.
|
||||||
|
///
|
||||||
|
/// The longest video name the subtitle's starts with, followed by a dot or
|
||||||
|
/// nothing: `S01E1.en` is not `S01E10`'s. Failing that, the only video there.
|
||||||
|
fn beside<'a, 'b>(
|
||||||
|
videos: &[(&'a proton_drive_rs::Node, &str)],
|
||||||
|
own: &'b str,
|
||||||
|
) -> Option<(&'a proton_drive_rs::Node, &'b str)> {
|
||||||
|
let lower = own.to_lowercase();
|
||||||
|
let named = videos
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(video, name)| {
|
||||||
|
let name = name.to_lowercase();
|
||||||
|
if lower == name {
|
||||||
|
Some((*video, name.len(), ""))
|
||||||
|
} else if lower.starts_with(&format!("{name}.")) {
|
||||||
|
// Lowercasing keeps the length of what these names hold;
|
||||||
|
// `get` covers the names where it does not.
|
||||||
|
Some((*video, name.len(), own.get(name.len() + 1..)?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.max_by_key(|(_, length, _)| *length)
|
||||||
|
.map(|(video, _, rest)| (video, rest));
|
||||||
|
named.or(match videos {
|
||||||
|
[(video, _)] => Some((*video, own)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language and the rest of what a subtitle's name says past its video's:
|
||||||
|
/// `en.forced` is English, "forced"; `2_English_SDH` is English, "SDH".
|
||||||
|
///
|
||||||
|
/// The last word naming a language is the language — in `Film.2020.English`
|
||||||
|
/// the words before it are the film's. Numbers are an index, not a title.
|
||||||
|
fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
|
||||||
|
let words: Vec<&str> = rest
|
||||||
|
.split(['.', '_', ' ', '-', '(', ')', '[', ']'])
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.collect();
|
||||||
|
let found = words
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.rev()
|
||||||
|
.find_map(|(index, word)| crate::language::language_tag(word).map(|tag| (index, tag)));
|
||||||
|
let title_words: Vec<&str> = words
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(index, word)| {
|
||||||
|
Some(*index) != found.map(|(at, _)| at) && !word.chars().all(|c| c.is_ascii_digit())
|
||||||
|
})
|
||||||
|
.map(|(_, word)| *word)
|
||||||
|
.collect();
|
||||||
|
let title = (!title_words.is_empty()).then(|| title_words.join(" "));
|
||||||
|
(found.map(|(_, tag)| tag.to_owned()), title)
|
||||||
|
}
|
||||||
|
|
||||||
/// What a file's ancestors say about it.
|
/// What a file's ancestors say about it.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct Ancestry {
|
struct Ancestry {
|
||||||
@@ -2321,6 +2592,30 @@ mod tests {
|
|||||||
assert!(catalog.crawls().expect("read").is_empty());
|
assert!(catalog.crawls().expect("read").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v12_opens_a_v11_catalog() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open");
|
||||||
|
for migration in [
|
||||||
|
MIGRATION_V1,
|
||||||
|
MIGRATION_V2,
|
||||||
|
MIGRATION_V3,
|
||||||
|
MIGRATION_V4,
|
||||||
|
MIGRATION_V5,
|
||||||
|
MIGRATION_V6,
|
||||||
|
MIGRATION_V7,
|
||||||
|
MIGRATION_V8,
|
||||||
|
MIGRATION_V9,
|
||||||
|
MIGRATION_V10,
|
||||||
|
MIGRATION_V11,
|
||||||
|
] {
|
||||||
|
conn.execute_batch(migration).expect("migrate");
|
||||||
|
}
|
||||||
|
conn.pragma_update(None, "user_version", 11)
|
||||||
|
.expect("set version");
|
||||||
|
let catalog = Catalog::from_connection(conn).expect("upgrade");
|
||||||
|
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// A file keeps the moment it first appeared across recrawls, and one
|
/// A file keeps the moment it first appeared across recrawls, and one
|
||||||
/// that was there before this was recorded reads as 0.
|
/// that was there before this was recorded reads as 0.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3082,4 +3377,128 @@ mod tests {
|
|||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- build_sidecars: subtitles paired by name --------------------------
|
||||||
|
|
||||||
|
fn paired(sidecars: &[Sidecar]) -> Vec<(&str, &str, Option<&str>, Option<&str>)> {
|
||||||
|
sidecars
|
||||||
|
.iter()
|
||||||
|
.map(|sidecar| {
|
||||||
|
(
|
||||||
|
sidecar.name.as_str(),
|
||||||
|
sidecar.video_link_id.as_str(),
|
||||||
|
sidecar.language.as_deref(),
|
||||||
|
sidecar.title.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_named_after_a_video_is_paired_with_it_and_says_its_language() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("s1", Some("root"), "Season 01"),
|
||||||
|
video("ep1", "s1", "S01E1.mkv"),
|
||||||
|
video("ep10", "s1", "S01E10.mkv"),
|
||||||
|
video("plain", "s1", "S01E1.srt"),
|
||||||
|
video("en", "s1", "S01E10.en.srt"),
|
||||||
|
video("forced", "s1", "S01E10.eng.forced.ass"),
|
||||||
|
video("nfo", "s1", "S01E10.nfo"),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
paired(&build_sidecars("share", &nodes)),
|
||||||
|
vec![
|
||||||
|
("S01E1.srt", "ep1", None, None),
|
||||||
|
("S01E10.en.srt", "ep10", Some("eng"), None),
|
||||||
|
("S01E10.eng.forced.ass", "ep10", Some("eng"), Some("forced")),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitles_folder_beside_the_videos_holds_theirs() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("film", Some("root"), "Film (2020)"),
|
||||||
|
video("video", "film", "Film (2020).mkv"),
|
||||||
|
folder("subs", Some("film"), "Subs"),
|
||||||
|
video("de", "subs", "Film (2020).German.srt"),
|
||||||
|
folder("per-video", Some("subs"), "Film (2020)"),
|
||||||
|
video("sdh", "per-video", "3_English_SDH.srt"),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
paired(&build_sidecars("share", &nodes)),
|
||||||
|
vec![
|
||||||
|
("3_English_SDH.srt", "video", Some("eng"), Some("SDH")),
|
||||||
|
("Film (2020).German.srt", "video", Some("ger"), None),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One video in the folder can only be the one; two leave a subtitle
|
||||||
|
/// named after neither unpaired rather than guessed.
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_named_after_no_video_is_paired_only_when_its_folder_has_one() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("film", Some("root"), "Film"),
|
||||||
|
video("video", "film", "film.1080p.mkv"),
|
||||||
|
video("english", "film", "English.srt"),
|
||||||
|
folder("s1", Some("root"), "Season 01"),
|
||||||
|
video("ep1", "s1", "S01E01.mkv"),
|
||||||
|
video("ep2", "s1", "S01E02.mkv"),
|
||||||
|
video("stray", "s1", "English.srt"),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
paired(&build_sidecars("share", &nodes)),
|
||||||
|
vec![("English.srt", "video", Some("eng"), None)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_recrawl_replaces_a_shares_subtitles_and_removing_the_share_drops_them() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let sidecar = |link: &str| Sidecar {
|
||||||
|
share_id: "s".into(),
|
||||||
|
link_id: link.into(),
|
||||||
|
volume_id: "vol".into(),
|
||||||
|
video_link_id: "a".into(),
|
||||||
|
name: format!("{link}.srt"),
|
||||||
|
language: Some("eng".into()),
|
||||||
|
title: None,
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.replace_sidecars("s", &[sidecar("old")])
|
||||||
|
.expect("crawl");
|
||||||
|
catalog
|
||||||
|
.replace_sidecars("s", &[sidecar("new")])
|
||||||
|
.expect("recrawl");
|
||||||
|
assert_eq!(
|
||||||
|
catalog.sidecars("s", "a").expect("read"),
|
||||||
|
vec![sidecar("new")]
|
||||||
|
);
|
||||||
|
catalog.remove_share("s").expect("remove");
|
||||||
|
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_subtitle_turned_on_is_one_in_the_preferred_language_and_never_another() {
|
||||||
|
let sidecar = |language: Option<&str>| Sidecar {
|
||||||
|
share_id: "s".into(),
|
||||||
|
link_id: "l".into(),
|
||||||
|
volume_id: "v".into(),
|
||||||
|
video_link_id: "a".into(),
|
||||||
|
name: "a.srt".into(),
|
||||||
|
language: language.map(str::to_owned),
|
||||||
|
title: None,
|
||||||
|
};
|
||||||
|
let ger_eng = [sidecar(Some("ger")), sidecar(Some("eng"))];
|
||||||
|
assert_eq!(preferred_sidecar(&ger_eng, Some("en")), Some(1));
|
||||||
|
assert_eq!(preferred_sidecar(&ger_eng, None), Some(0));
|
||||||
|
assert_eq!(preferred_sidecar(&ger_eng, Some("jpn")), None);
|
||||||
|
let ger_unknown = [sidecar(Some("ger")), sidecar(None)];
|
||||||
|
assert_eq!(preferred_sidecar(&ger_unknown, Some("eng")), Some(1));
|
||||||
|
assert_eq!(preferred_sidecar(&[], None), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -54,12 +54,45 @@ pub enum Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
|
/// The message, followed by every cause its own text leaves out.
|
||||||
|
///
|
||||||
|
/// A dropped connection reads as reqwest's "error sending request for url
|
||||||
|
/// (…)" and nothing else: why — a reset, a DNS failure, a certificate — is
|
||||||
|
/// further down the source chain. That is the part a viewer reporting a
|
||||||
|
/// failed crawl needs, so the URL, which only says which folder, is left
|
||||||
|
/// out to keep it on screen.
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
let mut text = match self {
|
||||||
|
Self::Drive(proton_sdk::error::ProtonError::Transport(error)) => {
|
||||||
|
let url = error.url().map(|url| format!(" for url ({url})"));
|
||||||
|
let own = self.to_string();
|
||||||
|
url.map_or_else(|| own.clone(), |url| own.replace(&url, ""))
|
||||||
|
}
|
||||||
|
_ => self.to_string(),
|
||||||
|
};
|
||||||
|
let mut seen = self.to_string();
|
||||||
|
let mut source = std::error::Error::source(self);
|
||||||
|
while let Some(cause) = source {
|
||||||
|
let line = cause.to_string();
|
||||||
|
if !seen.contains(&line) {
|
||||||
|
text.push_str(": ");
|
||||||
|
text.push_str(&line);
|
||||||
|
seen.push_str(&line);
|
||||||
|
}
|
||||||
|
source = cause.source();
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this is the account's session having ended: the refresh token
|
/// Whether this is the account's session having ended: the refresh token
|
||||||
/// was revoked or expired, or the token lacks a scope only a password
|
/// was revoked or expired, the token lacks a scope only a password
|
||||||
/// sign-in grants. Nothing but signing in again fixes it.
|
/// sign-in grants, or the stored key passphrases no longer unlock the
|
||||||
|
/// account because its mailbox password changed. Nothing but signing in
|
||||||
|
/// again fixes it.
|
||||||
pub fn is_signed_out(&self) -> bool {
|
pub fn is_signed_out(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::SignedOut(_) => true,
|
Self::SignedOut(_) => true,
|
||||||
|
Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true,
|
||||||
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
||||||
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
||||||
}
|
}
|
||||||
@@ -76,3 +109,31 @@ impl From<keyring::Error> for Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("connection closed before message completed")]
|
||||||
|
struct Closed;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("client error (SendRequest)")]
|
||||||
|
struct SendRequest(#[source] Closed);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_is_described_with_the_causes_its_own_text_leaves_out() {
|
||||||
|
let error = Error::Io(std::io::Error::other(SendRequest(Closed)));
|
||||||
|
assert_eq!(
|
||||||
|
error.describe(),
|
||||||
|
"io: client error (SendRequest): connection closed before message completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_passphrases_that_no_longer_unlock_mean_signed_out() {
|
||||||
|
let error = Error::Drive(proton_sdk::error::ProtonError::KeysLocked);
|
||||||
|
assert!(error.is_signed_out());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Language tags: what a container or a file name calls a language, and what a
|
||||||
|
//! viewer calls it.
|
||||||
|
|
||||||
|
/// The languages offered by name where a preference is chosen ahead of any
|
||||||
|
/// file, as the three-letter tag a Matroska file carries. The same list
|
||||||
|
/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two-
|
||||||
|
/// and three-letter forms as one.
|
||||||
|
pub const LANGUAGES: [&str; 29] = [
|
||||||
|
"jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor",
|
||||||
|
"fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum",
|
||||||
|
"gre", "ind", "may",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Each language's name and the tags it goes by, the [`LANGUAGES`] form first.
|
||||||
|
///
|
||||||
|
/// Deliberately a short list rather than a full ISO 639 table: it covers what
|
||||||
|
/// the shares this app is pointed at actually contain, and an unrecognised tag
|
||||||
|
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
|
||||||
|
/// on its own.
|
||||||
|
const TABLE: [(&str, &[&str]); 29] = [
|
||||||
|
("English", &["eng", "en"]),
|
||||||
|
("Japanese", &["jpn", "ja"]),
|
||||||
|
("German", &["ger", "de", "deu"]),
|
||||||
|
("French", &["fre", "fr", "fra"]),
|
||||||
|
("Spanish", &["spa", "es"]),
|
||||||
|
("Italian", &["ita", "it"]),
|
||||||
|
("Portuguese", &["por", "pt"]),
|
||||||
|
("Dutch", &["dut", "nl", "nld"]),
|
||||||
|
("Russian", &["rus", "ru"]),
|
||||||
|
("Polish", &["pol", "pl"]),
|
||||||
|
("Swedish", &["swe", "sv"]),
|
||||||
|
("Danish", &["dan", "da"]),
|
||||||
|
("Norwegian", &["nor", "no"]),
|
||||||
|
("Finnish", &["fin", "fi"]),
|
||||||
|
("Czech", &["cze", "cs", "ces"]),
|
||||||
|
("Hungarian", &["hun", "hu"]),
|
||||||
|
("Turkish", &["tur", "tr"]),
|
||||||
|
("Arabic", &["ara", "ar"]),
|
||||||
|
("Hebrew", &["heb", "he"]),
|
||||||
|
("Hindi", &["hin", "hi"]),
|
||||||
|
("Korean", &["kor", "ko"]),
|
||||||
|
("Chinese", &["chi", "zh", "zho"]),
|
||||||
|
("Thai", &["tha", "th"]),
|
||||||
|
("Vietnamese", &["vie", "vi"]),
|
||||||
|
("Ukrainian", &["ukr", "uk"]),
|
||||||
|
("Romanian", &["rum", "ro", "ron"]),
|
||||||
|
("Greek", &["gre", "el", "ell"]),
|
||||||
|
("Indonesian", &["ind", "id"]),
|
||||||
|
("Malay", &["may", "ms", "msa"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A readable name for a language tag, falling back to the tag itself.
|
||||||
|
pub fn language_name(tag: &str) -> String {
|
||||||
|
// Container tags come in both the two- and three-letter forms, and often
|
||||||
|
// with a region suffix: "en", "eng", "en-US", "pt-BR".
|
||||||
|
let base = tag.split(['-', '_']).next().unwrap_or(tag);
|
||||||
|
let Some((name, _)) = TABLE
|
||||||
|
.iter()
|
||||||
|
.find(|(_, tags)| tags.iter().any(|known| known.eq_ignore_ascii_case(base)))
|
||||||
|
else {
|
||||||
|
return tag.to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The region matters when there is one — Brazilian Portuguese is not
|
||||||
|
// Portuguese to anyone choosing between the two.
|
||||||
|
match tag.split_once(['-', '_']) {
|
||||||
|
Some((_, region)) if !region.is_empty() => {
|
||||||
|
format!("{name} ({})", region.to_ascii_uppercase())
|
||||||
|
}
|
||||||
|
_ => (*name).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two container tags name the same language: "en" and "eng" do.
|
||||||
|
pub fn same_language(a: &str, b: &str) -> bool {
|
||||||
|
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`LANGUAGES`] tag for one word of a file name, if it names a language:
|
||||||
|
/// "en", "eng" and "English" are all `"eng"`.
|
||||||
|
///
|
||||||
|
/// For the words around a subtitle file's name — `Episode 01.en.srt`,
|
||||||
|
/// `2_English.srt` — where a two-letter word is far more often a language than
|
||||||
|
/// anything else.
|
||||||
|
pub fn language_tag(word: &str) -> Option<&'static str> {
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.find(|(name, tags)| {
|
||||||
|
name.eq_ignore_ascii_case(word) || tags.iter().any(|tag| tag.eq_ignore_ascii_case(word))
|
||||||
|
})
|
||||||
|
.map(|(_, tags)| tags[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
|
||||||
|
assert_eq!(language_name("jpn"), "Japanese");
|
||||||
|
assert_eq!(language_name("ja"), "Japanese");
|
||||||
|
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
|
||||||
|
// An unknown tag is shown as it stands rather than as "Unknown", which
|
||||||
|
// would make two of them indistinguishable.
|
||||||
|
assert_eq!(language_name("mis"), "mis");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_language_named_by_either_tag_or_by_its_name_has_the_tag_a_preference_uses() {
|
||||||
|
assert_eq!(language_tag("en"), Some("eng"));
|
||||||
|
assert_eq!(language_tag("ENG"), Some("eng"));
|
||||||
|
assert_eq!(language_tag("German"), Some("ger"));
|
||||||
|
assert_eq!(language_tag("deu"), Some("ger"));
|
||||||
|
assert_eq!(language_tag("forced"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_language_offered_by_name_is_one_the_table_knows() {
|
||||||
|
let firsts: Vec<&str> = TABLE.iter().map(|(_, tags)| tags[0]).collect();
|
||||||
|
let mut offered = LANGUAGES.to_vec();
|
||||||
|
let mut known = firsts.clone();
|
||||||
|
offered.sort_unstable();
|
||||||
|
known.sort_unstable();
|
||||||
|
assert_eq!(offered, known);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod chapters;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod franchise;
|
pub mod franchise;
|
||||||
|
pub mod language;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod naming;
|
pub mod naming;
|
||||||
@@ -20,6 +21,7 @@ pub mod prefs;
|
|||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod shelves;
|
pub mod shelves;
|
||||||
|
pub mod subtitles;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use account::{Account, AccountStore};
|
pub use account::{Account, AccountStore};
|
||||||
|
|||||||
@@ -813,6 +813,15 @@ pub fn is_video_file(name: &str) -> bool {
|
|||||||
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subtitle formats mpv reads from a file of their own.
|
||||||
|
const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "ssa", "vtt"];
|
||||||
|
|
||||||
|
/// Whether a filename names a subtitle file a video can be paired with.
|
||||||
|
pub fn is_subtitle_file(name: &str) -> bool {
|
||||||
|
name.rsplit_once('.')
|
||||||
|
.is_some_and(|(_, ext)| SUBTITLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -27,11 +27,13 @@
|
|||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
|
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
|
||||||
};
|
};
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -973,7 +975,10 @@ impl SharedLibrary {
|
|||||||
.client(share_id)
|
.client(share_id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||||
|
|
||||||
let root = client.root_node().await?;
|
let root = retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
|
||||||
|
client.root_node()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
let mut level = vec![root.uid.clone()];
|
let mut level = vec![root.uid.clone()];
|
||||||
let mut found = vec![root];
|
let mut found = vec![root];
|
||||||
|
|
||||||
@@ -981,14 +986,10 @@ impl SharedLibrary {
|
|||||||
let listings = level.into_iter().map(|folder| {
|
let listings = level.into_iter().map(|folder| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
async move {
|
async move {
|
||||||
let child_uids: Vec<NodeUid> =
|
retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
|
||||||
client.enumerate_folder_children_node_uids(&folder).await?;
|
list_folder(&client, &folder)
|
||||||
if child_uids.is_empty() {
|
})
|
||||||
return Ok(Vec::new());
|
.await
|
||||||
}
|
|
||||||
// Chunked and fanned out inside the SDK too, so this is a
|
|
||||||
// multiplier on that concurrency rather than the only one.
|
|
||||||
client.enumerate_nodes(&child_uids).await
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1010,6 +1011,59 @@ impl SharedLibrary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One folder's children, with their details.
|
||||||
|
async fn list_folder(
|
||||||
|
client: &ShareClient,
|
||||||
|
folder: &NodeUid,
|
||||||
|
) -> proton_sdk::error::Result<Vec<Node>> {
|
||||||
|
let child_uids: Vec<NodeUid> = client.enumerate_folder_children_node_uids(folder).await?;
|
||||||
|
if child_uids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
// Chunked and fanned out inside the SDK too, so this is a multiplier on
|
||||||
|
// that concurrency rather than the only one.
|
||||||
|
client.enumerate_nodes(&child_uids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `operation`, and again after each delay while it fails in a way
|
||||||
|
/// `retriable` says another attempt could fix.
|
||||||
|
///
|
||||||
|
/// A crawl is hundreds of listings, and one that fails ends the crawl. The SDK
|
||||||
|
/// replays a timeout or a refused connection itself, but not a connection that
|
||||||
|
/// dropped mid-request — the request may have been applied, which matters for
|
||||||
|
/// a write and not at all for a listing. A phone sees exactly that: a pooled
|
||||||
|
/// connection the OS tore down while the app was off screen, or a hand-over
|
||||||
|
/// from Wi-Fi to mobile data.
|
||||||
|
async fn retrying<T, E, O, OF>(
|
||||||
|
delays: &[Duration],
|
||||||
|
retriable: impl Fn(&E) -> bool,
|
||||||
|
operation: O,
|
||||||
|
) -> std::result::Result<T, E>
|
||||||
|
where
|
||||||
|
O: Fn() -> OF,
|
||||||
|
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||||
|
{
|
||||||
|
let mut delays = delays.iter();
|
||||||
|
loop {
|
||||||
|
match operation().await {
|
||||||
|
Err(error) if retriable(&error) => match delays.next() {
|
||||||
|
Some(delay) => tokio::time::sleep(*delay).await,
|
||||||
|
None => return Err(error),
|
||||||
|
},
|
||||||
|
done => return done,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a crawl waits before listing a folder again. About a quarter of a
|
||||||
|
/// minute in all: long enough for a network to come back, short enough that a
|
||||||
|
/// share that is really gone still says so.
|
||||||
|
const CRAWL_RETRY_DELAYS: [Duration; 3] = [
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(3),
|
||||||
|
Duration::from_secs(9),
|
||||||
|
];
|
||||||
|
|
||||||
/// How many folders of one level to list at once.
|
/// How many folders of one level to list at once.
|
||||||
///
|
///
|
||||||
/// Each of these fans out again inside the SDK — up to four detail batches per
|
/// Each of these fans out again inside the SDK — up to four detail batches per
|
||||||
@@ -1087,6 +1141,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listing_that_drops_is_tried_again_until_it_comes_back() {
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(2, &calls)).await;
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listing_that_keeps_failing_reports_its_failure_once_the_delays_run_out() {
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(9, &calls)).await;
|
||||||
|
assert_eq!(result, Err(()));
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failure_another_attempt_cannot_fix_is_reported_at_once() {
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
let result = retrying(&[Duration::ZERO; 3], |_| false, flaky(1, &calls)).await;
|
||||||
|
assert_eq!(result, Err(()));
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_share_never_refreshed_is_refreshed_before_the_operation() {
|
async fn a_share_never_refreshed_is_refreshed_before_the_operation() {
|
||||||
let ages = SessionAges::default();
|
let ages = SessionAges::default();
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! Which of a file's subtitle tracks is the one to read.
|
||||||
|
//!
|
||||||
|
//! Anime releases often carry two subtitle tracks in one language: the
|
||||||
|
//! dialogue, and a "Signs & Songs" track that only translates the on-screen
|
||||||
|
//! text and the lyrics. The second is the one marked default or forced as
|
||||||
|
//! often as not, and mpv, picking by language, plays it — an episode where
|
||||||
|
//! nobody seems to speak. A language cannot tell the two apart; the track's
|
||||||
|
//! name and its forced flag can.
|
||||||
|
//!
|
||||||
|
//! Nothing here talks to a player. Every client reads the track list out of
|
||||||
|
//! mpv once a file is loaded and asks [`full_instead`] whether to switch.
|
||||||
|
|
||||||
|
use crate::language::same_language;
|
||||||
|
|
||||||
|
/// One subtitle track of the open file, as mpv lists it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct SubtitleTrack {
|
||||||
|
/// mpv's id for it, what `sid` is set to.
|
||||||
|
pub id: i64,
|
||||||
|
pub language: Option<String>,
|
||||||
|
/// The name the muxer gave it: "Dialogue", "Signs & Songs".
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// The container marks it as the default choice.
|
||||||
|
pub default: bool,
|
||||||
|
/// The container marks it as forced: on-screen text only, by convention.
|
||||||
|
pub forced: bool,
|
||||||
|
/// It is the one playing.
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of what is said a track carries, as far as its name and flags say.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum Coverage {
|
||||||
|
/// Signs, songs, or forced: not the dialogue.
|
||||||
|
Partial,
|
||||||
|
/// Nothing says either way.
|
||||||
|
Unknown,
|
||||||
|
/// Named as the dialogue: "Dialogue", "Full Subtitles", "SDH".
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words in a track's name that make it the whole dialogue. They win over the
|
||||||
|
/// partial ones: "Full (with Signs)" is still the full track.
|
||||||
|
const FULL_WORDS: [&str; 6] = ["full", "dialogue", "dialog", "dialogues", "sdh", "cc"];
|
||||||
|
|
||||||
|
/// Words in a track's name that make it something other than the dialogue.
|
||||||
|
const PARTIAL_WORDS: [&str; 13] = [
|
||||||
|
"sign", "signs", "song", "songs", "s&s", "forced", "karaoke", "lyrics", "op", "ed", "oped",
|
||||||
|
"opening", "ending",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl SubtitleTrack {
|
||||||
|
pub fn coverage(&self) -> Coverage {
|
||||||
|
let title = self.title.as_deref().unwrap_or_default().to_lowercase();
|
||||||
|
let words: Vec<&str> = title
|
||||||
|
.split(|c: char| !c.is_alphanumeric() && c != '&')
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.collect();
|
||||||
|
let has = |list: &[&str]| words.iter().any(|word| list.contains(word));
|
||||||
|
if has(&FULL_WORDS) {
|
||||||
|
Coverage::Full
|
||||||
|
} else if self.forced || has(&PARTIAL_WORDS) {
|
||||||
|
Coverage::Partial
|
||||||
|
} else {
|
||||||
|
Coverage::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The track to play in place of the one mpv chose, if it chose the wrong one.
|
||||||
|
///
|
||||||
|
/// Only a track in the same language as the playing one is considered — the
|
||||||
|
/// language is the viewer's choice, and this only corrects which of its tracks
|
||||||
|
/// was taken. The playing track is replaced when it is partial and the file has
|
||||||
|
/// one in its language that is not, or when nothing names it and another is
|
||||||
|
/// named as the dialogue. Among the rest, a track named as the dialogue comes
|
||||||
|
/// first, then the container's default, then the file's order. `None` leaves
|
||||||
|
/// the selection as it is, which includes subtitles being off.
|
||||||
|
pub fn full_instead(tracks: &[SubtitleTrack]) -> Option<i64> {
|
||||||
|
let playing = tracks.iter().find(|track| track.selected)?;
|
||||||
|
let current = playing.coverage();
|
||||||
|
if current == Coverage::Full {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let same = |track: &SubtitleTrack| match (&playing.language, &track.language) {
|
||||||
|
(Some(a), Some(b)) => same_language(a, b),
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|track| track.id != playing.id && same(track))
|
||||||
|
.filter(|track| track.coverage() > current)
|
||||||
|
// `max_by_key` keeps the last of equals; reversed, the first.
|
||||||
|
.rev()
|
||||||
|
.max_by_key(|track| (track.coverage(), track.default))
|
||||||
|
.map(|track| track.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn track(id: i64, language: &str, title: &str) -> SubtitleTrack {
|
||||||
|
SubtitleTrack {
|
||||||
|
id,
|
||||||
|
language: Some(language.into()),
|
||||||
|
title: (!title.is_empty()).then(|| title.into()),
|
||||||
|
..SubtitleTrack::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playing(mut track: SubtitleTrack) -> SubtitleTrack {
|
||||||
|
track.selected = true;
|
||||||
|
track
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signs_and_songs_playing_gives_way_to_the_dialogue_in_its_language() {
|
||||||
|
let tracks = [
|
||||||
|
playing(track(1, "eng", "Signs & Songs")),
|
||||||
|
track(2, "eng", "Dialogue"),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unnamed_track_gives_way_to_one_named_as_the_dialogue() {
|
||||||
|
let tracks = [playing(track(1, "en", "")), track(2, "en", "Dialogue")];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_forced_track_gives_way_to_an_unnamed_one_and_the_default_comes_first() {
|
||||||
|
let mut forced = playing(track(1, "eng", "English"));
|
||||||
|
forced.forced = true;
|
||||||
|
let mut default = track(3, "eng", "");
|
||||||
|
default.default = true;
|
||||||
|
let tracks = [forced, track(2, "eng", ""), default];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equal_candidates_resolve_to_the_first_in_the_file() {
|
||||||
|
let tracks = [
|
||||||
|
playing(track(1, "eng", "Songs")),
|
||||||
|
track(2, "eng", ""),
|
||||||
|
track(3, "eng", ""),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_track_in_another_language_is_never_switched_to() {
|
||||||
|
let tracks = [
|
||||||
|
playing(track(1, "eng", "Signs")),
|
||||||
|
track(2, "ger", "Dialogue"),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_letter_and_three_letter_tags_are_one_language() {
|
||||||
|
let tracks = [playing(track(1, "en", "OP/ED")), track(2, "eng", "")];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_dialogue_playing_stays_and_subtitles_off_stay_off() {
|
||||||
|
let tracks = [
|
||||||
|
track(1, "eng", "Signs & Songs"),
|
||||||
|
playing(track(2, "eng", "Dialogue")),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), None);
|
||||||
|
let off = [
|
||||||
|
track(1, "eng", "Signs & Songs"),
|
||||||
|
track(2, "eng", "Dialogue"),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&off), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_word_outweighs_a_partial_one_in_the_same_name() {
|
||||||
|
assert_eq!(
|
||||||
|
track(1, "eng", "Full (with Signs)").coverage(),
|
||||||
|
Coverage::Full
|
||||||
|
);
|
||||||
|
assert_eq!(track(1, "eng", "Signs/Songs").coverage(), Coverage::Partial);
|
||||||
|
assert_eq!(track(1, "eng", "English").coverage(), Coverage::Unknown);
|
||||||
|
// A word inside another is not that word.
|
||||||
|
assert_eq!(
|
||||||
|
track(1, "eng", "Designer cut").coverage(),
|
||||||
|
Coverage::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_partial_track_with_only_partial_company_stays() {
|
||||||
|
let tracks = [playing(track(1, "eng", "Signs")), track(2, "eng", "Songs")];
|
||||||
|
assert_eq!(full_instead(&tracks), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ use libloading::Library;
|
|||||||
///
|
///
|
||||||
/// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin
|
/// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin
|
||||||
/// will have picked — though with `libglvnd` in the way, either order works.
|
/// will have picked — though with `libglvnd` in the way, either order works.
|
||||||
#[cfg(unix)]
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
fn candidates() -> Vec<&'static str> {
|
fn candidates() -> Vec<&'static str> {
|
||||||
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
|
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||||
if wayland {
|
if wayland {
|
||||||
@@ -52,6 +52,15 @@ fn candidates() -> Vec<&'static str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// eframe's context on macOS is CGL, and the framework exports every GL
|
||||||
|
/// function it implements as a plain symbol — there is no `GetProcAddress` to
|
||||||
|
/// go through, so the direct lookup in [`GlLoader::resolve`] answers for all of
|
||||||
|
/// them.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn candidates() -> Vec<&'static str> {
|
||||||
|
vec!["/System/Library/Frameworks/OpenGL.framework/OpenGL"]
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn candidates() -> Vec<&'static str> {
|
fn candidates() -> Vec<&'static str> {
|
||||||
vec!["opengl32.dll"]
|
vec!["opengl32.dll"]
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ pub use player::{
|
|||||||
};
|
};
|
||||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||||
pub use render::VideoRenderer;
|
pub use render::VideoRenderer;
|
||||||
pub use tracks::{LANGUAGES, Track, TrackKind, language_name, pick_track};
|
pub use tracks::{LANGUAGES, Track, TrackKind, full_subtitles_instead, language_name, pick_track};
|
||||||
@@ -342,6 +342,28 @@ impl Player {
|
|||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a subtitle file to the one playing, read through the same
|
||||||
|
/// registry as the video, so mpv never learns where either came from.
|
||||||
|
///
|
||||||
|
/// Only once the video has loaded: mpv attaches an external track to the
|
||||||
|
/// file open now. `select` turns it on; otherwise it waits in the list.
|
||||||
|
/// The handle keeps it readable, like [`Self::play`]'s.
|
||||||
|
pub fn add_subtitle(
|
||||||
|
&self,
|
||||||
|
stream: VideoStream,
|
||||||
|
title: &str,
|
||||||
|
language: Option<&str>,
|
||||||
|
select: bool,
|
||||||
|
) -> Result<StreamHandle> {
|
||||||
|
let handle = self.registry.publish(stream);
|
||||||
|
let url = handle.url();
|
||||||
|
let flag = if select { "select" } else { "auto" };
|
||||||
|
let mut args = vec![url.as_str(), flag, title];
|
||||||
|
args.extend(language);
|
||||||
|
self.mpv.command("sub-add", &args)?;
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
/// Start playing a stream `seconds` in.
|
/// Start playing a stream `seconds` in.
|
||||||
///
|
///
|
||||||
/// Through mpv's `start` option rather than a seek once the file has
|
/// Through mpv's `start` option rather than a seek once the file has
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! an empty string.
|
//! an empty string.
|
||||||
|
|
||||||
use libmpv2::Mpv;
|
use libmpv2::Mpv;
|
||||||
|
use pstr_core::subtitles::{SubtitleTrack, full_instead};
|
||||||
|
|
||||||
/// What a track carries.
|
/// What a track carries.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -69,6 +70,9 @@ pub struct Track {
|
|||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
/// Whether the container marks it as the default choice.
|
/// Whether the container marks it as the default choice.
|
||||||
pub default: bool,
|
pub default: bool,
|
||||||
|
/// Whether the container marks it as forced: a subtitle track of the
|
||||||
|
/// on-screen text alone.
|
||||||
|
pub forced: bool,
|
||||||
/// True for a track added from outside the file — a sidecar subtitle. None
|
/// True for a track added from outside the file — a sidecar subtitle. None
|
||||||
/// of ours are, yet.
|
/// of ours are, yet.
|
||||||
pub external: bool,
|
pub external: bool,
|
||||||
@@ -152,9 +156,22 @@ pub fn pick_track(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether two container tags name the same language: "en" and "eng" do.
|
/// The dialogue track to play in place of the subtitle track mpv chose, when
|
||||||
fn same_language(a: &str, b: &str) -> bool {
|
/// mpv chose signs and songs; see [`pstr_core::subtitles::full_instead`].
|
||||||
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
pub fn full_subtitles_instead(tracks: &[Track]) -> Option<i64> {
|
||||||
|
let subtitles: Vec<_> = tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|track| track.kind == TrackKind::Subtitle)
|
||||||
|
.map(|track| SubtitleTrack {
|
||||||
|
id: track.id,
|
||||||
|
language: track.language.clone(),
|
||||||
|
title: track.title.clone(),
|
||||||
|
default: track.default,
|
||||||
|
forced: track.forced,
|
||||||
|
selected: track.selected,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
full_instead(&subtitles)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
||||||
@@ -172,6 +189,7 @@ fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
|||||||
codec: text(mpv, index, "codec"),
|
codec: text(mpv, index, "codec"),
|
||||||
selected: flag(mpv, index, "selected"),
|
selected: flag(mpv, index, "selected"),
|
||||||
default: flag(mpv, index, "default"),
|
default: flag(mpv, index, "default"),
|
||||||
|
forced: flag(mpv, index, "forced"),
|
||||||
external: flag(mpv, index, "external"),
|
external: flag(mpv, index, "external"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -190,73 +208,7 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The languages offered by name where a preference is chosen ahead of any
|
pub use pstr_core::language::{LANGUAGES, language_name, same_language};
|
||||||
/// file, as the three-letter tag a Matroska file carries. The same list
|
|
||||||
/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two-
|
|
||||||
/// and three-letter forms as one.
|
|
||||||
pub const LANGUAGES: [&str; 29] = [
|
|
||||||
"jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor",
|
|
||||||
"fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum",
|
|
||||||
"gre", "ind", "may",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// A readable name for a language tag, falling back to the tag itself.
|
|
||||||
///
|
|
||||||
/// Deliberately a short list rather than a full ISO 639 table: it covers what
|
|
||||||
/// the shares this app is pointed at actually contain, and an unrecognised tag
|
|
||||||
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
|
|
||||||
/// on its own.
|
|
||||||
pub fn language_name(tag: &str) -> String {
|
|
||||||
// Container tags come in both the two- and three-letter forms, and often
|
|
||||||
// with a region suffix: "en", "eng", "en-US", "pt-BR".
|
|
||||||
let base = tag
|
|
||||||
.split(['-', '_'])
|
|
||||||
.next()
|
|
||||||
.unwrap_or(tag)
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
|
|
||||||
let name = match base.as_str() {
|
|
||||||
"en" | "eng" => "English",
|
|
||||||
"ja" | "jpn" => "Japanese",
|
|
||||||
"de" | "ger" | "deu" => "German",
|
|
||||||
"fr" | "fre" | "fra" => "French",
|
|
||||||
"es" | "spa" => "Spanish",
|
|
||||||
"it" | "ita" => "Italian",
|
|
||||||
"pt" | "por" => "Portuguese",
|
|
||||||
"nl" | "dut" | "nld" => "Dutch",
|
|
||||||
"ru" | "rus" => "Russian",
|
|
||||||
"pl" | "pol" => "Polish",
|
|
||||||
"sv" | "swe" => "Swedish",
|
|
||||||
"da" | "dan" => "Danish",
|
|
||||||
"no" | "nor" => "Norwegian",
|
|
||||||
"fi" | "fin" => "Finnish",
|
|
||||||
"cs" | "cze" | "ces" => "Czech",
|
|
||||||
"hu" | "hun" => "Hungarian",
|
|
||||||
"tr" | "tur" => "Turkish",
|
|
||||||
"ar" | "ara" => "Arabic",
|
|
||||||
"he" | "heb" => "Hebrew",
|
|
||||||
"hi" | "hin" => "Hindi",
|
|
||||||
"ko" | "kor" => "Korean",
|
|
||||||
"zh" | "chi" | "zho" => "Chinese",
|
|
||||||
"th" | "tha" => "Thai",
|
|
||||||
"vi" | "vie" => "Vietnamese",
|
|
||||||
"uk" | "ukr" => "Ukrainian",
|
|
||||||
"ro" | "rum" | "ron" => "Romanian",
|
|
||||||
"el" | "gre" | "ell" => "Greek",
|
|
||||||
"id" | "ind" => "Indonesian",
|
|
||||||
"ms" | "may" | "msa" => "Malay",
|
|
||||||
_ => return tag.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// The region matters when there is one — Brazilian Portuguese is not
|
|
||||||
// Portuguese to anyone choosing between the two.
|
|
||||||
match tag.split_once(['-', '_']) {
|
|
||||||
Some((_, region)) if !region.is_empty() => {
|
|
||||||
format!("{name} ({})", region.to_ascii_uppercase())
|
|
||||||
}
|
|
||||||
_ => name.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -271,6 +223,7 @@ mod tests {
|
|||||||
codec: None,
|
codec: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
default: false,
|
default: false,
|
||||||
|
forced: false,
|
||||||
external: false,
|
external: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,16 +245,6 @@ mod tests {
|
|||||||
assert_eq!(sub.label(), "English — Signs & Songs [ass]");
|
assert_eq!(sub.label(), "English — Signs & Songs [ass]");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
|
|
||||||
assert_eq!(language_name("jpn"), "Japanese");
|
|
||||||
assert_eq!(language_name("ja"), "Japanese");
|
|
||||||
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
|
|
||||||
// An unknown tag is shown as it stands rather than as "Unknown", which
|
|
||||||
// would make two of them indistinguishable.
|
|
||||||
assert_eq!(language_name("mis"), "mis");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
||||||
Track {
|
Track {
|
||||||
language: language.map(Into::into),
|
language: language.map(Into::into),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Account and sync
|
||||||
|
|
||||||
|
Signing in to Proton is optional. Without it, the app plays public share links and keeps
|
||||||
|
everything on one device. Signing in adds two things:
|
||||||
|
|
||||||
|
- **Folders of your own Drive** as shares, with no link to copy.
|
||||||
|
- **Sync** of your place in every episode, your shares and your settings, across every device
|
||||||
|
you sign in on.
|
||||||
|
|
||||||
|
## Signing in
|
||||||
|
|
||||||
|
The **Proton account** section is at the top of the **Shares** page.
|
||||||
|
|
||||||
|
1. Enter your Proton address and password, then **Sign in**.
|
||||||
|
2. If your account has two-factor authentication, enter the code from your authenticator app.
|
||||||
|
3. If your account has a separate mailbox password, enter it and choose **Unlock**.
|
||||||
|
|
||||||
|
Proton sometimes asks to confirm you are human. The app then opens a small window with Proton's
|
||||||
|
own CAPTCHA page. Solve it, and sign-in continues.
|
||||||
|
|
||||||
|
The app keeps a session, not your password. The session goes into the system keyring. **Sign
|
||||||
|
out** ends it on Proton's side and deletes it here.
|
||||||
|
|
||||||
|
Not supported yet:
|
||||||
|
|
||||||
|
- **Security keys** (FIDO2). An account whose only second factor is a security key cannot sign
|
||||||
|
in. Add an authenticator app as a second method in your Proton settings.
|
||||||
|
- **Email or SMS verification** instead of a CAPTCHA.
|
||||||
|
|
||||||
|
## Adding a folder of your Drive
|
||||||
|
|
||||||
|
1. Sign in.
|
||||||
|
2. Under **Add from your Drive**, choose **Browse your Drive**.
|
||||||
|
3. Open folders until you reach the one that holds your videos.
|
||||||
|
4. Choose **Add this folder**.
|
||||||
|
|
||||||
|
The folder becomes a share like any link: it is crawled, shown in the library and streamed. It is
|
||||||
|
read through your account, so there is no link or password to store. The same folder has the
|
||||||
|
same identity on every device, so watch history follows it.
|
||||||
|
|
||||||
|
## Sync
|
||||||
|
|
||||||
|
When you are signed in, each device keeps a small file in your Drive. You find them under
|
||||||
|
**Computers** on the Proton Drive website, in a device called `proton-stream`, in the folder
|
||||||
|
`watch-history`.
|
||||||
|
|
||||||
|
**What syncs:**
|
||||||
|
|
||||||
|
- Where you stopped in each episode, and which episodes you watched
|
||||||
|
- Your shares, including their links and passwords (end-to-end encrypted, like every file in
|
||||||
|
your Drive)
|
||||||
|
- Settings: playback, appearance and posters and descriptions, including a TMDB key
|
||||||
|
- Matches you picked by hand, and your audio and subtitle choice for each show
|
||||||
|
|
||||||
|
**What stays on each device:** volume and mute, the streaming cache size, text size, Wi-Fi only,
|
||||||
|
background audio, hardware decoding, window size, and the library's filter and sort order.
|
||||||
|
|
||||||
|
**When it syncs:** when the app starts, every 5 minutes while it is open, when you stop playing,
|
||||||
|
and when it closes. **Sync now** runs it at once. Android also syncs in the background shortly
|
||||||
|
after you leave the app.
|
||||||
|
|
||||||
|
**How conflicts are settled:** each device writes only its own file, and reads everyone else's.
|
||||||
|
For each episode and each setting, the newest change wins. A device that signs in for the first
|
||||||
|
time takes the others' state and does not overwrite it with its own defaults.
|
||||||
|
|
||||||
|
A device that has not synced for 90 days has its file moved to the Drive trash.
|
||||||
|
|
||||||
|
The status line under your address says how the last sync went, for example *Synced 3 min ago ·
|
||||||
|
2 positions from other devices*. To stop syncing, sign out.
|
||||||
@@ -7,6 +7,18 @@ screens, while UniFFI exposes the existing catalog, share, streaming, watch-stat
|
|||||||
and offline logic. Keep reusable behavior in the Rust crates rather than
|
and offline logic. Keep reusable behavior in the Rust crates rather than
|
||||||
reimplementing it in Kotlin.
|
reimplementing it in Kotlin.
|
||||||
|
|
||||||
|
## Android TV
|
||||||
|
|
||||||
|
The same build installs on Android TV: it is listed in the TV launcher
|
||||||
|
(`LEANBACK_LAUNCHER`, with the banner `scripts/render-tv-banner.sh` renders)
|
||||||
|
and requires neither a touch screen nor leanback. Every screen is Compose
|
||||||
|
`clickable`s, which the D-pad already moves between. The player takes a
|
||||||
|
remote's keys while its controls are down: left and right seek ten seconds,
|
||||||
|
the middle button brings the controls up, and play/pause works as on a
|
||||||
|
keyboard. There is no TV-specific layout yet, and none of it has been run on a
|
||||||
|
TV. Signing in to an account, so that shares arrive through sync, is the way
|
||||||
|
to set one up without typing a share link on a remote.
|
||||||
|
|
||||||
## Toolchain
|
## Toolchain
|
||||||
|
|
||||||
Install JDK 17, Android SDK Platform/Build Tools 36, NDK
|
Install JDK 17, Android SDK Platform/Build Tools 36, NDK
|
||||||
|
|||||||
@@ -145,6 +145,27 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
|||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
### B65 — the desktop app quit at start on a variable CJK font
|
||||||
|
|
||||||
|
**Symptom.** On Secureblue (Fedora Atomic) the window showed for a moment and
|
||||||
|
the process panicked: `Error parsing "system-cjk-fallback" TTF/OTF font file:
|
||||||
|
Invalid index 262144 for font collection`
|
||||||
|
([#1](https://github.com/narrrl/proton-stream/issues/1)).
|
||||||
|
|
||||||
|
**Cause.** The CJK fallback is whatever `fc-match "Noto Sans CJK JP"` names,
|
||||||
|
with the face taken from `%{index}`. For a variable font fontconfig packs the
|
||||||
|
named instance into that number's high 16 bits; Fedora ships Noto Sans CJK as a
|
||||||
|
variable font, so it answered 262144 — instance 4 of face 0. egui took it as a
|
||||||
|
face index, found no such face, and panics on a font it cannot parse.
|
||||||
|
|
||||||
|
**Fix.** `theme.rs` keeps only the low 16 bits, the face. And the fallback is
|
||||||
|
parsed with skrifa, as egui parses it, before it is handed over; one that fails
|
||||||
|
is logged and left out, so no installed font can stop the app again.
|
||||||
|
|
||||||
|
**Verified.** Workspace gate, with
|
||||||
|
`a_variable_fonts_named_instance_is_not_taken_for_a_face` and
|
||||||
|
`a_fallback_font_egui_cannot_read_is_left_out`. Not yet run on Secureblue.
|
||||||
|
|
||||||
### B64 — Android offline downloads looked stuck
|
### B64 — Android offline downloads looked stuck
|
||||||
|
|
||||||
**Symptom.** A queued episode, and more so a queued season, sat at a progress
|
**Symptom.** A queued episode, and more so a queued season, sat at a progress
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Command-line reference
|
||||||
|
|
||||||
|
The desktop packages install two programs:
|
||||||
|
|
||||||
|
- `proton-stream`, the app
|
||||||
|
- `pstr`, a command-line tool that does the app's work without a window
|
||||||
|
|
||||||
|
Both use the same shares, library, settings and keyring, so a share added with `pstr` shows up in
|
||||||
|
the app.
|
||||||
|
|
||||||
|
## proton-stream
|
||||||
|
|
||||||
|
```bash
|
||||||
|
proton-stream # open the app
|
||||||
|
proton-stream 'https://drive.proton.me/urls/TOKEN#KEY' # open it with the Add form filled in
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one window runs at a time. A second `proton-stream` brings the open window forward, hands it
|
||||||
|
any share link it was given, and exits.
|
||||||
|
|
||||||
|
A link given as an argument is visible to other users of the computer in the process list while
|
||||||
|
the command runs, and it may land in your shell history. Pasting it into the window with
|
||||||
|
**Ctrl+V** does neither.
|
||||||
|
|
||||||
|
## pstr
|
||||||
|
|
||||||
|
`pstr --help` lists every command, and `pstr <command> --help` explains one. `pstr` never takes a
|
||||||
|
password or key as an argument; it asks for them.
|
||||||
|
|
||||||
|
### Shares
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
|---|---|
|
||||||
|
| `pstr add --name NAME URL` | Add a public share link. Quote the URL, because of the `#`. |
|
||||||
|
| `pstr add --name NAME URL --custom-password` | The same, and ask for the link's own password |
|
||||||
|
| `pstr add-folder --name NAME VOLUME:LINK` | Add a folder of your Drive, by the id `pstr browse` prints |
|
||||||
|
| `pstr shares` | List the shares and their ids |
|
||||||
|
| `pstr remove ID` | Remove a share and delete its link and password from the keyring |
|
||||||
|
|
||||||
|
### Account
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
|---|---|
|
||||||
|
| `pstr login ADDRESS` | Sign in. Asks for the password, a two-factor code and a mailbox password as the account needs them. |
|
||||||
|
| `pstr logout` | Sign out and delete the stored session |
|
||||||
|
| `pstr account` | Show who is signed in |
|
||||||
|
| `pstr browse [VOLUME:LINK]` | List your Drive's top-level places, or one folder |
|
||||||
|
| `pstr sync` | Sync watch history now |
|
||||||
|
|
||||||
|
`pstr login` cannot show a CAPTCHA. If Proton asks for one, sign in once in the app instead.
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
|---|---|
|
||||||
|
| `pstr crawl [--share ID]` | Read every share, or one, into the library |
|
||||||
|
| `pstr list [--share ID]` | Print what the library holds |
|
||||||
|
|
||||||
|
### Posters and descriptions
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
|---|---|
|
||||||
|
| `pstr metadata status` | Show whether lookups are on, and which provider |
|
||||||
|
| `pstr metadata set --on --provider anilist` | Turn lookups on with AniList |
|
||||||
|
| `pstr metadata set --on --provider tmdb --api-key` | Turn lookups on with TMDB, and ask for the key |
|
||||||
|
| `pstr metadata set --off` | Turn lookups off, and delete every stored answer |
|
||||||
|
| `pstr metadata match [--force] [--limit N]` | Look up titles now. `--force` asks again about titles that already have an answer. |
|
||||||
|
|
||||||
|
### Testing playback
|
||||||
|
|
||||||
|
These two are for finding out which layer is slow or broken.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pstr bench --file Frieren --verify # measure seek time, throughput and memory, no player
|
||||||
|
pstr play --file Frieren --disk-cache # play in a plain mpv window
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Does |
|
||||||
|
|---|---|
|
||||||
|
| `--file TEXT` | The first file whose name or title contains `TEXT` |
|
||||||
|
| `--share ID` | Look in one share only |
|
||||||
|
| `--disk-cache` | Keep decrypted blocks on disk, as the app does |
|
||||||
|
| `--readahead N` | Blocks to fetch ahead of the reader |
|
||||||
|
| `--ring-mib N` | Memory for blocks, in MiB (128 by default) |
|
||||||
|
| `--verify` | `bench` only: compare a read against the SDK's own download |
|
||||||
|
| `--throughput-mib N` | `bench` only: how much to read for the speed test (32 by default) |
|
||||||
|
| `--start SECONDS` | `play` only: start at this position |
|
||||||
|
| `--headless` | `play` only: decode with no window and no sound |
|
||||||
|
| `--for-seconds S` | `play` only: stop after this many seconds |
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Does |
|
||||||
|
|---|---|
|
||||||
|
| `RUST_LOG` | How much both programs log to the terminal, for example `RUST_LOG=debug` |
|
||||||
|
| `PSTR_FRAME_CAP` | The app's frame rate limit on Wayland. 60 by default; `0` turns it off. |
|
||||||
@@ -15,10 +15,7 @@
|
|||||||
|
|
||||||
## Build order from here
|
## Build order from here
|
||||||
|
|
||||||
1. **Sidecar subtitle files** — see the "not done" note at the end of this
|
1. Packaging is in place (`packaging/README.md`); what is left there is running
|
||||||
section. The work is in `pstr-core`: crawl them, and pair them to a video by
|
|
||||||
name.
|
|
||||||
2. Packaging is in place (`packaging/README.md`); what is left there is running
|
|
||||||
the macOS half on a real host, and a release workflow that drives every
|
the macOS half on a real host, and a release workflow that drives every
|
||||||
platform.
|
platform.
|
||||||
|
|
||||||
@@ -151,12 +148,20 @@ Note the glyph trap: egui's bundled fonts cover the transport icons
|
|||||||
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
|
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
|
||||||
on a button wants checking on screen before it ships.
|
on a button wants checking on screen before it ships.
|
||||||
|
|
||||||
Not done: **sidecar subtitle files**. A `.srt`/`.ass` sitting beside the video in
|
**Sidecar subtitle files.** A `.srt`, `.ass`, `.ssa` or `.vtt` beside a video
|
||||||
the share is invisible to all of this — the crawl stores video files only, so
|
is paired with it by name at crawl time (`catalog::build_sidecars`): the video's
|
||||||
there is nothing to offer in the menu. The player side is nearly free once the
|
name followed by a dot or nothing (`S01E01.en.forced.ass`), the same inside a
|
||||||
catalog has them (`sub-add pstr://<token>` through the same registry, since mpv
|
`Subs` folder beside the videos, the RARBG layout `Subs/<video>/2_English.srt`,
|
||||||
reads an external subtitle through the same protocol); the work is in
|
and failing those the only video in the folder. The words past the video's
|
||||||
`pstr-core`: crawl them, and pair them to a video by name.
|
name give the language and the picker's title. They are stored in `sidecars`
|
||||||
|
(schema v12), rebuilt by every crawl like `nodes`.
|
||||||
|
|
||||||
|
Each client opens them while mpv opens the video and adds them with `sub-add
|
||||||
|
pstr://<token>` through the same registry once mpv reports the file loaded. One
|
||||||
|
is turned on only when subtitles are on and the file has none of its own
|
||||||
|
playing, and only in the preferred language (`catalog::preferred_sidecar`); the
|
||||||
|
rest wait in the subtitle menu. A downloaded episode played without a session
|
||||||
|
goes without them, and they are not downloaded with it.
|
||||||
|
|
||||||
## Design decisions already taken
|
## Design decisions already taken
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# FAQ
|
||||||
|
|
||||||
|
## Is this an official Proton app?
|
||||||
|
|
||||||
|
No. It is an unofficial, open-source client written by one developer. Proton AG has not reviewed,
|
||||||
|
endorsed or supported it. "Proton" and "Proton Drive" are Proton AG's trademarks.
|
||||||
|
|
||||||
|
## Do I need a Proton account?
|
||||||
|
|
||||||
|
No, not to watch a public share link. You need one to add folders of your own Drive and to sync
|
||||||
|
your place across devices. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||||
|
|
||||||
|
## Does it download the whole file before playing?
|
||||||
|
|
||||||
|
No. Proton Drive stores a file as blocks that each decrypt on their own, so the app fetches only
|
||||||
|
the blocks around the point you are watching. A seek fetches the block it lands on. On a
|
||||||
|
26-minute 1080p episode the first frame takes about 2.3 seconds.
|
||||||
|
|
||||||
|
## Does it transcode?
|
||||||
|
|
||||||
|
No. The video plays as it is stored, through libmpv. HEVC 10-bit, HDR and ASS subtitles all work,
|
||||||
|
with hardware decoding where the device has it.
|
||||||
|
|
||||||
|
## Does it provide any videos?
|
||||||
|
|
||||||
|
No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue,
|
||||||
|
no search of other people's shares and no content of its own.
|
||||||
|
|
||||||
|
## Why are there no posters?
|
||||||
|
|
||||||
|
Posters and descriptions are off by default, because looking them up sends your title names to
|
||||||
|
AniList or TMDB. Turn them on in **Settings → Posters and descriptions**. See
|
||||||
|
[Privacy](PRIVACY.md).
|
||||||
|
|
||||||
|
## A title is matched to the wrong show. How do I fix it?
|
||||||
|
|
||||||
|
Open the title and choose **Match…** (or **Change match**). Search for the right entry and pick
|
||||||
|
it. A match you pick is never replaced, and it syncs to your other devices.
|
||||||
|
|
||||||
|
## The episodes of one show appear as separate titles.
|
||||||
|
|
||||||
|
The app takes the show's name from its folder. Put every episode of a show in one folder, or in
|
||||||
|
season folders below it. [How the library reads your
|
||||||
|
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has examples.
|
||||||
|
|
||||||
|
## Can several people share one library?
|
||||||
|
|
||||||
|
Yes. Send them the same share link. Each person's app keeps its own watch history; sync only
|
||||||
|
connects devices that sign in to the same Proton account.
|
||||||
|
|
||||||
|
## Is there a macOS version?
|
||||||
|
|
||||||
|
Not as a release. The app builds and runs on macOS from source, and `scripts/build.sh` can make a
|
||||||
|
`.app` and a `.dmg` on a Mac, but no release ships one yet.
|
||||||
|
|
||||||
|
## Can I cast to a TV?
|
||||||
|
|
||||||
|
Not yet. On an Android TV, install the APK and run the app on the TV itself.
|
||||||
|
|
||||||
|
## Where do I report a bug?
|
||||||
|
|
||||||
|
Open an issue on [GitHub](https://github.com/narrrl/proton-stream/issues). Say which platform
|
||||||
|
and version you use, and what you did.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Getting started
|
||||||
|
|
||||||
|
This page takes you from nothing to a playing episode. It takes a few minutes.
|
||||||
|
|
||||||
|
## 1. Install the app
|
||||||
|
|
||||||
|
Download the package for your system from the
|
||||||
|
[latest release](https://github.com/narrrl/proton-stream/releases/latest) and install it.
|
||||||
|
[Installation](INSTALL.md) has the commands for each platform.
|
||||||
|
|
||||||
|
## 2. Add a share
|
||||||
|
|
||||||
|
A *share* is a folder the app reads videos from. There are two kinds:
|
||||||
|
|
||||||
|
- **A public share link.** Someone sends you a Proton Drive link such as
|
||||||
|
`https://drive.proton.me/urls/ABC123#xyz`. You need no Proton account for this.
|
||||||
|
- **A folder of your own Drive.** Sign in to your Proton account, then pick a folder. See
|
||||||
|
[Account and sync](ACCOUNT_AND_SYNC.md).
|
||||||
|
|
||||||
|
To add a public link on the desktop:
|
||||||
|
|
||||||
|
1. Copy the whole link, including the part after `#`. That part is the key that decrypts the
|
||||||
|
share, so the link does not work without it.
|
||||||
|
2. Open the **Shares** page, or press **Ctrl+V** anywhere outside a text field.
|
||||||
|
3. Paste the link into **Link**.
|
||||||
|
4. If the person who shared it set a password of their own, turn on **The link asks for a
|
||||||
|
password** and type it.
|
||||||
|
5. Click **Add share**.
|
||||||
|
|
||||||
|
The app opens the link to check it before it saves anything. If the link is wrong, it tells you
|
||||||
|
why and stores nothing.
|
||||||
|
|
||||||
|
On Android and iOS, tap **Add share** on the **Shares** tab. On Android you can also share the
|
||||||
|
link to the app from your browser. [Android and iOS](MOBILE.md) has the details.
|
||||||
|
|
||||||
|
## 3. Wait for the library to fill
|
||||||
|
|
||||||
|
The app reads the share's folders. This is called a *crawl*. Titles appear in the **Library** as
|
||||||
|
they are found. A large share takes a minute or two.
|
||||||
|
|
||||||
|
The folder layout decides how titles are grouped. Each show should have its own folder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sousou no Frieren/
|
||||||
|
Season 01/
|
||||||
|
S01E01.mkv
|
||||||
|
S01E02.mkv
|
||||||
|
Paprika (2006).mkv
|
||||||
|
```
|
||||||
|
|
||||||
|
A file without episode numbers is shown as a film. [How the library reads your
|
||||||
|
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has the full rules.
|
||||||
|
|
||||||
|
## 4. Press play
|
||||||
|
|
||||||
|
Open a title and click **Play**. The episode starts in a few seconds, and you can drag the seek
|
||||||
|
bar to any point. The app remembers where you stopped, so **Resume** takes you back.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- **Posters and descriptions.** Turn them on in **Settings → Posters and descriptions**. They are
|
||||||
|
off by default, because the app then sends your title names to AniList or TMDB. See
|
||||||
|
[Privacy](PRIVACY.md).
|
||||||
|
- **Your place on every device.** Sign in to Proton on each device, and your position in every
|
||||||
|
episode follows you. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||||
|
- **Keyboard.** Press **?** on the desktop for the list of shortcuts.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has packages for Linux
|
||||||
|
and Windows, an APK for Android and an IPA for iOS, plus a `SHA256SUMS` file to check them
|
||||||
|
against. The version is part of each file name.
|
||||||
|
|
||||||
|
| Platform | File | Needs |
|
||||||
|
|---|---|---|
|
||||||
|
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` | x86_64 |
|
||||||
|
| Fedora | `proton-stream-<version>-1.x86_64.rpm` | x86_64 |
|
||||||
|
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` | x86_64, libmpv 2 |
|
||||||
|
| Arch Linux | Built from the repository with `makepkg` | |
|
||||||
|
| Windows | `proton-stream-<version>-x64.msi` or `…-x86_64-windows.zip` | x64 |
|
||||||
|
| Android, Android TV | `proton-stream-<version>-android.apk` | Android 12 or newer, arm64 or x86_64 |
|
||||||
|
| iOS, iPadOS | `proton-stream-<version>-ios.ipa` | iOS 17 or newer, SideStore or AltStore |
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
The desktop app needs libmpv, WebKitGTK 4.1 (for the CAPTCHA window when you sign in) and a
|
||||||
|
Secret Service keyring such as GNOME Keyring or KWallet. The packages pull in everything except
|
||||||
|
the keyring, which most desktops already have.
|
||||||
|
|
||||||
|
### Debian and Ubuntu
|
||||||
|
|
||||||
|
Download the `.deb`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./proton-stream_*_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora
|
||||||
|
|
||||||
|
Download the `.rpm`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install ./proton-stream-*.x86_64.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
The `PKGBUILD` in `packaging/` builds the checkout it sits in. It needs network access, so cargo
|
||||||
|
can fetch crates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/narrrl/proton-stream.git
|
||||||
|
cd proton-stream/packaging
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other distributions
|
||||||
|
|
||||||
|
Unpack the tarball anywhere. It holds `proton-stream` (the app), `pstr` (the
|
||||||
|
[command line](CLI.md)) and the icon. Install libmpv 2 and WebKitGTK 4.1 from your
|
||||||
|
distribution first.
|
||||||
|
|
||||||
|
Both packages install:
|
||||||
|
|
||||||
|
- `/usr/bin/proton-stream`, the app, listed as **Proton Stream** in your applications menu
|
||||||
|
- `/usr/bin/pstr`, the command line
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
Download either file from the release:
|
||||||
|
|
||||||
|
- **The `.msi` installer** installs to `Program Files\Proton Stream`, adds a Start menu entry
|
||||||
|
and puts `pstr` on the PATH.
|
||||||
|
- **The portable `.zip`** runs from any folder. Keep every file in it together: the app does not
|
||||||
|
start without `libmpv-2.dll` and `WebView2Loader.dll` beside it.
|
||||||
|
|
||||||
|
The binaries are not code-signed yet, so SmartScreen may warn the first time. Choose **More
|
||||||
|
info**, then **Run anyway**. The sign-in window uses the WebView2 runtime that comes with Windows
|
||||||
|
10 and 11.
|
||||||
|
|
||||||
|
## Android and Android TV
|
||||||
|
|
||||||
|
1. Download `proton-stream-<version>-android.apk` on the device.
|
||||||
|
2. Open it. Android asks once to allow installs from your browser or file manager.
|
||||||
|
3. Install.
|
||||||
|
|
||||||
|
The APK is signed, so later versions install over it and keep your library. On Android TV the app
|
||||||
|
appears in the launcher; installing needs a file manager or `adb install`.
|
||||||
|
|
||||||
|
## iOS and iPadOS
|
||||||
|
|
||||||
|
The `.ipa` is unsigned. [SideStore](https://sidestore.io) or [AltStore](https://altstore.io)
|
||||||
|
signs it with your own Apple ID when it installs:
|
||||||
|
|
||||||
|
1. Install SideStore or AltStore by following its own guide.
|
||||||
|
2. Download `proton-stream-<version>-ios.ipa`.
|
||||||
|
3. Open it with SideStore or AltStore.
|
||||||
|
|
||||||
|
A free Apple ID has to refresh the app every 7 days. SideStore and AltStore do that for you.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
Install the new release over the old one. Your shares, library, watch history and settings stay.
|
||||||
|
The app does not check for updates by itself; watch the
|
||||||
|
[releases](https://github.com/narrrl/proton-stream/releases) on GitHub.
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
Remove the package the way you installed it, for example `sudo apt remove proton-stream`.
|
||||||
|
This leaves your data behind. To remove that too:
|
||||||
|
|
||||||
|
1. In the app, remove each share on the **Shares** page. This deletes its link and password
|
||||||
|
from the keyring and its downloads from the disk.
|
||||||
|
2. Sign out on the **Shares** page, if you signed in.
|
||||||
|
3. Delete the app's folders, listed in [Privacy](PRIVACY.md#what-stays-on-your-device).
|
||||||
|
|
||||||
|
On Android and iOS, uninstalling the app deletes everything it stored.
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
Rust 1.96 or newer, plus the libmpv (≥ 2.0) development headers, and WebKitGTK 4.1's on Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/narrrl/proton-stream.git
|
||||||
|
cd proton-stream
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
[Development](DEVELOPMENT.md) and `packaging/README.md` have the details, including Windows, the
|
||||||
|
Android toolchain and iOS builds on macOS.
|
||||||
@@ -16,6 +16,7 @@ file of the same name is the one to change with it.
|
|||||||
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
|
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
|
||||||
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
|
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
|
||||||
| `download/*` | `Download/*` |
|
| `download/*` | `Download/*` |
|
||||||
|
| `sync/NewEpisodesWorker.kt` | `App/NewEpisodes.swift` |
|
||||||
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
|
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
|
||||||
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
|
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
|
||||||
|
|
||||||
@@ -79,6 +80,11 @@ Each of these is the platform's doing, not a choice to diverge.
|
|||||||
place of Android's `WatchSyncWorker`, and every five minutes while it is open.
|
place of Android's `WatchSyncWorker`, and every five minutes while it is open.
|
||||||
- **Background audio off pauses** the episode when the app leaves the screen, where
|
- **Background audio off pauses** the episode when the app leaves the screen, where
|
||||||
Android stops it.
|
Android stops it.
|
||||||
|
- **New-episode checks run when iOS chooses.** Android's `NewEpisodesWorker`
|
||||||
|
crawls every six hours on a network. iOS's `BGProcessingTask` is asked for no
|
||||||
|
sooner than six hours, and the system decides when, mostly overnight on a
|
||||||
|
charger. An app refresh would come more often but gets about thirty seconds,
|
||||||
|
which a crawl of a large share does not fit in.
|
||||||
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
|
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
|
||||||
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
|
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
|
||||||
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
|
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Android and iOS
|
||||||
|
|
||||||
|
The Android and iOS apps share the desktop app's engine, so a library, its watch history and its
|
||||||
|
settings behave the same everywhere. This page covers what is different on a phone, a tablet and
|
||||||
|
a TV. [Installation](INSTALL.md) explains how to install each one.
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><img src="../images/android-home.png" alt="Home" width="200"></td>
|
||||||
|
<td><img src="../images/android-library.png" alt="All titles" width="200"></td>
|
||||||
|
<td><img src="../images/android-title.png" alt="A title page" width="200"></td>
|
||||||
|
<td><img src="../images/android-episodes.png" alt="Episodes and downloads" width="200"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Adding a share
|
||||||
|
|
||||||
|
Tap **Add share** on the **Shares** tab, paste the link, and add a custom password if the link has
|
||||||
|
one. The app checks the link before it saves it.
|
||||||
|
|
||||||
|
- **Android:** you can also share a link to the app from your browser or a chat app, or open a
|
||||||
|
`drive.proton.me/urls/…` link directly if you allow the app to open supported links in the
|
||||||
|
system settings.
|
||||||
|
- **iOS:** there is no share sheet. Copy the link, then tap **Paste link** in the dialog.
|
||||||
|
|
||||||
|
Folders of your own Drive work as on the desktop: sign in on the **Shares** tab, then **Add from
|
||||||
|
your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||||
|
|
||||||
|
## The player
|
||||||
|
|
||||||
|
- Tap to show or hide the controls.
|
||||||
|
- **Double-tap** the left or right third of the screen to go back or forward 10 seconds. Double-tap
|
||||||
|
the middle to pause.
|
||||||
|
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks, and
|
||||||
|
**Lock to landscape**.
|
||||||
|
- Sound keeps playing with the screen off or the app in the background, with controls on the lock
|
||||||
|
screen. Turn off **Background audio** in Settings to stop that.
|
||||||
|
|
||||||
|
Picture-in-Picture is not available on either platform.
|
||||||
|
|
||||||
|
## Android TV
|
||||||
|
|
||||||
|
The app appears in the TV launcher. With the controls hidden:
|
||||||
|
|
||||||
|
| Remote | Does |
|
||||||
|
|---|---|
|
||||||
|
| Left / right | Back or forward 10 seconds |
|
||||||
|
| Centre, up or down | Show the controls |
|
||||||
|
| Play/pause | Pause and play |
|
||||||
|
|
||||||
|
There is no separate TV layout yet, and the app has not been tested on a real TV. Reports are
|
||||||
|
welcome.
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
Download an episode, a season or a show from its title page, and play it from the **Downloads**
|
||||||
|
tab without a connection.
|
||||||
|
|
||||||
|
- **Android** waits for Wi-Fi unless you turn off **Download on Wi-Fi only**. Downloads continue
|
||||||
|
with the app closed.
|
||||||
|
- **iOS** downloads only while the app is open, and for the short time iOS allows after you leave
|
||||||
|
it. A stopped download resumes where it stopped.
|
||||||
|
|
||||||
|
**Delete all offline episodes** in Settings frees the space in one step.
|
||||||
|
|
||||||
|
## New episodes
|
||||||
|
|
||||||
|
When a share gains episodes of a show, the app sends a notification.
|
||||||
|
|
||||||
|
- **Android** checks about every 6 hours, when the device has a network and the battery is not
|
||||||
|
low. Turn it off with **New episode notifications** in Settings.
|
||||||
|
- **iOS** asks the system to check every 6 hours or so; iOS decides when it actually runs.
|
||||||
|
|
||||||
|
## Where your secrets are kept
|
||||||
|
|
||||||
|
- **Android:** in the app's private storage, encrypted with a key in the Android Keystore. If you
|
||||||
|
change or remove the device's screen lock, Android may delete that key. The app then asks you
|
||||||
|
to **Re-enter link** for each share.
|
||||||
|
- **iOS:** in the Keychain, only on this device, and not in backups.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Android 12** or newer, on an arm64 or x86_64 device. 32-bit devices are not supported.
|
||||||
|
- **iOS 17** or newer, on iPhone or iPad.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Privacy
|
||||||
|
|
||||||
|
proton-stream has no server and no account with its developer. Each app talks to Proton directly,
|
||||||
|
and to nothing else unless you turn on posters and descriptions. This page lists what goes where.
|
||||||
|
|
||||||
|
## What the app sends, and to whom
|
||||||
|
|
||||||
|
| To | What | When |
|
||||||
|
|---|---|---|
|
||||||
|
| Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always |
|
||||||
|
| AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on |
|
||||||
|
|
||||||
|
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
|
||||||
|
|
||||||
|
**Posters and descriptions** are off by default. When they are on, the provider learns the names
|
||||||
|
of the titles in your library. It does not learn your files, your watch history or your shares.
|
||||||
|
Turning them off deletes every stored answer.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- **The part of a share link after `#`** is the key that decrypts the share. The app treats it as
|
||||||
|
a password.
|
||||||
|
- **A link's own password**, if it has one.
|
||||||
|
- **Your Proton session**, if you signed in. The app keeps the session, not your password.
|
||||||
|
|
||||||
|
All of these go into the system's credential store, never into a config file:
|
||||||
|
|
||||||
|
| Platform | Store |
|
||||||
|
|---|---|
|
||||||
|
| Linux | Secret Service (GNOME Keyring, KWallet, KeePassXC) |
|
||||||
|
| Windows | Credential Manager |
|
||||||
|
| Android | App storage, encrypted with a key in the Android Keystore |
|
||||||
|
| iOS | Keychain, this device only |
|
||||||
|
|
||||||
|
If you sign in, your shares, with their links and passwords, also sync through your own Drive.
|
||||||
|
Like every file there, they are end-to-end encrypted. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||||
|
|
||||||
|
## How the player gets the video
|
||||||
|
|
||||||
|
The player is fed through a callback inside the app's own process. There is no local web server,
|
||||||
|
so there is no port another program on the computer could ask for the decrypted video. The
|
||||||
|
player is handed an opaque token, not a link, so no secret ends up in its log or window title.
|
||||||
|
|
||||||
|
## What stays on your device
|
||||||
|
|
||||||
|
| What | Where on Linux |
|
||||||
|
|---|---|
|
||||||
|
| Settings and the share list (without secrets) | `~/.config/proton-stream/` |
|
||||||
|
| The library and watch history (`catalog.db`) and downloads (`offline/`) | `~/.local/share/proton-stream/` |
|
||||||
|
| Streamed video (`blocks/`), thumbnails and posters | `~/.cache/proton-stream/` |
|
||||||
|
|
||||||
|
On Windows the same folders are under `%APPDATA%\narl\proton-stream\` and
|
||||||
|
`%LOCALAPPDATA%\narl\proton-stream\`. On macOS they are under `~/Library/Application Support/`
|
||||||
|
and `~/Library/Caches/`, in `io.narl.proton-stream`.
|
||||||
|
|
||||||
|
**The streaming cache and downloads hold decrypted video.** Anyone who can read your user's files
|
||||||
|
can play them. Empty the cache in **Settings → Storage**, and delete downloads on the
|
||||||
|
**Downloads** page.
|
||||||
|
|
||||||
|
The library database holds file names and your watch history, in plain text. It holds no
|
||||||
|
secrets.
|
||||||
|
|
||||||
|
## Other people on the same computer
|
||||||
|
|
||||||
|
- A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in
|
||||||
|
the process list while the command runs, and may land in your shell history. Paste links into
|
||||||
|
the window instead.
|
||||||
|
- Your desktop's media controls are given the title, the episode and a picture already on disk.
|
||||||
|
They are never given a provider link, so the desktop does not contact a provider by itself.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# proton-stream documentation
|
||||||
|
|
||||||
|
proton-stream turns the videos in your Proton Drive, and the share links people send you, into
|
||||||
|
a library you can browse and stream. It runs on Linux, Windows, Android, Android TV and iOS.
|
||||||
|
This is an unofficial client, not a Proton product.
|
||||||
|
|
||||||
|
<img src="../images/desktop-home.png" alt="The library in the desktop app" width="820">
|
||||||
|
|
||||||
|
## Start here
|
||||||
|
|
||||||
|
- [Getting started](GETTING_STARTED.md): install, add your first share, press play.
|
||||||
|
- [Installation](INSTALL.md): every platform, and how to update and uninstall.
|
||||||
|
- [FAQ](FAQ.md): short answers to the questions people ask first.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
- [User guide](USER_GUIDE.md): the library, title pages, the player, downloads, history and
|
||||||
|
settings.
|
||||||
|
- [Account and sync](ACCOUNT_AND_SYNC.md): signing in, folders of your own Drive, and keeping
|
||||||
|
your place on every device.
|
||||||
|
- [Android and iOS](MOBILE.md): what is different on a phone, a tablet and Android TV.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- [Command-line reference](CLI.md): the `pstr` tool and the `proton-stream` launcher.
|
||||||
|
- [Privacy](PRIVACY.md): what goes where, and what stays on your device.
|
||||||
|
|
||||||
|
## When something goes wrong
|
||||||
|
|
||||||
|
- [Troubleshooting](TROUBLESHOOTING.md): links that will not add, sign-in problems, playback
|
||||||
|
problems, and where the logs are.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
- [Development](DEVELOPMENT.md): the state of each layer, the build order and the traps.
|
||||||
|
- [Architecture](ARCHITECTURE.md): the crates, the data flow when an episode plays, and where
|
||||||
|
secrets live.
|
||||||
|
- [Testing](TESTING.md), [Metadata](METADATA.md), [Design tokens](DESIGN-TOKENS.md),
|
||||||
|
[Android development](ANDROID.md) and [iOS development](IOS.md).
|
||||||
|
|
||||||
|
Bugs are tracked in [BUGS.md](BUGS.md) in the repository.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Adding a share
|
||||||
|
|
||||||
|
### "Proton share URL has no #password fragment; copy the full share link"
|
||||||
|
|
||||||
|
The link is missing the part after `#`, which is its decryption key. Copy the link again from
|
||||||
|
where you got it, all of it.
|
||||||
|
|
||||||
|
### "This link has a password of its own. Enter it to add the link."
|
||||||
|
|
||||||
|
The person who shared it also set a password. Turn on **The link asks for a password** (on a
|
||||||
|
phone, fill in **Custom password**) and type it.
|
||||||
|
|
||||||
|
### "That share is already added."
|
||||||
|
|
||||||
|
The link, or the same folder of your Drive, is already on the **Shares** page.
|
||||||
|
|
||||||
|
### "Last crawl failed: …"
|
||||||
|
|
||||||
|
The share could not be read. The link may have expired or been revoked, or its password
|
||||||
|
changed. If the password changed, choose **Re-enter link**.
|
||||||
|
|
||||||
|
## The keyring
|
||||||
|
|
||||||
|
### "share … has no stored credentials; remove and re-add it"
|
||||||
|
|
||||||
|
The link and password are gone from the keyring. This happens when a keyring is reset, or on
|
||||||
|
Android when the screen lock changes. Choose **Re-enter link** on the **Shares** page. Your
|
||||||
|
library, history and downloads stay.
|
||||||
|
|
||||||
|
### "credential store: …" on Linux
|
||||||
|
|
||||||
|
The app found no Secret Service. Install and start one, such as GNOME Keyring, KWallet or
|
||||||
|
KeePassXC with its Secret Service integration, and unlock it.
|
||||||
|
|
||||||
|
## Signing in
|
||||||
|
|
||||||
|
### "Proton did not accept that address and password"
|
||||||
|
|
||||||
|
Check both. Sign in on proton.me to make sure the account works.
|
||||||
|
|
||||||
|
### "Proton has blocked sign-ins from this app for now"
|
||||||
|
|
||||||
|
Proton's abuse protection stopped the sign-in. Wait a while and try again, or sign in once on
|
||||||
|
proton.me first.
|
||||||
|
|
||||||
|
### "This account's only second factor is a security key"
|
||||||
|
|
||||||
|
The app cannot use security keys yet. Add an authenticator app as a second method in your
|
||||||
|
Proton account settings, then sign in with its code.
|
||||||
|
|
||||||
|
### The CAPTCHA window does not open on Linux
|
||||||
|
|
||||||
|
The window needs WebKitGTK 4.1. The `.deb`, `.rpm` and Arch packages pull it in; with the
|
||||||
|
tarball, install it yourself.
|
||||||
|
|
||||||
|
### `pstr login` asks for a CAPTCHA
|
||||||
|
|
||||||
|
The command line cannot show one. Sign in once in the app.
|
||||||
|
|
||||||
|
## Playback
|
||||||
|
|
||||||
|
### Green blocks, a black picture or torn frames
|
||||||
|
|
||||||
|
Turn off **Hardware decoding** in Settings. It applies from the next file.
|
||||||
|
|
||||||
|
### An episode takes long to start
|
||||||
|
|
||||||
|
The first seconds of a file are fetched over the network. On a slow connection, start the
|
||||||
|
episode and give it a moment, or download it first. A second viewing comes from the disk cache.
|
||||||
|
|
||||||
|
### No skip button for the opening
|
||||||
|
|
||||||
|
The skip button comes from the file's chapter names. A file without named chapters gets none.
|
||||||
|
|
||||||
|
### The wrong subtitle track plays
|
||||||
|
|
||||||
|
Choose the track in the player. The app remembers it for the rest of the show. Set the
|
||||||
|
language to start in under **Settings → Playback**.
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
### The app does not start from the portable `.zip`
|
||||||
|
|
||||||
|
Keep every file from the archive in one folder. The app needs `libmpv-2.dll` and
|
||||||
|
`WebView2Loader.dll` next to `proton-stream.exe`.
|
||||||
|
|
||||||
|
### SmartScreen warns about the installer
|
||||||
|
|
||||||
|
The binaries are not code-signed yet. Choose **More info**, then **Run anyway**.
|
||||||
|
|
||||||
|
### A pinned taskbar icon shows a blank picture after an update
|
||||||
|
|
||||||
|
Unpin it and pin it again once.
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
### "Not responding" when the window is on another workspace
|
||||||
|
|
||||||
|
Fixed. On Wayland the app limits its own frame rate instead of waiting for vsync, which a
|
||||||
|
compositor withholds from a window it is not showing. Update to the latest release.
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Start the app from a terminal to see its log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug proton-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
The same works for `pstr`. When you
|
||||||
|
[open an issue](https://github.com/narrrl/proton-stream/issues), include the lines around the
|
||||||
|
error, but **remove any share link first**: the part after `#` is a key.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# User guide
|
||||||
|
|
||||||
|
This page describes the desktop app. The Android and iOS apps have the same pages as tabs at the
|
||||||
|
bottom of the screen; [Android and iOS](MOBILE.md) covers what is different there.
|
||||||
|
|
||||||
|
The app has five pages, in the bar at the top: **Library**, **History**, **Shares**,
|
||||||
|
**Downloads** and **Settings**. **Ctrl+1** to **Ctrl+5** open them.
|
||||||
|
|
||||||
|
## Library
|
||||||
|
|
||||||
|
<img src="../images/desktop-library.png" alt="The whole library, filtered and sorted" width="820">
|
||||||
|
|
||||||
|
The library shows every title from every share in one place. Two shares that hold the same show
|
||||||
|
show it once.
|
||||||
|
|
||||||
|
- **The banner** at the top shows the title you watched last, with **Resume** and **More info**.
|
||||||
|
- **Continue watching** has the episode you stopped in for each show, or the next one when you
|
||||||
|
finished an episode. To take a show off this row, right-click it and choose **Remove from
|
||||||
|
Continue watching**.
|
||||||
|
- **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when
|
||||||
|
posters and descriptions are on. The app shows at most four, and only for a director, studio or
|
||||||
|
genre with at least three titles.
|
||||||
|
- **All titles** is the full grid. Choose a filter and a sort order above it.
|
||||||
|
|
||||||
|
| Filters | Sort orders |
|
||||||
|
|---|---|
|
||||||
|
| All, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
|
||||||
|
|
||||||
|
**Group franchises** folds a series, its sequels and its films into one tile with a **+N** badge.
|
||||||
|
It is on by default, and needs posters and descriptions to know what belongs together.
|
||||||
|
|
||||||
|
**Search** with **Ctrl+F** or **/**. It matches every name a title has: the English one, the
|
||||||
|
romaji one and the name of its folder.
|
||||||
|
|
||||||
|
**Refresh** (or **F5**) reads every share again. The app also does this by itself when a share
|
||||||
|
has not been read for 6 hours.
|
||||||
|
|
||||||
|
### How the library reads your folders
|
||||||
|
|
||||||
|
The folder decides which show a file belongs to. The file name gives the season and episode.
|
||||||
|
|
||||||
|
| Layout | Read as |
|
||||||
|
|---|---|
|
||||||
|
| `Frieren/Season 01/S01E05.mkv` | Frieren, season 1, episode 5 |
|
||||||
|
| `Frieren/S01E05 - The Hero's Statue.mkv` | The same, with the episode title |
|
||||||
|
| `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 |
|
||||||
|
| `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 |
|
||||||
|
| `Akira (1988).mkv` | A film, from 1988 |
|
||||||
|
|
||||||
|
- Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`.
|
||||||
|
- Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name;
|
||||||
|
the folder above them is.
|
||||||
|
- An episode title is read only after a dash, as in `S01E01 - Name`.
|
||||||
|
- A file with no episode number is a film.
|
||||||
|
|
||||||
|
The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video
|
||||||
|
containers.
|
||||||
|
|
||||||
|
## A title's page
|
||||||
|
|
||||||
|
Click a title to open its page. **Esc** goes back.
|
||||||
|
|
||||||
|
- **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from
|
||||||
|
the beginning.
|
||||||
|
- **Download show** keeps every episode on this device. See [Downloads](#downloads).
|
||||||
|
- **Match…** opens the matcher, where you choose which AniList or TMDB entry the title is. Use it
|
||||||
|
when the app guessed wrong or did not guess at all. A match you pick is never replaced.
|
||||||
|
- **More on AniList** (or TMDB) opens the title's page there.
|
||||||
|
- Each season has **Mark season watched** and **Download season**.
|
||||||
|
- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark
|
||||||
|
watched up to here** to tick it and every episode before it.
|
||||||
|
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
|
||||||
|
|
||||||
|
An episode counts as watched once you have seen 90% of it.
|
||||||
|
|
||||||
|
## The player
|
||||||
|
|
||||||
|
The picture plays inside the app's window. Move the mouse to show the controls.
|
||||||
|
|
||||||
|
- **Skip opening** and **Skip ending** appear when the file's chapters mark an opening, an ending
|
||||||
|
or a preview. Files without chapter names get no skip button. Turn on **Skip openings and
|
||||||
|
credits** in Settings to skip them without asking.
|
||||||
|
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
|
||||||
|
**Play now**, **Cancel** or **Watch till the end**.
|
||||||
|
- **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your
|
||||||
|
choice for each show.
|
||||||
|
- **Speed** goes from 0.5× to 3×, and stays for the next episode.
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
Press **?** anywhere to see this list in the app.
|
||||||
|
|
||||||
|
| Key | Anywhere |
|
||||||
|
|---|---|
|
||||||
|
| Ctrl+F or / | Search the library |
|
||||||
|
| F5 or Ctrl+R | Read every share again |
|
||||||
|
| Ctrl+1 – 5 | Library, History, Shares, Downloads, Settings |
|
||||||
|
| Ctrl+, | Settings |
|
||||||
|
| Ctrl+V | Paste a share link to add it |
|
||||||
|
| Tab or arrows | Move between titles and episodes |
|
||||||
|
| Enter | Open or play what is highlighted |
|
||||||
|
| Alt+← or mouse back | Back |
|
||||||
|
|
||||||
|
| Key | Playing |
|
||||||
|
|---|---|
|
||||||
|
| Space or K | Pause and play |
|
||||||
|
| ← / → | Back 10 s, forward 30 s |
|
||||||
|
| ↑ / ↓ | Volume |
|
||||||
|
| M | Mute |
|
||||||
|
| N / P | Next and previous episode |
|
||||||
|
| [ / ] | Slower and faster |
|
||||||
|
| S | Skip the opening or the credits |
|
||||||
|
| E | The list of episodes |
|
||||||
|
| F or double-click | Fullscreen |
|
||||||
|
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
|
||||||
|
| Esc | Leave fullscreen, then the player |
|
||||||
|
|
||||||
|
Media keys and your desktop's media controls work too.
|
||||||
|
|
||||||
|
### Subtitle files beside the video
|
||||||
|
|
||||||
|
The app picks up `.srt`, `.ass`, `.ssa` and `.vtt` files next to a video:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frieren/Season 01/
|
||||||
|
S01E01.mkv
|
||||||
|
S01E01.en.srt English
|
||||||
|
S01E01.en.forced.ass English, "forced"
|
||||||
|
Subs/
|
||||||
|
S01E01/
|
||||||
|
2_English.srt English
|
||||||
|
```
|
||||||
|
|
||||||
|
A `Subs` folder beside the videos works, and so does any subtitle in a folder that holds a single
|
||||||
|
video. The language comes from the last word in the name that names one.
|
||||||
|
|
||||||
|
### Dialogue, not signs and songs
|
||||||
|
|
||||||
|
Many anime releases have two subtitle tracks in one language: the full dialogue, and a short
|
||||||
|
*Signs & Songs* track that only translates on-screen text. When a file would start on the short
|
||||||
|
track, the app switches to the full one in the same language.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
Everything you played, newest first, grouped by day. An entry says **Watched**, **Stopped at
|
||||||
|
12:04 of 24:00** or **Played**. Removing an entry marks a watched episode unwatched, or forgets
|
||||||
|
where you stopped in one you had not finished.
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
A download keeps a full copy of an episode on this device, to play without a connection. Start
|
||||||
|
one from a title's page: a whole show, a season or a single episode.
|
||||||
|
|
||||||
|
The **Downloads** page lists them with **Pause**, **Resume**, **Cancel**, **Retry**, **Play** and
|
||||||
|
**Delete**. Three run at a time. Downloads do not count against the streaming cache.
|
||||||
|
|
||||||
|
## Shares
|
||||||
|
|
||||||
|
The **Shares** page lists every share, with what it holds and when it was last read.
|
||||||
|
|
||||||
|
- **Crawl** reads the share again.
|
||||||
|
- **Rename** changes the name shown in the app.
|
||||||
|
- **Re-enter link** gives the app the link again, for a keyring that lost it or a password that
|
||||||
|
changed. The library, history and downloads stay.
|
||||||
|
- **Remove** takes the share's titles out of the library, deletes its downloads and removes its
|
||||||
|
link and password from the keyring.
|
||||||
|
|
||||||
|
The **Proton account** section on this page is where you sign in. See
|
||||||
|
[Account and sync](ACCOUNT_AND_SYNC.md).
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
**Appearance**
|
||||||
|
|
||||||
|
- **Palette:** Proton (the near-black default), Catppuccin Latte, Frappé, Macchiato and Mocha, or
|
||||||
|
Persona 5.
|
||||||
|
- **Accent:** Mauve, Pink, Sky, Pink → Sky, Lavender, Blue, Teal, Peach or Red. **Gradient
|
||||||
|
accent** draws it as a gradient.
|
||||||
|
- **Text size:** 90% to 150%. This one stays on this computer.
|
||||||
|
- **Library tiles:** posters or stills. **Title names:** as in the share, English or romaji.
|
||||||
|
|
||||||
|
**Playback**
|
||||||
|
|
||||||
|
- **Audio language** and **Subtitles:** the language to start in, or the file's own default.
|
||||||
|
- **Play the next episode** (on by default) and **Skip openings and credits** (off).
|
||||||
|
- **Hardware decoding** (on). Turn it off if the picture shows green blocks or stays black.
|
||||||
|
|
||||||
|
**Storage**
|
||||||
|
|
||||||
|
- **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed,
|
||||||
|
so watching again needs no network. **Clear the streaming cache** empties it.
|
||||||
|
|
||||||
|
**Posters and descriptions**
|
||||||
|
|
||||||
|
- Off by default. When it is on, the app sends the names of your titles to the provider you pick:
|
||||||
|
**AniList** (no key needed) or **TMDB** (needs a free API key). See [Privacy](PRIVACY.md).
|
||||||
|
- **Match the library** looks up new titles now. **Match everything again** looks up every title,
|
||||||
|
except those you matched by hand.
|
||||||
|
After Width: | Height: | Size: 653 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="proton-stream">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ps-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#A48BFF"/>
|
||||||
|
<stop offset="0.55" stop-color="#7552FF"/>
|
||||||
|
<stop offset="1" stop-color="#5B36F2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ps-glyph" x1="0" y1="36" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#ECE6FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<!-- The play mark, cut into the blocks a revision is fetched in: each one
|
||||||
|
decrypts on its own, which is the whole reason this app can seek. -->
|
||||||
|
<mask id="ps-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||||
|
<path d="M50 41.5v41a6 6 0 0 0 9.1 5.1l34.2-20.5a6 6 0 0 0 0-10.3L59.1 36.4A6 6 0 0 0 50 41.5z" fill="#FFFFFF"/>
|
||||||
|
<g fill="#000000">
|
||||||
|
<rect x="63" y="0" width="5.5" height="128"/>
|
||||||
|
<rect x="78.5" y="0" width="5.5" height="128"/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- base: a rounded tile with a darker lower edge for depth -->
|
||||||
|
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||||
|
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#ps-face)"/>
|
||||||
|
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||||
|
|
||||||
|
<!-- glyph shadow, then the glyph itself. The mark's box centres on x=73 and
|
||||||
|
its centroid on x=66; it sits at 70 -> 64, between the two, because the
|
||||||
|
box centre reads as too far left and the centroid as too far right. The
|
||||||
|
Android and iOS icons centre it the same way. -->
|
||||||
|
<g transform="translate(-6 0)">
|
||||||
|
<g transform="translate(0 3)">
|
||||||
|
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -152,10 +152,16 @@ final class AppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
|
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
|
||||||
|
///
|
||||||
|
/// Held under a background task: a crawl takes minutes, and without one
|
||||||
|
/// iOS suspends the app the moment it leaves the screen and tears down its
|
||||||
|
/// connections, so a glance at another app failed the crawl.
|
||||||
private func crawlShares(_ id: String?) async throws {
|
private func crawlShares(_ id: String?) async throws {
|
||||||
crawlsRunning += 1
|
crawlsRunning += 1
|
||||||
crawling = true
|
crawling = true
|
||||||
|
let task = BackgroundTask(name: "crawl")
|
||||||
defer {
|
defer {
|
||||||
|
task.end()
|
||||||
crawlsRunning -= 1
|
crawlsRunning -= 1
|
||||||
crawling = crawlsRunning > 0
|
crawling = crawlsRunning > 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Look at the shares while the app is away, and say when a show gained
|
||||||
|
/// episodes — what `NewEpisodesWorker` does on Android.
|
||||||
|
///
|
||||||
|
/// A processing task rather than an app refresh: a crawl of a large share
|
||||||
|
/// takes minutes, and an app refresh gets about thirty seconds. iOS runs it
|
||||||
|
/// when it sees fit, mostly overnight on a charger, so it is a best effort
|
||||||
|
/// where Android's is a schedule.
|
||||||
|
enum NewEpisodes {
|
||||||
|
static let identifier = "io.narl.protonstream.new-episodes"
|
||||||
|
private static let interval: TimeInterval = 6 * 60 * 60
|
||||||
|
|
||||||
|
/// At launch, before the app finishes launching: iOS refuses a handler
|
||||||
|
/// registered later.
|
||||||
|
static func register() {
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
|
||||||
|
guard let task = task as? BGProcessingTask else { return }
|
||||||
|
run(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the next look scheduled, or cancel it when the viewer turned it off.
|
||||||
|
static func schedule() {
|
||||||
|
guard SettingsStore().newEpisodeNotifications else {
|
||||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let request = BGProcessingTaskRequest(identifier: identifier)
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask once, when the viewer has the setting on; iOS remembers the answer.
|
||||||
|
static func requestPermission() {
|
||||||
|
guard SettingsStore().newEpisodeNotifications else { return }
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func run(_ task: BGProcessingTask) {
|
||||||
|
schedule()
|
||||||
|
let work = Task {
|
||||||
|
let news = (try? await NativeRuntime.engine().crawlForNews()) ?? []
|
||||||
|
await post(news)
|
||||||
|
task.setTaskCompleted(success: !Task.isCancelled)
|
||||||
|
}
|
||||||
|
task.expirationHandler = {
|
||||||
|
work.cancel()
|
||||||
|
Task { try? await NativeRuntime.engine().stopCrawl() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(_ news: [NewEpisodesRecord]) async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
guard await center.notificationSettings().authorizationStatus == .authorized else { return }
|
||||||
|
for item in news {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = item.title
|
||||||
|
content.body = describe(item)
|
||||||
|
content.threadIdentifier = "new-episodes"
|
||||||
|
// Keyed by the show, so a second look's news replaces the first.
|
||||||
|
let request = UNNotificationRequest(identifier: "new-episodes-\(item.titleKey)", content: content, trigger: nil)
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one show's notification says: "S01E04 and 2 more are new".
|
||||||
|
static func describe(_ news: NewEpisodesRecord) -> String {
|
||||||
|
let more = Int(news.count) - 1
|
||||||
|
guard let first = news.first else {
|
||||||
|
return news.count == 1 ? "1 new episode" : "\(news.count) new episodes"
|
||||||
|
}
|
||||||
|
switch more {
|
||||||
|
case 0: return "\(first) is new"
|
||||||
|
case 1: return "\(first) and 1 more are new"
|
||||||
|
default: return "\(first) and \(more) more are new"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,13 @@ struct ProtonStreamApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
NewEpisodes.register()
|
||||||
|
NewEpisodes.schedule()
|
||||||
|
NewEpisodes.requestPermission()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
|
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
|
||||||
MainActor.assumeIsolated { OrientationLock.mask }
|
MainActor.assumeIsolated { OrientationLock.mask }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ struct SettingsStore {
|
|||||||
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) }
|
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a background crawl may say that a show gained episodes.
|
||||||
|
var newEpisodeNotifications: Bool {
|
||||||
|
get { bool(Key.newEpisodeNotifications, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
|
||||||
|
}
|
||||||
|
|
||||||
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
|
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
|
||||||
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
|
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
|
||||||
|
|
||||||
@@ -88,5 +94,6 @@ struct SettingsStore {
|
|||||||
static let libraryFilter = "library_filter"
|
static let libraryFilter = "library_filter"
|
||||||
static let libraryGrouped = "library_grouped"
|
static let libraryGrouped = "library_grouped"
|
||||||
static let metadataOfferDismissed = "metadata_offer_dismissed"
|
static let metadataOfferDismissed = "metadata_offer_dismissed"
|
||||||
|
static let newEpisodeNotifications = "new_episode_notifications"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,10 @@ struct MpvTrack: Identifiable, Hashable {
|
|||||||
let language: String
|
let language: String
|
||||||
let title: String
|
let title: String
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
|
/// The container marks it as the default choice.
|
||||||
|
var isDefault = false
|
||||||
|
/// The container marks it as forced: on-screen text only, by convention.
|
||||||
|
var forced = false
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
@@ -91,6 +95,8 @@ final class MpvPlayer {
|
|||||||
/// Called when the duration or the pause state changes: what the lock
|
/// Called when the duration or the pause state changes: what the lock
|
||||||
/// screen shows, and whether the screen may sleep.
|
/// screen shows, and whether the screen may sleep.
|
||||||
@ObservationIgnored var onStateChanged: (() -> Void)?
|
@ObservationIgnored var onStateChanged: (() -> Void)?
|
||||||
|
/// Called once per file, when mpv has loaded it and picked its tracks.
|
||||||
|
@ObservationIgnored var onLoaded: (() -> Void)?
|
||||||
|
|
||||||
@ObservationIgnored let layer = MpvMetalLayer()
|
@ObservationIgnored let layer = MpvMetalLayer()
|
||||||
@ObservationIgnored private var mpv: OpaquePointer?
|
@ObservationIgnored private var mpv: OpaquePointer?
|
||||||
@@ -99,6 +105,22 @@ final class MpvPlayer {
|
|||||||
/// `END_FILE(stop)` that stopping it emits.
|
/// `END_FILE(stop)` that stopping it emits.
|
||||||
@ObservationIgnored private var loading = false
|
@ObservationIgnored private var loading = false
|
||||||
@ObservationIgnored private var pendingMedia: String?
|
@ObservationIgnored private var pendingMedia: String?
|
||||||
|
/// The load generation whose file mpv has loaded.
|
||||||
|
@ObservationIgnored private var loadedGeneration = -1
|
||||||
|
/// Subtitle files handed over before their file loaded, and whether each
|
||||||
|
/// is the one to turn on.
|
||||||
|
@ObservationIgnored private var pendingSubtitles: [(SubtitleFileRecord, Bool)] = []
|
||||||
|
/// The drawable size mpv last built its output for; see `outputResized`.
|
||||||
|
@ObservationIgnored private var outputSize: CGSize = .zero
|
||||||
|
/// Off while the app is in the background, where a refit must not bring
|
||||||
|
/// the picture back.
|
||||||
|
@ObservationIgnored private var videoEnabled = true
|
||||||
|
@ObservationIgnored private var refitTask: Task<Void, Never>?
|
||||||
|
/// Every `vid` write, in the order they were made. Each one tears down or
|
||||||
|
/// builds mpv's video output and waits on its render thread, which the
|
||||||
|
/// main thread must not; and a refit's "auto" must never land after the
|
||||||
|
/// background's "no".
|
||||||
|
@ObservationIgnored private let videoQueue = DispatchQueue(label: "mpv-video")
|
||||||
|
|
||||||
/// A core, or nil when libmpv would not start.
|
/// A core, or nil when libmpv would not start.
|
||||||
static func create() -> MpvPlayer? {
|
static func create() -> MpvPlayer? {
|
||||||
@@ -209,6 +231,9 @@ final class MpvPlayer {
|
|||||||
problem = nil
|
problem = nil
|
||||||
self.media = nil
|
self.media = nil
|
||||||
generation += 1
|
generation += 1
|
||||||
|
// Subtitles for the file being replaced will never be added.
|
||||||
|
pendingSubtitles.forEach { pstr_android_stream_release($0.0.handle) }
|
||||||
|
pendingSubtitles = []
|
||||||
pendingMedia = media
|
pendingMedia = media
|
||||||
loading = true
|
loading = true
|
||||||
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
||||||
@@ -219,6 +244,46 @@ final class MpvPlayer {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add the subtitle files beside the file of load `generation`.
|
||||||
|
///
|
||||||
|
/// Held until mpv has that file loaded, since an external track belongs to
|
||||||
|
/// the file open at the time, and released instead if another file has
|
||||||
|
/// been loaded since. `files.preferred` is turned on only when the file
|
||||||
|
/// has no subtitle of its own playing; `subtitles` false turns none on.
|
||||||
|
func addSubtitles(generation: Int, files: SubtitleFilesRecord, subtitles: Bool) {
|
||||||
|
let marked = files.files.enumerated().map { index, file in
|
||||||
|
(file, subtitles && files.preferred == UInt32(index))
|
||||||
|
}
|
||||||
|
if generation != self.generation {
|
||||||
|
marked.forEach { pstr_android_stream_release($0.0.handle) }
|
||||||
|
} else if loadedGeneration == generation {
|
||||||
|
attach(marked)
|
||||||
|
} else {
|
||||||
|
pendingSubtitles += marked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sub-add` off the main thread: mpv reads the whole file before it
|
||||||
|
/// returns, through the stream protocol and so from the network.
|
||||||
|
private func attach(_ subtitles: [(SubtitleFileRecord, Bool)]) {
|
||||||
|
guard let mpv, !subtitles.isEmpty else { return }
|
||||||
|
let showing = getString("sid").map { $0 != "no" } ?? false
|
||||||
|
let core = UncheckedCore(mpv)
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
for (file, select) in subtitles {
|
||||||
|
var args = ["sub-add", "pstr://\(file.handle)", select && !showing ? "select" : "auto", file.title]
|
||||||
|
if let language = file.language { args.append(language) }
|
||||||
|
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
|
||||||
|
pointers.append(nil)
|
||||||
|
let status = mpv_command(core.pointer, &pointers)
|
||||||
|
pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) }
|
||||||
|
// On failure mpv may never have opened it, so nothing will
|
||||||
|
// close it. Releasing one it did open and close is a no-op.
|
||||||
|
if status < 0 { pstr_android_stream_release(file.handle) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Written through rather than left to the observer: a tap that only shows
|
/// Written through rather than left to the observer: a tap that only shows
|
||||||
/// its effect on mpv's next property event reads as a button that missed.
|
/// its effect on mpv's next property event reads as a button that missed.
|
||||||
func setPaused(_ value: Bool) {
|
func setPaused(_ value: Bool) {
|
||||||
@@ -243,7 +308,42 @@ final class MpvPlayer {
|
|||||||
|
|
||||||
/// Showing no picture while backgrounded keeps the audio playing: iOS
|
/// Showing no picture while backgrounded keeps the audio playing: iOS
|
||||||
/// refuses GPU work from a background app, and would end the process for it.
|
/// refuses GPU work from a background app, and would end the process for it.
|
||||||
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
|
func setVideoEnabled(_ enabled: Bool) {
|
||||||
|
videoEnabled = enabled
|
||||||
|
setVideo([enabled ? "auto" : "no"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The layer's drawable changed size: a rotation, a Split View resize.
|
||||||
|
///
|
||||||
|
/// MPVKit's MoltenVK context reads `drawableSize` only when mpv builds its
|
||||||
|
/// output and never hears of a resize after that (mpvkit/MPVKit#3), so mpv
|
||||||
|
/// went on drawing a frame of the old size into the corner of the new
|
||||||
|
/// drawable. Turning the picture off and on builds the output again at the
|
||||||
|
/// new size — the same round trip the background makes, at the cost of a
|
||||||
|
/// refresh seek. Once the size has settled, so a Split View drag rebuilds
|
||||||
|
/// it once rather than per step.
|
||||||
|
func outputResized() {
|
||||||
|
refitTask?.cancel()
|
||||||
|
refitTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
let size = layer.drawableSize
|
||||||
|
// Only an output that exists and is the wrong size: nothing to fit
|
||||||
|
// while a file loads, and the background's "no" stays.
|
||||||
|
guard videoEnabled, videoWidth > 0, outputSize != .zero, size != outputSize,
|
||||||
|
size.width > 1, size.height > 1 else { return }
|
||||||
|
outputSize = size
|
||||||
|
setVideo(["no", "auto"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setVideo(_ values: [String]) {
|
||||||
|
guard let mpv else { return }
|
||||||
|
let core = UncheckedCore(mpv)
|
||||||
|
videoQueue.async {
|
||||||
|
for value in values { mpv_set_property_string(core.pointer, "vid", value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the open file. The stream behind it is released by mpv closing it,
|
/// Stop the open file. The stream behind it is released by mpv closing it,
|
||||||
/// which this causes; releasing it from here would race that close.
|
/// which this causes; releasing it from here would race that close.
|
||||||
@@ -266,6 +366,7 @@ final class MpvPlayer {
|
|||||||
case loaded
|
case loaded
|
||||||
case ended(Int64)
|
case ended(Int64)
|
||||||
case restarted
|
case restarted
|
||||||
|
case reconfigured
|
||||||
case log(prefix: String, text: String)
|
case log(prefix: String, text: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +398,8 @@ final class MpvPlayer {
|
|||||||
decoded = .loaded
|
decoded = .loaded
|
||||||
case MPV_EVENT_PLAYBACK_RESTART:
|
case MPV_EVENT_PLAYBACK_RESTART:
|
||||||
decoded = .restarted
|
decoded = .restarted
|
||||||
|
case MPV_EVENT_VIDEO_RECONFIG:
|
||||||
|
decoded = .reconfigured
|
||||||
case MPV_EVENT_END_FILE:
|
case MPV_EVENT_END_FILE:
|
||||||
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
|
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
|
||||||
decoded = .ended(Int64(end.reason.rawValue))
|
decoded = .ended(Int64(end.reason.rawValue))
|
||||||
@@ -370,8 +473,19 @@ final class MpvPlayer {
|
|||||||
case .loaded:
|
case .loaded:
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
refreshChapters()
|
refreshChapters()
|
||||||
|
// Mid-load, this is the outgoing file finishing its load.
|
||||||
|
if !loading {
|
||||||
|
loadedGeneration = generation
|
||||||
|
let ready = pendingSubtitles
|
||||||
|
pendingSubtitles = []
|
||||||
|
attach(ready)
|
||||||
|
onLoaded?()
|
||||||
|
}
|
||||||
case .restarted:
|
case .restarted:
|
||||||
seeking = false
|
seeking = false
|
||||||
|
case .reconfigured:
|
||||||
|
// mpv has just read the drawable to size its output by.
|
||||||
|
outputSize = layer.drawableSize
|
||||||
case let .ended(reason):
|
case let .ended(reason):
|
||||||
// The outgoing file's END_FILE arrives after the next load began.
|
// The outgoing file's END_FILE arrives after the next load began.
|
||||||
if loading { return }
|
if loading { return }
|
||||||
@@ -404,7 +518,9 @@ final class MpvPlayer {
|
|||||||
type: getString(base + "type") ?? "",
|
type: getString(base + "type") ?? "",
|
||||||
language: getString(base + "lang") ?? "",
|
language: getString(base + "lang") ?? "",
|
||||||
title: getString(base + "title") ?? "",
|
title: getString(base + "title") ?? "",
|
||||||
selected: getString(base + "selected") == "yes"
|
selected: getString(base + "selected") == "yes",
|
||||||
|
isDefault: getString(base + "default") == "yes",
|
||||||
|
forced: getString(base + "forced") == "yes"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,3 +580,11 @@ final class MpvPlayer {
|
|||||||
return String(cString: raw)
|
return String(cString: raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mpv's handle, for a command issued off the main thread. libmpv's client API
|
||||||
|
/// is thread-safe, and the core is only destroyed when it shuts down, which
|
||||||
|
/// nothing in the app asks it to.
|
||||||
|
private struct UncheckedCore: @unchecked Sendable {
|
||||||
|
let pointer: OpaquePointer
|
||||||
|
init(_ pointer: OpaquePointer) { self.pointer = pointer }
|
||||||
|
}
|
||||||
@@ -256,6 +256,7 @@ final class PlayerHost {
|
|||||||
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
|
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
|
||||||
player?.onTick = { [weak self] in self?.tick() }
|
player?.onTick = { [weak self] in self?.tick() }
|
||||||
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
|
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
|
||||||
|
player?.onLoaded = { [weak self] in self?.preferFullSubtitles() }
|
||||||
}
|
}
|
||||||
guard let player else {
|
guard let player else {
|
||||||
playbackError = "Playback is unavailable: libmpv did not start."
|
playbackError = "Playback is unavailable: libmpv did not start."
|
||||||
@@ -305,6 +306,16 @@ final class PlayerHost {
|
|||||||
player.setMuted(global.muted)
|
player.setMuted(global.muted)
|
||||||
player.setSpeed(global.speed)
|
player.setSpeed(global.speed)
|
||||||
player.setPaused(false)
|
player.setPaused(false)
|
||||||
|
// While mpv opens the episode; the player holds them until it has.
|
||||||
|
let generation = player.generation
|
||||||
|
let subtitleLanguage = show?.subtitleLanguage ?? global.subtitleLanguage
|
||||||
|
let subtitles = show?.subtitles ?? global.subtitles
|
||||||
|
Task {
|
||||||
|
guard let files = try? await NativeRuntime.engine().openSubtitles(
|
||||||
|
shareId: episode.shareId, linkId: episode.linkId, language: subtitleLanguage
|
||||||
|
) else { return }
|
||||||
|
player.addSubtitles(generation: generation, files: files, subtitles: subtitles)
|
||||||
|
}
|
||||||
startSaving()
|
startSaving()
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled || self.session !== session {
|
if Task.isCancelled || self.session !== session {
|
||||||
@@ -423,6 +434,18 @@ final class PlayerHost {
|
|||||||
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
|
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play the dialogue rather than the signs and songs mpv picked by
|
||||||
|
/// language (`pstr_core::subtitles`). Not remembered: it is the choice mpv
|
||||||
|
/// should have made, not the viewer's.
|
||||||
|
private func preferFullSubtitles() {
|
||||||
|
guard let player, playingOpenEpisode else { return }
|
||||||
|
let subtitles = player.tracks.filter { $0.type == "sub" }.map {
|
||||||
|
SubtitleTrackRecord(id: $0.id, language: $0.language, title: $0.title, default: $0.isDefault, forced: $0.forced, selected: $0.selected)
|
||||||
|
}
|
||||||
|
guard let id = fullSubtitlesInstead(tracks: subtitles), let track = player.tracks.first(where: { $0.type == "sub" && $0.id == id }) else { return }
|
||||||
|
player.select(type: "sub", track: track)
|
||||||
|
}
|
||||||
|
|
||||||
/// The skip button's offer at the current position, if any.
|
/// The skip button's offer at the current position, if any.
|
||||||
var offer: SkipOffer? {
|
var offer: SkipOffer? {
|
||||||
guard let player, playingOpenEpisode else { return nil }
|
guard let player, playingOpenEpisode else { return nil }
|
||||||
|
|||||||
@@ -724,16 +724,18 @@ struct MpvView: UIViewRepresentable {
|
|||||||
let player: MpvPlayer
|
let player: MpvPlayer
|
||||||
|
|
||||||
func makeUIView(context _: Context) -> LayerView {
|
func makeUIView(context _: Context) -> LayerView {
|
||||||
LayerView(layer: player.layer)
|
LayerView(player: player)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_: LayerView, context _: Context) {}
|
func updateUIView(_: LayerView, context _: Context) {}
|
||||||
|
|
||||||
final class LayerView: UIView {
|
final class LayerView: UIView {
|
||||||
|
private let player: MpvPlayer
|
||||||
private let video: CAMetalLayer
|
private let video: CAMetalLayer
|
||||||
|
|
||||||
init(layer video: CAMetalLayer) {
|
init(player: MpvPlayer) {
|
||||||
self.video = video
|
self.player = player
|
||||||
|
video = player.layer
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
backgroundColor = .black
|
backgroundColor = .black
|
||||||
video.removeFromSuperlayer()
|
video.removeFromSuperlayer()
|
||||||
@@ -759,6 +761,8 @@ struct MpvView: UIViewRepresentable {
|
|||||||
video.contentsScale = scale
|
video.contentsScale = scale
|
||||||
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
|
// A new drawable is not enough on its own: mpv has to be told.
|
||||||
|
player.outputResized()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func didMoveToWindow() {
|
override func didMoveToWindow() {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
SwitchRow(headline: "Hardware decoding", supporting: "Turn off if video shows green or torn frames", isOn: $settings.hardwareDecoding)
|
SwitchRow(headline: "Hardware decoding", supporting: "Turn off if video shows green or torn frames", isOn: $settings.hardwareDecoding)
|
||||||
SwitchRow(headline: "Background audio", supporting: "Keeps playing with the app closed, from the lock screen", isOn: $settings.backgroundAudio)
|
SwitchRow(headline: "Background audio", supporting: "Keeps playing with the app closed, from the lock screen", isOn: $settings.backgroundAudio)
|
||||||
|
SwitchRow(headline: "New episode notifications", supporting: "Looks at your shares now and then and says when a show gains episodes", isOn: $settings.newEpisodeNotifications)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var storage: some View {
|
@ViewBuilder private var storage: some View {
|
||||||
@@ -264,12 +265,20 @@ private final class SettingsValues {
|
|||||||
private let store = SettingsStore()
|
private let store = SettingsStore()
|
||||||
var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } }
|
var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } }
|
||||||
var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } }
|
var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } }
|
||||||
|
var newEpisodeNotifications: Bool {
|
||||||
|
didSet {
|
||||||
|
store.newEpisodeNotifications = newEpisodeNotifications
|
||||||
|
NewEpisodes.schedule()
|
||||||
|
NewEpisodes.requestPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
var hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } }
|
var hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } }
|
||||||
var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } }
|
var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } }
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
wifiOnly = store.wifiOnly
|
wifiOnly = store.wifiOnly
|
||||||
backgroundAudio = store.backgroundAudio
|
backgroundAudio = store.backgroundAudio
|
||||||
|
newEpisodeNotifications = store.newEpisodeNotifications
|
||||||
hardwareDecoding = store.hardwareDecoding
|
hardwareDecoding = store.hardwareDecoding
|
||||||
cacheBudgetGib = store.cacheBudgetGib
|
cacheBudgetGib = store.cacheBudgetGib
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ targets:
|
|||||||
CFBundleDisplayName: Proton Stream
|
CFBundleDisplayName: Proton Stream
|
||||||
LSRequiresIPhoneOS: true
|
LSRequiresIPhoneOS: true
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
UIBackgroundModes: [audio]
|
UIBackgroundModes: [audio, processing]
|
||||||
|
BGTaskSchedulerPermittedIdentifiers:
|
||||||
|
- io.narl.protonstream.new-episodes
|
||||||
# The Proton palette's page colour, so a dark app does not open on white.
|
# The Proton palette's page colour, so a dark app does not open on white.
|
||||||
UILaunchScreen:
|
UILaunchScreen:
|
||||||
UIColorName: LaunchBackground
|
UIColorName: LaunchBackground
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
# builds from the registry alone.
|
# builds from the registry alone.
|
||||||
|
|
||||||
pkgname=proton-stream
|
pkgname=proton-stream
|
||||||
pkgver=1.5.0
|
pkgver=1.6.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -26,9 +26,14 @@
|
|||||||
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#ps-face)"/>
|
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#ps-face)"/>
|
||||||
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||||
|
|
||||||
<!-- glyph shadow, then the glyph itself -->
|
<!-- glyph shadow, then the glyph itself. The mark's box centres on x=73 and
|
||||||
<g transform="translate(0 3)">
|
its centroid on x=66; it sits at 70 -> 64, between the two, because the
|
||||||
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
|
box centre reads as too far left and the centroid as too far right. The
|
||||||
|
Android and iOS icons centre it the same way. -->
|
||||||
|
<g transform="translate(-6 0)">
|
||||||
|
<g transform="translate(0 3)">
|
||||||
|
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
|
||||||
</g>
|
</g>
|
||||||
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publish the documentation in docs/ to the GitHub wiki.
|
||||||
|
#
|
||||||
|
# The pages in docs/ are the source of truth; the wiki is a generated copy for
|
||||||
|
# people who look there first. Each run checks every link in docs/, clones the
|
||||||
|
# wiki, replaces all of its pages with the ones listed in SECTIONS below,
|
||||||
|
# rewrites links between them to wiki page names and every other repository
|
||||||
|
# link to an absolute GitHub URL, copies the images they show, writes the
|
||||||
|
# sidebar and footer, and commits. Nothing is pushed without --push.
|
||||||
|
#
|
||||||
|
# --check only checks the links and that every page in docs/ is listed or
|
||||||
|
# excluded, without touching the wiki. CI runs it for every change to docs/,
|
||||||
|
# and every push to main that passes it publishes.
|
||||||
|
#
|
||||||
|
# GitHub creates the wiki repository only when the first page is saved, so
|
||||||
|
# create any page once in the web UI before the first run. WIKI_REMOTE
|
||||||
|
# overrides the remote, which is how the workflow pushes with its token.
|
||||||
|
#
|
||||||
|
# usage: scripts/publish-wiki.sh [--check | --push] [WORKDIR]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_url="https://github.com/narrrl/proton-stream"
|
||||||
|
wiki_remote="${WIKI_REMOTE:-git@github.com:narrrl/proton-stream.wiki.git}"
|
||||||
|
|
||||||
|
mode=commit
|
||||||
|
case "${1:-}" in
|
||||||
|
--check) mode=check; shift ;;
|
||||||
|
--push) mode=push; shift ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
workdir="${1:-$(mktemp -d)}"
|
||||||
|
wiki="$workdir/wiki"
|
||||||
|
|
||||||
|
if [ "$mode" = check ]; then
|
||||||
|
wiki="$workdir/check"
|
||||||
|
mkdir -p "$wiki"
|
||||||
|
elif [ -d "$wiki/.git" ]; then
|
||||||
|
git -C "$wiki" pull --ff-only --quiet
|
||||||
|
else
|
||||||
|
git clone --quiet "$wiki_remote" "$wiki"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rev="$(git -C "$root" rev-parse --short HEAD)"
|
||||||
|
version="$(grep -m1 '^version' "$root/Cargo.toml" | cut -d'"' -f2)"
|
||||||
|
|
||||||
|
python3 - "$root" "$wiki" "$repo_url" "$version" "$rev" <<'EOF'
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root, wiki, repo_url, version, rev = sys.argv[1:]
|
||||||
|
root = pathlib.Path(root)
|
||||||
|
wiki = pathlib.Path(wiki)
|
||||||
|
docs = root / "docs"
|
||||||
|
|
||||||
|
HOME = ("README.md", "Home")
|
||||||
|
|
||||||
|
# Sidebar sections: docs/ file -> wiki page name, in order.
|
||||||
|
SECTIONS = [
|
||||||
|
("Start here", [
|
||||||
|
("GETTING_STARTED.md", "Getting-Started"),
|
||||||
|
("INSTALL.md", "Installation"),
|
||||||
|
("FAQ.md", "FAQ"),
|
||||||
|
]),
|
||||||
|
("Using it", [
|
||||||
|
("USER_GUIDE.md", "User-Guide"),
|
||||||
|
("ACCOUNT_AND_SYNC.md", "Account-and-Sync"),
|
||||||
|
("MOBILE.md", "Android-and-iOS"),
|
||||||
|
]),
|
||||||
|
("Reference", [
|
||||||
|
("CLI.md", "Command-Line-Reference"),
|
||||||
|
("PRIVACY.md", "Privacy"),
|
||||||
|
]),
|
||||||
|
("When something goes wrong", [
|
||||||
|
("TROUBLESHOOTING.md", "Troubleshooting"),
|
||||||
|
]),
|
||||||
|
("Contributing", [
|
||||||
|
("DEVELOPMENT.md", "Development"),
|
||||||
|
("ARCHITECTURE.md", "Architecture"),
|
||||||
|
("TESTING.md", "Testing"),
|
||||||
|
("METADATA.md", "Metadata"),
|
||||||
|
("DESIGN-TOKENS.md", "Design-Tokens"),
|
||||||
|
("ANDROID.md", "Android-Development"),
|
||||||
|
("IOS.md", "iOS-Development"),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||||
|
EXCLUDED = {"BUGS.md"}
|
||||||
|
|
||||||
|
# Files outside docs/ whose links are checked but not published.
|
||||||
|
CHECKED = ["README.md"]
|
||||||
|
|
||||||
|
PAGES = dict([HOME] + [page for _, pages in SECTIONS for page in pages])
|
||||||
|
images_url = f"https://raw.githubusercontent.com/wiki/{repo_url.removeprefix('https://github.com/')}"
|
||||||
|
|
||||||
|
LINK = re.compile(r"(\]\(|src=\")([^)\"\s]+)([)\"])")
|
||||||
|
FENCE = re.compile(r"^\s*(```|~~~)")
|
||||||
|
INLINE_CODE = re.compile(r"(`[^`\n]*`)")
|
||||||
|
HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
||||||
|
EXPLICIT_ID = re.compile(r"<a\s+id=\"([^\"]+)\"")
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
images = set()
|
||||||
|
|
||||||
|
|
||||||
|
def outside_fences(text):
|
||||||
|
"""Yield (is_prose, chunk) so links in code blocks are left alone."""
|
||||||
|
chunk, fenced = [], False
|
||||||
|
for line in text.splitlines(keepends=True):
|
||||||
|
if FENCE.match(line):
|
||||||
|
if chunk:
|
||||||
|
yield not fenced, "".join(chunk)
|
||||||
|
chunk, fenced = [], not fenced
|
||||||
|
yield False, line
|
||||||
|
continue
|
||||||
|
chunk.append(line)
|
||||||
|
if chunk:
|
||||||
|
yield not fenced, "".join(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def slug(heading):
|
||||||
|
"""GitHub's anchor for a heading."""
|
||||||
|
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", heading)
|
||||||
|
return re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
|
anchor_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def anchors(path):
|
||||||
|
if path not in anchor_cache:
|
||||||
|
found, seen = set(), {}
|
||||||
|
for prose, chunk in outside_fences(path.read_text()):
|
||||||
|
if not prose:
|
||||||
|
continue
|
||||||
|
for line in chunk.splitlines():
|
||||||
|
found.update(EXPLICIT_ID.findall(line))
|
||||||
|
m = HEADING.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
base = slug(m.group(2))
|
||||||
|
n = seen.get(base, 0)
|
||||||
|
seen[base] = n + 1
|
||||||
|
found.add(base if n == 0 else f"{base}-{n}")
|
||||||
|
anchor_cache[path] = found
|
||||||
|
return anchor_cache[path]
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(source, target):
|
||||||
|
"""The wiki form of one link in source, recording broken links."""
|
||||||
|
if re.match(r"^[a-z]+:", target):
|
||||||
|
return target
|
||||||
|
path, _, anchor = target.partition("#")
|
||||||
|
resolved = (source.parent / path).resolve() if path else source
|
||||||
|
where = f"{source.relative_to(root)}: {target}"
|
||||||
|
if not resolved.is_relative_to(root) or not resolved.exists():
|
||||||
|
errors.append(f"{where}: no such file")
|
||||||
|
return target
|
||||||
|
if anchor and resolved.suffix == ".md" and anchor not in anchors(resolved):
|
||||||
|
errors.append(f"{where}: no heading #{anchor}")
|
||||||
|
relative = resolved.relative_to(root)
|
||||||
|
anchor = f"#{anchor}" if anchor else ""
|
||||||
|
if not path:
|
||||||
|
return anchor
|
||||||
|
if relative.parent == pathlib.Path("docs") and relative.name in PAGES:
|
||||||
|
return PAGES[relative.name] + anchor
|
||||||
|
if relative.parts[0] == "images":
|
||||||
|
images.add(relative)
|
||||||
|
return f"{images_url}/{relative}"
|
||||||
|
kind = "tree" if resolved.is_dir() else "blob"
|
||||||
|
return f"{repo_url}/{kind}/main/{relative}{anchor}"
|
||||||
|
|
||||||
|
|
||||||
|
def convert(source):
|
||||||
|
out = []
|
||||||
|
for prose, chunk in outside_fences(source.read_text()):
|
||||||
|
if prose:
|
||||||
|
# Odd pieces are `inline code`, which is left alone too.
|
||||||
|
pieces = INLINE_CODE.split(chunk)
|
||||||
|
pieces[::2] = [
|
||||||
|
LINK.sub(lambda m: m.group(1) + rewrite(source, m.group(2)) + m.group(3), p)
|
||||||
|
for p in pieces[::2]
|
||||||
|
]
|
||||||
|
chunk = "".join(pieces)
|
||||||
|
out.append(chunk)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def title(source):
|
||||||
|
return (docs / source).read_text().splitlines()[0].lstrip("# ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def without_title(text):
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
if lines and lines[0].startswith("# "):
|
||||||
|
lines = lines[1:]
|
||||||
|
while lines and not lines[0].strip():
|
||||||
|
lines = lines[1:]
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
for name in sorted(p.name for p in docs.glob("*.md")):
|
||||||
|
if name not in PAGES and name not in EXCLUDED:
|
||||||
|
errors.append(f"docs/{name}: add it to SECTIONS or EXCLUDED in scripts/publish-wiki.sh")
|
||||||
|
|
||||||
|
converted = {source: convert(docs / source) for source in PAGES}
|
||||||
|
published_images = set(images)
|
||||||
|
for name in CHECKED:
|
||||||
|
convert(root / name)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("Broken documentation links:", file=sys.stderr)
|
||||||
|
for error in errors:
|
||||||
|
print(f" {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# The wiki is generated: anything not written below is stale.
|
||||||
|
for old in wiki.glob("*.md"):
|
||||||
|
old.unlink()
|
||||||
|
shutil.rmtree(wiki / "images", ignore_errors=True)
|
||||||
|
|
||||||
|
note = (
|
||||||
|
"<!-- Generated from docs/ in the repository by scripts/publish-wiki.sh."
|
||||||
|
" Edit the source there; changes made here are overwritten. -->\n\n"
|
||||||
|
)
|
||||||
|
for source, page in PAGES.items():
|
||||||
|
(wiki / f"{page}.md").write_text(note + without_title(converted[source]))
|
||||||
|
|
||||||
|
for image in published_images:
|
||||||
|
(wiki / image).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(root / image, wiki / image)
|
||||||
|
|
||||||
|
sidebar = ["**[Home](Home)**\n"]
|
||||||
|
for section, pages in SECTIONS:
|
||||||
|
sidebar.append(f"\n**{section}**\n\n")
|
||||||
|
sidebar += [f"- [{title(source)}]({page})\n" for source, page in pages]
|
||||||
|
sidebar += [
|
||||||
|
"\n---\n\n",
|
||||||
|
f"[Releases]({repo_url}/releases) · [Issues]({repo_url}/issues)\n",
|
||||||
|
]
|
||||||
|
(wiki / "_Sidebar.md").write_text(note + "".join(sidebar))
|
||||||
|
|
||||||
|
(wiki / "_Footer.md").write_text(
|
||||||
|
note
|
||||||
|
+ f"Documentation of proton-stream {version}, generated from "
|
||||||
|
+ f"[`docs/`]({repo_url}/tree/{rev}/docs) at `{rev}`. "
|
||||||
|
+ f"To change a page, edit it in the [repository]({repo_url}).\n"
|
||||||
|
)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ "$mode" = check ]; then
|
||||||
|
echo "Documentation links are fine."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$wiki"
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Wiki is already up to date."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit --quiet -m "Update from docs at $rev"
|
||||||
|
git show --stat --format='%s' HEAD
|
||||||
|
|
||||||
|
if [ "$mode" = push ]; then
|
||||||
|
git push --quiet
|
||||||
|
echo "Pushed to the wiki."
|
||||||
|
else
|
||||||
|
echo "Committed in $wiki. Review it, then push from there or re-run with --push."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Render the Android TV launcher banner, 320 by 180 dp, from the iOS icon: its
|
||||||
|
# canvas widened to 16:9 with the face gradient run across the whole width,
|
||||||
|
# and the play mark a little smaller, since a banner is shown as is rather
|
||||||
|
# than cut down by a mask. Twice the size, in drawable-nodpi: one file for
|
||||||
|
# every screen, which the launcher scales to its slot, sharp on a 4K one.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
icon="$script_dir/../ios/ProtonStream/Resources/AppIcon.svg"
|
||||||
|
out="$script_dir/../android/app/src/main/res/drawable-nodpi/tv_banner.png"
|
||||||
|
|
||||||
|
command -v rsvg-convert >/dev/null || { echo "rsvg-convert is required (librsvg)" >&2; exit 1; }
|
||||||
|
command -v magick >/dev/null || { echo "magick is required (ImageMagick)" >&2; exit 1; }
|
||||||
|
mkdir -p "$(dirname "$out")"
|
||||||
|
sed -e 's/width="1024" height="1024" viewBox="0 0 108 108"/width="320" height="180" viewBox="-42 0 192 108"/' \
|
||||||
|
-e 's/<rect width="108" height="108"/<rect x="-42" width="192" height="108"/' \
|
||||||
|
-e 's/x1="0" y1="0" x2="108" y2="108"/x1="-42" y1="0" x2="150" y2="108"/' \
|
||||||
|
-e 's/scale(1.5)/scale(1.15)/' \
|
||||||
|
"$icon" | rsvg-convert -w 640 -h 360 | magick - -alpha off -strip "$out"
|
||||||
|
echo "wrote $out"
|
||||||