Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658c620c82 | ||
|
|
c1487c31d0 | ||
|
|
a8a18be248 | ||
|
|
16c2b4c9d3 | ||
|
|
2871a13ba6 | ||
|
|
6e0be9e3c8 | ||
|
|
0868c2af69 | ||
|
|
4a3a7291b8 |
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]]
|
||||
name = "pgp"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
||||
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||
dependencies = [
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
@@ -5218,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.7.3"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f"
|
||||
checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
@@ -5245,9 +5245,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.7.3"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0"
|
||||
checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
@@ -5274,11 +5274,12 @@ dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pstr-android"
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"futures",
|
||||
@@ -5298,7 +5299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5332,7 +5333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5351,7 +5352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -5371,7 +5372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
@@ -5384,7 +5385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
@@ -5399,7 +5400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
|
||||
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.6.0"
|
||||
version = "1.6.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
opt-level = 2
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.7.3" }
|
||||
proton-drive-rs = { version = "0.7.3" }
|
||||
proton-sdk = { version = "0.9.0" }
|
||||
proton-drive-rs = { version = "0.9.0" }
|
||||
|
||||
# Internal crates.
|
||||
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
|
||||
|
||||
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 account, no server to host, no download step — episodes start playing in
|
||||
seconds and the seek bar works.
|
||||
**[proton-stream.narl.io](https://proton-stream.narl.io)**
|
||||
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust
|
||||
Proton Drive SDK.
|
||||
[](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
|
||||
[](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
|
||||
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet
|
||||
decrypted under one shared session key. There is no sequential streaming session
|
||||
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode
|
||||
is playable without downloading 2 GB of it.
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
|
||||
shared session key. There is no sequential streaming session to resume, so seeking costs exactly
|
||||
the blocks the seek lands on: a 2 GB episode plays 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
|
||||
mpv window of its own. The SDK streaming surface, the catalog, the streaming
|
||||
layer and the player are done and verified against a real share, and the app has
|
||||
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, subtitle files beside the video, optional metadata, themes and share
|
||||
management. What is left is mostly running the macOS packaging on a real Mac. See
|
||||
`docs/DEVELOPMENT.md`.
|
||||
The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
|
||||
no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
|
||||
whole data flow.
|
||||
|
||||
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
||||
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
||||
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).
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
|
||||
SDK.
|
||||
|
||||
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
|
||||
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
|
||||
[`docs/IOS.md`](docs/IOS.md).
|
||||
## Privacy
|
||||
|
||||
```bash
|
||||
cargo run --release -p pstr-app # the app
|
||||
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||
```
|
||||
- **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
|
||||
posters and descriptions, which sends your title names to AniList or TMDB.
|
||||
- **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
|
||||
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. |
|
||||
[Privacy](docs/PRIVACY.md) lists every connection and every file.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -85,53 +147,26 @@ Rust 2024, MSRV 1.96.
|
||||
|
||||
```bash
|
||||
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-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
|
||||
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.
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
|
||||
WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
|
||||
|
||||
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`,
|
||||
and a portable `.zip` plus an `.msi` on Windows — are produced by
|
||||
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
|
||||
per-platform detail.
|
||||
|
||||
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
||||
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
||||
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.
|
||||
- **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
|
||||
`scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
|
||||
[`packaging/README.md`](packaging/README.md).
|
||||
- **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
|
||||
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||
- **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
|
||||
[`docs/IOS.md`](docs/IOS.md).
|
||||
|
||||
## License
|
||||
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android and
|
||||
iOS applications 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
|
||||
the bundled-component notices.
|
||||
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
|
||||
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 the bundled
|
||||
component notices. The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||
and the Phosphor icon font, under MIT.
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -33,7 +33,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
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::ids::NodeUid;
|
||||
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 \
|
||||
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 —
|
||||
/// see `ShareStore::open`.
|
||||
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
|
||||
.iter()
|
||||
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
|
||||
.map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -424,9 +431,9 @@ impl AccountStore {
|
||||
/// back the account.
|
||||
///
|
||||
/// 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
|
||||
/// password: it unlocks the user keys, and a wrong password unlocks none of
|
||||
/// them. Only the passphrases are stored; the password goes no further.
|
||||
/// the key salts need. Unlocking the user keys is what proves the mailbox
|
||||
/// password: a wrong one unlocks none of them. Only the passphrases are
|
||||
/// stored; the password goes no further.
|
||||
async fn finish(
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
session: ProtonApiSession,
|
||||
@@ -435,10 +442,10 @@ async fn finish(
|
||||
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
||||
probe
|
||||
.account()
|
||||
.addresses()
|
||||
.unlock()
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
ProtonError::InvalidOperation(_) => {
|
||||
ProtonError::KeysLocked => {
|
||||
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
||||
}
|
||||
other => other.into(),
|
||||
@@ -447,10 +454,10 @@ async fn finish(
|
||||
.account()
|
||||
.key_passphrases()
|
||||
.await?
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|(id, passphrase)| {
|
||||
String::from_utf8(passphrase)
|
||||
.map(|passphrase| (id, passphrase))
|
||||
std::str::from_utf8(passphrase)
|
||||
.map(|passphrase| (id.to_owned(), passphrase.to_owned()))
|
||||
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
|
||||
})
|
||||
.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
|
||||
/// [`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));
|
||||
}
|
||||
if api.code == ResponseCode::Blocked {
|
||||
return Error::SignIn(SIGN_IN_BLOCKED.to_owned());
|
||||
}
|
||||
if api.http_status == 422 && !api.is_human_verification_required() {
|
||||
return Error::SignIn(if api.message.is_empty() {
|
||||
"Proton did not accept that address and password".to_owned()
|
||||
@@ -642,6 +653,27 @@ mod tests {
|
||||
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]
|
||||
fn a_stored_session_keeps_its_password_mode() {
|
||||
let params = stored().resume_parameters();
|
||||
|
||||
@@ -85,11 +85,14 @@ impl Error {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// sign-in grants. Nothing but signing in again fixes it.
|
||||
/// was revoked or expired, the token lacks a scope only a password
|
||||
/// 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 {
|
||||
match self {
|
||||
Self::SignedOut(_) => true,
|
||||
Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true,
|
||||
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
||||
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
||||
}
|
||||
@@ -127,4 +130,10 @@ mod tests {
|
||||
"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());
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ use libloading::Library;
|
||||
///
|
||||
/// 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.
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn candidates() -> Vec<&'static str> {
|
||||
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||
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)]
|
||||
fn candidates() -> Vec<&'static str> {
|
||||
vec!["opengl32.dll"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -14,7 +14,7 @@
|
||||
# builds from the registry alone.
|
||||
|
||||
pkgname=proton-stream
|
||||
pkgver=1.6.0
|
||||
pkgver=1.6.2
|
||||
pkgrel=1
|
||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||
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.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- glyph shadow, then the glyph itself -->
|
||||
<g transform="translate(0 3)">
|
||||
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
|
||||
<!-- 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>
|
||||
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
|
||||
</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
|
||||