Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a475ad3575 | ||
|
|
da13f0547e | ||
|
|
4ca57d9449 | ||
|
|
c9925b4b09 | ||
|
|
fe69d2fae4 | ||
|
|
3fe4900337 | ||
|
|
973a8f2749 | ||
|
|
cf8ee58f6b | ||
|
|
466630a9ba | ||
|
|
a3bc9023c7 | ||
|
|
7bfebc5fd5 | ||
|
|
87ccaf26e1 | ||
|
|
fafe0c3087 | ||
|
|
f66faf6b75 | ||
|
|
cd9e8eea81 | ||
|
|
c026f81283 | ||
|
|
c6d303f269 | ||
|
|
255e463fc8 | ||
|
|
3ebfd1a57b | ||
|
|
f4c08d03c7 | ||
|
|
a7b87cdc79 | ||
|
|
70952fa7ca | ||
|
|
3ca431feca | ||
|
|
bb5a6638c4 | ||
|
|
b5ba59b41d | ||
|
|
79f0d74e1a | ||
|
|
648c35e92a | ||
|
|
ffd6e959bb | ||
|
|
69d961d7b8 | ||
|
|
238ecd8220 | ||
|
|
65a669e231 | ||
|
|
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]]
|
[[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.6.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5298,7 +5299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "1.6.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5332,7 +5333,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "1.6.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5351,7 +5352,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "1.6.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5371,7 +5372,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "1.6.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -5384,7 +5385,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "1.6.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -5399,7 +5400,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "1.6.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.6.0"
|
version = "2.0.1"
|
||||||
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, 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`.
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun chapterPlanningCrossesTheBoundaryIntact() {
|
fun chapterPlanningCrossesTheBoundaryIntact() {
|
||||||
val plan = chapterPlan(emptyList(), 1560.0)
|
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
|
||||||
assertNotNull(plan)
|
assertNotNull(plan)
|
||||||
assertNull(skipOffer(plan, 12.0))
|
assertNull(skipOffer(plan, 12.0))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,8 +323,11 @@ class Player {
|
|||||||
// needs no window, and a load that cannot get one plays audio anyway —
|
// needs no window, and a load that cannot get one plays audio anyway —
|
||||||
// waiting for a surface here is what made background playback fail after
|
// waiting for a surface here is what made background playback fail after
|
||||||
// a five-second stall.
|
// a five-second stall.
|
||||||
if (!audio.empty()) set_string("alang", audio);
|
//
|
||||||
if (!subtitle.empty()) set_string("slang", subtitle);
|
// Set even when empty: mpv keeps both across `loadfile`, so a file
|
||||||
|
// with no language preference used to inherit the last one's.
|
||||||
|
set_string("alang", audio);
|
||||||
|
set_string("slang", subtitle);
|
||||||
set_string("sid", subtitles ? "auto" : "no");
|
set_string("sid", subtitles ? "auto" : "no");
|
||||||
pending_start_.store(std::max(0.0, start));
|
pending_start_.store(std::max(0.0, start));
|
||||||
// Retire the outgoing file's position, duration and end flag *before*
|
// Retire the outgoing file's position, duration and end flag *before*
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.narl.protonstream.playback
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import io.narl.protonstream.ui.ThumbnailSource
|
import io.narl.protonstream.ui.ThumbnailSource
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -19,6 +20,7 @@ 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.SkipRangeRecord
|
||||||
import uniffi.pstr_android.SubtitleFilesRecord
|
import uniffi.pstr_android.SubtitleFilesRecord
|
||||||
import uniffi.pstr_android.SubtitleTrackRecord
|
import uniffi.pstr_android.SubtitleTrackRecord
|
||||||
import uniffi.pstr_android.chapterPlan
|
import uniffi.pstr_android.chapterPlan
|
||||||
@@ -104,6 +106,15 @@ data class NowPlaying(
|
|||||||
val hasNext: Boolean,
|
val hasNext: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A sleep timer that is running. */
|
||||||
|
sealed interface SleepTimer {
|
||||||
|
/** Stop when [media] ends, rather than going on to the next episode. */
|
||||||
|
data class EndOf(val media: String) : SleepTimer
|
||||||
|
|
||||||
|
/** Pause at [at], on the [SystemClock.elapsedRealtime] clock. */
|
||||||
|
data class At(val at: Long) : SleepTimer
|
||||||
|
}
|
||||||
|
|
||||||
data class MpvTrack(
|
data class MpvTrack(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val type: String,
|
val type: String,
|
||||||
@@ -130,11 +141,15 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
||||||
private val mutableAdvancing = MutableStateFlow(false)
|
private val mutableAdvancing = MutableStateFlow(false)
|
||||||
private val mutableProblem = MutableStateFlow<String?>(null)
|
private val mutableProblem = MutableStateFlow<String?>(null)
|
||||||
|
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
|
||||||
|
private var sleepJob: Job? = null
|
||||||
private var poller: Job? = null
|
private var poller: Job? = null
|
||||||
/** The episode key and load generation the native player currently holds. */
|
/** The episode key and load generation the native player currently holds. */
|
||||||
@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
|
||||||
|
/** What AniSkip said about the open episode, keyed by that episode. */
|
||||||
|
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
|
||||||
/** The load generation whose subtitle pick has been looked at. */
|
/** The load generation whose subtitle pick has been looked at. */
|
||||||
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||||
var onPlaybackStarted: (() -> Unit)? = null
|
var onPlaybackStarted: (() -> Unit)? = null
|
||||||
@@ -175,6 +190,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
*/
|
*/
|
||||||
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sleep timer. Kept here rather than on the screen, because the
|
||||||
|
* episode it is about keeps playing with the screen off.
|
||||||
|
*/
|
||||||
|
val sleep: StateFlow<SleepTimer?> = mutableSleep.asStateFlow()
|
||||||
|
|
||||||
|
/** Start a sleep timer, replacing any running one, or turn it off. */
|
||||||
|
fun setSleepTimer(timer: SleepTimer?) {
|
||||||
|
sleepJob?.cancel()
|
||||||
|
mutableSleep.value = timer
|
||||||
|
if (timer is SleepTimer.At) {
|
||||||
|
sleepJob = scope.launch {
|
||||||
|
delay((timer.at - SystemClock.elapsedRealtime()).coerceAtLeast(0))
|
||||||
|
mutableSleep.value = null
|
||||||
|
setPaused(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Dismiss the current [problem]. */
|
/** Dismiss the current [problem]. */
|
||||||
fun clearProblem() { mutableProblem.value = null }
|
fun clearProblem() { mutableProblem.value = null }
|
||||||
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||||
@@ -226,6 +260,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
mutableTracks.value = emptyList()
|
mutableTracks.value = emptyList()
|
||||||
mutableChapters.value = ChapterPlan(emptyList(), null)
|
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||||
plannedFor = null
|
plannedFor = null
|
||||||
|
skipRanges = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} ?: false
|
} ?: false
|
||||||
@@ -268,6 +303,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take AniSkip's answer for [media], and plan the chapters again with it.
|
||||||
|
* Dropped when another episode has opened since it was asked for.
|
||||||
|
*/
|
||||||
|
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
|
||||||
|
if (media != currentMedia) return
|
||||||
|
skipRanges = media to ranges
|
||||||
|
plannedFor = null
|
||||||
|
refreshChapters(mutableState.value)
|
||||||
|
}
|
||||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||||
|
|
||||||
@@ -276,8 +322,18 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
fun selectTrack(track: MpvTrack?) {
|
fun selectTrack(track: MpvTrack?) {
|
||||||
val audio = track?.type == "audio"
|
val audio = track?.type == "audio"
|
||||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||||
|
// mpv makes the switch on its own thread and lists it a moment later,
|
||||||
|
// so a track list read back now still ticks the old track. The tick
|
||||||
|
// moves here, and a read shortly after confirms it.
|
||||||
|
val type = if (audio) "audio" else "sub"
|
||||||
|
mutableTracks.value = mutableTracks.value.map {
|
||||||
|
if (it.type == type) it.copy(selected = it.id == track?.id) else it
|
||||||
|
}
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
delay(TRACK_SETTLE_MS)
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicit user stop: stop media and retire the foreground service.
|
* Explicit user stop: stop media and retire the foreground service.
|
||||||
@@ -394,7 +450,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
}
|
}
|
||||||
}.onSuccess { chapters ->
|
}.onSuccess { chapters ->
|
||||||
plannedFor = wanted
|
plannedFor = wanted
|
||||||
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
|
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
|
||||||
|
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +568,9 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private const val CACHED_UNTIL = 13
|
private const val CACHED_UNTIL = 13
|
||||||
private const val LOADED = 14
|
private const val LOADED = 14
|
||||||
|
|
||||||
|
/** How long after a track switch mpv's track list is read again. */
|
||||||
|
private const val TRACK_SETTLE_MS = 300L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
System.loadLibrary("pstr_mpv")
|
System.loadLibrary("pstr_mpv")
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
|
import androidx.compose.material.icons.filled.BrightnessMedium
|
||||||
|
import androidx.compose.material.icons.filled.FastForward
|
||||||
|
import androidx.compose.material.icons.filled.FastRewind
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** What a swipe on the picture is doing, for the bubble that says so. */
|
||||||
|
sealed interface Swipe {
|
||||||
|
/** The window's brightness, from 0 to 1. */
|
||||||
|
data class Brightness(val level: Float) : Swipe
|
||||||
|
|
||||||
|
/** The device's media volume, from 0 to 1. */
|
||||||
|
data class Volume(val level: Float) : Swipe
|
||||||
|
|
||||||
|
/** Where the playhead will land when the finger lifts, and where it was. */
|
||||||
|
data class Seek(val from: Double, val to: Double) : Swipe
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much of the file one swipe across the whole width is worth, in seconds. */
|
||||||
|
private const val SEEK_SPAN_SECONDS = 90.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A finger that comes down this close to the top or bottom of the screen is
|
||||||
|
* the system's: pulling down the shade, swiping home.
|
||||||
|
*/
|
||||||
|
private val SYSTEM_EDGE = 48.dp
|
||||||
|
|
||||||
|
/** Which way a swipe went, settled by its first movement past the touch slop. */
|
||||||
|
private enum class Axis { Brightness, Volume, Seek, Ignored }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The swipes every phone video player has taught: up and down on the left for
|
||||||
|
* brightness, on the right for volume, and sideways to seek — shown while the
|
||||||
|
* finger is down and only seeking when it lifts, because a seek per movement
|
||||||
|
* would throw away every block in flight.
|
||||||
|
*/
|
||||||
|
fun Modifier.playerSwipes(
|
||||||
|
enabled: Boolean,
|
||||||
|
activity: Activity?,
|
||||||
|
position: () -> Double,
|
||||||
|
duration: () -> Double,
|
||||||
|
onSwipe: (Swipe?) -> Unit,
|
||||||
|
onSeek: (Double) -> Unit,
|
||||||
|
): Modifier = pointerInput(enabled, activity) {
|
||||||
|
if (!enabled) return@pointerInput
|
||||||
|
val audio = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
val edge = SYSTEM_EDGE.toPx()
|
||||||
|
var axis = Axis.Ignored
|
||||||
|
var start = Offset.Zero
|
||||||
|
var travelled = Offset.Zero
|
||||||
|
var level = 0f
|
||||||
|
var seekFrom = 0.0
|
||||||
|
var seekTo = 0.0
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
start = offset
|
||||||
|
travelled = Offset.Zero
|
||||||
|
axis = if (offset.y < edge || offset.y > size.height - edge) Axis.Ignored else Axis.Seek
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
if (axis == Axis.Seek && seekTo != seekFrom) onSeek(seekTo)
|
||||||
|
onSwipe(null)
|
||||||
|
},
|
||||||
|
onDragCancel = { onSwipe(null) },
|
||||||
|
) { change, amount ->
|
||||||
|
if (axis == Axis.Ignored) return@detectDragGestures
|
||||||
|
change.consume()
|
||||||
|
val first = travelled == Offset.Zero
|
||||||
|
travelled += amount
|
||||||
|
if (first) {
|
||||||
|
// The first movement past the slop decides what the swipe is for.
|
||||||
|
axis = when {
|
||||||
|
abs(amount.x) > abs(amount.y) -> Axis.Seek
|
||||||
|
start.x < size.width / 2 -> Axis.Brightness
|
||||||
|
else -> Axis.Volume
|
||||||
|
}
|
||||||
|
level = when (axis) {
|
||||||
|
Axis.Brightness -> activity?.brightness() ?: 0.5f
|
||||||
|
Axis.Volume -> audio?.mediaLevel() ?: 0f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
seekFrom = position()
|
||||||
|
seekTo = seekFrom
|
||||||
|
}
|
||||||
|
when (axis) {
|
||||||
|
// A swipe the whole height of the screen goes from none to all.
|
||||||
|
Axis.Brightness -> {
|
||||||
|
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||||
|
activity?.setBrightness(level)
|
||||||
|
onSwipe(Swipe.Brightness(level))
|
||||||
|
}
|
||||||
|
Axis.Volume -> {
|
||||||
|
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||||
|
audio?.setMediaLevel(level)
|
||||||
|
onSwipe(Swipe.Volume(level))
|
||||||
|
}
|
||||||
|
Axis.Seek -> {
|
||||||
|
val length = duration()
|
||||||
|
if (length <= 0.0) return@detectDragGestures
|
||||||
|
seekTo = (seekFrom + travelled.x / size.width * SEEK_SPAN_SECONDS).coerceIn(0.0, length)
|
||||||
|
onSwipe(Swipe.Seek(seekFrom, seekTo))
|
||||||
|
}
|
||||||
|
Axis.Ignored -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The window's own brightness, or the screen's when the window has none. */
|
||||||
|
private fun Activity.brightness(): Float {
|
||||||
|
val own = window.attributes.screenBrightness
|
||||||
|
if (own >= 0f) return own
|
||||||
|
return runCatching {
|
||||||
|
android.provider.Settings.System.getInt(contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) / 255f
|
||||||
|
}.getOrDefault(0.5f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Activity.setBrightness(level: Float) {
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
// Never quite off: a black screen under a playing film looks broken.
|
||||||
|
screenBrightness = level.coerceAtLeast(0.01f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand brightness back to the system, as the player is left. */
|
||||||
|
fun Activity.releaseBrightness() {
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioManager.mediaLevel(): Float {
|
||||||
|
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
|
||||||
|
return getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioManager.setMediaLevel(level: Float) {
|
||||||
|
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
// No flags: the bubble is the feedback, and the system's own volume panel
|
||||||
|
// over the picture would be a second one.
|
||||||
|
setStreamVolume(AudioManager.STREAM_MUSIC, (level * max).roundToInt(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bubble in the middle of the picture while a swipe is under way. */
|
||||||
|
@Composable
|
||||||
|
fun SwipeBubble(swipe: Swipe, format: (Double) -> String, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
when (swipe) {
|
||||||
|
is Swipe.Brightness -> Level(Icons.Default.BrightnessMedium, "Brightness", swipe.level)
|
||||||
|
is Swipe.Volume -> Level(Icons.AutoMirrored.Filled.VolumeUp, "Volume", swipe.level)
|
||||||
|
is Swipe.Seek -> {
|
||||||
|
val delta = swipe.to - swipe.from
|
||||||
|
Icon(
|
||||||
|
if (delta < 0) Icons.Default.FastRewind else Icons.Default.FastForward,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
(if (delta < 0) "−" else "+") + format(abs(delta)),
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(format(swipe.to), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Level(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, level: Float) {
|
||||||
|
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
|
||||||
|
Text("$label ${(level * 100).roundToInt()}%", color = Color.White, style = MaterialTheme.typography.labelLarge)
|
||||||
|
Box(Modifier.width(120.dp)) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { level },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = Color.White.copy(alpha = 0.25f),
|
||||||
|
drawStopIndicator = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.playback
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.os.SystemClock
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -35,7 +36,10 @@ import androidx.compose.material.icons.automirrored.filled.Toc
|
|||||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Audiotrack
|
import androidx.compose.material.icons.filled.Audiotrack
|
||||||
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.LockOpen
|
||||||
import androidx.compose.material.icons.filled.Speed
|
import androidx.compose.material.icons.filled.Speed
|
||||||
import androidx.compose.material.icons.filled.Forward30
|
import androidx.compose.material.icons.filled.Forward30
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
import androidx.compose.material.icons.filled.Fullscreen
|
||||||
@@ -84,6 +88,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -97,6 +104,7 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.shownName
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
import io.narl.protonstream.ui.theme.AccentTrack
|
import io.narl.protonstream.ui.theme.AccentTrack
|
||||||
import io.narl.protonstream.ui.theme.EdgedButton
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
@@ -121,6 +129,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import uniffi.pstr_android.ChapterEntry
|
import uniffi.pstr_android.ChapterEntry
|
||||||
|
import uniffi.pstr_android.ChapterKind
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TrackPreferencesRecord
|
import uniffi.pstr_android.TrackPreferencesRecord
|
||||||
@@ -140,6 +149,9 @@ private const val DOUBLE_TAP_SECONDS = 10
|
|||||||
/** How long the double-tap seek mark stays up. */
|
/** How long the double-tap seek mark stays up. */
|
||||||
private const val NUDGE_MS = 700L
|
private const val NUDGE_MS = 700L
|
||||||
|
|
||||||
|
/** How long the unlock button stays up after a tap on a locked player. */
|
||||||
|
private const val UNLOCK_SHOWN_MS = 3_000L
|
||||||
|
|
||||||
/** How close to the end the next episode's opening blocks are fetched. */
|
/** How close to the end the next episode's opening blocks are fetched. */
|
||||||
private const val WARM_NEXT_SECONDS = 120.0
|
private const val WARM_NEXT_SECONDS = 120.0
|
||||||
|
|
||||||
@@ -185,6 +197,7 @@ fun PlayerScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
val titleKey = title.key
|
val titleKey = title.key
|
||||||
val episode = episodes.getOrNull(index)
|
val episode = episodes.getOrNull(index)
|
||||||
@@ -365,8 +378,22 @@ fun PlayerScreen(
|
|||||||
// own reading — never on a failure, and never on the outgoing file's.
|
// own reading — never on a failure, and never on the outgoing file's.
|
||||||
// Cancelled from the end-of-file card: the viewer chose to stay.
|
// Cancelled from the end-of-file card: the viewer chose to stay.
|
||||||
var stay by remember(key) { mutableStateOf(false) }
|
var stay by remember(key) { mutableStateOf(false) }
|
||||||
|
// The sleep timer set for the end of this episode stops it there instead.
|
||||||
|
val sleep by nativeHost.sleep.collectAsState()
|
||||||
|
val stopsAfterThis = (sleep as? SleepTimer.EndOf)?.media == key
|
||||||
LaunchedEffect(state.endReason, state.media) {
|
LaunchedEffect(state.endReason, state.media) {
|
||||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance()
|
if (state.endReason != EndReason.Eof || state.media != key) return@LaunchedEffect
|
||||||
|
if (stopsAfterThis) {
|
||||||
|
nativeHost.setSleepTimer(null)
|
||||||
|
} else if (autoplay && !stay && next != null) {
|
||||||
|
advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A timer for the end of an episode the viewer has since moved on from
|
||||||
|
// was about that episode.
|
||||||
|
LaunchedEffect(key) {
|
||||||
|
val timer = nativeHost.sleep.value
|
||||||
|
if (timer is SleepTimer.EndOf && timer.media != key) nativeHost.setSleepTimer(null)
|
||||||
}
|
}
|
||||||
// A file mpv could not play ends like any other. Saying so is the whole
|
// A file mpv could not play ends like any other. Saying so is the whole
|
||||||
// difference between a broken episode and an app that silently skipped one.
|
// difference between a broken episode and an app that silently skipped one.
|
||||||
@@ -420,6 +447,20 @@ fun PlayerScreen(
|
|||||||
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
|
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file whose chapters name no opening or ending asks AniSkip, once, when
|
||||||
|
// its length is known. Only with posters and descriptions on: anything
|
||||||
|
// else, an error included, leaves the file as its chapters made it.
|
||||||
|
val lengthKnown = playingThis && state.duration > 0.0
|
||||||
|
LaunchedEffect(key, lengthKnown) {
|
||||||
|
if (!lengthKnown) return@LaunchedEffect
|
||||||
|
val named = chapters.entries.any { it.kind == ChapterKind.OPENING || it.kind == ChapterKind.ENDING }
|
||||||
|
if (named) return@LaunchedEffect
|
||||||
|
val ranges = runCatching {
|
||||||
|
NativeRuntime.engine().skipTimes(titleKey, episode.shareId, episode.linkId, state.duration)
|
||||||
|
}.getOrNull()
|
||||||
|
if (!ranges.isNullOrEmpty()) nativeHost.setSkipRanges(key, ranges)
|
||||||
|
}
|
||||||
|
|
||||||
var landscapeLocked by remember { mutableStateOf(false) }
|
var landscapeLocked by remember { mutableStateOf(false) }
|
||||||
// Set by the rotation button: from then on the viewer decides.
|
// Set by the rotation button: from then on the viewer decides.
|
||||||
var rotationChosen by remember { mutableStateOf(false) }
|
var rotationChosen by remember { mutableStateOf(false) }
|
||||||
@@ -436,8 +477,12 @@ fun PlayerScreen(
|
|||||||
// four seconds after it is reached cannot be found again.
|
// four seconds after it is reached cannot be found again.
|
||||||
val exploring = rememberTouchExploration()
|
val exploring = rememberTouchExploration()
|
||||||
LaunchedEffect(exploring) { if (exploring) showControls = true }
|
LaunchedEffect(exploring) { if (exploring) showControls = true }
|
||||||
LaunchedEffect(showControls, state.paused, exploring) {
|
// A chooser open over the controls. They hold still under it: the
|
||||||
if (showControls && !state.paused && !exploring) {
|
// choosers live inside the controls, so hiding them closed whatever was
|
||||||
|
// being picked from, four seconds after the controls came up.
|
||||||
|
var picking by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(showControls, state.paused, exploring, picking) {
|
||||||
|
if (showControls && !state.paused && !exploring && !picking) {
|
||||||
delay(CONTROLS_SECONDS * 1_000)
|
delay(CONTROLS_SECONDS * 1_000)
|
||||||
showControls = false
|
showControls = false
|
||||||
}
|
}
|
||||||
@@ -452,6 +497,21 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val current by rememberUpdatedState(state)
|
val current by rememberUpdatedState(state)
|
||||||
|
// Locked, the picture takes no touches but one: the button that unlocks
|
||||||
|
// it, which a tap brings up for a moment. For a phone in a pocket, or in
|
||||||
|
// a child's hands.
|
||||||
|
var locked by remember { mutableStateOf(false) }
|
||||||
|
var unlockShown by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(unlockShown) {
|
||||||
|
if (unlockShown > 0) {
|
||||||
|
delay(UNLOCK_SHOWN_MS)
|
||||||
|
unlockShown = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(enabled = locked && !minimized) { unlockShown++ }
|
||||||
|
var swipe by remember { mutableStateOf<Swipe?>(null) }
|
||||||
|
// Brightness set by a swipe is the player's, not the app's.
|
||||||
|
DisposableEffect(activity) { onDispose { activity?.releaseBrightness() } }
|
||||||
// Where a remote's keys land while the controls are down. Once they are
|
// 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.
|
// up, the D-pad moves between their buttons as it does anywhere else.
|
||||||
val keys = remember { FocusRequester() }
|
val keys = remember { FocusRequester() }
|
||||||
@@ -495,7 +555,22 @@ fun PlayerScreen(
|
|||||||
// 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.
|
||||||
.pointerInput(host) {
|
.playerSwipes(
|
||||||
|
enabled = !locked,
|
||||||
|
activity = activity,
|
||||||
|
position = { current.position },
|
||||||
|
duration = { current.duration },
|
||||||
|
onSwipe = { swipe = it },
|
||||||
|
onSeek = { target ->
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
host.seek(target)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.pointerInput(host, locked) {
|
||||||
|
if (locked) {
|
||||||
|
detectTapGestures(onTap = { unlockShown++ })
|
||||||
|
return@pointerInput
|
||||||
|
}
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onTap = { showControls = !showControls },
|
onTap = { showControls = !showControls },
|
||||||
onDoubleTap = { offset ->
|
onDoubleTap = { offset ->
|
||||||
@@ -505,6 +580,9 @@ fun PlayerScreen(
|
|||||||
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
|
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
// Felt as well as seen: the bubble is under the thumb
|
||||||
|
// that asked for it.
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
if (seconds == 0) {
|
if (seconds == 0) {
|
||||||
host.setPaused(!current.paused)
|
host.setPaused(!current.paused)
|
||||||
} else {
|
} else {
|
||||||
@@ -536,17 +614,20 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showControls,
|
visible = showControls && !locked,
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
modifier = Modifier.align(Alignment.TopStart),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
// A gradient rather than a band: dark where the text is, and gone
|
||||||
|
// by the time it reaches the picture, so the top of the frame is
|
||||||
|
// dimmed rather than cut off by a grey bar.
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
|
.background(Brush.verticalGradient(listOf(Scrim.copy(alpha = 0.75f), Color.Transparent)))
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 28.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onMinimize) {
|
IconButton(onClick = onMinimize) {
|
||||||
@@ -567,8 +648,11 @@ fun PlayerScreen(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
|
// The episode's name, never its file name: a release
|
||||||
|
// group's tag is no caption, and it is what a screenshot
|
||||||
|
// of the player would carry away.
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
|
listOfNotNull(title.displayName, episode.shownName())
|
||||||
.joinToString(" • "),
|
.joinToString(" • "),
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -593,7 +677,7 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showControls,
|
visible = showControls && !locked,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
@@ -616,9 +700,35 @@ fun PlayerScreen(
|
|||||||
landscapeLocked = activity?.toggleLandscape() ?: false
|
landscapeLocked = activity?.toggleLandscape() ?: false
|
||||||
},
|
},
|
||||||
landscapeLocked = landscapeLocked,
|
landscapeLocked = landscapeLocked,
|
||||||
|
media = key,
|
||||||
|
sleep = sleep,
|
||||||
|
onPicking = { picking = it },
|
||||||
|
onLock = {
|
||||||
|
locked = true
|
||||||
|
showControls = false
|
||||||
|
unlockShown++
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
swipe?.let { SwipeBubble(it, ::formatTime, Modifier.align(Alignment.Center)) }
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = locked && unlockShown > 0,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
TonalButton(onClick = {
|
||||||
|
locked = false
|
||||||
|
unlockShown = 0
|
||||||
|
showControls = true
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.LockOpen, contentDescription = null)
|
||||||
|
Text("Unlock", modifier = Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drawn whether or not the rest of the controls are up: an opening lasts
|
// Drawn whether or not the rest of the controls are up: an opening lasts
|
||||||
// ninety seconds, and a button you have to wake with a tap is one nobody
|
// ninety seconds, and a button you have to wake with a tap is one nobody
|
||||||
// reaches in time.
|
// reaches in time.
|
||||||
@@ -628,7 +738,7 @@ fun PlayerScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
.padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
|
||||||
) { Text(skip.label) }
|
) { Text(skip.label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,7 +765,7 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
UpNextCard(
|
UpNextCard(
|
||||||
next = next,
|
next = next,
|
||||||
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
|
visible = autoplay && !stay && !stopsAfterThis && !state.paused && !state.ended && playingThis &&
|
||||||
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
||||||
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
||||||
onPlayNow = { advance() },
|
onPlayNow = { advance() },
|
||||||
@@ -663,12 +773,12 @@ fun PlayerScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
.padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Played to the end with nothing starting after it: the last episode,
|
// Played to the end with nothing starting after it: the last episode,
|
||||||
// autoplay off, or the viewer stayed. A black screen said none of that.
|
// autoplay off, or the viewer stayed. A black screen said none of that.
|
||||||
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
|
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay || stopsAfterThis)) {
|
||||||
EndCard(
|
EndCard(
|
||||||
finished = episode.label,
|
finished = episode.label,
|
||||||
show = title.displayName,
|
show = title.displayName,
|
||||||
@@ -842,12 +952,21 @@ private fun PlayerControls(
|
|||||||
onEpisode: (Int) -> Unit,
|
onEpisode: (Int) -> Unit,
|
||||||
onRotate: () -> Unit,
|
onRotate: () -> Unit,
|
||||||
landscapeLocked: Boolean,
|
landscapeLocked: Boolean,
|
||||||
|
media: String,
|
||||||
|
sleep: SleepTimer?,
|
||||||
|
onPicking: (Boolean) -> Unit,
|
||||||
|
onLock: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showSleep by remember { mutableStateOf(false) }
|
||||||
var choosing by remember { mutableStateOf<String?>(null) }
|
var choosing by remember { mutableStateOf<String?>(null) }
|
||||||
var showEpisodes by remember { mutableStateOf(false) }
|
var showEpisodes by remember { mutableStateOf(false) }
|
||||||
var showChapters by remember { mutableStateOf(false) }
|
var showChapters by remember { mutableStateOf(false) }
|
||||||
var showSpeeds by remember { mutableStateOf(false) }
|
var showSpeeds by remember { mutableStateOf(false) }
|
||||||
|
var more by remember { mutableStateOf(false) }
|
||||||
|
val picking = more || showSleep || choosing != null || showEpisodes || showChapters || showSpeeds
|
||||||
|
LaunchedEffect(picking) { onPicking(picking) }
|
||||||
|
DisposableEffect(Unit) { onDispose { onPicking(false) } }
|
||||||
// What the store says, so the dialog opens on the rate that is playing.
|
// What the store says, so the dialog opens on the rate that is playing.
|
||||||
var speed by remember { mutableStateOf(1.0) }
|
var speed by remember { mutableStateOf(1.0) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -870,9 +989,9 @@ private fun PlayerControls(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f))
|
.background(Brush.verticalGradient(listOf(Color.Transparent, Scrim.copy(alpha = 0.85f))))
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
.padding(start = 12.dp, end = 12.dp, top = 32.dp, bottom = 4.dp),
|
||||||
) {
|
) {
|
||||||
// Where the thumb is while a finger is on it. A seek per touch-move
|
// Where the thumb is while a finger is on it. A seek per touch-move
|
||||||
// aborts every outstanding prefetch, so a one-second drag used to issue
|
// aborts every outstanding prefetch, so a one-second drag used to issue
|
||||||
@@ -928,7 +1047,6 @@ private fun PlayerControls(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (!roomForAll) {
|
if (!roomForAll) {
|
||||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||||
var more by remember { mutableStateOf(false) }
|
|
||||||
Box {
|
Box {
|
||||||
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
|
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
|
||||||
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
|
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
|
||||||
@@ -951,6 +1069,8 @@ private fun PlayerControls(
|
|||||||
onRotate,
|
onRotate,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
add(Triple(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true })
|
||||||
|
add(Triple(Icons.Default.Lock, "Lock touch", onLock))
|
||||||
}
|
}
|
||||||
entries.forEach { (icon, label, action) ->
|
entries.forEach { (icon, label, action) ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -999,11 +1119,29 @@ private fun PlayerControls(
|
|||||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||||
onClick = onRotate,
|
onClick = onRotate,
|
||||||
)
|
)
|
||||||
|
CompactIcon(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true }
|
||||||
|
CompactIcon(Icons.Default.Lock, "Lock touch", onClick = onLock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSleep) {
|
||||||
|
SleepChooser(
|
||||||
|
current = sleep,
|
||||||
|
onDismiss = { showSleep = false },
|
||||||
|
onSelect = { minutes ->
|
||||||
|
showSleep = false
|
||||||
|
host.setSleepTimer(
|
||||||
|
when (minutes) {
|
||||||
|
null -> null
|
||||||
|
0 -> SleepTimer.EndOf(media)
|
||||||
|
else -> SleepTimer.At(SystemClock.elapsedRealtime() + minutes * 60_000L)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showSpeeds) {
|
if (showSpeeds) {
|
||||||
SpeedChooser(
|
SpeedChooser(
|
||||||
current = speed,
|
current = speed,
|
||||||
@@ -1128,6 +1266,59 @@ private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Doub
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the sleep timer button says: whether one is running, and how long it has. */
|
||||||
|
private fun sleepLabel(sleep: SleepTimer?): String = when (sleep) {
|
||||||
|
null -> "Sleep timer"
|
||||||
|
is SleepTimer.EndOf -> "Sleep timer: end of episode"
|
||||||
|
is SleepTimer.At -> {
|
||||||
|
val left = ((sleep.at - SystemClock.elapsedRealtime()) / 60_000L + 1).coerceAtLeast(1)
|
||||||
|
"Sleep timer: $left min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off, the end of this episode, or a number of minutes. [onSelect] gets
|
||||||
|
* `null` for off, `0` for the end of the episode, and the minutes otherwise.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SleepChooser(current: SleepTimer?, onDismiss: () -> Unit, onSelect: (Int?) -> Unit) {
|
||||||
|
val choices = listOf<Pair<Int?, String>>(
|
||||||
|
null to "Off",
|
||||||
|
0 to "End of this episode",
|
||||||
|
30 to "30 minutes",
|
||||||
|
60 to "60 minutes",
|
||||||
|
)
|
||||||
|
val chosen: Int? = when (current) {
|
||||||
|
null -> null
|
||||||
|
is SleepTimer.EndOf -> 0
|
||||||
|
is SleepTimer.At -> -1
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Sleep timer") },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
if (current is SleepTimer.At) {
|
||||||
|
Text(
|
||||||
|
sleepLabel(current).removePrefix("Sleep timer: ") + " left",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
choices.forEach { (minutes, label) ->
|
||||||
|
EdgedButton(
|
||||||
|
onClick = { onSelect(minutes) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text((if (minutes == chosen) "✓ " else "") + label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Where each chapter begins, ticked onto the seek bar. */
|
/** Where each chapter begins, ticked onto the seek bar. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
|
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
|
||||||
@@ -1456,3 +1647,9 @@ private fun formatTime(seconds: Double): String {
|
|||||||
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
|
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
|
||||||
else "%d:%02d".format(total / 60, total % 60)
|
else "%d:%02d".format(total / 60, total % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's scrims are black whatever the theme: what is under them is a
|
||||||
|
* video frame, not the page, and a light theme's scrim would wash it out.
|
||||||
|
*/
|
||||||
|
private val Scrim = Color.Black
|
||||||
@@ -28,6 +28,8 @@ import io.narl.protonstream.native.NativeRuntime
|
|||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import io.narl.protonstream.ui.theme.AppearanceState
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
import uniffi.pstr_android.ArrangementRecord
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
|
import uniffi.pstr_android.BrowseFacet
|
||||||
|
import uniffi.pstr_android.SearchExtrasRecord
|
||||||
import uniffi.pstr_android.LibraryFilter
|
import uniffi.pstr_android.LibraryFilter
|
||||||
import uniffi.pstr_android.LibrarySort
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
@@ -51,7 +53,16 @@ data class AppUiState(
|
|||||||
/** A crawl is running, which the banner offers to stop. */
|
/** A crawl is running, which the banner offers to stop. */
|
||||||
val crawling: Boolean = false,
|
val crawling: Boolean = false,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
|
/**
|
||||||
|
* The whole library, whatever is searched for: the grid narrows to the
|
||||||
|
* search through [arrangement], and a title opened from a search, or from
|
||||||
|
* an episode it found, stays one the app can show.
|
||||||
|
*/
|
||||||
val titles: List<TitleRecord> = emptyList(),
|
val titles: List<TitleRecord> = emptyList(),
|
||||||
|
/** What the search found besides titles; null with no search. */
|
||||||
|
val searchExtras: SearchExtrasRecord? = null,
|
||||||
|
/** The last few searches on this device, newest first. */
|
||||||
|
val recentSearches: List<String> = emptyList(),
|
||||||
/**
|
/**
|
||||||
* The grid: [titles] ordered, each franchise folded into one tile when
|
* The grid: [titles] ordered, each franchise folded into one tile when
|
||||||
* [grouped], and the shelves above it. Null until the bridge has answered,
|
* [grouped], and the shelves above it. Null until the bridge has answered,
|
||||||
@@ -168,6 +179,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().recentSearches() } }
|
||||||
|
.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
||||||
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
||||||
@@ -205,6 +220,32 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
searchQuery.value = query
|
searchQuery.value = query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the search being shown as one to offer again: called when a viewer
|
||||||
|
* opens something it found. Nothing for an empty one.
|
||||||
|
*/
|
||||||
|
fun rememberSearch() {
|
||||||
|
val query = mutableState.value.query.takeIf(String::isNotBlank) ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeRuntime.engine().run {
|
||||||
|
rememberSearch(query)
|
||||||
|
recentSearches()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forgetSearches() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetSearches() } }
|
||||||
|
.onSuccess { mutableState.update { it.copy(recentSearches = emptyList()) } }
|
||||||
|
.onFailure(::reportError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
busy("Refreshing the library…") {
|
busy("Refreshing the library…") {
|
||||||
@@ -591,7 +632,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (report.settingsChanged) AppearanceState.reload()
|
if (report.settingsChanged) AppearanceState.reload()
|
||||||
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
|
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
|
||||||
|
report.sharesRemoved.isNotEmpty()
|
||||||
|
) {
|
||||||
reloadNow()
|
reloadNow()
|
||||||
}
|
}
|
||||||
if (report.sharesAdded.isNotEmpty()) {
|
if (report.sharesAdded.isNotEmpty()) {
|
||||||
@@ -625,6 +668,26 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||||
* menu should not cost them.
|
* menu should not cost them.
|
||||||
*/
|
*/
|
||||||
|
/** Put a title on My List, or take it off. */
|
||||||
|
fun setOnList(title: TitleRecord, on: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keys of the titles most like [key], best first; none if the engine cannot say. */
|
||||||
|
suspend fun moreLike(key: String): List<String> =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().moreLike(key) } }.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/** The keys of every title [facet] stands for, in library order. */
|
||||||
|
suspend fun browse(facet: BrowseFacet): List<String> =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().browse(facet) } }
|
||||||
|
.onFailure(::reportError)
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
|
||||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||||
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||||
|
|
||||||
@@ -727,8 +790,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
val stored = runCatching {
|
val stored = runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
// TMDB's is the only key: the provider's with TMDB chosen, and
|
||||||
engine.setMetadataApiKey(provider, apiKey)
|
// film art's with AniList.
|
||||||
|
if (apiKey.isNotBlank()) {
|
||||||
|
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
|
||||||
}
|
}
|
||||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||||
engine.metadataSettings()
|
engine.metadataSettings()
|
||||||
@@ -864,7 +929,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
val query = current.query.takeIf(String::isNotBlank)
|
val query = current.query.takeIf(String::isNotBlank)
|
||||||
Reloaded(
|
Reloaded(
|
||||||
engine.shares(),
|
engine.shares(),
|
||||||
engine.library(query),
|
engine.library(null),
|
||||||
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||||
engine.offlineFiles(),
|
engine.offlineFiles(),
|
||||||
engine.metadataSettings(),
|
engine.metadataSettings(),
|
||||||
@@ -893,10 +958,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
val search = query.takeIf(String::isNotBlank)
|
val search = query.takeIf(String::isNotBlank)
|
||||||
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
|
Triple(
|
||||||
|
engine.library(null),
|
||||||
|
engine.arrangement(search, current.filter, current.sort, current.grouped),
|
||||||
|
search?.let(engine::searchExtras),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { (titles, arrangement) ->
|
}.onSuccess { (titles, arrangement, extras) ->
|
||||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
|
||||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,29 @@ import android.net.ConnectivityManager
|
|||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -137,6 +154,17 @@ internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = n
|
|||||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||||
?: 0
|
?: 0
|
||||||
|
|
||||||
|
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
|
||||||
|
internal fun formatRuntime(minutes: UInt): String {
|
||||||
|
val hours = minutes / 60u
|
||||||
|
val rest = minutes % 60u
|
||||||
|
return when {
|
||||||
|
hours == 0u -> "$rest min"
|
||||||
|
rest == 0u -> "$hours h"
|
||||||
|
else -> "$hours h $rest min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun formatBytes(bytes: ULong): String {
|
internal fun formatBytes(bytes: ULong): String {
|
||||||
val value = bytes.toDouble()
|
val value = bytes.toDouble()
|
||||||
return when {
|
return when {
|
||||||
@@ -221,6 +249,13 @@ internal fun rememberQueueReason(key: Any?): String? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The episode's own name: the provider's, else the one the filename gave it.
|
||||||
|
* `null` rather than the filename, which is a release group's tag and the
|
||||||
|
* episode's numbering again.
|
||||||
|
*/
|
||||||
|
internal fun EpisodeRecord.shownName(): String? = providerName ?: detail.takeIf { it != name && it.isNotBlank() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an episode row is headed with: "1. Mother and Children".
|
* What an episode row is headed with: "1. Mother and Children".
|
||||||
*
|
*
|
||||||
@@ -229,7 +264,7 @@ internal fun rememberQueueReason(key: Any?): String? {
|
|||||||
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
||||||
*/
|
*/
|
||||||
internal fun EpisodeRecord.heading(position: Int): String {
|
internal fun EpisodeRecord.heading(position: Int): String {
|
||||||
val named = providerName ?: detail.takeIf { it != name }
|
val named = shownName()
|
||||||
val number = number?.toInt()
|
val number = number?.toInt()
|
||||||
return when {
|
return when {
|
||||||
named != null && number != null -> "$number. $named"
|
named != null && number != null -> "$number. $named"
|
||||||
@@ -238,3 +273,86 @@ internal fun EpisodeRecord.heading(position: Int): String {
|
|||||||
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something the title is filed under — a genre — as a small rounded label.
|
||||||
|
* The desktop's `widgets::chip`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Browse $text", onClick = onClick) else Modifier)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
|
||||||
|
* [GenreChip] with a dot in front: the accent while more is coming, muted once
|
||||||
|
* it is all out. The desktop's `widgets::status_chip`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||||
|
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
||||||
|
* The desktop's `widgets::spec_badge`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SpecBadge(text: String) {
|
||||||
|
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||||
|
color = ink,
|
||||||
|
modifier = Modifier
|
||||||
|
.border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall)
|
||||||
|
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* My List's button: a plus to put the title on it, a tick once it is, which
|
||||||
|
* takes it off again. [compact] is the icon alone, for the banner's row, where
|
||||||
|
* a third labelled button does not fit a phone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
|
||||||
|
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
|
||||||
|
val label = if (onList) "Remove from My List" else "Add to My List"
|
||||||
|
if (compact) {
|
||||||
|
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
|
||||||
|
Icon(icon, contentDescription = label)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
|
||||||
|
Icon(icon, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("My List")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,28 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
@@ -25,6 +47,7 @@ import io.narl.protonstream.settings.SettingsStore
|
|||||||
import io.narl.protonstream.ui.theme.QuietButton
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
@@ -48,6 +71,7 @@ import androidx.compose.foundation.layout.aspectRatio
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
@@ -87,12 +111,21 @@ import androidx.compose.material.icons.filled.Check
|
|||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import uniffi.pstr_android.BrowseFacet
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import uniffi.pstr_android.FacetCountRecord
|
||||||
|
import uniffi.pstr_android.SearchExtrasRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
import uniffi.pstr_android.LibraryFilter
|
import uniffi.pstr_android.LibraryFilter
|
||||||
import uniffi.pstr_android.LibrarySort
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TitleType
|
import uniffi.pstr_android.TitleType
|
||||||
|
import uniffi.pstr_android.airingCountdown
|
||||||
|
import uniffi.pstr_android.facetKind
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many shows the continue-watching shelf holds.
|
* How many shows the continue-watching shelf holds.
|
||||||
@@ -122,6 +155,7 @@ internal fun LibraryFilter.label(): String = when (this) {
|
|||||||
LibraryFilter.WATCHING -> "Watching"
|
LibraryFilter.WATCHING -> "Watching"
|
||||||
LibraryFilter.UNWATCHED -> "Not started"
|
LibraryFilter.UNWATCHED -> "Not started"
|
||||||
LibraryFilter.DOWNLOADED -> "Downloaded"
|
LibraryFilter.DOWNLOADED -> "Downloaded"
|
||||||
|
LibraryFilter.MY_LIST -> "My List"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The offer of posters and synopses, and its two answers. */
|
/** The offer of posters and synopses, and its two answers. */
|
||||||
@@ -167,6 +201,10 @@ internal fun LibraryScreen(
|
|||||||
onAddShare: () -> Unit = {},
|
onAddShare: () -> Unit = {},
|
||||||
onOpenSettings: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
onFilter: (LibraryFilter) -> Unit = {},
|
onFilter: (LibraryFilter) -> Unit = {},
|
||||||
|
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||||
|
/** Open every title sharing a genre, studio or director a search named. */
|
||||||
|
onBrowse: (BrowseFacet) -> Unit = {},
|
||||||
|
onForgetSearches: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val download = rememberDownload()
|
val download = rememberDownload()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -198,27 +236,34 @@ internal fun LibraryScreen(
|
|||||||
onSearch("")
|
onSearch("")
|
||||||
}
|
}
|
||||||
BackHandler(enabled = searching, onBack = closeSearch)
|
BackHandler(enabled = searching, onBack = closeSearch)
|
||||||
// The large title folds into the bar as the grid scrolls, and comes back
|
// The banner turns through these. Worked out here rather than in the grid,
|
||||||
// only at the top: the page says what it is on arrival, then gets out of
|
// because whether there is one decides the shape of the whole page.
|
||||||
// the way.
|
val featured = remember(state.titles, resumable) { featured(state.titles, resumable) }
|
||||||
|
// With a banner, the bar lies over it, clear, and fills in as the banner
|
||||||
|
// scrolls away; without one, the large title folds into the bar as the
|
||||||
|
// grid scrolls and comes back only at the top.
|
||||||
|
val overBanner = !searching && !state.loading && state.query.isBlank() && featured.isNotEmpty()
|
||||||
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
val grid = rememberLazyGridState()
|
||||||
if (searching) {
|
val actions: @Composable RowScope.() -> Unit = {
|
||||||
SearchBar(state.query, onSearch, closeSearch)
|
|
||||||
} else {
|
|
||||||
LargeTopAppBar(
|
|
||||||
title = { Text("Library") },
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { searching = true }) {
|
IconButton(onClick = { searching = true }) {
|
||||||
Icon(Icons.Default.Search, contentDescription = "Search library")
|
Icon(Icons.Default.Search, contentDescription = "Search library")
|
||||||
}
|
}
|
||||||
ArrangeMenu(state.sort, state.grouped, onArrange)
|
ArrangeMenu(state.sort, state.grouped, onArrange)
|
||||||
// Pull to refresh is the gesture; the icon stays for a
|
// Pull to refresh is the gesture; the icon stays for a viewer who
|
||||||
// viewer who does not know it.
|
// does not know it.
|
||||||
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Column(Modifier.fillMaxSize().then(if (overBanner) Modifier else Modifier.nestedScroll(scroll.nestedScrollConnection))) {
|
||||||
|
if (searching) {
|
||||||
|
SearchBar(state.query, onSearch, closeSearch)
|
||||||
|
} else if (!overBanner) {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text("Library") },
|
||||||
|
actions = actions,
|
||||||
colors = pageBarColors(),
|
colors = pageBarColors(),
|
||||||
scrollBehavior = scroll,
|
scrollBehavior = scroll,
|
||||||
)
|
)
|
||||||
@@ -230,9 +275,17 @@ internal fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.loading -> LibrarySkeleton()
|
state.loading -> LibrarySkeleton()
|
||||||
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
|
// Empty and with the field open: the last few searches.
|
||||||
|
searching && state.query.isBlank() && state.recentSearches.isNotEmpty() -> RecentSearches(
|
||||||
|
state.recentSearches,
|
||||||
|
onPick = onSearch,
|
||||||
|
onForget = onForgetSearches,
|
||||||
|
)
|
||||||
|
state.query.isNotBlank() &&
|
||||||
|
state.arrangement?.tiles?.isEmpty() == true &&
|
||||||
|
state.searchExtras?.isEmpty() != false -> EmptyState(
|
||||||
"Nothing matches",
|
"Nothing matches",
|
||||||
"No title in the library is called “${state.query.trim()}”.",
|
"Nothing in the library is called “${state.query.trim()}”.",
|
||||||
)
|
)
|
||||||
// A share just added: say it is being looked through, rather
|
// A share just added: say it is being looked through, rather
|
||||||
// than offer to add the one that is already filling the page.
|
// than offer to add the one that is already filling the page.
|
||||||
@@ -261,10 +314,16 @@ internal fun LibraryScreen(
|
|||||||
SettingsStore(context).metadataOfferDismissed = true
|
SettingsStore(context).metadataOfferDismissed = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
featured = if (overBanner) featured else emptyList(),
|
||||||
|
grid = grid,
|
||||||
|
onSetOnList = onSetOnList,
|
||||||
|
onBrowse = onBrowse,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (overBanner) BarOverBanner(grid, actions)
|
||||||
|
}
|
||||||
menuFor?.let { title ->
|
menuFor?.let { title ->
|
||||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||||
@@ -273,6 +332,10 @@ internal fun LibraryScreen(
|
|||||||
onResume(title, nextUpIndex(playlist, title.continueIndex))
|
onResume(title, nextUpIndex(playlist, title.continueIndex))
|
||||||
}
|
}
|
||||||
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||||
|
MenuRow(
|
||||||
|
if (title.onList) Icons.Default.Check else Icons.Default.Add,
|
||||||
|
if (title.onList) "Remove from My List" else "Add to My List",
|
||||||
|
) { onSetOnList(title, !title.onList) }
|
||||||
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
|
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
|
||||||
MenuRow(
|
MenuRow(
|
||||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||||
@@ -457,6 +520,10 @@ private fun LibraryGrid(
|
|||||||
onResumeMenu: (Resumable) -> Unit,
|
onResumeMenu: (Resumable) -> Unit,
|
||||||
onFilter: (LibraryFilter) -> Unit = {},
|
onFilter: (LibraryFilter) -> Unit = {},
|
||||||
metadataOffer: MetadataOffer? = null,
|
metadataOffer: MetadataOffer? = null,
|
||||||
|
featured: List<TitleRecord> = emptyList(),
|
||||||
|
grid: LazyGridState = rememberLazyGridState(),
|
||||||
|
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||||
|
onBrowse: (BrowseFacet) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// The arrangement names titles by key; before it has arrived, the titles
|
// The arrangement names titles by key; before it has arrived, the titles
|
||||||
// as they came are the grid.
|
// as they came are the grid.
|
||||||
@@ -475,10 +542,25 @@ private fun LibraryGrid(
|
|||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(108.dp),
|
columns = GridCells.Adaptive(108.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = grid,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
// No room above a banner: it starts under the status bar, and the bar
|
||||||
|
// lies over it.
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = if (featured.isEmpty()) 8.dp else 0.dp, bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
|
if (featured.isNotEmpty()) {
|
||||||
|
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
FeaturedBanner(
|
||||||
|
featured,
|
||||||
|
resumable,
|
||||||
|
onPlay = onResume,
|
||||||
|
onOpen = onTitle,
|
||||||
|
onSetOnList = onSetOnList,
|
||||||
|
modifier = Modifier.bleed(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Above the grid, and only when there is something in it: what a viewer
|
// Above the grid, and only when there is something in it: what a viewer
|
||||||
// opening the app wants is almost always the thing they were part way
|
// opening the app wants is almost always the thing they were part way
|
||||||
// through. Inside the grid rather than pinned over it, so the shelf
|
// through. Inside the grid rather than pinned over it, so the shelf
|
||||||
@@ -486,17 +568,6 @@ private fun LibraryGrid(
|
|||||||
if (metadataOffer != null && state.query.isBlank()) {
|
if (metadataOffer != null && state.query.isBlank()) {
|
||||||
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
|
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
|
||||||
}
|
}
|
||||||
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
|
||||||
featured?.let { title ->
|
|
||||||
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
|
||||||
FeaturedBanner(
|
|
||||||
title,
|
|
||||||
resume = resumable.firstOrNull { it.title.key == title.key },
|
|
||||||
onPlay = { onResume(title, it) },
|
|
||||||
onOpen = { onTitle(title) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.query.isBlank() && resumable.isNotEmpty()) {
|
if (state.query.isBlank() && resumable.isNotEmpty()) {
|
||||||
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
|
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Column {
|
Column {
|
||||||
@@ -535,6 +606,9 @@ private fun LibraryGrid(
|
|||||||
onLongClick = { onTitleMenu(title) },
|
onLongClick = { onTitleMenu(title) },
|
||||||
modifier = Modifier.width(SHELF_TILE_WIDTH),
|
modifier = Modifier.width(SHELF_TILE_WIDTH),
|
||||||
shared = false,
|
shared = false,
|
||||||
|
// On a shelf, an airing show says when its
|
||||||
|
// next episode is out.
|
||||||
|
badge = title.badge ?: title.countdown(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -567,6 +641,38 @@ private fun LibraryGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// What the search found besides titles, above the titles it found.
|
||||||
|
state.searchExtras?.takeIf { state.query.isNotBlank() }?.let { extras ->
|
||||||
|
facetChips("People and studios", extras.people, onBrowse)
|
||||||
|
facetChips("Genres", extras.genres, onBrowse)
|
||||||
|
if (extras.episodes.isNotEmpty()) {
|
||||||
|
item(key = "search-episodes", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column {
|
||||||
|
Text("Episodes", style = MaterialTheme.typography.titleMedium)
|
||||||
|
extras.episodes.forEach { hit ->
|
||||||
|
val title = byKey[hit.titleKey] ?: return@forEach
|
||||||
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
|
val index = playlist.indexOfFirst { it.linkId == hit.linkId }
|
||||||
|
if (index < 0) return@forEach
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(hit.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = {
|
||||||
|
Text("${title.displayName} · ${hit.label}", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(Icons.Default.PlayArrow, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(onClickLabel = "Play") { onResume(title, index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tiles.isNotEmpty() && !extras.isEmpty()) {
|
||||||
|
item(key = "search-titles", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Text("Titles", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
items(tiles, key = { it.title.key }) { tile ->
|
items(tiles, key = { it.title.key }) { tile ->
|
||||||
PosterTile(
|
PosterTile(
|
||||||
tile.title,
|
tile.title,
|
||||||
@@ -578,6 +684,55 @@ private fun LibraryGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a search found nothing besides titles. */
|
||||||
|
internal fun SearchExtrasRecord.isEmpty(): Boolean = episodes.isEmpty() && people.isEmpty() && genres.isEmpty()
|
||||||
|
|
||||||
|
/** A row of the genres, studios or people a search named, each opening its page. */
|
||||||
|
private fun LazyGridScope.facetChips(heading: String, facets: List<FacetCountRecord>, onBrowse: (BrowseFacet) -> Unit) {
|
||||||
|
if (facets.isEmpty()) return
|
||||||
|
item(key = "search-$heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column {
|
||||||
|
Text(heading, style = MaterialTheme.typography.titleMedium)
|
||||||
|
FlowRow(
|
||||||
|
Modifier.padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
facets.forEach { found ->
|
||||||
|
GenreChip("${found.facet.label} · ${found.count}") { onBrowse(found.facet) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last few searches, under an empty search field. */
|
||||||
|
@Composable
|
||||||
|
private fun RecentSearches(recent: List<String>, onPick: (String) -> Unit, onForget: () -> Unit) {
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||||
|
item(key = "heading") {
|
||||||
|
Text(
|
||||||
|
"Recent searches",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(recent, key = { it }) { query ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(query) },
|
||||||
|
leadingContent = { Icon(Icons.Default.History, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { onPick(query) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "forget") {
|
||||||
|
QuietButton(onClick = onForget, modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||||
|
Text("Clear recent searches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The grid's shape before the catalog has answered: posters and their two
|
* The grid's shape before the catalog has answered: posters and their two
|
||||||
* lines of text as blocks, so the page does not jump when the titles land.
|
* lines of text as blocks, so the page does not jump when the titles land.
|
||||||
@@ -610,57 +765,200 @@ private val SHELF_TILE_WIDTH = 112.dp
|
|||||||
/** Enough placeholder tiles to fill a tablet's first screen. */
|
/** Enough placeholder tiles to fill a tablet's first screen. */
|
||||||
private const val SKELETON_TILES = 18
|
private const val SKELETON_TILES = 18
|
||||||
|
|
||||||
|
/** How many titles the banner turns through: the desktop's `FEATURED`. */
|
||||||
|
private const val FEATURED_MAX = 5
|
||||||
|
|
||||||
|
/** How long each title holds the banner: the desktop's `HOLD`. */
|
||||||
|
private const val HOLD_MS = 8_000L
|
||||||
|
|
||||||
|
/** How long one title takes to give way to the next: the desktop's `TURN`. */
|
||||||
|
private const val TURN_MS = 600
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The title the banner shows: the one watched last, or with nothing part
|
* The titles the banner turns through: the one watched last first, then titles
|
||||||
* watched, one of the titles the provider has a backdrop for, turning over
|
* the provider has a backdrop for, starting from a different one each day —
|
||||||
* once a day — the desktop's `featured`.
|
* the desktop's `featured`.
|
||||||
*/
|
*/
|
||||||
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
|
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): List<TitleRecord> {
|
||||||
resumable.firstOrNull()?.let { return it.title }
|
val picked = listOfNotNull(resumable.firstOrNull()?.title).toMutableList()
|
||||||
val pictured = titles.filter { it.backdropUrl != null }
|
val pictured = titles.filter { it.backdropUrl != null }
|
||||||
if (pictured.isEmpty()) return null
|
if (pictured.isEmpty()) return picked
|
||||||
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
|
val start = (LocalDate.now().toEpochDay() % pictured.size).toInt()
|
||||||
|
for (offset in pictured.indices) {
|
||||||
|
if (picked.size == FEATURED_MAX) break
|
||||||
|
val title = pictured[(start + offset) % pictured.size]
|
||||||
|
if (picked.none { it.key == title.key }) picked += title
|
||||||
|
}
|
||||||
|
return picked
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Large art behind the name, rating, genres and three lines of synopsis. */
|
/**
|
||||||
@Composable
|
* Wider than the space it is given by [horizontal] on each side: for the
|
||||||
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
|
* banner, which runs edge to edge inside a grid with a margin.
|
||||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
*/
|
||||||
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
|
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
|
||||||
RemoteArtwork(
|
val extra = horizontal.roundToPx() * 2
|
||||||
title.backdropUrl ?: title.posterUrl,
|
val placeable = measurable.measure(
|
||||||
title.displayName,
|
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
fallback = title.thumbnailSource,
|
|
||||||
labelled = false,
|
|
||||||
)
|
)
|
||||||
Box(
|
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
|
||||||
Modifier.fillMaxSize().background(
|
}
|
||||||
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
|
|
||||||
|
/**
|
||||||
|
* The bar over the banner: clear at the top, with white icons over the
|
||||||
|
* banner's own scrim, and filling in to the page's bar as the banner scrolls
|
||||||
|
* away — at which point it is the page's bar, title and all.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BarOverBanner(grid: LazyGridState, actions: @Composable RowScope.() -> Unit) {
|
||||||
|
val reveal = with(LocalDensity.current) { BAR_REVEAL.toPx() }
|
||||||
|
val solid by remember(grid) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (grid.firstVisibleItemIndex > 0) 1f else (grid.firstVisibleItemScrollOffset / reveal).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val ink = lerp(Color.White, scheme.onSurface, solid)
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Library", modifier = Modifier.graphicsLayer { alpha = solid }) },
|
||||||
|
actions = actions,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = scheme.surfaceContainer.copy(alpha = solid),
|
||||||
|
titleContentColor = ink,
|
||||||
|
actionIconContentColor = ink,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
}
|
||||||
Text(
|
|
||||||
title.displayName,
|
/** How far the banner scrolls before the bar over it is solid. */
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
private val BAR_REVEAL = 280.dp
|
||||||
color = Color.White,
|
|
||||||
maxLines = 2,
|
/**
|
||||||
overflow = TextOverflow.Ellipsis,
|
* The banner at the top of the library: one title at a time, edge to edge
|
||||||
|
* under the status bar, turning to the next every [HOLD_MS] unless a finger is
|
||||||
|
* on it. Each title's veil is tinted with its own picture's colour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FeaturedBanner(
|
||||||
|
titles: List<TitleRecord>,
|
||||||
|
resumable: List<Resumable>,
|
||||||
|
onPlay: (TitleRecord, Int) -> Unit,
|
||||||
|
onOpen: (TitleRecord) -> Unit,
|
||||||
|
onSetOnList: (TitleRecord, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val pager = rememberPagerState { titles.size }
|
||||||
|
val dragged by pager.interactionSource.collectIsDraggedAsState()
|
||||||
|
if (titles.size > 1 && !dragged) {
|
||||||
|
// Keyed on the settled page, not the current one: the current page
|
||||||
|
// flips half way through a turn, and restarting this effect there
|
||||||
|
// cancelled the turn and left the banner stuck between two titles.
|
||||||
|
LaunchedEffect(pager.settledPage, titles.size) {
|
||||||
|
delay(HOLD_MS)
|
||||||
|
pager.animateScrollToPage((pager.settledPage + 1) % titles.size, animationSpec = tween(TURN_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Most of a phone's first screen, but never so much of a tablet's that
|
||||||
|
// nothing under it shows.
|
||||||
|
val screenHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||||
|
val height = (screenHeight * 0.62f).coerceIn(360.dp, 620.dp)
|
||||||
|
Box(modifier.fillMaxWidth().height(height)) {
|
||||||
|
HorizontalPager(pager, Modifier.fillMaxSize(), key = { titles[it].key }) { page ->
|
||||||
|
val title = titles[page]
|
||||||
|
FeaturedPage(
|
||||||
|
title,
|
||||||
|
resumable.firstOrNull { it.title.key == title.key },
|
||||||
|
onPlay = { onPlay(title, it) },
|
||||||
|
onOpen = { onOpen(title) },
|
||||||
|
onSetOnList = { onSetOnList(title, it) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
if (titles.size > 1) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Row(
|
||||||
|
Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
titles.forEachIndexed { index, title ->
|
||||||
|
val shown = index == pager.currentPage
|
||||||
|
val width by animateDpAsState(if (shown) 18.dp else 6.dp, label = "dot")
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width, 6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = if (shown) 1f else 0.4f))
|
||||||
|
.clickable(onClickLabel = "Show ${title.displayName}") {
|
||||||
|
scope.launch { pager.animateScrollToPage(index, animationSpec = tween(TURN_MS)) }
|
||||||
|
}
|
||||||
|
.semantics { selected = shown },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
|
||||||
|
@Composable
|
||||||
|
private fun FeaturedPage(
|
||||||
|
title: TitleRecord,
|
||||||
|
resume: Resumable?,
|
||||||
|
onPlay: (Int) -> Unit,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onSetOnList: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
|
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
|
||||||
|
val background = MaterialTheme.colorScheme.background
|
||||||
|
val veil = moodVeil(mood)
|
||||||
|
val ink = MaterialTheme.colorScheme.onBackground
|
||||||
|
// A logo is lettering made for dark art, white as often as not; over a
|
||||||
|
// light palette's veil it is gone, so the light palette keeps the name.
|
||||||
|
val dark = background.luminance() < 0.5f
|
||||||
|
Box(Modifier.fillMaxSize().clickable(onClickLabel = "Open ${title.displayName}", onClick = onOpen)) {
|
||||||
|
Backdrop(title, Modifier.fillMaxSize(), onMood = { mood = it })
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
// Dark under the status bar and the bar's white icons…
|
||||||
|
0f to Color.Black.copy(alpha = 0.5f),
|
||||||
|
0.22f to Color.Transparent,
|
||||||
|
// …and the picture's own colour under the text, ending in
|
||||||
|
// the page it hands over to.
|
||||||
|
0.45f to veil.copy(alpha = 0.35f),
|
||||||
|
0.78f to veil.copy(alpha = 0.92f),
|
||||||
|
1f to background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier.align(Alignment.BottomStart).widthIn(max = 560.dp).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||||
|
) {
|
||||||
|
if (dark) {
|
||||||
|
RemoteLogo(
|
||||||
|
title.logoUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 8.dp),
|
||||||
|
) { HeroName(title.displayName, ink) }
|
||||||
|
} else {
|
||||||
|
HeroName(title.displayName, ink)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
title.rating?.let { "★ %.1f".format(it) },
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||||
).joinToString(" · ").ifEmpty { title.caption() },
|
).joinToString(" · ").ifEmpty { title.caption() },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color.White.copy(alpha = 0.8f),
|
color = ink.copy(alpha = 0.8f),
|
||||||
)
|
)
|
||||||
title.overview?.let {
|
title.overview?.let {
|
||||||
Text(
|
Text(
|
||||||
it.trim(),
|
it.trim(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color.White.copy(alpha = 0.8f),
|
color = ink.copy(alpha = 0.8f),
|
||||||
maxLines = 3,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
)
|
)
|
||||||
@@ -669,13 +967,48 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
|||||||
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
|
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
|
||||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(if (resume != null) "Resume" else "Play")
|
Text(if (resume?.episode?.resumeAt != null) "Resume" else "Play")
|
||||||
}
|
}
|
||||||
TonalButton(onClick = onOpen) { Text("More info") }
|
TonalButton(onClick = onOpen) {
|
||||||
|
Icon(Icons.Default.Info, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("More info")
|
||||||
|
}
|
||||||
|
ListButton(title.onList, onSetOnList, compact = true)
|
||||||
|
}
|
||||||
|
val episode = resume?.episode
|
||||||
|
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
|
||||||
|
if (progress != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.width(120.dp),
|
||||||
|
)
|
||||||
|
episode.timeLeft()?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = ink.copy(alpha = 0.8f),
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeroName(name: String, ink: Color) {
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = ink,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** A part-watched episode: its still, where it was left, and what is left of it. */
|
/** A part-watched episode: its still, where it was left, and what is left of it. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -739,6 +1072,7 @@ private fun PosterTile(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
folded: Int = 0,
|
folded: Int = 0,
|
||||||
shared: Boolean = true,
|
shared: Boolean = true,
|
||||||
|
badge: String? = title.badge,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
@@ -772,6 +1106,20 @@ private fun PosterTile(
|
|||||||
.semantics { contentDescription = "and $folded more" },
|
.semantics { contentDescription = "and $folded more" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (badge != null) {
|
||||||
|
Text(
|
||||||
|
badge,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -813,3 +1161,79 @@ private fun EpisodeRecord.timeLeft(): String? {
|
|||||||
val minutes = ((at / fraction - at) / 60).toInt()
|
val minutes = ((at / fraction - at) / 60).toInt()
|
||||||
return if (minutes < 1) "under a minute left" else "$minutes min left"
|
return if (minutes < 1) "under a minute left" else "$minutes min left"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `Ep 8 · in 2 d` for a show whose next episode is still to air. */
|
||||||
|
internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000): String? {
|
||||||
|
val at = nextAiringAt?.takeIf { it > now } ?: return null
|
||||||
|
val episode = nextEpisode ?: return null
|
||||||
|
return airingCountdown(episode, at, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every title sharing a genre, a studio or a director: what a chip on a title
|
||||||
|
* page opens. The desktop's `library::browse`.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun BrowseScreen(
|
||||||
|
facet: BrowseFacet,
|
||||||
|
titles: List<TitleRecord>,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
facetKind(facet),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(facet.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = pageBarColors(),
|
||||||
|
)
|
||||||
|
if (titles.isEmpty()) {
|
||||||
|
// A recrawl or a new match took the last one away.
|
||||||
|
Box(Modifier.padding(padding)) {
|
||||||
|
EmptyState(facet.label, "Nothing in the library has this any more.")
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(108.dp),
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
item(key = "count", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Text(
|
||||||
|
if (titles.size == 1) "1 title" else "${titles.size} titles",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(titles, key = { it.key }) { title ->
|
||||||
|
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitle(title) }, shared = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name a [BrowseFacet] stands for: the chip's text and its page's heading. */
|
||||||
|
internal val BrowseFacet.label: String
|
||||||
|
get() = when (this) {
|
||||||
|
is BrowseFacet.Genre -> name
|
||||||
|
is BrowseFacet.Studio -> name
|
||||||
|
is BrowseFacet.Person -> name
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -78,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import io.narl.protonstream.ui.theme.QuietButton
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||||
|
import uniffi.pstr_android.BrowseFacet
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
@@ -135,6 +137,28 @@ fun ProtonStreamApp(
|
|||||||
// into the video they had just left.
|
// into the video they had just left.
|
||||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||||
var addingShare by remember { mutableStateOf(false) }
|
var addingShare by remember { mutableStateOf(false) }
|
||||||
|
// What a genre, studio or director chip opened, over the library pane.
|
||||||
|
// As its two parts, which survive process recreation where the record
|
||||||
|
// would not.
|
||||||
|
var browseKind by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var browseName by rememberSaveable { mutableStateOf("") }
|
||||||
|
// The title it was opened from, which is where back goes, as on the
|
||||||
|
// desktop.
|
||||||
|
var browseFrom by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val browsing = when (browseKind) {
|
||||||
|
"genre" -> BrowseFacet.Genre(browseName)
|
||||||
|
"studio" -> BrowseFacet.Studio(browseName)
|
||||||
|
"person" -> BrowseFacet.Person(browseName)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val browse = { facet: BrowseFacet ->
|
||||||
|
browseKind = when (facet) {
|
||||||
|
is BrowseFacet.Genre -> "genre"
|
||||||
|
is BrowseFacet.Studio -> "studio"
|
||||||
|
is BrowseFacet.Person -> "person"
|
||||||
|
}
|
||||||
|
browseName = facet.label
|
||||||
|
}
|
||||||
|
|
||||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||||
val playing = playingTitleKey
|
val playing = playingTitleKey
|
||||||
@@ -172,7 +196,7 @@ fun ProtonStreamApp(
|
|||||||
Destination.entries.forEach { item ->
|
Destination.entries.forEach { item ->
|
||||||
item(
|
item(
|
||||||
selected = destination == item,
|
selected = destination == item,
|
||||||
onClick = { destination = item; selectedTitleKey = null },
|
onClick = { destination = item; selectedTitleKey = null; browseKind = null },
|
||||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||||
label = { Text(item.label) },
|
label = { Text(item.label) },
|
||||||
colors = navigation,
|
colors = navigation,
|
||||||
@@ -211,16 +235,34 @@ fun ProtonStreamApp(
|
|||||||
// library stays in view while a title is open, so moving
|
// library stays in view while a title is open, so moving
|
||||||
// between titles is one tap rather than back and in again.
|
// between titles is one tap rather than back and in again.
|
||||||
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val twoPane = maxWidth >= TWO_PANE_WIDTH
|
||||||
|
val browsed by produceState(emptyList<String>(), browsing, state.titles) {
|
||||||
|
value = browsing?.let { model.browse(it) }.orEmpty()
|
||||||
|
}
|
||||||
val libraryPane = @Composable {
|
val libraryPane = @Composable {
|
||||||
LibraryScreen(
|
browsing?.let { facet ->
|
||||||
|
BrowseScreen(
|
||||||
|
facet,
|
||||||
|
browsed.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
|
||||||
|
onBack = {
|
||||||
|
browseKind = null
|
||||||
|
browseFrom?.let { selectedTitleKey = it }
|
||||||
|
},
|
||||||
|
onTitle = { selectedTitleKey = it.key },
|
||||||
|
padding = body,
|
||||||
|
)
|
||||||
|
} ?: LibraryScreen(
|
||||||
state,
|
state,
|
||||||
model::search,
|
model::search,
|
||||||
{ title, index ->
|
{ title, index ->
|
||||||
|
// A search that led somewhere is one
|
||||||
|
// worth offering again.
|
||||||
|
model.rememberSearch()
|
||||||
playingTitleKey = title.key
|
playingTitleKey = title.key
|
||||||
playingIndex = index
|
playingIndex = index
|
||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
},
|
},
|
||||||
{ selectedTitleKey = it.key },
|
{ model.rememberSearch(); selectedTitleKey = it.key },
|
||||||
model::refresh,
|
model::refresh,
|
||||||
model::setTitleWatched,
|
model::setTitleWatched,
|
||||||
model::forgetPosition,
|
model::forgetPosition,
|
||||||
@@ -234,9 +276,19 @@ fun ProtonStreamApp(
|
|||||||
},
|
},
|
||||||
onOpenSettings = { destination = Destination.Settings },
|
onOpenSettings = { destination = Destination.Settings },
|
||||||
onFilter = model::filter,
|
onFilter = model::filter,
|
||||||
|
onSetOnList = model::setOnList,
|
||||||
|
onBrowse = { facet ->
|
||||||
|
model.rememberSearch()
|
||||||
|
browse(facet)
|
||||||
|
browseFrom = null
|
||||||
|
},
|
||||||
|
onForgetSearches = model::forgetSearches,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val titlePane = @Composable { selected: TitleRecord ->
|
val titlePane = @Composable { selected: TitleRecord ->
|
||||||
|
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
|
||||||
|
value = model.moreLike(selected.key)
|
||||||
|
}
|
||||||
TitleScreen(
|
TitleScreen(
|
||||||
selected,
|
selected,
|
||||||
playerHost != null,
|
playerHost != null,
|
||||||
@@ -255,9 +307,18 @@ fun ProtonStreamApp(
|
|||||||
state.titles.firstOrNull { it.key == key }
|
state.titles.firstOrNull { it.key == key }
|
||||||
},
|
},
|
||||||
onOpenTitle = { selectedTitleKey = it.key },
|
onOpenTitle = { selectedTitleKey = it.key },
|
||||||
|
onSetOnList = model::setOnList,
|
||||||
|
moreLike = similar.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
|
||||||
|
onBrowse = { facet ->
|
||||||
|
browse(facet)
|
||||||
|
browseFrom = selected.key
|
||||||
|
// On a phone the list is under the title;
|
||||||
|
// the page it opens is the library pane's.
|
||||||
|
if (!twoPane) selectedTitleKey = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (maxWidth >= TWO_PANE_WIDTH) {
|
if (twoPane) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
Box(Modifier.weight(0.45f)) { libraryPane() }
|
Box(Modifier.weight(0.45f)) { libraryPane() }
|
||||||
VerticalDivider()
|
VerticalDivider()
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import android.graphics.BitmapFactory
|
|||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -24,22 +27,30 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.graphics.lerp
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.core.graphics.scale
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.moodVeil as sharedMoodVeil
|
||||||
|
import uniffi.pstr_android.pictureMood as sharedPictureMood
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -77,9 +88,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
|||||||
*
|
*
|
||||||
* [fallback] is used only when there is no provider artwork or it fails to
|
* [fallback] is used only when there is no provider artwork or it fails to
|
||||||
* load, which — with metadata off — is the common case rather than the rare
|
* load, which — with metadata off — is the common case rather than the rare
|
||||||
* one. Until either answers, the box pulses as a skeleton; when neither has
|
* one. Until either answers, the box shimmers as a skeleton; when neither has
|
||||||
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
||||||
* initials when [labelled] is false, for art with the name already over it.
|
* initials when [labelled] is false, for art with the name already over it.
|
||||||
|
*
|
||||||
|
* Art that arrives fades in over the skeleton rather than replacing it in one
|
||||||
|
* frame, which across a grid reads as flicker. Art already decoded is drawn
|
||||||
|
* at once: a tile scrolled back into view has nothing to arrive.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RemoteArtwork(
|
internal fun RemoteArtwork(
|
||||||
@@ -89,28 +104,199 @@ internal fun RemoteArtwork(
|
|||||||
fallback: ThumbnailSource? = null,
|
fallback: ThumbnailSource? = null,
|
||||||
labelled: Boolean = true,
|
labelled: Boolean = true,
|
||||||
maxEdge: Dp? = null,
|
maxEdge: Dp? = null,
|
||||||
|
onMood: ((Color) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val enabled = LocalArtworkLoading.current
|
val enabled = LocalArtworkLoading.current
|
||||||
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
||||||
// and decoding its poster at 1024 holds four times the memory for nothing.
|
// and decoding its poster at 1024 holds four times the memory for nothing.
|
||||||
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
|
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
|
||||||
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) }
|
||||||
LaunchedEffect(url, fallback, enabled) {
|
LaunchedEffect(url, fallback, enabled) {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
image = ArtworkLoad.Missing
|
image = ArtworkLoad.Missing
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
|
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
|
||||||
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
|
||||||
?: ArtworkLoad.Missing
|
if (bitmap != null && onMood != null) {
|
||||||
}
|
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
|
||||||
when (val loaded = image) {
|
|
||||||
ArtworkLoad.Pending -> Skeleton(modifier)
|
|
||||||
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
|
|
||||||
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
|
||||||
|
when (loaded) {
|
||||||
|
ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
|
||||||
|
ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled)
|
||||||
|
is ArtworkLoad.Loaded -> Image(
|
||||||
|
loaded.image,
|
||||||
|
description,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the decoded cache already holds for this art, so it skips the fade. */
|
||||||
|
private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad {
|
||||||
|
val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) }
|
||||||
|
return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long art takes to fade in over its skeleton: the desktop's
|
||||||
|
* `motion::ARRIVE`.
|
||||||
|
*/
|
||||||
|
private const val ARRIVE_MS = 240
|
||||||
|
|
||||||
|
/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */
|
||||||
|
private const val SHIMMER_MS = 1_400
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A title's art across the top of a page: its backdrop, or with none, its
|
||||||
|
* poster as a wash of its own colours — decoded tiny and blurred, so it reads as
|
||||||
|
* the poster's light rather than a blown-up slice of it. The desktop's
|
||||||
|
* `Fit::Ambient`. Android 11 and older cannot blur; there the tiny decode,
|
||||||
|
* stretched, is what softens it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
|
||||||
|
val wash = title.backdropUrl == null && title.posterUrl != null
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.displayName,
|
||||||
|
if (wash) modifier.blur(WASH_BLUR) else modifier,
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
maxEdge = if (wash) WASH_EDGE else null,
|
||||||
|
onMood = onMood,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour a picture leaves behind — the shared `appearance::mood`, over the
|
||||||
|
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
|
||||||
|
* banner paging back to a title does not work it out again.
|
||||||
|
*/
|
||||||
|
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
|
||||||
|
moods.get(key)?.let { return Color(it) }
|
||||||
|
val argb = withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
val small = bitmap.scale(MOOD_EDGE, MOOD_EDGE)
|
||||||
|
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
|
||||||
|
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
|
||||||
|
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
|
||||||
|
}.getOrNull()
|
||||||
|
} ?: return null
|
||||||
|
moods.put(key, argb)
|
||||||
|
return Color(argb)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val moods = LruCache<String, Int>(64)
|
||||||
|
|
||||||
|
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
|
||||||
|
private const val MOOD_EDGE = 24
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [background] tinted by a picture's [mood], for the veils over that picture —
|
||||||
|
* the shared `appearance::mood_veil`. [background] itself until the mood is
|
||||||
|
* known.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
|
||||||
|
val light = background.luminance() > 0.5f
|
||||||
|
val target = remember(mood, background) {
|
||||||
|
mood?.let {
|
||||||
|
runCatching {
|
||||||
|
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
} ?: background
|
||||||
|
}
|
||||||
|
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
|
||||||
|
}
|
||||||
|
|
||||||
|
private val WASH_EDGE = 48.dp
|
||||||
|
private val WASH_BLUR = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A title's logo — its name as lettering — in place of [fallback], once it has
|
||||||
|
* loaded. [fallback] stands until then, and for good when there is none, so a
|
||||||
|
* title without a logo looks as it always did and one with a logo only gains.
|
||||||
|
*
|
||||||
|
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
|
||||||
|
* logo to a square, and the padding would otherwise decide its size and place.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun RemoteLogo(
|
||||||
|
url: String?,
|
||||||
|
description: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fallback: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val enabled = LocalArtworkLoading.current
|
||||||
|
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
LaunchedEffect(url, enabled) {
|
||||||
|
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
|
||||||
|
}
|
||||||
|
Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
|
||||||
|
when (loaded) {
|
||||||
|
null -> fallback()
|
||||||
|
else -> Image(
|
||||||
|
loaded,
|
||||||
|
description,
|
||||||
|
modifier,
|
||||||
|
alignment = Alignment.BottomStart,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
|
||||||
|
val requested = url ?: return null
|
||||||
|
val key = "logo:$requested"
|
||||||
|
decoded.get(key)?.let { return it }
|
||||||
|
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
|
||||||
|
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
|
||||||
|
private fun trimTransparent(bitmap: Bitmap): Bitmap {
|
||||||
|
if (!bitmap.hasAlpha()) return bitmap
|
||||||
|
val pixels = IntArray(bitmap.width * bitmap.height)
|
||||||
|
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||||
|
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
|
||||||
|
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
|
||||||
|
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
|
||||||
|
* row-major image of [width] that are not (nearly) transparent; `null` when
|
||||||
|
* none are. Below the threshold is anti-aliasing haze, not lettering.
|
||||||
|
*/
|
||||||
|
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
|
||||||
|
var left = Int.MAX_VALUE
|
||||||
|
var top = Int.MAX_VALUE
|
||||||
|
var right = -1
|
||||||
|
var bottom = -1
|
||||||
|
for (index in pixels.indices) {
|
||||||
|
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
|
||||||
|
val x = index % width
|
||||||
|
val y = index / width
|
||||||
|
if (x < left) left = x
|
||||||
|
if (x > right) right = x
|
||||||
|
if (y < top) top = y
|
||||||
|
if (y > bottom) bottom = y
|
||||||
|
}
|
||||||
|
return if (right < 0) null else intArrayOf(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val VISIBLE_ALPHA = 8
|
||||||
|
|
||||||
|
/** A logo is drawn a few hundred pixels wide at most. */
|
||||||
|
private const val LOGO_EDGE = 800
|
||||||
|
|
||||||
private sealed interface ArtworkLoad {
|
private sealed interface ArtworkLoad {
|
||||||
data object Pending : ArtworkLoad
|
data object Pending : ArtworkLoad
|
||||||
@@ -128,20 +314,38 @@ private sealed interface ArtworkLoad {
|
|||||||
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Something being loaded: the surface tint, breathing.
|
* Something being loaded: the surface tint, with a soft highlight sweeping
|
||||||
|
* across it on a diagonal.
|
||||||
*
|
*
|
||||||
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||||
* says what is coming and where, and settles the layout before it arrives.
|
* says what is coming and where, and settles the layout before it arrives.
|
||||||
|
* The sweep is measured in the tile's own width, so neighbouring tiles of one
|
||||||
|
* size shimmer in step, as one surface.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun Skeleton(modifier: Modifier = Modifier) {
|
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||||
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||||
initialValue = 0.55f,
|
initialValue = -1f,
|
||||||
targetValue = 1f,
|
targetValue = 2f,
|
||||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
|
||||||
label = "skeleton alpha",
|
label = "skeleton sweep",
|
||||||
|
)
|
||||||
|
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f)
|
||||||
|
Box(
|
||||||
|
modifier.drawBehind {
|
||||||
|
drawRect(base)
|
||||||
|
val centre = size.width * sweep
|
||||||
|
val half = size.width * 0.6f
|
||||||
|
drawRect(
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(Color.Transparent, highlight, Color.Transparent),
|
||||||
|
start = Offset(centre - half, 0f),
|
||||||
|
end = Offset(centre + half, size.height),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -438,30 +438,42 @@ private fun MetadataSettingsDialog(
|
|||||||
title = { Text("Metadata enrichment") },
|
title = { Text("Metadata enrichment") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
|
||||||
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
MetadataProvider.entries.forEach { option ->
|
listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).forEach { option ->
|
||||||
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
|
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
|
if (provider == MetadataProvider.ANI_LIST) {
|
||||||
else "Film and television; requires a free TMDB API key.",
|
"Anime, and television from TVmaze; no account or API key required. " +
|
||||||
|
"With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone."
|
||||||
|
} else {
|
||||||
|
"Film and television; requires a free TMDB API key."
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
if (provider == MetadataProvider.TMDB) {
|
if (provider == MetadataProvider.TMDB) {
|
||||||
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
apiKey,
|
apiKey,
|
||||||
{ apiKey = it },
|
{ apiKey = it },
|
||||||
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
|
label = {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)"
|
||||||
|
current.ready -> "TMDB API key (leave blank to keep)"
|
||||||
|
else -> "TMDB API key"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||||
// Said before Save rather than after: what is forgotten
|
// Said before Save rather than after: what is forgotten
|
||||||
// includes matches chosen by hand, which no look-up redoes.
|
// includes matches chosen by hand, which no look-up redoes.
|
||||||
@@ -497,6 +509,7 @@ private fun MetadataSettingsDialog(
|
|||||||
internal fun MetadataProvider.displayName(): String = when (this) {
|
internal fun MetadataProvider.displayName(): String = when (this) {
|
||||||
MetadataProvider.ANI_LIST -> "AniList"
|
MetadataProvider.ANI_LIST -> "AniList"
|
||||||
MetadataProvider.TMDB -> "TMDB"
|
MetadataProvider.TMDB -> "TMDB"
|
||||||
|
MetadataProvider.TV_MAZE -> "TVmaze"
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class LegalDocument(val title: String, val assetPath: String)
|
private data class LegalDocument(val title: String, val assetPath: String)
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import androidx.activity.compose.PredictiveBackHandler
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
@@ -12,6 +18,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -43,6 +50,9 @@ import androidx.compose.material.icons.filled.CheckCircle
|
|||||||
import androidx.compose.material.icons.outlined.CheckCircle
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.DoneAll
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.RemoveDone
|
import androidx.compose.material.icons.filled.RemoveDone
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
@@ -81,6 +91,9 @@ import io.narl.protonstream.ui.theme.QuietButton
|
|||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
import io.narl.protonstream.ui.theme.solid
|
import io.narl.protonstream.ui.theme.solid
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import uniffi.pstr_android.BrowseFacet
|
||||||
|
import uniffi.pstr_android.CastRecord
|
||||||
|
import androidx.compose.material.icons.filled.Movie
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TitleType
|
import uniffi.pstr_android.TitleType
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
@@ -88,10 +101,13 @@ import uniffi.pstr_android.SeasonRecord
|
|||||||
import uniffi.pstr_android.MatchRecord
|
import uniffi.pstr_android.MatchRecord
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Replay
|
import androidx.compose.material.icons.filled.Replay
|
||||||
import androidx.compose.material.icons.filled.DownloadDone
|
import androidx.compose.material.icons.filled.DownloadDone
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -109,6 +125,11 @@ internal fun TitleScreen(
|
|||||||
/** Every title of its franchise, in release order, this one included. */
|
/** Every title of its franchise, in release order, this one included. */
|
||||||
franchise: List<TitleRecord> = emptyList(),
|
franchise: List<TitleRecord> = emptyList(),
|
||||||
onOpenTitle: (TitleRecord) -> Unit = {},
|
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||||
|
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||||
|
/** The titles most like it, best first. */
|
||||||
|
moreLike: List<TitleRecord> = emptyList(),
|
||||||
|
/** Open every title sharing a genre, studio or director. */
|
||||||
|
onBrowse: (BrowseFacet) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val download = rememberDownload()
|
val download = rememberDownload()
|
||||||
@@ -172,8 +193,12 @@ internal fun TitleScreen(
|
|||||||
) {
|
) {
|
||||||
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||||
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||||
|
val list = rememberLazyListState()
|
||||||
|
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
|
||||||
|
val veil = moodVeil(mood)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = list,
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -181,37 +206,46 @@ internal fun TitleScreen(
|
|||||||
// Edge to edge, fading into the page: the art is the first thing
|
// Edge to edge, fading into the page: the art is the first thing
|
||||||
// the page says, and a framed thumbnail with a margin round it read
|
// the page says, and a framed thumbnail with a margin round it read
|
||||||
// as one card among the many below it.
|
// as one card among the many below it.
|
||||||
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight).clipToBounds()) {
|
||||||
RemoteArtwork(
|
// At half the speed of the page, so the picture stays
|
||||||
title.backdropUrl ?: title.posterUrl,
|
// behind the page rather than being part of it.
|
||||||
title.displayName,
|
Backdrop(
|
||||||
Modifier.fillMaxSize(),
|
title,
|
||||||
fallback = title.thumbnailSource,
|
Modifier.fillMaxSize().graphicsLayer {
|
||||||
labelled = false,
|
translationY = if (list.firstVisibleItemIndex == 0) list.firstVisibleItemScrollOffset * PARALLAX else 0f
|
||||||
|
},
|
||||||
|
onMood = { mood = it },
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
0.45f to Color.Transparent,
|
0.4f to Color.Transparent,
|
||||||
|
// The picture's own colour on the way into the
|
||||||
|
// page, as on the library's banner.
|
||||||
|
0.75f to veil.copy(alpha = 0.55f),
|
||||||
1f to MaterialTheme.colorScheme.background,
|
1f to MaterialTheme.colorScheme.background,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
IconButton(
|
|
||||||
onClick = onBack,
|
|
||||||
modifier = Modifier
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Black.copy(alpha = 0.4f)),
|
|
||||||
) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(key = "heading") {
|
item(key = "heading") {
|
||||||
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||||
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
val heading = @Composable {
|
||||||
|
Text(title.displayName, style = MaterialTheme.typography.displayLarge)
|
||||||
|
}
|
||||||
|
// A light page keeps the name: a clear logo is lettering
|
||||||
|
// made for dark art, white as often as not.
|
||||||
|
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
|
||||||
|
RemoteLogo(
|
||||||
|
title.logoUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 4.dp),
|
||||||
|
heading,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
heading()
|
||||||
|
}
|
||||||
// The other names it goes by: the share's when the shown one
|
// The other names it goes by: the share's when the shown one
|
||||||
// is the provider's, and the original-language one.
|
// is the provider's, and the original-language one.
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
@@ -224,6 +258,8 @@ internal fun TitleScreen(
|
|||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
title.formatLabel,
|
title.formatLabel,
|
||||||
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
|
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
|
||||||
|
// A series' running time is its episodes', which each row says.
|
||||||
|
title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
|
||||||
title.rating?.let { "★ %.1f".format(it) },
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
@@ -231,19 +267,20 @@ internal fun TitleScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
)
|
)
|
||||||
// Who made it, and whether more is coming — the desktop's
|
// Where the title stands, then what the files are rather
|
||||||
// line of facts, on a line of its own so neither wraps.
|
// than what the title is.
|
||||||
listOfNotNull(
|
val status = statusText(title)
|
||||||
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
|
if (title.quality.isNotEmpty() || status != null) {
|
||||||
title.directors.firstOrNull()?.let { "dir. $it" },
|
Row(
|
||||||
airingNote(title),
|
Modifier.padding(top = 8.dp),
|
||||||
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
Text(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
it,
|
) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
status?.let { (text, live) ->
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
StatusChip(text, live, Modifier.padding(end = 4.dp))
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
}
|
||||||
)
|
title.quality.forEach { SpecBadge(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The one thing the page is for, full width and naming what it
|
// The one thing the page is for, full width and naming what it
|
||||||
// will play: "Resume" alone left the viewer guessing which
|
// will play: "Resume" alone left the viewer guessing which
|
||||||
@@ -272,6 +309,13 @@ internal fun TitleScreen(
|
|||||||
// three button styles used to wrap over three lines and bury
|
// three button styles used to wrap over three lines and bury
|
||||||
// the episodes under them.
|
// the episodes under them.
|
||||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
TitleAction(
|
||||||
|
if (title.onList) Icons.Default.Check else Icons.Default.Add,
|
||||||
|
"My List",
|
||||||
|
Modifier.weight(1f).semantics {
|
||||||
|
stateDescription = if (title.onList) "On My List" else "Not on My List"
|
||||||
|
},
|
||||||
|
) { onSetOnList(title, !title.onList) }
|
||||||
// Only where it would do something different: an unstarted
|
// Only where it would do something different: an unstarted
|
||||||
// show already starts at the beginning.
|
// show already starts at the beginning.
|
||||||
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||||
@@ -309,15 +353,63 @@ internal fun TitleScreen(
|
|||||||
.clickable { overviewOpen = !overviewOpen },
|
.clickable { overviewOpen = !overviewOpen },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// In the browser: the app itself never requests it.
|
||||||
|
title.trailerUrl?.let { url ->
|
||||||
|
QuietButton(
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||||
|
}.onFailure(onPreferenceError)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Movie, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Trailer")
|
||||||
|
}
|
||||||
|
}
|
||||||
// Genres as chips, then the provider's tags quieter after
|
// Genres as chips, then the provider's tags quieter after
|
||||||
// them: a tag is its community describing it, not what it
|
// them: a tag is its community describing it, not what it
|
||||||
// is filed as.
|
// is filed as.
|
||||||
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
|
if (title.genres.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
Modifier.padding(top = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
title.genres.forEach { genre -> GenreChip(genre) { onBrowse(BrowseFacet.Genre(genre)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Who made it, as chips that open everything else by them:
|
||||||
|
// the desktop's credits row.
|
||||||
|
val studios = title.studios.take(2)
|
||||||
|
val director = title.directors.take(1)
|
||||||
|
if (studios.isNotEmpty() || director.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
Modifier.padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
Triple("Studio", studios) { name: String -> BrowseFacet.Studio(name) },
|
||||||
|
Triple("Director", director) { name: String -> BrowseFacet.Person(name) },
|
||||||
|
).filter { it.second.isNotEmpty() }.forEach { (label, names, facet) ->
|
||||||
Text(
|
Text(
|
||||||
(title.genres + title.tags.take(4)).joinToString(" · "),
|
label,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 10.dp),
|
)
|
||||||
|
names.forEach { name -> GenreChip(name) { onBrowse(facet(name)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (title.tags.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
title.tags.take(4).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Said plainly, because it changes what a re-match will do: a
|
// Said plainly, because it changes what a re-match will do: a
|
||||||
@@ -356,6 +448,11 @@ internal fun TitleScreen(
|
|||||||
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (title.seasons.size > SEASON_CHIPS) {
|
||||||
|
// A long-running show's row of chips is a scroll of
|
||||||
|
// its own; past a handful it is a list to pick from.
|
||||||
|
SeasonList(title.seasons, seasonIndex, { seasonIndex = it }, Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// By position: two seasons can share a label, and a
|
// By position: two seasons can share a label, and a
|
||||||
// repeated key is a crash in a lazy list.
|
// repeated key is a crash in a lazy list.
|
||||||
@@ -367,6 +464,7 @@ internal fun TitleScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
season?.let {
|
season?.let {
|
||||||
// For someone joining the show partway, who would
|
// For someone joining the show partway, who would
|
||||||
// otherwise tick every earlier episode one by one.
|
// otherwise tick every earlier episode one by one.
|
||||||
@@ -411,8 +509,50 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (title.cast.isNotEmpty()) {
|
||||||
|
item(key = "cast") {
|
||||||
|
Column(Modifier.padding(top = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Cast",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = side + 16.dp),
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
|
||||||
|
) {
|
||||||
|
// Unkeyed: two characters can share a name.
|
||||||
|
items(title.cast) { member ->
|
||||||
|
CastTile(member) { actor -> onBrowse(BrowseFacet.Person(actor)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Under the episodes rather than over them: where to go once this
|
||||||
|
// one is done, not what the page is for.
|
||||||
|
if (moreLike.isNotEmpty()) {
|
||||||
|
item(key = "more-like-this") {
|
||||||
|
Column(Modifier.padding(top = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
"More like this",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = side + 16.dp),
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(moreLike, key = { it.key }) { other ->
|
||||||
|
FranchiseTile(other, here = false) { onOpenTitle(other) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack)
|
||||||
|
}
|
||||||
if (showMatch) {
|
if (showMatch) {
|
||||||
ChangeMatchDialog(
|
ChangeMatchDialog(
|
||||||
title = title,
|
title = title,
|
||||||
@@ -423,9 +563,108 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How fast the backdrop moves against the page scrolling over it. */
|
||||||
|
private const val PARALLAX = 0.5f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One part of the franchise: its cover, what it is and when. This title's own
|
* The bar over the title page: only the back button while the backdrop is on
|
||||||
* is marked rather than left out, so the row says where it sits in the story.
|
* screen, then — once the backdrop has scrolled away — a solid bar with the
|
||||||
|
* title's name, so the page still says whose episodes these are.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack: () -> Unit) {
|
||||||
|
val reach = with(LocalDensity.current) { TITLE_BAR.toPx() }
|
||||||
|
val solid by remember(list, backdrop) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (list.firstVisibleItemIndex > 0) {
|
||||||
|
1f
|
||||||
|
} else {
|
||||||
|
((list.firstVisibleItemScrollOffset - (backdrop - 2 * reach)) / reach).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(scheme.surfaceContainer.copy(alpha = solid))
|
||||||
|
.statusBarsPadding()
|
||||||
|
.height(TITLE_BAR),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
// A disc behind the arrow over the picture, gone once the bar
|
||||||
|
// is there to sit on.
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f * (1f - solid))),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back to library",
|
||||||
|
tint = lerp(Color.White, scheme.onSurface, solid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f).padding(end = 16.dp).graphicsLayer { alpha = solid },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TITLE_BAR = 56.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One character: a round portrait over their name and who voices them. A voice
|
||||||
|
* actor opens every title in the library they are in.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CastTile(member: CastRecord, onActor: (String) -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(88.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.then(
|
||||||
|
member.actor?.let { actor ->
|
||||||
|
Modifier.clickable(onClickLabel = "Browse $actor") { onActor(actor) }
|
||||||
|
} ?: Modifier,
|
||||||
|
),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
member.characterImage ?: member.actorImage,
|
||||||
|
member.character,
|
||||||
|
Modifier.size(76.dp).clip(CircleShape),
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
member.character,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
member.actor?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One part of the franchise, or one title like this one: its cover, what it is
|
||||||
|
* and when. In the franchise row this title's own is marked rather than left
|
||||||
|
* out, so the row says where it sits in the story.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
|
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
|
||||||
@@ -472,17 +711,51 @@ private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
|
* What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
|
||||||
* no date yet, else nothing.
|
* and whether more is coming. The desktop's `status_text`.
|
||||||
*/
|
*/
|
||||||
private fun airingNote(title: TitleRecord): String? {
|
internal fun statusText(title: TitleRecord): Pair<String, Boolean>? {
|
||||||
val at = title.nextAiringAt
|
val at = title.nextAiringAt
|
||||||
val episode = title.nextEpisode
|
val episode = title.nextEpisode
|
||||||
if (at != null && episode != null) {
|
val next = at != null && episode != null
|
||||||
|
val label = title.status ?: (if (next || title.airing) "Airing" else return null)
|
||||||
|
val live = next || title.airing
|
||||||
|
if (at == null || episode == null) return label to live
|
||||||
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||||
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
|
return "$label · episode $episode " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) to live
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most seasons picked from a row of chips; past it, a list. The desktop's `SEASON_PILLS`. */
|
||||||
|
private const val SEASON_CHIPS = 5
|
||||||
|
|
||||||
|
/** The season shown, as a button that opens the list of all of them. */
|
||||||
|
@Composable
|
||||||
|
private fun SeasonList(seasons: List<SeasonRecord>, picked: Int, onPick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box(modifier) {
|
||||||
|
FilterChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = { open = true },
|
||||||
|
label = { Text(seasons.getOrNull(picked)?.label.orEmpty()) },
|
||||||
|
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||||
|
)
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
seasons.forEachIndexed { index, season ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(season.label) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onPick(index)
|
||||||
|
},
|
||||||
|
trailingIcon = if (index == picked) {
|
||||||
|
{ Icon(Icons.Default.Check, contentDescription = "Shown") }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return if (title.airing) "airing" else null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How wide the page's text and controls run on a window wider than a phone. */
|
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||||
@@ -607,6 +880,21 @@ private fun EpisodeRow(
|
|||||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Seen, said on the picture as well as by the mark at the
|
||||||
|
// row's end: a season is scanned by its stills.
|
||||||
|
if (episode.watched) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(5.dp)
|
||||||
|
.size(18.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.padding(2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -625,7 +913,9 @@ private fun EpisodeRow(
|
|||||||
Text(
|
Text(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
numbering,
|
numbering,
|
||||||
episode.size?.let(::formatBytes),
|
// How long it runs says more than how big it is; the
|
||||||
|
// size is what there is when no provider said.
|
||||||
|
episode.runtime?.let(::formatRuntime) ?: episode.size?.let(::formatBytes),
|
||||||
episode.airDate,
|
episode.airDate,
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -740,7 +1030,7 @@ internal fun ChangeMatchDialog(
|
|||||||
title = { Text("Change match") },
|
title = { Text("Change match") },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
|
Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
|
||||||
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
|
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
|
||||||
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
|
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
|
||||||
searching = true
|
searching = true
|
||||||
@@ -775,7 +1065,10 @@ internal fun ChangeMatchDialog(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Text(option.name, fontWeight = FontWeight.SemiBold)
|
Text(option.name, fontWeight = FontWeight.SemiBold)
|
||||||
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
|
Text(
|
||||||
|
listOfNotNull(option.year?.toString(), option.originalName, option.provider.displayName())
|
||||||
|
.joinToString(" · "),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ internal val Inter = FontFamily(
|
|||||||
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inter Display Bold, the optical size drawn for large text, for the one
|
||||||
|
* [HERO] line: the banner's name and the name at the top of a title's page.
|
||||||
|
* The desktop's `theme::display` family, from the same file.
|
||||||
|
*/
|
||||||
|
internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type ramp, which is the desktop client's type ramp.
|
* The type ramp, which is the desktop client's type ramp.
|
||||||
*
|
*
|
||||||
@@ -33,7 +40,10 @@ internal val Inter = FontFamily(
|
|||||||
* were built to share one palette; sharing one scale is the other half of why
|
* were built to share one palette; sharing one scale is the other half of why
|
||||||
* they should read as one app.
|
* they should read as one app.
|
||||||
*
|
*
|
||||||
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
* The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||||
|
* The top one is the hero line, in [InterDisplay] and set tighter, at
|
||||||
|
* [HERO_LEADING]: at that size the text face's leading pushes a two-line name
|
||||||
|
* apart.
|
||||||
*
|
*
|
||||||
* Where a role maps is decided by what actually draws with it here, not by
|
* Where a role maps is decided by what actually draws with it here, not by
|
||||||
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
|
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
|
||||||
@@ -44,7 +54,8 @@ internal val Inter = FontFamily(
|
|||||||
* [TypeRampTest] pins every one of these against the Rust ramp.
|
* [TypeRampTest] pins every one of these against the Rust ramp.
|
||||||
*/
|
*/
|
||||||
internal val ProtonTypography = Typography(
|
internal val ProtonTypography = Typography(
|
||||||
displayLarge = role(DISPLAY, FontWeight.SemiBold),
|
// The banner's name and the title page's name, where no logo stands in.
|
||||||
|
displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING),
|
||||||
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||||
displaySmall = role(TITLE, FontWeight.SemiBold),
|
displaySmall = role(TITLE, FontWeight.SemiBold),
|
||||||
// The name of the title you opened — the desktop's display line.
|
// The name of the title you opened — the desktop's display line.
|
||||||
@@ -69,6 +80,7 @@ internal val ProtonTypography = Typography(
|
|||||||
labelSmall = role(MICRO, FontWeight.Medium),
|
labelSmall = role(MICRO, FontWeight.Medium),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal const val HERO = 34f
|
||||||
internal const val DISPLAY = 26f
|
internal const val DISPLAY = 26f
|
||||||
internal const val TITLE = 20f
|
internal const val TITLE = 20f
|
||||||
internal const val HEADING = 18f
|
internal const val HEADING = 18f
|
||||||
@@ -79,6 +91,9 @@ internal const val LABEL = 13f
|
|||||||
internal const val CAPTION = 12f
|
internal const val CAPTION = 12f
|
||||||
internal const val MICRO = 11f
|
internal const val MICRO = 11f
|
||||||
|
|
||||||
|
internal const val LEADING = 1.35f
|
||||||
|
internal const val HERO_LEADING = 1.1f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One rung as a `TextStyle`.
|
* One rung as a `TextStyle`.
|
||||||
*
|
*
|
||||||
@@ -87,9 +102,14 @@ internal const val MICRO = 11f
|
|||||||
* enough for the two-line title names in the grid and tight enough that a
|
* enough for the two-line title names in the grid and tight enough that a
|
||||||
* label on a control does not float.
|
* label on a control does not float.
|
||||||
*/
|
*/
|
||||||
private fun role(size: Float, weight: FontWeight) = TextStyle(
|
private fun role(
|
||||||
|
size: Float,
|
||||||
|
weight: FontWeight,
|
||||||
|
family: FontFamily = Inter,
|
||||||
|
leading: Float = LEADING,
|
||||||
|
) = TextStyle(
|
||||||
fontSize = size.sp,
|
fontSize = size.sp,
|
||||||
lineHeight = (size * 1.35f).sp,
|
lineHeight = (size * leading).sp,
|
||||||
fontWeight = weight,
|
fontWeight = weight,
|
||||||
fontFamily = Inter,
|
fontFamily = family,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
class EpisodeNameTest {
|
||||||
|
private fun episode(name: String, detail: String, providerName: String? = null) = EpisodeRecord(
|
||||||
|
shareId = "s", volumeId = "v", linkId = "l", name = name, label = "S01E01", detail = detail,
|
||||||
|
season = 1u, number = 1u, size = null, progress = null, resumeAt = null,
|
||||||
|
watched = false, offline = false, providerName = providerName, providerOverview = null, stillUrl = null,
|
||||||
|
airDate = null, lastPlayed = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the player captions an episode with its name and never its filename`() {
|
||||||
|
val file = "[Group] Show - S01E01.mkv"
|
||||||
|
assertEquals("Dog & Chainsaw", episode(file, file, providerName = "Dog & Chainsaw").shownName())
|
||||||
|
assertEquals("Winter Is Coming", episode(file, "Winter Is Coming").shownName())
|
||||||
|
assertNull(episode(file, file).shownName())
|
||||||
|
assertNull(episode(file, "").shownName())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LogoAndRuntimeTest {
|
||||||
|
@Test
|
||||||
|
fun `a padded logo is bounded by its lettering and not its haze`() {
|
||||||
|
val width = 10
|
||||||
|
val pixels = IntArray(width * 6)
|
||||||
|
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
|
||||||
|
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
|
||||||
|
pixels[5 * width + 9] = 0x03FFFFFF
|
||||||
|
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a logo with nothing visible has no bounds`() {
|
||||||
|
assertNull(opaqueBounds(IntArray(16), 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a running time reads in hours and minutes`() {
|
||||||
|
assertEquals("24 min", formatRuntime(24u))
|
||||||
|
assertEquals("2 h", formatRuntime(120u))
|
||||||
|
assertEquals("2 h 4 min", formatRuntime(124u))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import uniffi.pstr_android.ShareRecord
|
|||||||
import uniffi.pstr_android.ShelfRecord
|
import uniffi.pstr_android.ShelfRecord
|
||||||
import uniffi.pstr_android.StorageUsageRecord
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
import uniffi.pstr_android.TileRecord
|
import uniffi.pstr_android.TileRecord
|
||||||
|
import uniffi.pstr_android.CastRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TitleType
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
@@ -291,6 +292,11 @@ class ScreenshotTest {
|
|||||||
directors = listOf("Daisuke Hiramaki"),
|
directors = listOf("Daisuke Hiramaki"),
|
||||||
tags = listOf("Idol", "Reincarnation"),
|
tags = listOf("Idol", "Reincarnation"),
|
||||||
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
|
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
|
||||||
|
trailerUrl = "https://www.youtube.com/watch?v=trailer",
|
||||||
|
cast = listOf(
|
||||||
|
CastRecord("Ai Hoshino", null, "Rie Takahashi", null),
|
||||||
|
CastRecord("Aqua", null, "Takeo Ootsuka", null),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
|
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
class StatusTest {
|
||||||
|
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
|
||||||
|
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||||
|
episodeCount = 0uL, canonicalName = null, originalName = null, overview = null,
|
||||||
|
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||||
|
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||||
|
manualMatch = false, seasons = emptyList(),
|
||||||
|
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||||
|
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
|
||||||
|
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the status chip names the next episode while one is due`() {
|
||||||
|
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
|
||||||
|
assertTrue(text, text.startsWith("Airing · episode 8 "))
|
||||||
|
assertTrue(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finished title is said plainly and is not live`() {
|
||||||
|
assertEquals("Finished" to false, statusText(title("Finished")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title with no status and nothing airing has no chip`() {
|
||||||
|
assertNull(statusText(title(null)))
|
||||||
|
assertEquals("Airing" to true, statusText(title(null, airing = true)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,10 +22,10 @@ import org.junit.Test
|
|||||||
*/
|
*/
|
||||||
class TypeRampTest {
|
class TypeRampTest {
|
||||||
/** `Role::size()`, in the order `theme.rs` declares it. */
|
/** `Role::size()`, in the order `theme.rs` declares it. */
|
||||||
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
private val ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||||
|
|
||||||
private val expected = mapOf(
|
private val expected = mapOf(
|
||||||
"displayLarge" to 26f,
|
"displayLarge" to 34f,
|
||||||
"displayMedium" to 26f,
|
"displayMedium" to 26f,
|
||||||
"displaySmall" to 20f,
|
"displaySmall" to 20f,
|
||||||
"headlineLarge" to 26f,
|
"headlineLarge" to 26f,
|
||||||
@@ -62,12 +62,14 @@ class TypeRampTest {
|
|||||||
/**
|
/**
|
||||||
* Leading is derived from the size, so the only thing worth asserting is
|
* Leading is derived from the size, so the only thing worth asserting is
|
||||||
* that it *was* derived — a style that kept Material's line height carries
|
* that it *was* derived — a style that kept Material's line height carries
|
||||||
* a number with no relation to its own size.
|
* a number with no relation to its own size. The hero line is the one set
|
||||||
|
* tighter.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `line height follows the size it belongs to`() {
|
fun `line height follows the size it belongs to`() {
|
||||||
styles().forEach { (name, style) ->
|
styles().forEach { (name, style) ->
|
||||||
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
|
val leading = if (name == "displayLarge") HERO_LEADING else LEADING
|
||||||
|
assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 867 KiB |
|
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 672 KiB After Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 806 KiB After Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 619 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -246,6 +246,9 @@ pub struct SyncRecord {
|
|||||||
pub settings_changed: bool,
|
pub settings_changed: bool,
|
||||||
/// Hand-picked matches and per-show track choices taken.
|
/// Hand-picked matches and per-show track choices taken.
|
||||||
pub titles: u32,
|
pub titles: u32,
|
||||||
|
/// Whether My List changed: reload the library.
|
||||||
|
#[uniffi(default = false)]
|
||||||
|
pub list_changed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||||
@@ -304,6 +307,9 @@ pub enum TitleType {
|
|||||||
pub enum MetadataProvider {
|
pub enum MetadataProvider {
|
||||||
AniList,
|
AniList,
|
||||||
Tmdb,
|
Tmdb,
|
||||||
|
/// Never chosen in the settings: what AniList hands a show it does not
|
||||||
|
/// have. Seen on a [`MatchRecord`] from a hand search.
|
||||||
|
TvMaze,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A palette family. The shared `pstr_core::appearance::Flavor`.
|
/// A palette family. The shared `pstr_core::appearance::Flavor`.
|
||||||
@@ -350,10 +356,69 @@ pub enum TitleNamesChoice {
|
|||||||
Romaji,
|
Romaji,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a genre, studio or director chip opens: the shared
|
||||||
|
/// `pstr_core::discover::Facet`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
|
||||||
|
pub enum BrowseFacet {
|
||||||
|
Genre { name: String },
|
||||||
|
Studio { name: String },
|
||||||
|
Person { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode a search found by its provider name.
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct EpisodeMatchRecord {
|
||||||
|
pub title_key: String,
|
||||||
|
pub link_id: String,
|
||||||
|
/// `S01E05`.
|
||||||
|
pub label: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A genre, studio or person a search named, with how many titles carry it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct FacetCountRecord {
|
||||||
|
pub facet: BrowseFacet,
|
||||||
|
pub count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a search found besides titles; the shared
|
||||||
|
/// `pstr_core::discover::Found`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct SearchExtrasRecord {
|
||||||
|
pub episodes: Vec<EpisodeMatchRecord>,
|
||||||
|
/// Directors and studios.
|
||||||
|
pub people: Vec<FacetCountRecord>,
|
||||||
|
pub genres: Vec<FacetCountRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pstr_core::discover::Facet> for BrowseFacet {
|
||||||
|
fn from(facet: pstr_core::discover::Facet) -> Self {
|
||||||
|
use pstr_core::discover::Facet;
|
||||||
|
match facet {
|
||||||
|
Facet::Genre(name) => Self::Genre { name },
|
||||||
|
Facet::Studio(name) => Self::Studio { name },
|
||||||
|
Facet::Person(name) => Self::Person { name },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowseFacet {
|
||||||
|
fn core(&self) -> pstr_core::discover::Facet {
|
||||||
|
use pstr_core::discover::Facet;
|
||||||
|
match self {
|
||||||
|
Self::Genre { name } => Facet::Genre(name.clone()),
|
||||||
|
Self::Studio { name } => Facet::Studio(name.clone()),
|
||||||
|
Self::Person { name } => Facet::Person(name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared `pstr_core::browse::Filter`.
|
/// The shared `pstr_core::browse::Filter`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||||
pub enum LibraryFilter {
|
pub enum LibraryFilter {
|
||||||
All,
|
All,
|
||||||
|
MyList,
|
||||||
Series,
|
Series,
|
||||||
Films,
|
Films,
|
||||||
Watching,
|
Watching,
|
||||||
@@ -454,6 +519,49 @@ pub fn stored_palette(paths: AndroidPaths) -> PaletteRecord {
|
|||||||
palette_record(&appearance_record(appearance))
|
palette_record(&appearance_record(appearance))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The colour a picture leaves behind, as `0xAARRGGBB`, opaque: the shared
|
||||||
|
/// `pstr_core::appearance::mood` over [`pixels`], which are `0xAARRGGBB` too —
|
||||||
|
/// what `Bitmap.getPixels` hands back. `None` for nothing opaque.
|
||||||
|
///
|
||||||
|
/// The client squeezes the picture to a few hundred pixels first; this is the
|
||||||
|
/// weighting, kept in one place so every client lands on the same colour.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
|
||||||
|
pstr_core::appearance::mood(pixels.into_iter().map(|argb| {
|
||||||
|
let channel = |shift: u32| ((argb >> shift) & 0xff) as u8;
|
||||||
|
(
|
||||||
|
pstr_core::appearance::Rgb::new(channel(16), channel(8), channel(0)),
|
||||||
|
channel(24),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.map(pstr_core::appearance::Rgb::argb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an airing show's tile says about its next episode, `Ep 8 · in 2 d`;
|
||||||
|
/// see `pstr_core::news::countdown`.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
|
||||||
|
pstr_core::news::countdown(episode, at, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`,
|
||||||
|
/// `Person` — for over the heading of its page.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn facet_kind(facet: BrowseFacet) -> String {
|
||||||
|
facet.core().kind().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page colour `background` tinted by a picture's [`picture_mood`], for
|
||||||
|
/// the veils over that picture. All `0xAARRGGBB`; see
|
||||||
|
/// `pstr_core::appearance::mood_veil`.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
|
||||||
|
let rgb = |argb: u32| {
|
||||||
|
pstr_core::appearance::Rgb::new((argb >> 16) as u8, (argb >> 8) as u8, argb as u8)
|
||||||
|
};
|
||||||
|
pstr_core::appearance::mood_veil(rgb(background), rgb(mood), light).argb()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct MetadataSettingsRecord {
|
pub struct MetadataSettingsRecord {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -517,6 +625,9 @@ pub struct EpisodeRecord {
|
|||||||
/// What a continue-watching shelf orders by — "where I was last" is a
|
/// What a continue-watching shelf orders by — "where I was last" is a
|
||||||
/// question about time, and progress alone cannot answer it.
|
/// question about time, and progress alone cannot answer it.
|
||||||
pub last_played: i64,
|
pub last_played: i64,
|
||||||
|
/// The provider's running time, in minutes.
|
||||||
|
#[uniffi(default = None)]
|
||||||
|
pub runtime: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
@@ -574,6 +685,44 @@ pub struct TitleRecord {
|
|||||||
/// episode finished last. `None` keeps it off the shelf. See
|
/// episode finished last. `None` keeps it off the shelf. See
|
||||||
/// `pstr_core::library::Title::continue_with`.
|
/// `pstr_core::library::Title::continue_with`.
|
||||||
pub continue_index: Option<u32>,
|
pub continue_index: Option<u32>,
|
||||||
|
/// The title's name as lettering, a transparent PNG with its source's
|
||||||
|
/// padding still around it — trim it before drawing.
|
||||||
|
#[uniffi(default = None)]
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
/// Minutes: an episode's length for a series, the running time of a film.
|
||||||
|
#[uniffi(default = None)]
|
||||||
|
pub runtime: Option<u32>,
|
||||||
|
/// What most of its files' names say about them — `4K`, `HDR`, `10-bit`,
|
||||||
|
/// `Dual audio` — in that order. Never a release group's name.
|
||||||
|
#[uniffi(default = [])]
|
||||||
|
pub quality: Vec<String>,
|
||||||
|
/// `Airing`, `Finished`, `Upcoming`, `On hiatus` or `Cancelled`: the
|
||||||
|
/// shared `TitleDetails::status_label`.
|
||||||
|
#[uniffi(default = None)]
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// On My List.
|
||||||
|
#[uniffi(default = false)]
|
||||||
|
pub on_list: bool,
|
||||||
|
/// `New`, or `3 new` on a show being watched; see `pstr_core::news`.
|
||||||
|
#[uniffi(default = None)]
|
||||||
|
pub badge: Option<String>,
|
||||||
|
/// The characters, main ones first, with who voices them.
|
||||||
|
#[uniffi(default = [])]
|
||||||
|
pub cast: Vec<CastRecord>,
|
||||||
|
/// The trailer's page, to open in the browser; see
|
||||||
|
/// `pstr_core::metadata::Trailer::url`.
|
||||||
|
#[uniffi(default = None)]
|
||||||
|
pub trailer_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One character and who voices them: the shared
|
||||||
|
/// `pstr_core::metadata::CastMember`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct CastRecord {
|
||||||
|
pub character: String,
|
||||||
|
pub character_image: Option<String>,
|
||||||
|
pub actor: Option<String>,
|
||||||
|
pub actor_image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playback preferences that outlive one file, one title and one launch.
|
/// Playback preferences that outlive one file, one title and one launch.
|
||||||
@@ -663,6 +812,16 @@ pub struct ChapterPlan {
|
|||||||
pub credits_start: Option<f64>,
|
pub credits_start: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stretch AniSkip says is the opening or the ending of the open file.
|
||||||
|
/// See `pstr_core::chapters::with_skip_ranges`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
|
pub struct SkipRangeRecord {
|
||||||
|
/// `Opening` or `Ending`.
|
||||||
|
pub kind: ChapterKind,
|
||||||
|
pub start: f64,
|
||||||
|
pub end: f64,
|
||||||
|
}
|
||||||
|
|
||||||
/// What the player may offer to skip right now.
|
/// What the player may offer to skip right now.
|
||||||
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||||
pub struct SkipOffer {
|
pub struct SkipOffer {
|
||||||
@@ -674,8 +833,15 @@ pub struct SkipOffer {
|
|||||||
/// Resolve a file's chapters through the same rules the desktop player uses
|
/// Resolve a file's chapters through the same rules the desktop player uses
|
||||||
/// (`pstr_core::chapters`), so an opening called `Intro` is read identically on
|
/// (`pstr_core::chapters`), so an opening called `Intro` is read identically on
|
||||||
/// both. Kotlin reads the list out of mpv; the verdict is reached here.
|
/// both. Kotlin reads the list out of mpv; the verdict is reached here.
|
||||||
|
///
|
||||||
|
/// `skips` is what AniSkip said about the file, empty until it has: cut into
|
||||||
|
/// the chapters when the file's own name no opening or ending.
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> ChapterPlan {
|
pub fn chapter_plan(
|
||||||
|
chapters: Vec<ChapterRecord>,
|
||||||
|
duration: Option<f64>,
|
||||||
|
skips: Vec<SkipRangeRecord>,
|
||||||
|
) -> ChapterPlan {
|
||||||
let duration = duration.filter(|duration| *duration > 0.0);
|
let duration = duration.filter(|duration| *duration > 0.0);
|
||||||
let chapters: Vec<Chapter> = chapters
|
let chapters: Vec<Chapter> = chapters
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -688,6 +854,22 @@ pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> Chap
|
|||||||
start: chapter.start,
|
start: chapter.start,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let skips: Vec<pstr_core::chapters::SkipRange> = skips
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|skip| {
|
||||||
|
let role = match skip.kind {
|
||||||
|
ChapterKind::Opening => ChapterRole::Opening,
|
||||||
|
ChapterKind::Ending => ChapterRole::Ending,
|
||||||
|
ChapterKind::Preview | ChapterKind::Content => return None,
|
||||||
|
};
|
||||||
|
Some(pstr_core::chapters::SkipRange {
|
||||||
|
role,
|
||||||
|
start: skip.start,
|
||||||
|
end: skip.end,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let chapters = pstr_core::chapters::with_skip_ranges(&chapters, &skips, duration);
|
||||||
let roles = pstr_core::chapters::roles(&chapters, duration);
|
let roles = pstr_core::chapters::roles(&chapters, duration);
|
||||||
ChapterPlan {
|
ChapterPlan {
|
||||||
credits_start: pstr_core::chapters::credits_start(&chapters, &roles),
|
credits_start: pstr_core::chapters::credits_start(&chapters, &roles),
|
||||||
@@ -1278,6 +1460,7 @@ impl AndroidEngine {
|
|||||||
shares_removed: profile.shares_removed,
|
shares_removed: profile.shares_removed,
|
||||||
settings_changed: profile.settings,
|
settings_changed: profile.settings,
|
||||||
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
|
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
|
||||||
|
list_changed: profile.list,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Err(error) if error.is_signed_out() => {
|
Err(error) if error.is_signed_out() => {
|
||||||
@@ -1605,11 +1788,15 @@ impl AndroidEngine {
|
|||||||
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
|
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|title| {
|
.filter(|title| {
|
||||||
filter.admits(title, |node| {
|
filter.admits(
|
||||||
|
title,
|
||||||
|
|node| {
|
||||||
cached
|
cached
|
||||||
.offline
|
.offline
|
||||||
.contains(&(node.share_id.clone(), node.link_id.clone()))
|
.contains(&(node.share_id.clone(), node.link_id.clone()))
|
||||||
})
|
},
|
||||||
|
&cached.list,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let tiles = pstr_core::browse::arrange(
|
let tiles = pstr_core::browse::arrange(
|
||||||
@@ -1625,7 +1812,15 @@ impl AndroidEngine {
|
|||||||
// Shelves are the whole library's, and only when it is shown
|
// Shelves are the whole library's, and only when it is shown
|
||||||
// whole.
|
// whole.
|
||||||
let shelves = if query.trim().is_empty() && filter == Filter::All {
|
let shelves = if query.trim().is_empty() && filter == Filter::All {
|
||||||
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
|
pstr_core::shelves::home(
|
||||||
|
&cached.library.titles,
|
||||||
|
&cached.metadata,
|
||||||
|
cached.names,
|
||||||
|
&cached.list,
|
||||||
|
&cached.first_seen,
|
||||||
|
now(),
|
||||||
|
SHELVES,
|
||||||
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|shelf| ShelfRecord {
|
.map(|shelf| ShelfRecord {
|
||||||
label: shelf.label,
|
label: shelf.label,
|
||||||
@@ -1648,6 +1843,92 @@ impl AndroidEngine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The keys of the titles most like `key`, best first: a title page's
|
||||||
|
/// "More like this" row. Worked out on asking rather than for every
|
||||||
|
/// record, since it compares the title with the whole library.
|
||||||
|
pub fn more_like(&self, key: String) -> Result<Vec<String>, BridgeError> {
|
||||||
|
self.with_library(|cached| {
|
||||||
|
pstr_core::discover::more_like(&key, &cached.library.titles, &cached.metadata, SIMILAR)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `search` finds besides titles: episodes by name, and the people,
|
||||||
|
/// studios and genres it names.
|
||||||
|
pub fn search_extras(&self, search: String) -> Result<SearchExtrasRecord, BridgeError> {
|
||||||
|
self.with_library(|cached| {
|
||||||
|
let found = pstr_core::discover::find(
|
||||||
|
&cached.library,
|
||||||
|
&cached.metadata,
|
||||||
|
&cached.guides,
|
||||||
|
&search,
|
||||||
|
);
|
||||||
|
let facets = |facets: Vec<(pstr_core::discover::Facet, usize)>| {
|
||||||
|
facets
|
||||||
|
.into_iter()
|
||||||
|
.map(|(facet, count)| FacetCountRecord {
|
||||||
|
facet: BrowseFacet::from(facet),
|
||||||
|
count: count as u32,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
SearchExtrasRecord {
|
||||||
|
episodes: found
|
||||||
|
.episodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| EpisodeMatchRecord {
|
||||||
|
title_key: hit.title_key,
|
||||||
|
link_id: hit.link_id,
|
||||||
|
label: hit.label,
|
||||||
|
name: hit.name,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
people: facets(found.people),
|
||||||
|
genres: facets(found.genres),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last few searches on this device, newest first.
|
||||||
|
pub fn recent_searches(&self) -> Result<Vec<String>, BridgeError> {
|
||||||
|
pstr_core::recent::load(&self.dirs)
|
||||||
|
.map(|recent| recent.queries)
|
||||||
|
.map_err(BridgeError::from_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `query` at the front of the recent searches. Refused when the
|
||||||
|
/// file would not parse, rather than writing a new list over it.
|
||||||
|
pub fn remember_search(&self, query: String) -> Result<(), BridgeError> {
|
||||||
|
let mut recent = pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
|
||||||
|
if recent.remember(&query) {
|
||||||
|
pstr_core::recent::save(&self.dirs, &recent).map_err(BridgeError::from_display)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget every recent search.
|
||||||
|
pub fn forget_searches(&self) -> Result<(), BridgeError> {
|
||||||
|
pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
|
||||||
|
pstr_core::recent::save(&self.dirs, &Default::default()).map_err(BridgeError::from_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys of every title `facet` stands for, in library order.
|
||||||
|
pub fn browse(&self, facet: BrowseFacet) -> Result<Vec<String>, BridgeError> {
|
||||||
|
let facet = facet.core();
|
||||||
|
self.with_library(|cached| facet.titles(&cached.library.titles, &cached.metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a title on My List or take it off. Refused when the list on disk
|
||||||
|
/// would not parse, rather than writing an empty one over it.
|
||||||
|
pub fn set_on_list(&self, key: String, on: bool) -> Result<(), BridgeError> {
|
||||||
|
let mut list = pstr_core::mylist::load(&self.dirs).map_err(BridgeError::from_display)?;
|
||||||
|
if on {
|
||||||
|
list.add(&key, now());
|
||||||
|
} else {
|
||||||
|
list.remove(&key);
|
||||||
|
}
|
||||||
|
pstr_core::mylist::save(&self.dirs, &list).map_err(BridgeError::from_display)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
|
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
|
||||||
let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?;
|
let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?;
|
||||||
let ready = !config.provider.needs_api_key()
|
let ready = !config.provider.needs_api_key()
|
||||||
@@ -1723,6 +2004,56 @@ impl AndroidEngine {
|
|||||||
.map_err(BridgeError::from_display)?
|
.map_err(BridgeError::from_display)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where AniSkip says the opening and ending of one episode are, in a
|
||||||
|
/// file `duration` seconds long. Empty when the episode's provider entry
|
||||||
|
/// has no MyAnimeList id or nobody has marked it; an error with enrichment
|
||||||
|
/// off, like every other metadata call.
|
||||||
|
pub async fn skip_times(
|
||||||
|
self: Arc<Self>,
|
||||||
|
title_key: String,
|
||||||
|
share_id: String,
|
||||||
|
link_id: String,
|
||||||
|
duration: f64,
|
||||||
|
) -> Result<Vec<SkipRangeRecord>, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let service = self.metadata_service()?;
|
||||||
|
let found = self.with_library(|cached| {
|
||||||
|
let title = cached
|
||||||
|
.library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.find(|title| title.key == title_key)?;
|
||||||
|
let episode = title.episodes().find(|episode| {
|
||||||
|
episode.node.share_id == share_id && episode.node.link_id == link_id
|
||||||
|
})?;
|
||||||
|
let number = episode.node.parsed.episode?;
|
||||||
|
let found = cached
|
||||||
|
.guides
|
||||||
|
.get(&title.key)?
|
||||||
|
.get(episode.node.parsed.season, number)?;
|
||||||
|
Some((found.mal_id?, found.entry_number?))
|
||||||
|
})?;
|
||||||
|
let Some((mal_id, number)) = found else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let ranges = service
|
||||||
|
.skip_times(mal_id, number, duration)
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
Ok(ranges
|
||||||
|
.into_iter()
|
||||||
|
.map(|range| SkipRangeRecord {
|
||||||
|
kind: chapter_kind(range.role),
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
/// Pin a title to the entry the viewer picked, then fetch that entry's
|
/// Pin a title to the entry the viewer picked, then fetch that entry's
|
||||||
/// episodes, backdrop and franchise chain — the same enrichment a match run
|
/// episodes, backdrop and franchise chain — the same enrichment a match run
|
||||||
/// does, so the title page shows the new match's episodes as soon as this
|
/// does, so the title page shows the new match's episodes as soon as this
|
||||||
@@ -1736,7 +2067,7 @@ impl AndroidEngine {
|
|||||||
spawned(&runtime, async move {
|
spawned(&runtime, async move {
|
||||||
let service = self.metadata_service()?;
|
let service = self.metadata_service()?;
|
||||||
let metadata = title_metadata(found);
|
let metadata = title_metadata(found);
|
||||||
if metadata.provider != service.provider() {
|
if !service.chain().contains(&metadata.provider) {
|
||||||
return Err(BridgeError::Failure {
|
return Err(BridgeError::Failure {
|
||||||
reason: "match came from a different metadata provider".to_owned(),
|
reason: "match came from a different metadata provider".to_owned(),
|
||||||
});
|
});
|
||||||
@@ -1752,7 +2083,7 @@ impl AndroidEngine {
|
|||||||
if let Some(enrichment) = &outcome.enrichment {
|
if let Some(enrichment) = &outcome.enrichment {
|
||||||
let stored = self.catalog.lock().set_enrichment(
|
let stored = self.catalog.lock().set_enrichment(
|
||||||
&title_key,
|
&title_key,
|
||||||
service.provider(),
|
outcome.provider,
|
||||||
now(),
|
now(),
|
||||||
enrichment,
|
enrichment,
|
||||||
);
|
);
|
||||||
@@ -1909,7 +2240,11 @@ impl AndroidEngine {
|
|||||||
pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> {
|
pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> {
|
||||||
// Sanitized rather than trusted: the volume arrives from a slider, and
|
// Sanitized rather than trusted: the volume arrives from a slider, and
|
||||||
// a language from a text field that can hold spaces and nothing else.
|
// a language from a text field that can hold spaces and nothing else.
|
||||||
let prefs = playback_prefs(prefs).sanitized();
|
// Onto what is stored, not the defaults: the record has no subtitle
|
||||||
|
// style yet, and saving one without it would reset the desktop's and,
|
||||||
|
// through sync, every other installation's.
|
||||||
|
let stored = pstr_core::prefs::load(&self.dirs).unwrap_or_default();
|
||||||
|
let prefs = playback_prefs(prefs, stored).sanitized();
|
||||||
pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display)
|
pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2349,21 +2684,13 @@ impl AndroidEngine {
|
|||||||
/// behaviour.
|
/// behaviour.
|
||||||
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
|
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
|
||||||
let service = Arc::new(self.metadata_service()?);
|
let service = Arc::new(self.metadata_service()?);
|
||||||
let provider = service.provider();
|
|
||||||
let (titles, stored) = self.titles_and_metadata()?;
|
let (titles, stored) = self.titles_and_metadata()?;
|
||||||
let enriched = self
|
let enriched = self
|
||||||
.catalog
|
.catalog
|
||||||
.lock()
|
.lock()
|
||||||
.enrichment_ages()
|
.enrichment_ages()
|
||||||
.map_err(BridgeError::from_display)?;
|
.map_err(BridgeError::from_display)?;
|
||||||
let pending = pstr_meta::service::plan(
|
let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
|
||||||
titles,
|
|
||||||
&stored,
|
|
||||||
&enriched,
|
|
||||||
provider,
|
|
||||||
service.has_details(),
|
|
||||||
force,
|
|
||||||
);
|
|
||||||
|
|
||||||
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
|
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
|
||||||
let mut lookups = tokio::task::JoinSet::new();
|
let mut lookups = tokio::task::JoinSet::new();
|
||||||
@@ -2395,7 +2722,7 @@ impl AndroidEngine {
|
|||||||
summary.episodes += enrichment.episodes.len() as u32;
|
summary.episodes += enrichment.episodes.len() as u32;
|
||||||
let stored = self.catalog.lock().set_enrichment(
|
let stored = self.catalog.lock().set_enrichment(
|
||||||
&outcome.title_key,
|
&outcome.title_key,
|
||||||
provider,
|
outcome.provider,
|
||||||
now(),
|
now(),
|
||||||
enrichment,
|
enrichment,
|
||||||
);
|
);
|
||||||
@@ -2423,7 +2750,7 @@ impl AndroidEngine {
|
|||||||
reason: "turn on metadata enrichment first".to_owned(),
|
reason: "turn on metadata enrichment first".to_owned(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), config.provider);
|
let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), ProviderId::Tmdb);
|
||||||
pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display)
|
pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2434,11 +2761,15 @@ impl AndroidEngine {
|
|||||||
let names = pstr_core::appearance::load(&self.dirs)
|
let names = pstr_core::appearance::load(&self.dirs)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.names;
|
.names;
|
||||||
|
// Read each time like the appearance: it is one small file, and the
|
||||||
|
// phone's list changes without the catalog being written.
|
||||||
|
let list = pstr_core::mylist::load(&self.dirs).unwrap_or_default();
|
||||||
let catalog = self.catalog.lock();
|
let catalog = self.catalog.lock();
|
||||||
let writes = catalog.writes();
|
let writes = catalog.writes();
|
||||||
if let Some(cached) = self.library_cache.lock().as_ref()
|
if let Some(cached) = self.library_cache.lock().as_ref()
|
||||||
&& cached.writes == writes
|
&& cached.writes == writes
|
||||||
&& cached.names == names
|
&& cached.names == names
|
||||||
|
&& cached.list == list
|
||||||
{
|
{
|
||||||
return Ok(read(cached));
|
return Ok(read(cached));
|
||||||
}
|
}
|
||||||
@@ -2472,6 +2803,7 @@ impl AndroidEngine {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let now = now();
|
let now = now();
|
||||||
|
let badges = pstr_core::news::badges(&library.titles, &first_seen, now);
|
||||||
let titles: Vec<TitleRecord> = library
|
let titles: Vec<TitleRecord> = library
|
||||||
.titles
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2485,6 +2817,8 @@ impl AndroidEngine {
|
|||||||
record.display_name =
|
record.display_name =
|
||||||
pstr_core::browse::display_name(title, &metadata, names).to_owned();
|
pstr_core::browse::display_name(title, &metadata, names).to_owned();
|
||||||
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
|
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
|
||||||
|
record.on_list = list.contains(&title.key);
|
||||||
|
record.badge = badges.get(&title.key).map(|badge| badge.label());
|
||||||
if let Some(details) = metadata
|
if let Some(details) = metadata
|
||||||
.get(&title.key)
|
.get(&title.key)
|
||||||
.and_then(|record| record.metadata.as_ref())
|
.and_then(|record| record.metadata.as_ref())
|
||||||
@@ -2501,12 +2835,14 @@ impl AndroidEngine {
|
|||||||
let cached = CachedLibrary {
|
let cached = CachedLibrary {
|
||||||
writes,
|
writes,
|
||||||
names,
|
names,
|
||||||
|
list,
|
||||||
library,
|
library,
|
||||||
metadata,
|
metadata,
|
||||||
added,
|
added,
|
||||||
titles,
|
titles,
|
||||||
offline: offline.into_keys().collect(),
|
offline: offline.into_keys().collect(),
|
||||||
first_seen,
|
first_seen,
|
||||||
|
guides,
|
||||||
};
|
};
|
||||||
let answer = read(&cached);
|
let answer = read(&cached);
|
||||||
*self.library_cache.lock() = Some(cached);
|
*self.library_cache.lock() = Some(cached);
|
||||||
@@ -3379,6 +3715,7 @@ fn normalized_language(language: Option<String>) -> Option<String> {
|
|||||||
struct CachedLibrary {
|
struct CachedLibrary {
|
||||||
writes: u64,
|
writes: u64,
|
||||||
names: TitleNames,
|
names: TitleNames,
|
||||||
|
list: pstr_core::mylist::MyList,
|
||||||
library: Library,
|
library: Library,
|
||||||
metadata: HashMap<String, MetadataRecord>,
|
metadata: HashMap<String, MetadataRecord>,
|
||||||
/// When each title last gained a file.
|
/// When each title last gained a file.
|
||||||
@@ -3388,6 +3725,9 @@ struct CachedLibrary {
|
|||||||
offline: HashSet<(String, String)>,
|
offline: HashSet<(String, String)>,
|
||||||
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
|
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
|
||||||
first_seen: HashMap<(String, String), i64>,
|
first_seen: HashMap<(String, String), i64>,
|
||||||
|
/// What the providers said about each title's episodes, for searching
|
||||||
|
/// them by name.
|
||||||
|
guides: HashMap<String, EpisodeGuide>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The titles whose files first appeared at or after `since`, with how many
|
/// The titles whose files first appeared at or after `since`, with how many
|
||||||
@@ -3489,6 +3829,7 @@ fn filter_choice(filter: LibraryFilter) -> Filter {
|
|||||||
LibraryFilter::Watching => Filter::Watching,
|
LibraryFilter::Watching => Filter::Watching,
|
||||||
LibraryFilter::Unwatched => Filter::Unwatched,
|
LibraryFilter::Unwatched => Filter::Unwatched,
|
||||||
LibraryFilter::Downloaded => Filter::Downloaded,
|
LibraryFilter::Downloaded => Filter::Downloaded,
|
||||||
|
LibraryFilter::MyList => Filter::MyList,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3507,6 +3848,9 @@ fn sort_choice(sort: LibrarySort) -> Sort {
|
|||||||
/// desktop's four, because on a phone each is a screen's height.
|
/// desktop's four, because on a phone each is a screen's height.
|
||||||
const SHELVES: usize = 3;
|
const SHELVES: usize = 3;
|
||||||
|
|
||||||
|
/// How many titles a title page's "More like this" row offers.
|
||||||
|
const SIMILAR: usize = 12;
|
||||||
|
|
||||||
fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
||||||
Appearance {
|
Appearance {
|
||||||
flavor: match record.flavor {
|
flavor: match record.flavor {
|
||||||
@@ -3580,7 +3924,7 @@ fn playback_prefs_record(prefs: PlaybackPrefs) -> PlaybackPrefsRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
|
fn playback_prefs(record: PlaybackPrefsRecord, stored: PlaybackPrefs) -> PlaybackPrefs {
|
||||||
PlaybackPrefs {
|
PlaybackPrefs {
|
||||||
volume: record.volume,
|
volume: record.volume,
|
||||||
muted: record.muted,
|
muted: record.muted,
|
||||||
@@ -3590,6 +3934,7 @@ fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
|
|||||||
autoplay_next: record.autoplay_next,
|
autoplay_next: record.autoplay_next,
|
||||||
auto_skip: record.auto_skip,
|
auto_skip: record.auto_skip,
|
||||||
speed: record.speed,
|
speed: record.speed,
|
||||||
|
..stored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3641,9 +3986,37 @@ fn title_record(
|
|||||||
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
|
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
|
||||||
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
|
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
|
||||||
airing: details.is_some_and(|details| details.is_airing()),
|
airing: details.is_some_and(|details| details.is_airing()),
|
||||||
|
status: details
|
||||||
|
.and_then(|details| details.status_label())
|
||||||
|
.map(str::to_owned),
|
||||||
next_episode: None,
|
next_episode: None,
|
||||||
next_airing_at: None,
|
next_airing_at: None,
|
||||||
|
on_list: false,
|
||||||
|
badge: None,
|
||||||
|
cast: details.map_or_else(Vec::new, |details| {
|
||||||
|
details
|
||||||
|
.cast
|
||||||
|
.iter()
|
||||||
|
.map(|member| CastRecord {
|
||||||
|
character: member.character.clone(),
|
||||||
|
character_image: member.character_image.clone(),
|
||||||
|
actor: member.actor.clone(),
|
||||||
|
actor_image: member.actor_image.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}),
|
||||||
|
trailer_url: details
|
||||||
|
.and_then(|details| details.trailer.as_ref())
|
||||||
|
.and_then(|trailer| trailer.url()),
|
||||||
franchise: Vec::new(),
|
franchise: Vec::new(),
|
||||||
|
logo_url: metadata.and_then(|found| found.logo_url.clone()),
|
||||||
|
runtime: details.and_then(|details| details.runtime),
|
||||||
|
quality: title
|
||||||
|
.quality()
|
||||||
|
.badges()
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect(),
|
||||||
continue_index: title.continue_with().and_then(|episode| {
|
continue_index: title.continue_with().and_then(|episode| {
|
||||||
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
|
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
|
||||||
u32::try_from(at).ok()
|
u32::try_from(at).ok()
|
||||||
@@ -3668,6 +4041,7 @@ fn metadata_provider(provider: ProviderId) -> MetadataProvider {
|
|||||||
match provider {
|
match provider {
|
||||||
ProviderId::AniList => MetadataProvider::AniList,
|
ProviderId::AniList => MetadataProvider::AniList,
|
||||||
ProviderId::Tmdb => MetadataProvider::Tmdb,
|
ProviderId::Tmdb => MetadataProvider::Tmdb,
|
||||||
|
ProviderId::TvMaze => MetadataProvider::TvMaze,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3675,6 +4049,7 @@ fn provider_id(provider: MetadataProvider) -> ProviderId {
|
|||||||
match provider {
|
match provider {
|
||||||
MetadataProvider::AniList => ProviderId::AniList,
|
MetadataProvider::AniList => ProviderId::AniList,
|
||||||
MetadataProvider::Tmdb => ProviderId::Tmdb,
|
MetadataProvider::Tmdb => ProviderId::Tmdb,
|
||||||
|
MetadataProvider::TvMaze => ProviderId::TvMaze,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3725,6 +4100,8 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata {
|
|||||||
kind: title_kind(record.kind),
|
kind: title_kind(record.kind),
|
||||||
poster_url: record.poster_url,
|
poster_url: record.poster_url,
|
||||||
backdrop_url: record.backdrop_url,
|
backdrop_url: record.backdrop_url,
|
||||||
|
// A search answer carries none; enriching the choice finds it.
|
||||||
|
logo_url: None,
|
||||||
rating: record.rating.map(|rating| rating as f32),
|
rating: record.rating.map(|rating| rating as f32),
|
||||||
genres: record.genres,
|
genres: record.genres,
|
||||||
episodes: record.episode_count,
|
episodes: record.episode_count,
|
||||||
@@ -3768,6 +4145,7 @@ fn episode_record(
|
|||||||
still_url: named.and_then(|found| found.still_url.clone()),
|
still_url: named.and_then(|found| found.still_url.clone()),
|
||||||
air_date: named.and_then(|found| found.air_date.clone()),
|
air_date: named.and_then(|found| found.air_date.clone()),
|
||||||
last_played: episode.last_played(),
|
last_played: episode.last_played(),
|
||||||
|
runtime: named.and_then(|found| found.runtime),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3822,6 +4200,22 @@ mod tests {
|
|||||||
/// Material's containment ladder has five rungs where the palette names
|
/// Material's containment ladder has five rungs where the palette names
|
||||||
/// three, and Kotlin lays them out in this order. A rung that is not past
|
/// three, and Kotlin lays them out in this order. A rung that is not past
|
||||||
/// the one below it is a rung that reads as the same surface.
|
/// the one below it is a rung that reads as the same surface.
|
||||||
|
#[test]
|
||||||
|
fn a_picture_handed_over_as_argb_pixels_keeps_its_colour_and_skips_the_clear_ones() {
|
||||||
|
let teal = 0xff14_8c96;
|
||||||
|
assert_eq!(super::picture_mood(vec![teal; 9]), Some(teal));
|
||||||
|
assert_eq!(super::picture_mood(vec![teal, 0x00ff_ffff]), Some(teal));
|
||||||
|
assert_eq!(super::picture_mood(vec![0x00ff_ffff]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_veil_over_a_picture_is_the_shared_one_packed_as_argb() {
|
||||||
|
let background = 0xff0b_0b0f;
|
||||||
|
let veil = super::mood_veil(background, 0xffdc_1e1e, false);
|
||||||
|
assert_eq!(veil >> 24, 0xff);
|
||||||
|
assert!((veil >> 16) & 0xff > 0x0b, "{veil:08x}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_derived_rung_continues_the_ladder_in_both_polarities() {
|
fn the_derived_rung_continues_the_ladder_in_both_polarities() {
|
||||||
for flavor in FLAVORS {
|
for flavor in FLAVORS {
|
||||||
@@ -4007,6 +4401,7 @@ mod tests {
|
|||||||
chapter(3, "Cast", 4680.0),
|
chapter(3, "Cast", 4680.0),
|
||||||
],
|
],
|
||||||
Some(4800.0),
|
Some(4800.0),
|
||||||
|
Vec::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Eleven minutes called `Intro` is the story, not a theme song.
|
// Eleven minutes called `Intro` is the story, not a theme song.
|
||||||
@@ -4028,6 +4423,7 @@ mod tests {
|
|||||||
chapter(1, "OP", 24.0),
|
chapter(1, "OP", 24.0),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
|
Vec::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(plan.entries[0].label, "Chapter 1");
|
assert_eq!(plan.entries[0].label, "Chapter 1");
|
||||||
@@ -4037,6 +4433,25 @@ mod tests {
|
|||||||
assert_eq!(skip_offer(&plan, 30.0), None);
|
assert_eq!(skip_offer(&plan, 30.0), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file with no chapters of its own offers AniSkip's opening, through
|
||||||
|
/// the same skip as one that came with chapters.
|
||||||
|
#[test]
|
||||||
|
fn aniskips_opening_is_offered_in_a_file_without_chapters() {
|
||||||
|
let plan = chapter_plan(
|
||||||
|
Vec::new(),
|
||||||
|
Some(1440.0),
|
||||||
|
vec![super::SkipRangeRecord {
|
||||||
|
kind: ChapterKind::Opening,
|
||||||
|
start: 60.0,
|
||||||
|
end: 150.0,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let offer = skip_offer(&plan, 70.0).expect("inside the opening");
|
||||||
|
assert_eq!(offer.label, "Skip opening");
|
||||||
|
assert_eq!(offer.target, 150.0);
|
||||||
|
assert_eq!(skip_offer(&plan, 10.0), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// A skip is offered inside the opening and nowhere else — including at its
|
/// A skip is offered inside the opening and nowhere else — including at its
|
||||||
/// very last second, where seeking would land the viewer where they already
|
/// very last second, where seeking would land the viewer where they already
|
||||||
/// are, and after it, where the button would never go away.
|
/// are, and after it, where the button would never go away.
|
||||||
@@ -4050,6 +4465,7 @@ mod tests {
|
|||||||
chapter(3, "ED", 1320.0),
|
chapter(3, "ED", 1320.0),
|
||||||
],
|
],
|
||||||
Some(1440.0),
|
Some(1440.0),
|
||||||
|
Vec::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(skip_offer(&plan, 10.0), None, "content is not skippable");
|
assert_eq!(skip_offer(&plan, 10.0), None, "content is not skippable");
|
||||||
@@ -4105,6 +4521,7 @@ mod tests {
|
|||||||
kind: TitleKind::Series,
|
kind: TitleKind::Series,
|
||||||
poster_url: Some("https://example.test/poster.jpg".to_owned()),
|
poster_url: Some("https://example.test/poster.jpg".to_owned()),
|
||||||
backdrop_url: Some("https://example.test/backdrop.jpg".to_owned()),
|
backdrop_url: Some("https://example.test/backdrop.jpg".to_owned()),
|
||||||
|
logo_url: None,
|
||||||
rating: Some(8.7),
|
rating: Some(8.7),
|
||||||
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
|
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
|
||||||
episodes: Some(26),
|
episodes: Some(26),
|
||||||
@@ -4199,6 +4616,7 @@ mod tests {
|
|||||||
kind,
|
kind,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
@@ -4264,6 +4682,66 @@ mod tests {
|
|||||||
let titles = engine.library(Some("frieren".into())).expect("library");
|
let titles = engine.library(Some("frieren".into())).expect("library");
|
||||||
assert_eq!(titles[0].display_name, "Sousou no Frieren");
|
assert_eq!(titles[0].display_name, "Sousou no Frieren");
|
||||||
|
|
||||||
|
// My List: the record says so straight away, the filter narrows to
|
||||||
|
// it, and it is a row above the grid.
|
||||||
|
engine
|
||||||
|
.set_on_list("sousou no frieren".into(), true)
|
||||||
|
.expect("list");
|
||||||
|
let titles = engine.library(Some("frieren".into())).expect("library");
|
||||||
|
assert!(titles[0].on_list);
|
||||||
|
let listed = engine
|
||||||
|
.arrangement(
|
||||||
|
None,
|
||||||
|
super::LibraryFilter::MyList,
|
||||||
|
super::LibrarySort::Name,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("arrange");
|
||||||
|
assert_eq!(listed.tiles.len(), 1);
|
||||||
|
let whole = engine
|
||||||
|
.arrangement(
|
||||||
|
None,
|
||||||
|
super::LibraryFilter::All,
|
||||||
|
super::LibrarySort::Name,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("arrange");
|
||||||
|
assert_eq!(whole.shelves[0].label, "My List");
|
||||||
|
engine
|
||||||
|
.set_on_list("sousou no frieren".into(), false)
|
||||||
|
.expect("list");
|
||||||
|
assert!(!engine.library(None).expect("library")[0].on_list);
|
||||||
|
|
||||||
|
// The film is Frieren's own franchise, so nothing else is like it.
|
||||||
|
assert!(
|
||||||
|
engine
|
||||||
|
.more_like("sousou no frieren".into())
|
||||||
|
.expect("similar")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let genre = super::BrowseFacet::Genre {
|
||||||
|
name: "Drama".into(),
|
||||||
|
};
|
||||||
|
assert!(engine.browse(genre.clone()).expect("browse").is_empty());
|
||||||
|
assert_eq!(super::facet_kind(genre), "Genre");
|
||||||
|
|
||||||
|
// Recent searches: newest first, and forgotten on asking.
|
||||||
|
engine.remember_search("frieren".into()).expect("remember");
|
||||||
|
engine.remember_search("abyss".into()).expect("remember");
|
||||||
|
assert_eq!(
|
||||||
|
engine.recent_searches().expect("recent"),
|
||||||
|
["abyss", "frieren"]
|
||||||
|
);
|
||||||
|
engine.forget_searches().expect("forget");
|
||||||
|
assert!(engine.recent_searches().expect("recent").is_empty());
|
||||||
|
assert!(
|
||||||
|
engine
|
||||||
|
.search_extras("frieren".into())
|
||||||
|
.expect("extras")
|
||||||
|
.episodes
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(root);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -13,6 +13,7 @@ use pstr_core::appearance::Appearance;
|
|||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::Library;
|
use pstr_core::library::Library;
|
||||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
|
||||||
|
use pstr_core::mylist::MyList;
|
||||||
|
|
||||||
use crate::engine::{
|
use crate::engine::{
|
||||||
AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
|
AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
|
||||||
@@ -49,6 +50,7 @@ impl FrameTimer {
|
|||||||
Page::Library => "library",
|
Page::Library => "library",
|
||||||
Page::History => "history",
|
Page::History => "history",
|
||||||
Page::Title(_) => "title",
|
Page::Title(_) => "title",
|
||||||
|
Page::Browse { .. } => "browse",
|
||||||
Page::Shares => "shares",
|
Page::Shares => "shares",
|
||||||
Page::Downloads => "downloads",
|
Page::Downloads => "downloads",
|
||||||
Page::Settings => "settings",
|
Page::Settings => "settings",
|
||||||
@@ -96,6 +98,12 @@ pub enum Page {
|
|||||||
History,
|
History,
|
||||||
/// One title, by [`pstr_core::library::Title::key`].
|
/// One title, by [`pstr_core::library::Title::key`].
|
||||||
Title(String),
|
Title(String),
|
||||||
|
/// The titles a genre, studio or person chip stands for, with the title
|
||||||
|
/// it was opened from, for the way back.
|
||||||
|
Browse {
|
||||||
|
facet: pstr_core::discover::Facet,
|
||||||
|
from: Option<String>,
|
||||||
|
},
|
||||||
Shares,
|
Shares,
|
||||||
Downloads,
|
Downloads,
|
||||||
/// How the app looks, how it plays, and what it looks up.
|
/// How the app looks, how it plays, and what it looks up.
|
||||||
@@ -235,6 +243,14 @@ pub enum Action {
|
|||||||
subtitles: bool,
|
subtitles: bool,
|
||||||
subtitle: Option<String>,
|
subtitle: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// How subtitles look, for the file playing and every one after. Written
|
||||||
|
/// to the preferences once the mouse is let go; see
|
||||||
|
/// [`App::save_subtitle_style`].
|
||||||
|
SetSubtitleStyle(pstr_core::prefs::SubtitleStyle),
|
||||||
|
/// Shift the playing file's subtitles by this many seconds more.
|
||||||
|
NudgeSubtitleDelay(f64),
|
||||||
|
/// Put the playing file's subtitles back on the file's own timing.
|
||||||
|
ResetSubtitleDelay,
|
||||||
/// Play faster or slower, and keep doing so for the next file.
|
/// Play faster or slower, and keep doing so for the next file.
|
||||||
SetSpeed(f64),
|
SetSpeed(f64),
|
||||||
/// Seek past openings and credits without being asked.
|
/// Seek past openings and credits without being asked.
|
||||||
@@ -265,8 +281,10 @@ pub enum Action {
|
|||||||
share_id: String,
|
share_id: String,
|
||||||
link_id: String,
|
link_id: String,
|
||||||
},
|
},
|
||||||
/// Open or close the list of episodes beside the picture.
|
/// Open or close one of the panels beside the picture.
|
||||||
ToggleEpisodes,
|
TogglePanel(ui::player::PlayerPanel),
|
||||||
|
/// Pause when this runs out, or turn the timer off.
|
||||||
|
SetSleep(Option<ui::player::SleepChoice>),
|
||||||
/// Put watch states back as they were — what Undo does.
|
/// Put watch states back as they were — what Undo does.
|
||||||
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
|
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
|
||||||
ClearStreamCache,
|
ClearStreamCache,
|
||||||
@@ -285,6 +303,11 @@ pub enum Action {
|
|||||||
SetShelf(Filter, Sort),
|
SetShelf(Filter, Sort),
|
||||||
/// Fold each franchise into one tile, or show every title.
|
/// Fold each franchise into one tile, or show every title.
|
||||||
SetGrouped(bool),
|
SetGrouped(bool),
|
||||||
|
/// Put a title on My List, or take it off.
|
||||||
|
SetOnList {
|
||||||
|
key: String,
|
||||||
|
on: bool,
|
||||||
|
},
|
||||||
/// Open or close the list of keyboard shortcuts.
|
/// Open or close the list of keyboard shortcuts.
|
||||||
ToggleShortcuts,
|
ToggleShortcuts,
|
||||||
/// One page back: a title to the library, the library to nowhere.
|
/// One page back: a title to the library, the library to nowhere.
|
||||||
@@ -402,6 +425,15 @@ pub struct LibraryView {
|
|||||||
pub history: Vec<ui::history::Entry>,
|
pub history: Vec<ui::history::Entry>,
|
||||||
/// The "Continue watching" shelf, most recent first, as indices.
|
/// The "Continue watching" shelf, most recent first, as indices.
|
||||||
pub resumable: Vec<usize>,
|
pub resumable: Vec<usize>,
|
||||||
|
/// What each tile says about what arrived — `New`, `3 new` — by key.
|
||||||
|
pub badges: HashMap<String, String>,
|
||||||
|
/// The keys on My List.
|
||||||
|
pub listed: std::collections::HashSet<String>,
|
||||||
|
/// What the search found besides titles.
|
||||||
|
pub found: pstr_core::discover::Found,
|
||||||
|
/// "More like this" for the title page open, worked out once per title:
|
||||||
|
/// its key, and the keys of the titles like it.
|
||||||
|
pub similar: Option<(String, Vec<String>)>,
|
||||||
/// The library or what is known about it changed under the lists.
|
/// The library or what is known about it changed under the lists.
|
||||||
stale: bool,
|
stale: bool,
|
||||||
/// Only watch state changed: the shelves and franchises still stand,
|
/// Only watch state changed: the shelves and franchises still stand,
|
||||||
@@ -437,6 +469,10 @@ impl Default for LibraryView {
|
|||||||
shelves: Vec::new(),
|
shelves: Vec::new(),
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
resumable: Vec::new(),
|
resumable: Vec::new(),
|
||||||
|
badges: HashMap::new(),
|
||||||
|
listed: std::collections::HashSet::new(),
|
||||||
|
similar: None,
|
||||||
|
found: pstr_core::discover::Found::default(),
|
||||||
stale: false,
|
stale: false,
|
||||||
progressed: false,
|
progressed: false,
|
||||||
}
|
}
|
||||||
@@ -454,11 +490,21 @@ pub struct ViewInputs<'a> {
|
|||||||
pub added: &'a HashMap<String, i64>,
|
pub added: &'a HashMap<String, i64>,
|
||||||
/// The files kept on this computer, for the Downloaded filter.
|
/// The files kept on this computer, for the Downloaded filter.
|
||||||
pub offline: &'a std::collections::HashSet<DownloadKey>,
|
pub offline: &'a std::collections::HashSet<DownloadKey>,
|
||||||
|
pub list: &'a MyList,
|
||||||
|
/// When each file first appeared in a crawl, for what is new.
|
||||||
|
pub first_seen: &'a HashMap<(String, String), i64>,
|
||||||
|
/// What the providers said about each title's episodes, for searching
|
||||||
|
/// them by name.
|
||||||
|
pub guides: &'a HashMap<String, EpisodeGuide>,
|
||||||
|
pub now: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many shelves the library shows above the grid.
|
/// How many shelves the library shows above the grid.
|
||||||
const SHELVES: usize = 4;
|
const SHELVES: usize = 4;
|
||||||
|
|
||||||
|
/// How many titles a title page's "More like this" row offers.
|
||||||
|
const SIMILAR: usize = 12;
|
||||||
|
|
||||||
impl LibraryView {
|
impl LibraryView {
|
||||||
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
|
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
|
||||||
use pstr_core::library::Title;
|
use pstr_core::library::Title;
|
||||||
@@ -473,6 +519,7 @@ impl LibraryView {
|
|||||||
if !self.stale && self.progressed && unchanged && !watch_shapes_grid {
|
if !self.stale && self.progressed && unchanged && !watch_shapes_grid {
|
||||||
self.history = ui::history::entries(library);
|
self.history = ui::history::entries(library);
|
||||||
self.resumable = Self::resumable(library);
|
self.resumable = Self::resumable(library);
|
||||||
|
self.badges = Self::badges(library, &inputs);
|
||||||
self.progressed = false;
|
self.progressed = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -481,6 +528,7 @@ impl LibraryView {
|
|||||||
}
|
}
|
||||||
let metadata = inputs.metadata;
|
let metadata = inputs.metadata;
|
||||||
if self.stale {
|
if self.stale {
|
||||||
|
self.similar = None;
|
||||||
let all: Vec<&Title> = library.titles.iter().collect();
|
let all: Vec<&Title> = library.titles.iter().collect();
|
||||||
self.franchises = pstr_core::franchise::group(&all, metadata)
|
self.franchises = pstr_core::franchise::group(&all, metadata)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -500,7 +548,20 @@ impl LibraryView {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(at, title)| (title.key.as_str(), at))
|
.map(|(at, title)| (title.key.as_str(), at))
|
||||||
.collect();
|
.collect();
|
||||||
self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES)
|
self.listed = inputs
|
||||||
|
.list
|
||||||
|
.entries()
|
||||||
|
.map(|(key, _)| key.to_owned())
|
||||||
|
.collect();
|
||||||
|
self.shelves = pstr_core::shelves::home(
|
||||||
|
&library.titles,
|
||||||
|
metadata,
|
||||||
|
inputs.names,
|
||||||
|
inputs.list,
|
||||||
|
inputs.first_seen,
|
||||||
|
inputs.now,
|
||||||
|
SHELVES,
|
||||||
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|shelf| {
|
.map(|shelf| {
|
||||||
let indices = shelf
|
let indices = shelf
|
||||||
@@ -516,12 +577,16 @@ impl LibraryView {
|
|||||||
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
|
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|title| {
|
.filter(|title| {
|
||||||
self.filter.admits(title, |node| {
|
self.filter.admits(
|
||||||
|
title,
|
||||||
|
|node| {
|
||||||
inputs.offline.contains(&DownloadKey {
|
inputs.offline.contains(&DownloadKey {
|
||||||
share_id: node.share_id.clone(),
|
share_id: node.share_id.clone(),
|
||||||
link_id: node.link_id.clone(),
|
link_id: node.link_id.clone(),
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
inputs.list,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let tiles = pstr_core::browse::arrange(
|
let tiles = pstr_core::browse::arrange(
|
||||||
@@ -556,12 +621,23 @@ impl LibraryView {
|
|||||||
if self.stale || self.progressed {
|
if self.stale || self.progressed {
|
||||||
self.history = ui::history::entries(library);
|
self.history = ui::history::entries(library);
|
||||||
self.resumable = Self::resumable(library);
|
self.resumable = Self::resumable(library);
|
||||||
|
self.badges = Self::badges(library, &inputs);
|
||||||
|
}
|
||||||
|
if self.query != inputs.query || self.stale {
|
||||||
|
self.found = pstr_core::discover::find(library, metadata, inputs.guides, inputs.query);
|
||||||
}
|
}
|
||||||
self.query = inputs.query.to_owned();
|
self.query = inputs.query.to_owned();
|
||||||
self.stale = false;
|
self.stale = false;
|
||||||
self.progressed = false;
|
self.progressed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap<String, String> {
|
||||||
|
pstr_core::news::badges(&library.titles, inputs.first_seen, inputs.now)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, badge)| (key, badge.label()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The "Continue watching" shelf, as indices into the library.
|
/// The "Continue watching" shelf, as indices into the library.
|
||||||
fn resumable(library: &Library) -> Vec<usize> {
|
fn resumable(library: &Library) -> Vec<usize> {
|
||||||
library
|
library
|
||||||
@@ -620,6 +696,9 @@ pub struct App {
|
|||||||
pub metadata: HashMap<String, MetadataRecord>,
|
pub metadata: HashMap<String, MetadataRecord>,
|
||||||
/// When each title last gained a file, by key — for "Recently added".
|
/// When each title last gained a file, by key — for "Recently added".
|
||||||
pub added: HashMap<String, i64>,
|
pub added: HashMap<String, i64>,
|
||||||
|
/// When each file first appeared in a crawl, for the New badges.
|
||||||
|
first_seen: HashMap<(String, String), i64>,
|
||||||
|
my_list: MyList,
|
||||||
/// What they said about the episodes under those titles.
|
/// What they said about the episodes under those titles.
|
||||||
pub episodes: HashMap<String, EpisodeGuide>,
|
pub episodes: HashMap<String, EpisodeGuide>,
|
||||||
pub settings: MetadataConfig,
|
pub settings: MetadataConfig,
|
||||||
@@ -635,7 +714,7 @@ pub struct App {
|
|||||||
/// Whether the list of keyboard shortcuts is open.
|
/// Whether the list of keyboard shortcuts is open.
|
||||||
pub shortcuts_open: bool,
|
pub shortcuts_open: bool,
|
||||||
/// What a key just did in the player, and when: "Volume 55%", "+30 s".
|
/// What a key just did in the player, and when: "Volume 55%", "+30 s".
|
||||||
osd: Option<(String, f64)>,
|
osd: Option<ui::player::Flash>,
|
||||||
pub view: LibraryView,
|
pub view: LibraryView,
|
||||||
/// Whether the catalog has been read at all yet. Until it has, an empty
|
/// Whether the catalog has been read at all yet. Until it has, an empty
|
||||||
/// library means "not loaded", not "nothing in it".
|
/// library means "not loaded", not "nothing in it".
|
||||||
@@ -654,8 +733,12 @@ pub struct App {
|
|||||||
/// Chapters already skipped on their own in this player, so seeking back
|
/// Chapters already skipped on their own in this player, so seeking back
|
||||||
/// into an opening on purpose is not undone a frame later.
|
/// into an opening on purpose is not undone a frame later.
|
||||||
auto_skipped: (u64, Vec<i64>),
|
auto_skipped: (u64, Vec<i64>),
|
||||||
/// Whether the episode list is out on the player page.
|
/// The subtitle style changed and has not been written yet.
|
||||||
pub episode_drawer: bool,
|
subtitle_style_unsaved: bool,
|
||||||
|
/// Which panel is out beside the picture on the player page.
|
||||||
|
pub player_panel: Option<ui::player::PlayerPanel>,
|
||||||
|
/// The sleep timer, when one is set.
|
||||||
|
sleep: Option<Sleep>,
|
||||||
/// Set while the window is fullscreen, so `F` can toggle rather than only
|
/// Set while the window is fullscreen, so `F` can toggle rather than only
|
||||||
/// ever entering. egui has no way to ask the platform.
|
/// ever entering. egui has no way to ask the platform.
|
||||||
pub fullscreen: bool,
|
pub fullscreen: bool,
|
||||||
@@ -713,6 +796,7 @@ impl App {
|
|||||||
let window = crate::window::WindowMemory::new(dirs.window_file());
|
let window = crate::window::WindowMemory::new(dirs.window_file());
|
||||||
let notifier = crate::notify::Notifier::new(&dirs.cache);
|
let notifier = crate::notify::Notifier::new(&dirs.cache);
|
||||||
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
||||||
|
let my_list = engine.my_list();
|
||||||
theme::install_fonts(&cc.egui_ctx);
|
theme::install_fonts(&cc.egui_ctx);
|
||||||
theme::apply(&cc.egui_ctx, engine.appearance());
|
theme::apply(&cc.egui_ctx, engine.appearance());
|
||||||
cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom());
|
cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom());
|
||||||
@@ -742,6 +826,8 @@ impl App {
|
|||||||
posters: ImageCache::default(),
|
posters: ImageCache::default(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
added: HashMap::new(),
|
added: HashMap::new(),
|
||||||
|
first_seen: HashMap::new(),
|
||||||
|
my_list,
|
||||||
episodes: HashMap::new(),
|
episodes: HashMap::new(),
|
||||||
settings,
|
settings,
|
||||||
api_key: String::new(),
|
api_key: String::new(),
|
||||||
@@ -760,8 +846,10 @@ impl App {
|
|||||||
up_next: UpNext::default(),
|
up_next: UpNext::default(),
|
||||||
warmed: 0,
|
warmed: 0,
|
||||||
auto_skipped: (0, Vec::new()),
|
auto_skipped: (0, Vec::new()),
|
||||||
|
subtitle_style_unsaved: false,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
episode_drawer: false,
|
player_panel: None,
|
||||||
|
sleep: None,
|
||||||
opening: None,
|
opening: None,
|
||||||
connecting: true,
|
connecting: true,
|
||||||
crawling: false,
|
crawling: false,
|
||||||
@@ -786,9 +874,26 @@ impl App {
|
|||||||
|
|
||||||
/// Say over the picture what a control just did. Only on the player page;
|
/// Say over the picture what a control just did. Only on the player page;
|
||||||
/// anywhere else the transport bar already shows it.
|
/// anywhere else the transport bar already shows it.
|
||||||
fn flash(&mut self, ctx: &egui::Context, text: impl Into<String>) {
|
fn flash(&mut self, ctx: &egui::Context, icon: &'static str, text: impl Into<String>) {
|
||||||
|
self.flash_level(ctx, icon, text, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, with a level drawn under it.
|
||||||
|
fn flash_level(
|
||||||
|
&mut self,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
icon: &'static str,
|
||||||
|
text: impl Into<String>,
|
||||||
|
level: Option<f32>,
|
||||||
|
) {
|
||||||
if self.page == Page::Player {
|
if self.page == Page::Player {
|
||||||
self.osd = Some((text.into(), ctx.input(|input| input.time)));
|
self.osd = Some(ui::player::Flash {
|
||||||
|
icon,
|
||||||
|
text: text.into(),
|
||||||
|
level,
|
||||||
|
seek: None,
|
||||||
|
at: ctx.input(|input| input.time),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,6 +980,11 @@ impl App {
|
|||||||
self.connecting = false;
|
self.connecting = false;
|
||||||
self.note(ctx, format!("could not open the shares: {error}"), true);
|
self.note(ctx, format!("could not open the shares: {error}"), true);
|
||||||
}
|
}
|
||||||
|
Event::FirstSeen(first_seen) => self.first_seen = first_seen,
|
||||||
|
Event::MyList(list) => {
|
||||||
|
self.my_list = list;
|
||||||
|
self.view.stale = true;
|
||||||
|
}
|
||||||
Event::LibraryLoaded(library, added) => {
|
Event::LibraryLoaded(library, added) => {
|
||||||
self.library = library;
|
self.library = library;
|
||||||
self.added = added;
|
self.added = added;
|
||||||
@@ -1011,6 +1121,11 @@ impl App {
|
|||||||
matcher.error = Some(error);
|
matcher.error = Some(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::SkipTimes { player, ranges } => {
|
||||||
|
if let Some(playback) = self.playback.as_mut().filter(|p| p.id == player) {
|
||||||
|
playback.set_skip_ranges(ranges);
|
||||||
|
}
|
||||||
|
}
|
||||||
Event::PlaybackReady { target, stream } => {
|
Event::PlaybackReady { target, stream } => {
|
||||||
let opening = self.opening.take();
|
let opening = self.opening.take();
|
||||||
// Dropped before the new one is built: two mpv cores would
|
// Dropped before the new one is built: two mpv cores would
|
||||||
@@ -1266,7 +1381,7 @@ impl App {
|
|||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) -> Option<UpNextCard> {
|
) -> Option<UpNextCard> {
|
||||||
let has_next = self.neighbours().next;
|
let has_next = self.neighbours().next;
|
||||||
let autoplay = self.engine.playback_prefs().autoplay_next;
|
let autoplay = self.engine.playback_prefs().autoplay_next && !self.stops_after_this();
|
||||||
let next = self.adjacent(Adjacent::Next);
|
let next = self.adjacent(Adjacent::Next);
|
||||||
|
|
||||||
let Some(playback) = &self.playback else {
|
let Some(playback) = &self.playback else {
|
||||||
@@ -1352,6 +1467,62 @@ impl App {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the sleep timer is set to stop at the end of what is playing.
|
||||||
|
fn stops_after_this(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
(self.sleep, &self.playback),
|
||||||
|
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id == id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause once the sleep timer runs out. A timer for the end of an episode
|
||||||
|
/// that is no longer playing — the viewer moved on to another — is
|
||||||
|
/// dropped: it was about that episode.
|
||||||
|
fn tick_sleep(&mut self, ctx: &egui::Context) {
|
||||||
|
match (self.sleep, &self.playback) {
|
||||||
|
(Some(Sleep::At(at)), Some(playback)) if std::time::Instant::now() >= at => {
|
||||||
|
if !playback.paused {
|
||||||
|
playback.send(crate::playback::Command::TogglePause);
|
||||||
|
}
|
||||||
|
self.sleep = None;
|
||||||
|
self.note(ctx, "Sleep timer: paused", false);
|
||||||
|
}
|
||||||
|
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id != id => {
|
||||||
|
self.sleep = None;
|
||||||
|
}
|
||||||
|
(Some(_), None) => self.sleep = None,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Once a file has loaded with no chapters naming its opening or ending,
|
||||||
|
/// ask AniSkip, when the episode's provider entry has a MyAnimeList id.
|
||||||
|
fn ask_skip_times(&mut self) {
|
||||||
|
let Some(playback) = &mut self.playback else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(length) = playback.wants_skip_times() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
playback.skips_asked = true;
|
||||||
|
let target = &playback.target;
|
||||||
|
let found = self.library.get(&target.title_key).and_then(|title| {
|
||||||
|
let episode = title.episodes().find(|episode| {
|
||||||
|
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
|
||||||
|
})?;
|
||||||
|
let number = episode.node.parsed.episode?;
|
||||||
|
self.episodes
|
||||||
|
.get(&title.key)?
|
||||||
|
.get(episode.node.parsed.season, number)
|
||||||
|
});
|
||||||
|
if let Some((mal_id, number)) =
|
||||||
|
found.and_then(|found| Some((found.mal_id?, found.entry_number?)))
|
||||||
|
{
|
||||||
|
self.engine
|
||||||
|
.request_skip_times(playback.id, mal_id, number, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Near the end of a file, fetch the opening blocks of the next one.
|
/// Near the end of a file, fetch the opening blocks of the next one.
|
||||||
///
|
///
|
||||||
/// The last two minutes, or the credits if the chapters say where they
|
/// The last two minutes, or the credits if the chapters say where they
|
||||||
@@ -1417,6 +1588,11 @@ impl App {
|
|||||||
/// end too, and auto-advancing through a broken share one episode at a
|
/// end too, and auto-advancing through a broken share one episode at a
|
||||||
/// time is how a player earns being turned off.
|
/// time is how a player earns being turned off.
|
||||||
fn autoplay_next(&mut self, ctx: &egui::Context) {
|
fn autoplay_next(&mut self, ctx: &egui::Context) {
|
||||||
|
if self.stops_after_this() {
|
||||||
|
// The timer has done its job.
|
||||||
|
self.sleep = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !self.engine.playback_prefs().autoplay_next {
|
if !self.engine.playback_prefs().autoplay_next {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1437,6 +1613,21 @@ impl App {
|
|||||||
self.apply(ctx, Action::Play(target));
|
self.apply(ctx, Action::Play(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the subtitle style, once whatever is changing it has let go.
|
||||||
|
///
|
||||||
|
/// A slider or a colour wheel changes the style every frame it is dragged,
|
||||||
|
/// and each change is shown on the playing file straight away. Writing
|
||||||
|
/// each one would be a file write per frame, so it waits for the mouse —
|
||||||
|
/// which also covers a menu closed mid-drag, where the control that would
|
||||||
|
/// have said "released" is no longer drawn.
|
||||||
|
fn save_subtitle_style(&mut self, ctx: &egui::Context) {
|
||||||
|
if self.subtitle_style_unsaved && !ctx.input(|input| input.pointer.any_down()) {
|
||||||
|
self.subtitle_style_unsaved = false;
|
||||||
|
self.engine
|
||||||
|
.set_playback_prefs(self.engine.playback_prefs(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn send_player(&self, command: crate::playback::Command) {
|
fn send_player(&self, command: crate::playback::Command) {
|
||||||
if let Some(playback) = &self.playback {
|
if let Some(playback) = &self.playback {
|
||||||
playback.send(command);
|
playback.send(command);
|
||||||
@@ -1505,6 +1696,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply(&mut self, ctx: &egui::Context, action: Action) {
|
fn apply(&mut self, ctx: &egui::Context, action: Action) {
|
||||||
|
// A search that led somewhere is one worth offering again.
|
||||||
|
if self.page == Page::Library
|
||||||
|
&& matches!(
|
||||||
|
action,
|
||||||
|
Action::Goto(Page::Title(_) | Page::Browse { .. }) | Action::Play(_)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
self.engine.remember_search(Some(&self.search));
|
||||||
|
}
|
||||||
match action {
|
match action {
|
||||||
Action::Goto(page) => self.page = page,
|
Action::Goto(page) => self.page = page,
|
||||||
Action::Play(mut target) => {
|
Action::Play(mut target) => {
|
||||||
@@ -1644,7 +1844,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
Action::WatchToEnd => self.up_next.dismissed = true,
|
Action::WatchToEnd => self.up_next.dismissed = true,
|
||||||
Action::FocusSearch => {
|
Action::FocusSearch => {
|
||||||
if !matches!(self.page, Page::Library | Page::Title(_)) {
|
if !matches!(
|
||||||
|
self.page,
|
||||||
|
Page::Library | Page::Title(_) | Page::Browse { .. }
|
||||||
|
) {
|
||||||
self.page = Page::Library;
|
self.page = Page::Library;
|
||||||
}
|
}
|
||||||
self.focus_search = true;
|
self.focus_search = true;
|
||||||
@@ -1659,8 +1862,15 @@ impl App {
|
|||||||
self.view.grouped = grouped;
|
self.view.grouped = grouped;
|
||||||
self.remember_arrangement();
|
self.remember_arrangement();
|
||||||
}
|
}
|
||||||
Action::Back => match self.page {
|
Action::SetOnList { key, on } => {
|
||||||
|
self.my_list = self.engine.set_on_list(&key, on);
|
||||||
|
self.view.stale = true;
|
||||||
|
}
|
||||||
|
Action::Back => match &self.page {
|
||||||
Page::Title(_) => self.page = Page::Library,
|
Page::Title(_) => self.page = Page::Library,
|
||||||
|
Page::Browse { from, .. } => {
|
||||||
|
self.page = from.clone().map_or(Page::Library, Page::Title)
|
||||||
|
}
|
||||||
// Back out of a search before out of anything else: it is the
|
// Back out of a search before out of anything else: it is the
|
||||||
// last thing the viewer did.
|
// last thing the viewer did.
|
||||||
Page::Library if !self.search.is_empty() => self.search.clear(),
|
Page::Library if !self.search.is_empty() => self.search.clear(),
|
||||||
@@ -1699,7 +1909,7 @@ impl App {
|
|||||||
playback.position,
|
playback.position,
|
||||||
));
|
));
|
||||||
self.send_player(crate::playback::Command::Screenshot(path));
|
self.send_player(crate::playback::Command::Screenshot(path));
|
||||||
self.flash(ctx, "screenshot saved".to_owned());
|
self.flash(ctx, egui_phosphor::regular::CAMERA, "Screenshot saved");
|
||||||
}
|
}
|
||||||
Action::PasteShare(url) => {
|
Action::PasteShare(url) => {
|
||||||
self.page = Page::Shares;
|
self.page = Page::Shares;
|
||||||
@@ -1789,15 +1999,11 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Action::Player(command) => {
|
Action::Player(command) => {
|
||||||
if let crate::playback::Command::SeekBy(seconds) = command {
|
if let crate::playback::Command::SeekBy(seconds) = command
|
||||||
self.flash(
|
&& self.page == Page::Player
|
||||||
ctx,
|
{
|
||||||
if seconds < 0.0 {
|
let now = ctx.input(|input| input.time);
|
||||||
format!("− {:.0} s", -seconds)
|
self.osd = Some(ui::player::Flash::seek(self.osd.as_ref(), seconds, now));
|
||||||
} else {
|
|
||||||
format!("+ {seconds:.0} s")
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if let Some(playback) = &self.playback {
|
if let Some(playback) = &self.playback {
|
||||||
playback.send(command);
|
playback.send(command);
|
||||||
@@ -1825,7 +2031,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
let text = format!("Speed {}", ui::format_speed(speed));
|
let text = format!("Speed {}", ui::format_speed(speed));
|
||||||
if self.page == Page::Player {
|
if self.page == Page::Player {
|
||||||
self.flash(ctx, text);
|
self.flash(ctx, egui_phosphor::regular::GAUGE, text);
|
||||||
} else {
|
} else {
|
||||||
self.note(ctx, text, false);
|
self.note(ctx, text, false);
|
||||||
}
|
}
|
||||||
@@ -1910,7 +2116,32 @@ impl App {
|
|||||||
Action::RemoveFromHistory { share_id, link_id } => {
|
Action::RemoveFromHistory { share_id, link_id } => {
|
||||||
self.forget_watch(ctx, share_id, link_id, "history");
|
self.forget_watch(ctx, share_id, link_id, "history");
|
||||||
}
|
}
|
||||||
Action::ToggleEpisodes => self.episode_drawer = !self.episode_drawer,
|
Action::SetSleep(choice) => {
|
||||||
|
self.sleep = match choice {
|
||||||
|
None => None,
|
||||||
|
Some(ui::player::SleepChoice::EndOfEpisode) => self
|
||||||
|
.playback
|
||||||
|
.as_ref()
|
||||||
|
.map(|playback| Sleep::EndOfEpisode(playback.id)),
|
||||||
|
Some(ui::player::SleepChoice::Minutes(minutes)) => Some(Sleep::At(
|
||||||
|
std::time::Instant::now()
|
||||||
|
+ std::time::Duration::from_secs(u64::from(minutes) * 60),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let text = match choice {
|
||||||
|
None => "Sleep timer off".to_owned(),
|
||||||
|
Some(ui::player::SleepChoice::EndOfEpisode) => {
|
||||||
|
"Stopping after this episode".to_owned()
|
||||||
|
}
|
||||||
|
Some(ui::player::SleepChoice::Minutes(minutes)) => {
|
||||||
|
format!("Pausing in {minutes} min")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.flash(ctx, egui_phosphor::regular::MOON, text);
|
||||||
|
}
|
||||||
|
Action::TogglePanel(panel) => {
|
||||||
|
self.player_panel = (self.player_panel != Some(panel)).then_some(panel);
|
||||||
|
}
|
||||||
Action::RestoreWatch(states) => {
|
Action::RestoreWatch(states) => {
|
||||||
for (share_id, link_id, state) in states {
|
for (share_id, link_id, state) in states {
|
||||||
self.engine.save_watch_state(share_id, link_id, state);
|
self.engine.save_watch_state(share_id, link_id, state);
|
||||||
@@ -1938,6 +2169,32 @@ impl App {
|
|||||||
prefs.subtitle_language = subtitle;
|
prefs.subtitle_language = subtitle;
|
||||||
self.engine.set_playback_prefs(prefs, true);
|
self.engine.set_playback_prefs(prefs, true);
|
||||||
}
|
}
|
||||||
|
Action::SetSubtitleStyle(style) => {
|
||||||
|
let mut prefs = self.engine.playback_prefs();
|
||||||
|
prefs.subtitle_style = style;
|
||||||
|
self.engine.set_playback_prefs(prefs, false);
|
||||||
|
self.subtitle_style_unsaved = true;
|
||||||
|
let style = self.engine.playback_prefs().subtitle_style;
|
||||||
|
if let Some(playback) = &mut self.playback {
|
||||||
|
playback.restyle_subtitles(&style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::NudgeSubtitleDelay(delta) => {
|
||||||
|
if let Some(playback) = &mut self.playback {
|
||||||
|
playback.set_subtitle_delay(playback.subtitle_delay + delta);
|
||||||
|
let text = format!(
|
||||||
|
"Subtitle delay {}",
|
||||||
|
ui::format_delay(playback.subtitle_delay)
|
||||||
|
);
|
||||||
|
self.flash(ctx, egui_phosphor::regular::SUBTITLES, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::ResetSubtitleDelay => {
|
||||||
|
if let Some(playback) = &mut self.playback {
|
||||||
|
playback.set_subtitle_delay(0.0);
|
||||||
|
self.flash(ctx, egui_phosphor::regular::SUBTITLES, "Subtitle delay 0 s");
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::SetAppearance(appearance) => {
|
Action::SetAppearance(appearance) => {
|
||||||
self.engine.set_appearance(appearance);
|
self.engine.set_appearance(appearance);
|
||||||
theme::apply(ctx, appearance);
|
theme::apply(ctx, appearance);
|
||||||
@@ -1946,7 +2203,18 @@ impl App {
|
|||||||
self.set_volume(volume, commit);
|
self.set_volume(volume, commit);
|
||||||
// A slider shows its own value; a key press shows it here.
|
// A slider shows its own value; a key press shows it here.
|
||||||
if commit {
|
if commit {
|
||||||
self.flash(ctx, format!("Volume {volume:.0}%"));
|
self.flash_level(
|
||||||
|
ctx,
|
||||||
|
if volume <= 0.0 {
|
||||||
|
egui_phosphor::regular::SPEAKER_SLASH
|
||||||
|
} else if volume < 50.0 {
|
||||||
|
egui_phosphor::regular::SPEAKER_LOW
|
||||||
|
} else {
|
||||||
|
egui_phosphor::regular::SPEAKER_HIGH
|
||||||
|
},
|
||||||
|
format!("Volume {volume:.0}%"),
|
||||||
|
Some((volume / pstr_player::MAX_VOLUME) as f32),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ToggleMute => {
|
Action::ToggleMute => {
|
||||||
@@ -1959,7 +2227,11 @@ impl App {
|
|||||||
None => !prefs.muted,
|
None => !prefs.muted,
|
||||||
};
|
};
|
||||||
self.send_player(crate::playback::Command::SetMuted(prefs.muted));
|
self.send_player(crate::playback::Command::SetMuted(prefs.muted));
|
||||||
self.flash(ctx, if prefs.muted { "Muted" } else { "Sound on" });
|
if prefs.muted {
|
||||||
|
self.flash(ctx, egui_phosphor::regular::SPEAKER_SLASH, "Muted");
|
||||||
|
} else {
|
||||||
|
self.flash(ctx, egui_phosphor::regular::SPEAKER_HIGH, "Sound on");
|
||||||
|
}
|
||||||
self.engine.set_playback_prefs(prefs, true);
|
self.engine.set_playback_prefs(prefs, true);
|
||||||
}
|
}
|
||||||
Action::SelectTrack { kind, id } => {
|
Action::SelectTrack { kind, id } => {
|
||||||
@@ -2023,7 +2295,10 @@ impl App {
|
|||||||
);
|
);
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
let on_library = matches!(self.page, Page::Library | Page::Title(_));
|
let on_library = matches!(
|
||||||
|
self.page,
|
||||||
|
Page::Library | Page::Title(_) | Page::Browse { .. }
|
||||||
|
);
|
||||||
let active = self
|
let active = self
|
||||||
.downloads
|
.downloads
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2088,7 +2363,9 @@ impl App {
|
|||||||
|
|
||||||
if on_library && !self.library.is_empty() {
|
if on_library && !self.library.is_empty() {
|
||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
ui::search_field(ui, search, focus_search);
|
if ui::search_field(ui, search, focus_search, &self.engine.recent_searches()) {
|
||||||
|
self.engine.remember_search(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2128,7 +2405,10 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.warm_next();
|
self.warm_next();
|
||||||
|
self.ask_skip_times();
|
||||||
|
self.tick_sleep(ctx);
|
||||||
self.auto_skip(ctx);
|
self.auto_skip(ctx);
|
||||||
|
self.save_subtitle_style(ctx);
|
||||||
self.window.observe(ctx);
|
self.window.observe(ctx);
|
||||||
|
|
||||||
let mut actions: Vec<Action> = Vec::new();
|
let mut actions: Vec<Action> = Vec::new();
|
||||||
@@ -2169,13 +2449,7 @@ impl eframe::App for App {
|
|||||||
speed: playback.speed,
|
speed: playback.speed,
|
||||||
skip_to: playback.skippable().map(|(_, end)| end),
|
skip_to: playback.skippable().map(|(_, end)| end),
|
||||||
});
|
});
|
||||||
shortcuts(
|
shortcuts(ctx, self.fullscreen, self.player_panel, state, &mut actions);
|
||||||
ctx,
|
|
||||||
self.fullscreen,
|
|
||||||
self.episode_drawer,
|
|
||||||
state,
|
|
||||||
&mut actions,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Before the draw, because drawing does not mutate — and it can
|
// Before the draw, because drawing does not mutate — and it can
|
||||||
// push an action of its own, which is why it takes the same list.
|
// push an action of its own, which is why it takes the same list.
|
||||||
@@ -2191,7 +2465,8 @@ impl eframe::App for App {
|
|||||||
posters,
|
posters,
|
||||||
metadata,
|
metadata,
|
||||||
episodes,
|
episodes,
|
||||||
episode_drawer,
|
player_panel,
|
||||||
|
sleep,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
// Before the list borrows the art caches for the rest of the frame.
|
// Before the list borrows the art caches for the rest of the frame.
|
||||||
@@ -2215,6 +2490,45 @@ impl eframe::App for App {
|
|||||||
next: opening.from == Page::Player,
|
next: opening.from == Page::Player,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Only while paused, which is the only time the pause screen
|
||||||
|
// can show: the logo is a texture lookup, and could be a fetch.
|
||||||
|
let about = playback
|
||||||
|
.as_ref()
|
||||||
|
.filter(|playback| playback.paused)
|
||||||
|
.and_then(|playback| {
|
||||||
|
let title = library.get(&playback.target.title_key)?;
|
||||||
|
let mut art = ui::Art {
|
||||||
|
engine,
|
||||||
|
thumbs,
|
||||||
|
posters,
|
||||||
|
metadata,
|
||||||
|
episodes,
|
||||||
|
names: engine.appearance().names,
|
||||||
|
};
|
||||||
|
let target = &playback.target;
|
||||||
|
let episode = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.flat_map(|season| &season.episodes)
|
||||||
|
.find(|episode| {
|
||||||
|
episode.node.share_id == target.share_id
|
||||||
|
&& episode.node.link_id == target.link_id
|
||||||
|
});
|
||||||
|
let synopsis = episode
|
||||||
|
.and_then(|episode| art.episode(&title.key, episode))
|
||||||
|
.and_then(|found| found.overview.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
art.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|found| found.overview.clone())
|
||||||
|
});
|
||||||
|
Some(ui::player::About {
|
||||||
|
name: art.name(title).to_owned(),
|
||||||
|
logo: art.logo(title),
|
||||||
|
synopsis,
|
||||||
|
})
|
||||||
|
});
|
||||||
let list = playback
|
let list = playback
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|playback| library.get(&playback.target.title_key))
|
.and_then(|playback| library.get(&playback.target.title_key))
|
||||||
@@ -2229,7 +2543,6 @@ impl eframe::App for App {
|
|||||||
episodes,
|
episodes,
|
||||||
names: engine.appearance().names,
|
names: engine.appearance().names,
|
||||||
},
|
},
|
||||||
open: *episode_drawer,
|
|
||||||
});
|
});
|
||||||
// No panels: the controls are drawn over the picture, and a nav bar
|
// No panels: the controls are drawn over the picture, and a nav bar
|
||||||
// above a film is the one thing every player agrees not to do.
|
// above a film is the one thing every player agrees not to do.
|
||||||
@@ -2246,12 +2559,15 @@ impl eframe::App for App {
|
|||||||
up_next,
|
up_next,
|
||||||
neighbours,
|
neighbours,
|
||||||
episodes: list,
|
episodes: list,
|
||||||
|
panel: *player_panel,
|
||||||
|
about,
|
||||||
|
sleep: sleep.map(Sleep::choice),
|
||||||
},
|
},
|
||||||
&mut actions,
|
&mut actions,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if let Some((text, at)) = &self.osd
|
if let Some(flash) = &self.osd
|
||||||
&& !ui::player::osd(ctx, text, *at)
|
&& !flash.show(ctx, ctx.content_rect())
|
||||||
{
|
{
|
||||||
self.osd = None;
|
self.osd = None;
|
||||||
}
|
}
|
||||||
@@ -2338,8 +2654,24 @@ impl eframe::App for App {
|
|||||||
names: self.engine.appearance().names,
|
names: self.engine.appearance().names,
|
||||||
added: &self.added,
|
added: &self.added,
|
||||||
offline: &self.offline_files,
|
offline: &self.offline_files,
|
||||||
|
list: &self.my_list,
|
||||||
|
first_seen: &self.first_seen,
|
||||||
|
guides: &self.episodes,
|
||||||
|
now: crate::engine::unix_now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// After the refresh, which forgets it when the library changed.
|
||||||
|
if let Page::Title(key) = &self.page
|
||||||
|
&& self.view.similar.as_ref().is_none_or(|(of, _)| of != key)
|
||||||
|
{
|
||||||
|
let keys = pstr_core::discover::more_like(
|
||||||
|
key,
|
||||||
|
&self.library.titles,
|
||||||
|
&self.metadata,
|
||||||
|
SIMILAR,
|
||||||
|
);
|
||||||
|
self.view.similar = Some((key.clone(), keys));
|
||||||
|
}
|
||||||
|
|
||||||
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
|
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
|
||||||
let App {
|
let App {
|
||||||
@@ -2418,7 +2750,19 @@ impl eframe::App for App {
|
|||||||
.frame(
|
.frame(
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::background())
|
.fill(theme::background())
|
||||||
.inner_margin(egui::Margin::symmetric(18, 12)),
|
.inner_margin(egui::Margin {
|
||||||
|
left: ui::PAGE_MARGIN.x as i8,
|
||||||
|
right: ui::PAGE_MARGIN.x as i8,
|
||||||
|
// The library's banner starts right under the bar;
|
||||||
|
// the page keeps the margin itself when it has no
|
||||||
|
// banner to show.
|
||||||
|
top: if *page == Page::Library {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
ui::PAGE_MARGIN.y as i8
|
||||||
|
},
|
||||||
|
bottom: ui::PAGE_MARGIN.y as i8,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.multiply_opacity(fade);
|
ui.multiply_opacity(fade);
|
||||||
@@ -2454,13 +2798,34 @@ impl eframe::App for App {
|
|||||||
&mut art,
|
&mut art,
|
||||||
library,
|
library,
|
||||||
key,
|
key,
|
||||||
view.franchises.get(key.as_str()).map(Vec::as_slice),
|
ui::title::Context {
|
||||||
|
franchise: view.franchises.get(key.as_str()).map(Vec::as_slice),
|
||||||
|
similar: view
|
||||||
|
.similar
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(of, _)| of == key)
|
||||||
|
.map(|(_, keys)| keys.as_slice())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
on_list: view.listed.contains(key.as_str()),
|
||||||
|
},
|
||||||
ui::title::OfflineView {
|
ui::title::OfflineView {
|
||||||
downloads,
|
downloads,
|
||||||
files: offline_files,
|
files: offline_files,
|
||||||
},
|
},
|
||||||
&mut actions,
|
&mut actions,
|
||||||
),
|
),
|
||||||
|
Page::Browse { facet, from } => ui::library::browse(
|
||||||
|
ui,
|
||||||
|
&mut art,
|
||||||
|
library,
|
||||||
|
ui::library::Browsing {
|
||||||
|
facet,
|
||||||
|
from: from.as_deref(),
|
||||||
|
},
|
||||||
|
view,
|
||||||
|
engine.appearance().tiles,
|
||||||
|
&mut actions,
|
||||||
|
),
|
||||||
Page::Shares => ui::shares::show(
|
Page::Shares => ui::shares::show(
|
||||||
ui,
|
ui,
|
||||||
shares,
|
shares,
|
||||||
@@ -2759,12 +3124,21 @@ fn changed_art(
|
|||||||
.map(|(url, _)| url.to_owned()),
|
.map(|(url, _)| url.to_owned()),
|
||||||
found.and_then(|metadata| metadata.poster_url.clone()),
|
found.and_then(|metadata| metadata.poster_url.clone()),
|
||||||
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
|
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
|
||||||
|
found.and_then(|metadata| metadata.logo_url.clone()),
|
||||||
|
// The poster again: the blurred band is made from it.
|
||||||
|
found.and_then(|metadata| metadata.poster_url.clone()),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
let mut keys: Vec<String> = Vec::new();
|
let mut keys: Vec<String> = Vec::new();
|
||||||
for key in before.keys().chain(after.keys()) {
|
for key in before.keys().chain(after.keys()) {
|
||||||
let (old, new) = (urls(before, key), urls(after, key));
|
let (old, new) = (urls(before, key), urls(after, key));
|
||||||
let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)];
|
let texture_keys = [
|
||||||
|
key.clone(),
|
||||||
|
ui::poster_key(key),
|
||||||
|
ui::wide_key(key),
|
||||||
|
ui::logo_key(key),
|
||||||
|
ui::ambient_key(key),
|
||||||
|
];
|
||||||
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
|
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
|
||||||
if old != new {
|
if old != new {
|
||||||
keys.push(texture);
|
keys.push(texture);
|
||||||
@@ -2803,7 +3177,9 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
|
|||||||
}
|
}
|
||||||
if input.consume_key(Modifiers::ALT, Key::ArrowLeft)
|
if input.consume_key(Modifiers::ALT, Key::ArrowLeft)
|
||||||
|| input.pointer.button_pressed(egui::PointerButton::Extra1)
|
|| input.pointer.button_pressed(egui::PointerButton::Extra1)
|
||||||
|| (!typing && matches!(page, Page::Title(_)) && input.key_pressed(Key::Escape))
|
|| (!typing
|
||||||
|
&& matches!(page, Page::Title(_) | Page::Browse { .. })
|
||||||
|
&& input.key_pressed(Key::Escape))
|
||||||
|| (!typing && *page == Page::Library && input.key_pressed(Key::Backspace))
|
|| (!typing && *page == Page::Library && input.key_pressed(Key::Backspace))
|
||||||
{
|
{
|
||||||
actions.push(Action::Back);
|
actions.push(Action::Back);
|
||||||
@@ -2853,9 +3229,33 @@ struct PlayerKeys {
|
|||||||
skip_to: Option<f64>,
|
skip_to: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sleep timer that is running.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum Sleep {
|
||||||
|
/// Stop when player `id`'s file ends, rather than going on to the next.
|
||||||
|
EndOfEpisode(u64),
|
||||||
|
/// Pause at this moment.
|
||||||
|
At(std::time::Instant),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sleep {
|
||||||
|
/// What the panel shows as chosen.
|
||||||
|
fn choice(self) -> ui::player::SleepChoice {
|
||||||
|
match self {
|
||||||
|
Self::EndOfEpisode(_) => ui::player::SleepChoice::EndOfEpisode,
|
||||||
|
Self::At(at) => ui::player::SleepChoice::Minutes(
|
||||||
|
(at.saturating_duration_since(std::time::Instant::now())
|
||||||
|
.as_secs()
|
||||||
|
/ 60) as u32
|
||||||
|
+ 1,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a
|
/// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a
|
||||||
/// tenth, because 1.0 has to be somewhere a viewer can land on again.
|
/// tenth, because 1.0 has to be somewhere a viewer can land on again.
|
||||||
const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
|
use crate::ui::transport::SPEEDS;
|
||||||
|
|
||||||
/// The next step from `speed` in `direction`, from wherever it is now.
|
/// The next step from `speed` in `direction`, from wherever it is now.
|
||||||
fn step_speed(speed: f64, faster: bool) -> f64 {
|
fn step_speed(speed: f64, faster: bool) -> f64 {
|
||||||
@@ -2878,7 +3278,7 @@ fn step_speed(speed: f64, faster: bool) -> f64 {
|
|||||||
fn shortcuts(
|
fn shortcuts(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
fullscreen: bool,
|
fullscreen: bool,
|
||||||
drawer: bool,
|
panel: Option<ui::player::PlayerPanel>,
|
||||||
playing: Option<PlayerKeys>,
|
playing: Option<PlayerKeys>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -2909,6 +3309,8 @@ fn shortcuts(
|
|||||||
Key::OpenBracket,
|
Key::OpenBracket,
|
||||||
Key::CloseBracket,
|
Key::CloseBracket,
|
||||||
Key::E,
|
Key::E,
|
||||||
|
Key::Z,
|
||||||
|
Key::X,
|
||||||
Key::Escape,
|
Key::Escape,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -2952,9 +3354,14 @@ fn shortcuts(
|
|||||||
// Escape leaves fullscreen first and the page second, which is what
|
// Escape leaves fullscreen first and the page second, which is what
|
||||||
// it does everywhere else and what stops one press from both
|
// it does everywhere else and what stops one press from both
|
||||||
// un-maximising the window and hiding the film.
|
// un-maximising the window and hiding the film.
|
||||||
Key::E => actions.push(Action::ToggleEpisodes),
|
// mpv's own keys for it, and the step mpv uses.
|
||||||
// The episode list is the innermost thing open, so it goes first.
|
Key::Z => actions.push(Action::NudgeSubtitleDelay(-0.1)),
|
||||||
Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
|
Key::X => actions.push(Action::NudgeSubtitleDelay(0.1)),
|
||||||
|
Key::E => actions.push(Action::TogglePanel(ui::player::PlayerPanel::Episodes)),
|
||||||
|
// A panel is the innermost thing open, so it goes first.
|
||||||
|
Key::Escape if panel.is_some() => {
|
||||||
|
actions.push(Action::TogglePanel(panel.expect("checked above")));
|
||||||
|
}
|
||||||
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
|
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
|
||||||
Key::Escape => actions.push(Action::LeavePlayer),
|
Key::Escape => actions.push(Action::LeavePlayer),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -3015,6 +3422,7 @@ mod tests {
|
|||||||
kind: TitleKind::Series,
|
kind: TitleKind::Series,
|
||||||
poster_url: Some(url.into()),
|
poster_url: Some(url.into()),
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
@@ -3088,12 +3496,16 @@ mod tests {
|
|||||||
record("added", Some("w")),
|
record("added", Some("w")),
|
||||||
]
|
]
|
||||||
.into();
|
.into();
|
||||||
// The tile and the cover are both drawn from the poster here; the
|
// The tile, the cover and the blurred band are all drawn from the
|
||||||
// wide art has nothing to be.
|
// poster here; the wide art has nothing to be.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed_art(&before, &after),
|
changed_art(&before, &after),
|
||||||
vec![
|
vec![
|
||||||
"added",
|
"added",
|
||||||
|
"ambient:added",
|
||||||
|
"ambient:dropped",
|
||||||
|
"ambient:found",
|
||||||
|
"ambient:moved",
|
||||||
"dropped",
|
"dropped",
|
||||||
"found",
|
"found",
|
||||||
"moved",
|
"moved",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use pstr_core::catalog::{
|
|||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
||||||
|
use pstr_core::mylist::MyList;
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
use pstr_core::profile::ProfileChanges;
|
use pstr_core::profile::ProfileChanges;
|
||||||
use pstr_core::proton_drive_rs::ThumbnailType;
|
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||||
@@ -170,6 +171,35 @@ const THUMBNAIL_MAX_EDGE: u32 = 640;
|
|||||||
/// best there is.
|
/// best there is.
|
||||||
pub const WIDE_MAX_EDGE: u32 = 1920;
|
pub const WIDE_MAX_EDGE: u32 = 1920;
|
||||||
|
|
||||||
|
/// The longest edge of a title's logo once its padding is trimmed. Drawn at
|
||||||
|
/// most a few hundred pixels wide; this leaves room for a 2× display.
|
||||||
|
const LOGO_MAX_EDGE: u32 = 800;
|
||||||
|
|
||||||
|
/// The size a poster is squeezed to for [`Fit::Ambient`]: 16:9, and small
|
||||||
|
/// enough that what is left of it is colour and light rather than shapes.
|
||||||
|
const AMBIENT_SIZE: (u32, u32) = (64, 36);
|
||||||
|
|
||||||
|
/// How far the squeezed poster is blurred, in its own pixels. With linear
|
||||||
|
/// filtering stretching it across the window, this is what keeps the stretch
|
||||||
|
/// from showing its pixels.
|
||||||
|
const AMBIENT_BLUR: f32 = 3.0;
|
||||||
|
|
||||||
|
/// How a fetched picture is fitted before it becomes a texture.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Fit {
|
||||||
|
/// Scaled down to this edge, nothing else.
|
||||||
|
Scaled(u32),
|
||||||
|
/// A logo: cropped to what is not transparent, then scaled to
|
||||||
|
/// [`LOGO_MAX_EDGE`]. TVDB pads every clear logo to a square, and the
|
||||||
|
/// padding would otherwise decide where it sits and how big it is drawn.
|
||||||
|
Logo,
|
||||||
|
/// A poster made into a wash of its own colours, for a band across the
|
||||||
|
/// window when a title has no wide art: squeezed to [`AMBIENT_SIZE`] and
|
||||||
|
/// blurred. A poster cropped to that shape was a blown-up slice of it,
|
||||||
|
/// which read as broken; this reads as the poster's light.
|
||||||
|
Ambient,
|
||||||
|
}
|
||||||
|
|
||||||
/// What the background side tells the UI.
|
/// What the background side tells the UI.
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
/// The share list changed on disk.
|
/// The share list changed on disk.
|
||||||
@@ -192,6 +222,12 @@ pub enum Event {
|
|||||||
/// The catalog was re-read: the library, and when each title last
|
/// The catalog was re-read: the library, and when each title last
|
||||||
/// gained a file.
|
/// gained a file.
|
||||||
LibraryLoaded(Library, HashMap<String, i64>),
|
LibraryLoaded(Library, HashMap<String, i64>),
|
||||||
|
/// When each file first appeared in a crawl, for the New badges; see
|
||||||
|
/// [`pstr_core::catalog::Catalog::first_seen`]. Sent just before
|
||||||
|
/// [`Event::LibraryLoaded`].
|
||||||
|
FirstSeen(HashMap<(String, String), i64>),
|
||||||
|
/// My List changed on another device.
|
||||||
|
MyList(MyList),
|
||||||
/// A crawl finished for one share.
|
/// A crawl finished for one share.
|
||||||
Crawled {
|
Crawled {
|
||||||
share_id: String,
|
share_id: String,
|
||||||
@@ -256,6 +292,12 @@ pub enum Event {
|
|||||||
},
|
},
|
||||||
/// The artwork for this title could not be had.
|
/// The artwork for this title could not be had.
|
||||||
PosterMissing { key: String },
|
PosterMissing { key: String },
|
||||||
|
/// Where AniSkip says the opening and ending of what player `player` has
|
||||||
|
/// open are. Empty when it does not know.
|
||||||
|
SkipTimes {
|
||||||
|
player: u64,
|
||||||
|
ranges: Vec<pstr_core::chapters::SkipRange>,
|
||||||
|
},
|
||||||
/// A stream opened and is ready to hand to mpv.
|
/// A stream opened and is ready to hand to mpv.
|
||||||
PlaybackReady {
|
PlaybackReady {
|
||||||
target: Box<PlaybackTarget>,
|
target: Box<PlaybackTarget>,
|
||||||
@@ -386,6 +428,12 @@ pub struct Engine {
|
|||||||
/// Which theme the window wears. Kept beside the playback preferences for
|
/// Which theme the window wears. Kept beside the playback preferences for
|
||||||
/// the same reason: it is read from the UI and written to disk off it.
|
/// the same reason: it is read from the UI and written to disk off it.
|
||||||
appearance: Arc<Mutex<Appearance>>,
|
appearance: Arc<Mutex<Appearance>>,
|
||||||
|
/// My List, read once at launch. `None` when the file would not parse: it
|
||||||
|
/// is then never written over, and changing the list is refused.
|
||||||
|
my_list: Arc<Mutex<Option<MyList>>>,
|
||||||
|
/// The last few searches, newest first. `None`, and never written, when
|
||||||
|
/// the file would not parse.
|
||||||
|
recent: Arc<Mutex<Option<pstr_core::recent::RecentSearches>>>,
|
||||||
/// Preloaded so clicking Play never runs SQLite on egui's thread.
|
/// Preloaded so clicking Play never runs SQLite on egui's thread.
|
||||||
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
|
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
|
||||||
downloads: Arc<Mutex<Downloads>>,
|
downloads: Arc<Mutex<Downloads>>,
|
||||||
@@ -445,7 +493,7 @@ impl Enrichment {
|
|||||||
/// Resolve a service for `config`, if one can be had.
|
/// Resolve a service for `config`, if one can be had.
|
||||||
fn build(config: MetadataConfig) -> Self {
|
fn build(config: MetadataConfig) -> Self {
|
||||||
let service = config.enabled.then(|| {
|
let service = config.enabled.then(|| {
|
||||||
let key = pstr_meta::settings::api_key(config.provider);
|
let key = pstr_meta::settings::api_key(ProviderId::Tmdb);
|
||||||
MetadataService::new(&config, key)
|
MetadataService::new(&config, key)
|
||||||
.inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}"))
|
.inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}"))
|
||||||
.ok()
|
.ok()
|
||||||
@@ -493,12 +541,20 @@ impl Engine {
|
|||||||
Appearance::default()
|
Appearance::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
// A provider with an API key needs it out of the OS credential store,
|
let my_list = pstr_core::mylist::load(&dirs)
|
||||||
// and that can be a D-Bus round trip to a keyring that has to unlock
|
.inspect_err(|error| tracing::warn!("read My List: {error}"))
|
||||||
// first — not something to hold the first frame for. Built on the
|
.ok();
|
||||||
// blocking pool instead; until it lands, everything that needs the
|
let recent = pstr_core::recent::load(&dirs)
|
||||||
// provider waits for it rather than concluding there is none.
|
.inspect_err(|error| tracing::warn!("read recent searches: {error}"))
|
||||||
let deferred = config.enabled && config.provider.needs_api_key();
|
.ok();
|
||||||
|
|
||||||
|
// The TMDB key, which every provider looks for, comes out of the OS
|
||||||
|
// credential store, and that can be a D-Bus round trip to a keyring
|
||||||
|
// that has to unlock first — not something to hold the first frame
|
||||||
|
// for. Built on the blocking pool instead; until it lands, everything
|
||||||
|
// that needs the provider waits for it rather than concluding there is
|
||||||
|
// none.
|
||||||
|
let deferred = config.enabled;
|
||||||
let enrichment = if deferred {
|
let enrichment = if deferred {
|
||||||
Enrichment {
|
Enrichment {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
@@ -530,6 +586,8 @@ impl Engine {
|
|||||||
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
|
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
|
||||||
prefs: Arc::new(Mutex::new(prefs)),
|
prefs: Arc::new(Mutex::new(prefs)),
|
||||||
appearance: Arc::new(Mutex::new(appearance)),
|
appearance: Arc::new(Mutex::new(appearance)),
|
||||||
|
my_list: Arc::new(Mutex::new(my_list)),
|
||||||
|
recent: Arc::new(Mutex::new(recent)),
|
||||||
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
||||||
downloads: Arc::new(Mutex::new(Downloads::default())),
|
downloads: Arc::new(Mutex::new(Downloads::default())),
|
||||||
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||||
@@ -594,6 +652,75 @@ impl Engine {
|
|||||||
*self.appearance.lock()
|
*self.appearance.lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// My List as it stands.
|
||||||
|
pub fn my_list(&self) -> MyList {
|
||||||
|
self.my_list.lock().clone().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a title on My List or take it off, write the list, and return it
|
||||||
|
/// as it now is.
|
||||||
|
pub fn set_on_list(&self, key: &str, on: bool) -> MyList {
|
||||||
|
let mut guard = self.my_list.lock();
|
||||||
|
let Some(list) = guard.as_mut() else {
|
||||||
|
self.emit(Event::Error(
|
||||||
|
"My List could not be read, so it was left as it is.".to_owned(),
|
||||||
|
));
|
||||||
|
return MyList::default();
|
||||||
|
};
|
||||||
|
if on {
|
||||||
|
list.add(key, unix_now());
|
||||||
|
} else {
|
||||||
|
list.remove(key);
|
||||||
|
}
|
||||||
|
let saved = list.clone();
|
||||||
|
drop(guard);
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn_blocking(move || {
|
||||||
|
// Whatever the list is by now, under the lock: two clicks in a row
|
||||||
|
// must not land on disk in the wrong order.
|
||||||
|
let guard = engine.my_list.lock();
|
||||||
|
if let Some(list) = guard.as_ref()
|
||||||
|
&& let Err(error) = pstr_core::mylist::save(&engine.dirs, list)
|
||||||
|
{
|
||||||
|
drop(guard);
|
||||||
|
engine.fail("save My List", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
saved
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last few searches, newest first.
|
||||||
|
pub fn recent_searches(&self) -> Vec<String> {
|
||||||
|
self.recent
|
||||||
|
.lock()
|
||||||
|
.as_ref()
|
||||||
|
.map(|recent| recent.queries.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `query` at the front of the recent searches, or with `None`
|
||||||
|
/// forget them all, and write the list off the UI thread.
|
||||||
|
pub fn remember_search(&self, query: Option<&str>) {
|
||||||
|
let changed = match (self.recent.lock().as_mut(), query) {
|
||||||
|
(Some(recent), Some(query)) => recent.remember(query),
|
||||||
|
(Some(recent), None) => !std::mem::take(&mut recent.queries).is_empty(),
|
||||||
|
(None, _) => false,
|
||||||
|
};
|
||||||
|
if !changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn_blocking(move || {
|
||||||
|
let guard = engine.recent.lock();
|
||||||
|
if let Some(recent) = guard.as_ref()
|
||||||
|
&& let Err(error) = pstr_core::recent::save(&engine.dirs, recent)
|
||||||
|
{
|
||||||
|
drop(guard);
|
||||||
|
engine.fail("save recent searches", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Change the theme and write it.
|
/// Change the theme and write it.
|
||||||
///
|
///
|
||||||
/// Always committed, unlike the volume: a theme is picked with a click and
|
/// Always committed, unlike the volume: a theme is picked with a click and
|
||||||
@@ -994,6 +1121,15 @@ impl Engine {
|
|||||||
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
|
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if profile.list {
|
||||||
|
match pstr_core::mylist::load(&self.dirs) {
|
||||||
|
Ok(list) => {
|
||||||
|
*self.my_list.lock() = Some(list.clone());
|
||||||
|
self.emit(Event::MyList(list));
|
||||||
|
}
|
||||||
|
Err(error) => tracing::warn!("reread My List: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
if profile.titles > 0 {
|
if profile.titles > 0 {
|
||||||
match self.catalog.lock().all_title_track_prefs() {
|
match self.catalog.lock().all_title_track_prefs() {
|
||||||
Ok(prefs) => *self.track_prefs.lock() = prefs,
|
Ok(prefs) => *self.track_prefs.lock() = prefs,
|
||||||
@@ -1259,6 +1395,7 @@ impl Engine {
|
|||||||
let library = Library::build(files, &watch);
|
let library = Library::build(files, &watch);
|
||||||
let targets = download_targets(&library);
|
let targets = download_targets(&library);
|
||||||
let added = library.added_at(&first_seen);
|
let added = library.added_at(&first_seen);
|
||||||
|
engine.emit(Event::FirstSeen(first_seen));
|
||||||
engine.emit(Event::LibraryLoaded(library, added));
|
engine.emit(Event::LibraryLoaded(library, added));
|
||||||
engine.refresh_offline(Some(targets));
|
engine.refresh_offline(Some(targets));
|
||||||
}
|
}
|
||||||
@@ -2590,8 +2727,6 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
return self.emit(Event::Error(self.no_service_reason()));
|
return self.emit(Event::Error(self.no_service_reason()));
|
||||||
};
|
};
|
||||||
let provider = service.provider();
|
|
||||||
|
|
||||||
let (stored, enriched) = {
|
let (stored, enriched) = {
|
||||||
let catalog = self.catalog.lock();
|
let catalog = self.catalog.lock();
|
||||||
match catalog
|
match catalog
|
||||||
@@ -2603,14 +2738,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let pending = pstr_meta::service::plan(
|
let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
|
||||||
titles,
|
|
||||||
&stored,
|
|
||||||
&enriched,
|
|
||||||
provider,
|
|
||||||
service.has_details(),
|
|
||||||
force,
|
|
||||||
);
|
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
if automatic {
|
if automatic {
|
||||||
return;
|
return;
|
||||||
@@ -2621,7 +2749,7 @@ impl Engine {
|
|||||||
self.emit(Event::Status(format!(
|
self.emit(Event::Status(format!(
|
||||||
"matching {} titles against {}…",
|
"matching {} titles against {}…",
|
||||||
pending.len(),
|
pending.len(),
|
||||||
provider.label()
|
service.label()
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize);
|
let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize);
|
||||||
@@ -2658,7 +2786,7 @@ impl Engine {
|
|||||||
episodes_found += enrichment.episodes.len();
|
episodes_found += enrichment.episodes.len();
|
||||||
let stored = self.catalog.lock().set_enrichment(
|
let stored = self.catalog.lock().set_enrichment(
|
||||||
&outcome.title_key,
|
&outcome.title_key,
|
||||||
provider,
|
outcome.provider,
|
||||||
now(),
|
now(),
|
||||||
enrichment,
|
enrichment,
|
||||||
);
|
);
|
||||||
@@ -2740,7 +2868,7 @@ impl Engine {
|
|||||||
if let Some(enrichment) = &outcome.enrichment {
|
if let Some(enrichment) = &outcome.enrichment {
|
||||||
let stored = engine.catalog.lock().set_enrichment(
|
let stored = engine.catalog.lock().set_enrichment(
|
||||||
&title.key,
|
&title.key,
|
||||||
service.provider(),
|
outcome.provider,
|
||||||
now(),
|
now(),
|
||||||
enrichment,
|
enrichment,
|
||||||
);
|
);
|
||||||
@@ -2780,6 +2908,26 @@ impl Engine {
|
|||||||
path.is_file().then_some(path)
|
path.is_file().then_some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask AniSkip where episode `episode` of the MyAnimeList entry `mal_id`
|
||||||
|
/// has its opening and ending, for player `player`, whose file runs
|
||||||
|
/// `length` seconds. Only with enrichment on; a failure is logged and is
|
||||||
|
/// the same as no answer, which leaves the file as its chapters made it.
|
||||||
|
pub fn request_skip_times(&self, player: u64, mal_id: u32, episode: u32, length: f64) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let Some(service) = engine.metadata_service().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match service.skip_times(mal_id, episode, length).await {
|
||||||
|
Ok(ranges) if !ranges.is_empty() => {
|
||||||
|
engine.emit(Event::SkipTimes { player, ranges });
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => tracing::debug!("AniSkip for {mal_id}/{episode}: {error}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch, decode and post one title's artwork.
|
/// Fetch, decode and post one title's artwork.
|
||||||
///
|
///
|
||||||
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
|
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
|
||||||
@@ -2794,18 +2942,35 @@ impl Engine {
|
|||||||
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
|
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
|
||||||
/// most of why those looked soft.
|
/// most of why those looked soft.
|
||||||
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
|
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
|
||||||
|
self.fetch_art(title_key, url, Fit::Scaled(max_edge));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Engine::request_poster`], for a title's logo: trimmed to its
|
||||||
|
/// lettering. See [`Fit::Logo`].
|
||||||
|
pub fn request_logo(&self, key: String, url: String) {
|
||||||
|
self.fetch_art(key, url, Fit::Logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Engine::request_poster`], blurred for a band. See [`Fit::Ambient`].
|
||||||
|
pub fn request_ambient(&self, key: String, url: String) {
|
||||||
|
self.fetch_art(key, url, Fit::Ambient);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_art(&self, title_key: String, url: String, fit: Fit) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
// A size of its own on disk: the tile-sized copy of the same URL is
|
// A size of its own on disk: the tile-sized copy of the same URL is
|
||||||
// already there under the plain name, scaled down.
|
// already there under the plain name, scaled down.
|
||||||
let name = match max_edge {
|
let name = match fit {
|
||||||
THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)),
|
Fit::Scaled(THUMBNAIL_MAX_EDGE) => format!("{}.img", digest(&url)),
|
||||||
edge => format!("{}-{edge}.img", digest(&url)),
|
Fit::Scaled(edge) => format!("{}-{edge}.img", digest(&url)),
|
||||||
|
Fit::Logo => format!("{}-logo.img", digest(&url)),
|
||||||
|
Fit::Ambient => format!("{}-ambient.img", digest(&url)),
|
||||||
};
|
};
|
||||||
let path = self.dirs.poster_cache().join(name);
|
let path = self.dirs.poster_cache().join(name);
|
||||||
|
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
if let Ok(bytes) = tokio::fs::read(&path).await {
|
if let Ok(bytes) = tokio::fs::read(&path).await {
|
||||||
match decode_scaled(&bytes, max_edge) {
|
match decode_fitted(&bytes, fit) {
|
||||||
Ok(decoded) => {
|
Ok(decoded) => {
|
||||||
if let Some(smaller) = &decoded.smaller {
|
if let Some(smaller) = &decoded.smaller {
|
||||||
store_image(&path, smaller).await;
|
store_image(&path, smaller).await;
|
||||||
@@ -2832,7 +2997,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match decode_scaled(&bytes, max_edge) {
|
match decode_fitted(&bytes, fit) {
|
||||||
Ok(decoded) => {
|
Ok(decoded) => {
|
||||||
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
|
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
|
||||||
engine.emit(Event::Poster {
|
engine.emit(Event::Poster {
|
||||||
@@ -2984,11 +3149,43 @@ fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
|
|||||||
|
|
||||||
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
|
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
|
||||||
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
|
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
|
||||||
let decoded = image::load_from_memory(bytes)?;
|
decode_fitted(bytes, Fit::Scaled(max_edge))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`decode_scaled`], fitted as `fit` says. A trimmed logo is handed back
|
||||||
|
/// for the cache like a scaled picture is, so the trim is paid once.
|
||||||
|
fn decode_fitted(bytes: &[u8], fit: Fit) -> anyhow::Result<Decoded> {
|
||||||
|
let mut decoded = image::load_from_memory(bytes)?;
|
||||||
|
let mut changed = false;
|
||||||
|
let max_edge = match fit {
|
||||||
|
Fit::Scaled(edge) => edge,
|
||||||
|
Fit::Logo => {
|
||||||
|
if let Some(trimmed) = trim_alpha(&decoded) {
|
||||||
|
decoded = trimmed;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
LOGO_MAX_EDGE
|
||||||
|
}
|
||||||
|
Fit::Ambient => {
|
||||||
|
// Already blurred when it came out of the cache: only a picture
|
||||||
|
// of another size is the poster itself.
|
||||||
|
let (width, height) = AMBIENT_SIZE;
|
||||||
|
if (decoded.width(), decoded.height()) != AMBIENT_SIZE {
|
||||||
|
decoded = decoded
|
||||||
|
.resize_exact(width, height, image::imageops::FilterType::Triangle)
|
||||||
|
.blur(AMBIENT_BLUR);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
width
|
||||||
|
}
|
||||||
|
};
|
||||||
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
|
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
|
||||||
let scaled = decoded.thumbnail(max_edge, max_edge);
|
let scaled = decoded.thumbnail(max_edge, max_edge);
|
||||||
let encoded = encode_for_cache(&scaled);
|
let encoded = encode_for_cache(&scaled);
|
||||||
(scaled, encoded)
|
(scaled, encoded)
|
||||||
|
} else if changed {
|
||||||
|
let encoded = encode_for_cache(&decoded);
|
||||||
|
(decoded, encoded)
|
||||||
} else {
|
} else {
|
||||||
(decoded, None)
|
(decoded, None)
|
||||||
};
|
};
|
||||||
@@ -3000,6 +3197,36 @@ fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `image` cropped to the pixels that are not (nearly) transparent, or
|
||||||
|
/// `None` when there is nothing to crop — no padding, no alpha, or nothing
|
||||||
|
/// but padding.
|
||||||
|
fn trim_alpha(image: &image::DynamicImage) -> Option<image::DynamicImage> {
|
||||||
|
/// Below this the pixel is anti-aliasing haze or compression noise, not
|
||||||
|
/// lettering.
|
||||||
|
const VISIBLE: u8 = 8;
|
||||||
|
if !image.color().has_alpha() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rgba = image.to_rgba8();
|
||||||
|
let (mut left, mut top, mut right, mut bottom) = (u32::MAX, u32::MAX, 0, 0);
|
||||||
|
for (x, y, pixel) in rgba.enumerate_pixels() {
|
||||||
|
if pixel[3] > VISIBLE {
|
||||||
|
left = left.min(x);
|
||||||
|
top = top.min(y);
|
||||||
|
right = right.max(x);
|
||||||
|
bottom = bottom.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if left > right {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (width, height) = (right - left + 1, bottom - top + 1);
|
||||||
|
if (width, height) == (rgba.width(), rgba.height()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(image.crop_imm(left, top, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode a scaled picture for the cache: PNG when it has transparency to keep
|
/// Encode a scaled picture for the cache: PNG when it has transparency to keep
|
||||||
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
|
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
|
||||||
/// holding the original — slower to load, never wrong.
|
/// holding the original — slower to load, never wrong.
|
||||||
@@ -3076,6 +3303,24 @@ struct Entry {
|
|||||||
texture: egui::TextureHandle,
|
texture: egui::TextureHandle,
|
||||||
used: u64,
|
used: u64,
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
|
/// The picture's colour — see [`pstr_core::appearance::mood`]. Worked out
|
||||||
|
/// once, at upload, from the pixels on their way to the GPU.
|
||||||
|
mood: Option<egui::Color32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// About how many pixels [`mood_of`] reads. A 64×36 wash is read whole; a
|
||||||
|
/// 1920 px backdrop is read at a stride that lands on about this many, which
|
||||||
|
/// is plenty for an average and costs nothing worth a frame.
|
||||||
|
const MOOD_SAMPLES: usize = 4096;
|
||||||
|
|
||||||
|
/// [`pstr_core::appearance::mood`] over a decoded picture.
|
||||||
|
fn mood_of(image: &egui::ColorImage) -> Option<egui::Color32> {
|
||||||
|
let stride = (image.pixels.len() / MOOD_SAMPLES).max(1);
|
||||||
|
pstr_core::appearance::mood(image.pixels.iter().step_by(stride).map(|pixel| {
|
||||||
|
let [r, g, b, a] = pixel.to_srgba_unmultiplied();
|
||||||
|
(pstr_core::appearance::Rgb::new(r, g, b), a)
|
||||||
|
}))
|
||||||
|
.map(|rgb| egui::Color32::from_rgb(rgb.r, rgb.g, rgb.b))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about
|
/// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about
|
||||||
@@ -3143,12 +3388,14 @@ impl ImageCache {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let bytes = image.pixels.len() * 4;
|
let bytes = image.pixels.len() * 4;
|
||||||
|
let mood = mood_of(&image);
|
||||||
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
|
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
|
||||||
self.clock += 1;
|
self.clock += 1;
|
||||||
let entry = Entry {
|
let entry = Entry {
|
||||||
texture,
|
texture,
|
||||||
used: self.clock,
|
used: self.clock,
|
||||||
bytes,
|
bytes,
|
||||||
|
mood,
|
||||||
};
|
};
|
||||||
if let Some(old) = self.textures.insert(key, entry) {
|
if let Some(old) = self.textures.insert(key, entry) {
|
||||||
self.bytes -= old.bytes;
|
self.bytes -= old.bytes;
|
||||||
@@ -3178,6 +3425,12 @@ impl ImageCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The colour of the picture under `key`, once it is a texture. A lookup
|
||||||
|
/// that does not count as asking for it: only drawing a picture keeps it.
|
||||||
|
pub fn mood(&self, key: &str) -> Option<egui::Color32> {
|
||||||
|
self.textures.get(key).and_then(|entry| entry.mood)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mark_missing(&mut self, key: String) {
|
pub fn mark_missing(&mut self, key: String) {
|
||||||
self.missing.insert(key);
|
self.missing.insert(key);
|
||||||
}
|
}
|
||||||
@@ -3238,6 +3491,18 @@ mod image_cache_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uploaded_picture_keeps_its_colour_and_a_waiting_one_has_none() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut cache = ImageCache::default();
|
||||||
|
let teal = egui::Color32::from_rgb(20, 140, 150);
|
||||||
|
cache.texture("a", || {});
|
||||||
|
cache.insert("a".into(), egui::ColorImage::filled([8, 8], teal));
|
||||||
|
assert_eq!(cache.mood("a"), None);
|
||||||
|
cache.upload(&ctx);
|
||||||
|
assert_eq!(cache.mood("a"), Some(teal));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn uploads_are_spread_over_frames() {
|
fn uploads_are_spread_over_frames() {
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
@@ -3261,6 +3526,52 @@ mod image_cache_tests {
|
|||||||
cache.upload(&ctx);
|
cache.upload(&ctx);
|
||||||
assert!(cache.textures.is_empty());
|
assert!(cache.textures.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TVDB pads a clear logo to a square; trimmed, it is only its lettering.
|
||||||
|
#[test]
|
||||||
|
fn a_padded_logo_is_trimmed_to_its_lettering() {
|
||||||
|
let mut padded = image::RgbaImage::new(100, 100);
|
||||||
|
for x in 20..80 {
|
||||||
|
for y in 40..60 {
|
||||||
|
padded.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Haze below the visibility threshold does not count.
|
||||||
|
padded.put_pixel(2, 2, image::Rgba([255, 255, 255, 3]));
|
||||||
|
let trimmed = trim_alpha(&image::DynamicImage::ImageRgba8(padded)).expect("trimmed");
|
||||||
|
assert_eq!((trimmed.width(), trimmed.height()), (60, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A poster for a band is squeezed and blurred once; what the cache keeps
|
||||||
|
/// is already the wash, and decoding it again leaves it as it is.
|
||||||
|
#[test]
|
||||||
|
fn a_poster_for_a_band_becomes_a_small_blurred_wash_once() {
|
||||||
|
let mut poster = image::RgbImage::new(200, 300);
|
||||||
|
for y in 0..150 {
|
||||||
|
for x in 0..200 {
|
||||||
|
poster.put_pixel(x, y, image::Rgb([255, 255, 255]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let poster = encode_for_cache(&image::DynamicImage::ImageRgb8(poster)).expect("encode");
|
||||||
|
let decoded = decode_fitted(&poster, Fit::Ambient).expect("decode");
|
||||||
|
assert_eq!(decoded.image.size, [64, 36]);
|
||||||
|
// White above, black below, and a ramp between rather than an edge.
|
||||||
|
let middle = decoded.image.pixels[18 * 64 + 32];
|
||||||
|
assert!(middle.r() > 40 && middle.r() < 215, "{middle:?}");
|
||||||
|
|
||||||
|
let cached = decoded.smaller.expect("the wash is what is cached");
|
||||||
|
let again = decode_fitted(&cached, Fit::Ambient).expect("decode");
|
||||||
|
assert_eq!(again.image.size, [64, 36]);
|
||||||
|
assert!(again.smaller.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_with_nothing_to_trim_is_left_alone() {
|
||||||
|
let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::new(10, 10));
|
||||||
|
assert!(trim_alpha(&opaque).is_none());
|
||||||
|
let blank = image::DynamicImage::ImageRgba8(image::RgbaImage::new(10, 10));
|
||||||
|
assert!(trim_alpha(&blank).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ pub enum Command {
|
|||||||
/// Move subtitles, as a percentage of the window's height. See
|
/// Move subtitles, as a percentage of the window's height. See
|
||||||
/// [`Playback::lift_subtitles`].
|
/// [`Playback::lift_subtitles`].
|
||||||
SetSubtitlePosition(f64),
|
SetSubtitlePosition(f64),
|
||||||
|
/// Restyle subtitles, the line already showing included.
|
||||||
|
SetSubtitleStyle(pstr_core::prefs::SubtitleStyle),
|
||||||
|
/// Shift subtitles this many seconds later; negative is earlier.
|
||||||
|
SetSubtitleDelay(f64),
|
||||||
/// 1.0 is normal speed.
|
/// 1.0 is normal speed.
|
||||||
SetSpeed(f64),
|
SetSpeed(f64),
|
||||||
/// Save the current frame to this file.
|
/// Save the current frame to this file.
|
||||||
@@ -184,10 +188,26 @@ pub struct Playback {
|
|||||||
/// Where subtitles were last put, so they are only moved when that changes
|
/// Where subtitles were last put, so they are only moved when that changes
|
||||||
/// rather than once a frame.
|
/// rather than once a frame.
|
||||||
subtitle_position: f64,
|
subtitle_position: f64,
|
||||||
|
/// How the viewer has subtitles drawn, as last handed to this player.
|
||||||
|
pub subtitle_style: pstr_core::prefs::SubtitleStyle,
|
||||||
|
/// Where the controls would lift subtitles to. The higher of this and the
|
||||||
|
/// style's own position wins.
|
||||||
|
lifted_position: f64,
|
||||||
|
/// How far subtitles are shifted, in seconds. Per file rather than a
|
||||||
|
/// preference: an offset belongs to one release, not to the viewer.
|
||||||
|
pub subtitle_delay: f64,
|
||||||
/// What the file contains. Empty until mpv has demuxed it.
|
/// What the file contains. Empty until mpv has demuxed it.
|
||||||
pub tracks: Vec<Track>,
|
pub tracks: Vec<Track>,
|
||||||
/// The file's chapters, in order. Empty for a file muxed without them.
|
/// The chapters to show and act on, in order: the file's own, with
|
||||||
|
/// AniSkip's opening and ending cut in when the file names neither. Empty
|
||||||
|
/// for a file muxed without chapters that AniSkip does not know.
|
||||||
pub chapters: Vec<Chapter>,
|
pub chapters: Vec<Chapter>,
|
||||||
|
/// The chapters as the file has them.
|
||||||
|
file_chapters: Vec<Chapter>,
|
||||||
|
/// What AniSkip said, once it has.
|
||||||
|
skip_ranges: Vec<pstr_core::chapters::SkipRange>,
|
||||||
|
/// Whether AniSkip has been asked about this file, so it is asked once.
|
||||||
|
pub skips_asked: bool,
|
||||||
/// What each of those chapters is, resolved against the rest of the file —
|
/// What each of those chapters is, resolved against the rest of the file —
|
||||||
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
|
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
|
||||||
/// whenever either the chapters or the duration change.
|
/// whenever either the chapters or the duration change.
|
||||||
@@ -259,10 +279,16 @@ impl Playback {
|
|||||||
paused: false,
|
paused: false,
|
||||||
volume: prefs.volume,
|
volume: prefs.volume,
|
||||||
muted: prefs.muted,
|
muted: prefs.muted,
|
||||||
subtitle_position: 100.0,
|
subtitle_position: prefs.subtitle_style.position,
|
||||||
|
subtitle_style: prefs.subtitle_style.clone(),
|
||||||
|
lifted_position: 100.0,
|
||||||
|
subtitle_delay: 0.0,
|
||||||
speed: prefs.speed,
|
speed: prefs.speed,
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
|
file_chapters: Vec::new(),
|
||||||
|
skip_ranges: Vec::new(),
|
||||||
|
skips_asked: false,
|
||||||
roles: Vec::new(),
|
roles: Vec::new(),
|
||||||
credits_at: None,
|
credits_at: None,
|
||||||
position: target.resume_at.unwrap_or(0.0),
|
position: target.resume_at.unwrap_or(0.0),
|
||||||
@@ -282,17 +308,57 @@ impl Playback {
|
|||||||
/// the mouse is moving, which is exactly when someone is looking at the
|
/// the mouse is moving, which is exactly when someone is looking at the
|
||||||
/// screen.
|
/// screen.
|
||||||
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
|
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
|
||||||
let position = if covered > 0.0 && height > 0.0 {
|
self.lifted_position = if covered > 0.0 && height > 0.0 {
|
||||||
(100.0 * (1.0 - f64::from(covered / height))).round()
|
(100.0 * (1.0 - f64::from(covered / height))).round()
|
||||||
} else {
|
} else {
|
||||||
100.0
|
100.0
|
||||||
};
|
};
|
||||||
|
self.place_subtitles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move subtitles to [`Self::subtitle_position`], when that has changed.
|
||||||
|
fn place_subtitles(&mut self) {
|
||||||
|
let position = self.subtitle_position();
|
||||||
if position != self.subtitle_position {
|
if position != self.subtitle_position {
|
||||||
self.subtitle_position = position;
|
self.subtitle_position = position;
|
||||||
self.send(Command::SetSubtitlePosition(position));
|
self.send(Command::SetSubtitlePosition(position));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where subtitles go: where the style puts them, unless the controls
|
||||||
|
/// would cover that, in which case just above the controls.
|
||||||
|
///
|
||||||
|
/// A styled track ignores the style's position unless it is restyled:
|
||||||
|
/// mpv applies `sub-pos` to it either way, and its signs were placed by
|
||||||
|
/// hand. The lift still applies, because a line under the seek bar cannot
|
||||||
|
/// be read however carefully it was placed.
|
||||||
|
fn subtitle_position(&self) -> f64 {
|
||||||
|
let styled = self
|
||||||
|
.selected_track(TrackKind::Subtitle)
|
||||||
|
.is_some_and(|track| is_styled(track.codec.as_deref()));
|
||||||
|
let position = if styled && !self.subtitle_style.restyle_styled {
|
||||||
|
100.0
|
||||||
|
} else {
|
||||||
|
self.subtitle_style.position
|
||||||
|
};
|
||||||
|
position.min(self.lifted_position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restyle the subtitles of this file, now.
|
||||||
|
pub fn restyle_subtitles(&mut self, style: &pstr_core::prefs::SubtitleStyle) {
|
||||||
|
self.subtitle_style = style.clone();
|
||||||
|
self.subtitle_position = self.subtitle_position();
|
||||||
|
let mut style = style.clone();
|
||||||
|
style.position = self.subtitle_position;
|
||||||
|
self.send(Command::SetSubtitleStyle(style));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shift subtitles to `seconds`, rounded to the tenth a keypress moves.
|
||||||
|
pub fn set_subtitle_delay(&mut self, seconds: f64) {
|
||||||
|
self.subtitle_delay = (seconds * 10.0).round() / 10.0;
|
||||||
|
self.send(Command::SetSubtitleDelay(self.subtitle_delay));
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the picture is drawn in this window rather than mpv's own.
|
/// Whether the picture is drawn in this window rather than mpv's own.
|
||||||
pub fn is_embedded(&self) -> bool {
|
pub fn is_embedded(&self) -> bool {
|
||||||
self.video.is_some()
|
self.video.is_some()
|
||||||
@@ -362,6 +428,11 @@ impl Playback {
|
|||||||
/// changed. Both are needed: the length of a chapter is what settles a name
|
/// changed. Both are needed: the length of a chapter is what settles a name
|
||||||
/// that could mean two things.
|
/// that could mean two things.
|
||||||
fn reclassify(&mut self) {
|
fn reclassify(&mut self) {
|
||||||
|
self.chapters = pstr_core::chapters::with_skip_ranges(
|
||||||
|
&self.file_chapters,
|
||||||
|
&self.skip_ranges,
|
||||||
|
self.duration,
|
||||||
|
);
|
||||||
self.roles = pstr_player::roles(&self.chapters, self.duration);
|
self.roles = pstr_player::roles(&self.chapters, self.duration);
|
||||||
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
|
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
|
||||||
}
|
}
|
||||||
@@ -372,9 +443,13 @@ impl Playback {
|
|||||||
PlayerEvent::FileLoaded => self.loaded = true,
|
PlayerEvent::FileLoaded => self.loaded = true,
|
||||||
PlayerEvent::Volume(volume) => self.volume = *volume,
|
PlayerEvent::Volume(volume) => self.volume = *volume,
|
||||||
PlayerEvent::Muted(muted) => self.muted = *muted,
|
PlayerEvent::Muted(muted) => self.muted = *muted,
|
||||||
PlayerEvent::Tracks(tracks) => self.tracks = tracks.clone(),
|
PlayerEvent::Tracks(tracks) => {
|
||||||
|
self.tracks = tracks.clone();
|
||||||
|
// A switch between a plain and a styled track moves the line.
|
||||||
|
self.place_subtitles();
|
||||||
|
}
|
||||||
PlayerEvent::Chapters(chapters) => {
|
PlayerEvent::Chapters(chapters) => {
|
||||||
self.chapters = chapters.clone();
|
self.file_chapters = chapters.clone();
|
||||||
self.reclassify();
|
self.reclassify();
|
||||||
}
|
}
|
||||||
PlayerEvent::Position(position) => {
|
PlayerEvent::Position(position) => {
|
||||||
@@ -407,6 +482,26 @@ impl Playback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file's length, when it is time to ask AniSkip where its opening
|
||||||
|
/// and ending are: once, after it has loaded, and only when its own
|
||||||
|
/// chapters name neither.
|
||||||
|
pub fn wants_skip_times(&self) -> Option<f64> {
|
||||||
|
if self.skips_asked || !self.loaded {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let own = pstr_player::roles(&self.file_chapters, self.duration);
|
||||||
|
let named = own
|
||||||
|
.iter()
|
||||||
|
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending));
|
||||||
|
self.duration.filter(|duration| *duration > 0.0 && !named)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take AniSkip's answer.
|
||||||
|
pub fn set_skip_ranges(&mut self, ranges: Vec<pstr_core::chapters::SkipRange>) {
|
||||||
|
self.skip_ranges = ranges;
|
||||||
|
self.reclassify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
|
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
|
||||||
pub fn progress(&self) -> Option<f32> {
|
pub fn progress(&self) -> Option<f32> {
|
||||||
let duration = self.duration.filter(|duration| *duration > 0.0)?;
|
let duration = self.duration.filter(|duration| *duration > 0.0)?;
|
||||||
@@ -479,6 +574,11 @@ fn build_player(
|
|||||||
Ok((Arc::new(player), None))
|
Ok((Arc::new(player), None))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a subtitle codec carries its own styling, as mpv names them.
|
||||||
|
fn is_styled(codec: Option<&str>) -> bool {
|
||||||
|
matches!(codec, Some("ass" | "ssa"))
|
||||||
|
}
|
||||||
|
|
||||||
/// The player defaults with the viewer's preferences folded in.
|
/// The player defaults with the viewer's preferences folded in.
|
||||||
fn from_prefs(
|
fn from_prefs(
|
||||||
prefs: &PlaybackPrefs,
|
prefs: &PlaybackPrefs,
|
||||||
@@ -494,6 +594,7 @@ fn from_prefs(
|
|||||||
.and_then(|p| p.subtitle_language.clone())
|
.and_then(|p| p.subtitle_language.clone())
|
||||||
.or_else(|| prefs.subtitle_language.clone()),
|
.or_else(|| prefs.subtitle_language.clone()),
|
||||||
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||||
|
subtitle_style: prefs.subtitle_style.clone(),
|
||||||
// Carried from file to file, like the volume: somebody who watches at
|
// Carried from file to file, like the volume: somebody who watches at
|
||||||
// 1.25 watches the next episode at 1.25 too.
|
// 1.25 watches the next episode at 1.25 too.
|
||||||
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
|
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
|
||||||
@@ -658,6 +759,11 @@ fn apply_command(
|
|||||||
Command::SetVolume(volume) => player.set_volume(volume),
|
Command::SetVolume(volume) => player.set_volume(volume),
|
||||||
Command::SetMuted(muted) => player.set_muted(muted),
|
Command::SetMuted(muted) => player.set_muted(muted),
|
||||||
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
||||||
|
Command::SetSubtitleStyle(style) => {
|
||||||
|
player.apply_subtitle_style(&style);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::SetSubtitleDelay(seconds) => player.set_subtitle_delay(seconds),
|
||||||
Command::SetSpeed(speed) => player.set_speed(speed),
|
Command::SetSpeed(speed) => player.set_speed(speed),
|
||||||
Command::Screenshot(path) => player.screenshot(&path),
|
Command::Screenshot(path) => player.screenshot(&path),
|
||||||
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
||||||
@@ -820,3 +926,17 @@ fn save(
|
|||||||
watch_state(position, duration, watched),
|
watch_state(position, duration, watched),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_ass_and_ssa_tracks_count_as_styled() {
|
||||||
|
assert!(is_styled(Some("ass")));
|
||||||
|
assert!(is_styled(Some("ssa")));
|
||||||
|
assert!(!is_styled(Some("subrip")));
|
||||||
|
assert!(!is_styled(Some("hdmv_pgs_subtitle")));
|
||||||
|
assert!(!is_styled(None));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0;
|
|||||||
pub const POSTER_WIDTH: f32 = 168.0;
|
pub const POSTER_WIDTH: f32 = 168.0;
|
||||||
/// Covers are 2:3.
|
/// Covers are 2:3.
|
||||||
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
||||||
|
/// How much a tile's picture grows under the pointer, as a fraction of its
|
||||||
|
/// size. Half of it lands on each side, which has to fit in half of
|
||||||
|
/// [`CARD_GAP`] at the widest card, or a lifted tile overlaps its neighbour.
|
||||||
|
pub const TILE_LIFT: f32 = 0.04;
|
||||||
|
|
||||||
/// The type ramp, named once.
|
/// The type ramp, named once.
|
||||||
///
|
///
|
||||||
@@ -55,6 +59,11 @@ pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
|||||||
/// the one monospace style in the app is egui's own, set in [`apply`].
|
/// the one monospace style in the app is egui's own, set in [`apply`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Role {
|
pub enum Role {
|
||||||
|
/// The name in the library's banner and at the top of a title's page,
|
||||||
|
/// where there is no logo to stand in for it. Set in Inter Display, the
|
||||||
|
/// cut drawn for this size: tighter and heavier than the text face, which
|
||||||
|
/// at forty points looks like body copy that was zoomed.
|
||||||
|
Hero,
|
||||||
/// The name of the thing you are looking at, once per page.
|
/// The name of the thing you are looking at, once per page.
|
||||||
Display,
|
Display,
|
||||||
/// An empty state, or the heading of a page that has no display line.
|
/// An empty state, or the heading of a page that has no display line.
|
||||||
@@ -78,6 +87,7 @@ pub enum Role {
|
|||||||
impl Role {
|
impl Role {
|
||||||
pub const fn size(self) -> f32 {
|
pub const fn size(self) -> f32 {
|
||||||
match self {
|
match self {
|
||||||
|
Self::Hero => 34.0,
|
||||||
Self::Display => 26.0,
|
Self::Display => 26.0,
|
||||||
Self::Title => 20.0,
|
Self::Title => 20.0,
|
||||||
Self::Heading => 18.0,
|
Self::Heading => 18.0,
|
||||||
@@ -90,10 +100,11 @@ impl Role {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Headings are set in the semibold cut; everything below them in the
|
/// The hero line is set in the display cut, headings in the semibold
|
||||||
/// regular one.
|
/// one, and everything below them in the regular one.
|
||||||
pub fn family(self) -> egui::FontFamily {
|
pub fn family(self) -> egui::FontFamily {
|
||||||
match self {
|
match self {
|
||||||
|
Self::Hero => display(),
|
||||||
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
|
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
|
||||||
_ => egui::FontFamily::Proportional,
|
_ => egui::FontFamily::Proportional,
|
||||||
}
|
}
|
||||||
@@ -159,6 +170,9 @@ pub mod motion {
|
|||||||
/// Artwork arriving over its placeholder. Slower, because it replaces
|
/// Artwork arriving over its placeholder. Slower, because it replaces
|
||||||
/// something rather than adding to it, and a fast swap reads as a flicker.
|
/// something rather than adding to it, and a fast swap reads as a flicker.
|
||||||
pub const ARRIVE: f32 = 0.24;
|
pub const ARRIVE: f32 = 0.24;
|
||||||
|
/// One pass of the highlight across a loading skeleton. Slow, because it
|
||||||
|
/// is something to wait in front of, not something happening.
|
||||||
|
pub const SHIMMER: f32 = 1.4;
|
||||||
|
|
||||||
/// Fast out, slow in: the curve every hand-driven transition uses.
|
/// Fast out, slow in: the curve every hand-driven transition uses.
|
||||||
pub fn ease_out(t: f32) -> f32 {
|
pub fn ease_out(t: f32) -> f32 {
|
||||||
@@ -170,7 +184,7 @@ pub mod motion {
|
|||||||
/// sets the same on every desktop. Inter, under the OFL — the licence is
|
/// sets the same on every desktop. Inter, under the OFL — the licence is
|
||||||
/// beside the files.
|
/// beside the files.
|
||||||
///
|
///
|
||||||
/// Both files have their private use area mappings removed, because the
|
/// All three files have their private use area mappings removed, because the
|
||||||
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
||||||
/// own alternate glyphs, and as the first font in the family it won: the
|
/// own alternate glyphs, and as the first font in the family it won: the
|
||||||
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
||||||
@@ -179,6 +193,9 @@ pub mod motion {
|
|||||||
/// U+E000..=U+F8FF).
|
/// U+E000..=U+F8FF).
|
||||||
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
|
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
|
||||||
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
|
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
|
||||||
|
/// Inter Display Bold, the optical size for the hero line. Stripped like the
|
||||||
|
/// other two.
|
||||||
|
const INTER_DISPLAY: &[u8] = include_bytes!("../assets/fonts/InterDisplay-Bold.ttf");
|
||||||
|
|
||||||
/// The family the headings are set in. egui has no notion of weight, so the
|
/// The family the headings are set in. egui has no notion of weight, so the
|
||||||
/// semibold cut is a family of its own.
|
/// semibold cut is a family of its own.
|
||||||
@@ -186,9 +203,53 @@ pub fn semibold() -> egui::FontFamily {
|
|||||||
egui::FontFamily::Name("semibold".into())
|
egui::FontFamily::Name("semibold".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The family [`Role::Hero`] is set in.
|
||||||
|
pub fn display() -> egui::FontFamily {
|
||||||
|
egui::FontFamily::Name("display".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The family the subtitle preview is drawn in: the face the chosen subtitle
|
||||||
|
/// font resolves to, ahead of the app's own fonts, which stand in until it has
|
||||||
|
/// loaded and for any glyph it lacks.
|
||||||
|
pub fn subtitle_family() -> egui::FontFamily {
|
||||||
|
egui::FontFamily::Name("subtitle".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The system fonts loaded so far. Each install builds the definitions from
|
||||||
|
/// all of them, so loading one does not drop the other.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SystemFonts {
|
||||||
|
cjk: Option<Arc<egui::FontData>>,
|
||||||
|
subtitle: Option<(SubtitleRequest, Arc<egui::FontData>, SubtitleFace)>,
|
||||||
|
/// The subtitle face last asked for, loading or loaded.
|
||||||
|
wanted: Option<SubtitleRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SYSTEM_FONTS: parking_lot::Mutex<SystemFonts> = parking_lot::Mutex::new(SystemFonts {
|
||||||
|
cjk: None,
|
||||||
|
subtitle: None,
|
||||||
|
wanted: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct SubtitleRequest {
|
||||||
|
family: String,
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the loaded subtitle face draws by itself. A style it lacks is one
|
||||||
|
/// libass makes up, and so must the preview.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SubtitleFace {
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
|
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
|
||||||
/// symbols Inter does not carry. `cjk` goes last, when there is one.
|
/// symbols Inter does not carry. The CJK fallback goes last, when there is
|
||||||
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
/// one.
|
||||||
|
fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
|
||||||
use egui::FontFamily::{Monospace, Proportional};
|
use egui::FontFamily::{Monospace, Proportional};
|
||||||
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
@@ -199,6 +260,10 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
|||||||
"inter-semibold".into(),
|
"inter-semibold".into(),
|
||||||
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
|
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
|
||||||
);
|
);
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"inter-display".into(),
|
||||||
|
Arc::new(egui::FontData::from_static(INTER_DISPLAY)),
|
||||||
|
);
|
||||||
fonts
|
fonts
|
||||||
.families
|
.families
|
||||||
.entry(Proportional)
|
.entry(Proportional)
|
||||||
@@ -207,21 +272,32 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
|||||||
// Right behind Inter. The icons are in the private use area, which the
|
// Right behind Inter. The icons are in the private use area, which the
|
||||||
// bundled Inter no longer maps, so neither font shadows the other.
|
// bundled Inter no longer maps, so neither font shadows the other.
|
||||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||||
if let Some(cjk) = cjk {
|
if let Some(cjk) = &system.cjk {
|
||||||
let name = "system-cjk-fallback".to_owned();
|
let name = "system-cjk-fallback".to_owned();
|
||||||
fonts.font_data.insert(name.clone(), Arc::new(cjk));
|
fonts.font_data.insert(name.clone(), cjk.clone());
|
||||||
for family in [Proportional, Monospace] {
|
for family in [Proportional, Monospace] {
|
||||||
fonts.families.entry(family).or_default().push(name.clone());
|
fonts.families.entry(family).or_default().push(name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let fallbacks = fonts.families[&Proportional]
|
let fallbacks: Vec<String> = fonts.families[&Proportional]
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|name| *name != "inter")
|
.filter(|name| *name != "inter")
|
||||||
.cloned();
|
.cloned()
|
||||||
let semibold_chain = std::iter::once("inter-semibold".to_owned())
|
|
||||||
.chain(fallbacks)
|
|
||||||
.collect();
|
.collect();
|
||||||
fonts.families.insert(semibold(), semibold_chain);
|
let chain = |first: &str| {
|
||||||
|
std::iter::once(first.to_owned())
|
||||||
|
.chain(fallbacks.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
fonts.families.insert(semibold(), chain("inter-semibold"));
|
||||||
|
fonts.families.insert(display(), chain("inter-display"));
|
||||||
|
let mut subtitle_chain = fonts.families[&Proportional].clone();
|
||||||
|
if let Some((_, face, _)) = &system.subtitle {
|
||||||
|
let name = "system-subtitle".to_owned();
|
||||||
|
fonts.font_data.insert(name.clone(), face.clone());
|
||||||
|
subtitle_chain.insert(0, name);
|
||||||
|
}
|
||||||
|
fonts.families.insert(subtitle_family(), subtitle_chain);
|
||||||
fonts
|
fonts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +310,7 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
|||||||
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
|
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
|
||||||
/// and native desktops already provide one.
|
/// and native desktops already provide one.
|
||||||
pub fn install_fonts(ctx: &egui::Context) {
|
pub fn install_fonts(ctx: &egui::Context) {
|
||||||
ctx.set_fonts(font_definitions(None));
|
ctx.set_fonts(font_definitions(&SYSTEM_FONTS.lock()));
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let spawned = std::thread::Builder::new()
|
let spawned = std::thread::Builder::new()
|
||||||
.name("fonts".into())
|
.name("fonts".into())
|
||||||
@@ -251,20 +327,80 @@ fn install_cjk_fallback(ctx: &egui::Context) {
|
|||||||
let Ok(bytes) = std::fs::read(&path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(data) = cjk_font_data(bytes, index) else {
|
let Some(data) = system_font_data(bytes, index) else {
|
||||||
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
|
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
ctx.set_fonts(font_definitions(Some(data)));
|
let mut system = SYSTEM_FONTS.lock();
|
||||||
|
system.cjk = Some(Arc::new(data));
|
||||||
|
ctx.set_fonts(font_definitions(&system));
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fallback font, if egui can read it.
|
/// The face the subtitle preview should be drawn in for this font, bold and
|
||||||
|
/// italic, once [`subtitle_family`] holds it.
|
||||||
|
///
|
||||||
|
/// `None` while it loads, and for good where the system cannot say which file
|
||||||
|
/// a family is — everywhere but Linux. The first call for a font starts the
|
||||||
|
/// load on a thread of its own, since `fc-match` and reading the file are too
|
||||||
|
/// slow for a frame, and asks for a repaint when it is in.
|
||||||
|
pub fn subtitle_face(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
family: &str,
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
) -> Option<SubtitleFace> {
|
||||||
|
let request = SubtitleRequest {
|
||||||
|
family: family.to_owned(),
|
||||||
|
bold,
|
||||||
|
italic,
|
||||||
|
};
|
||||||
|
let mut system = SYSTEM_FONTS.lock();
|
||||||
|
if let Some((loaded, _, face)) = &system.subtitle
|
||||||
|
&& *loaded == request
|
||||||
|
{
|
||||||
|
return Some(*face);
|
||||||
|
}
|
||||||
|
if system.wanted.as_ref() != Some(&request) {
|
||||||
|
system.wanted = Some(request.clone());
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("subtitle-font".into())
|
||||||
|
.spawn(move || install_subtitle_face(&ctx, request));
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
tracing::debug!("subtitle font thread: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_subtitle_face(ctx: &egui::Context, request: SubtitleRequest) {
|
||||||
|
let Some((path, index, face)) = system_subtitle_font(&request) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(data) = system_font_data(bytes, index) else {
|
||||||
|
tracing::debug!("subtitle font {}#{index} does not parse", path.display());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut system = SYSTEM_FONTS.lock();
|
||||||
|
// Another font was picked while this one loaded.
|
||||||
|
if system.wanted.as_ref() != Some(&request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
system.subtitle = Some((request, Arc::new(data), face));
|
||||||
|
ctx.set_fonts(font_definitions(&system));
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A system font, if egui can read it.
|
||||||
///
|
///
|
||||||
/// egui panics on a font it cannot parse, and this one comes from whatever the
|
/// 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,
|
/// 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.
|
/// 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> {
|
fn system_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
|
||||||
skrifa::FontRef::from_index(&bytes, index).ok()?;
|
skrifa::FontRef::from_index(&bytes, index).ok()?;
|
||||||
let mut data = egui::FontData::from_owned(bytes);
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
data.index = index;
|
data.index = index;
|
||||||
@@ -299,6 +435,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|||||||
path.is_file().then_some((path, index))
|
path.is_file().then_some((path, index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file fontconfig picks for a subtitle font, as libass asks for it: the
|
||||||
|
/// family with the weight and slant wanted, falling back as libass would to
|
||||||
|
/// whatever fontconfig likes best when the family is not installed.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn system_subtitle_font(
|
||||||
|
request: &SubtitleRequest,
|
||||||
|
) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
|
||||||
|
let output = std::process::Command::new("fc-match")
|
||||||
|
.args([
|
||||||
|
"-f",
|
||||||
|
"%{file}\\n%{index}\\n%{weight}\\n%{slant}",
|
||||||
|
&fontconfig_pattern(request),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let text = std::str::from_utf8(&output.stdout).ok()?;
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let path = std::path::PathBuf::from(lines.next()?);
|
||||||
|
let mut number = || lines.next().and_then(|value| value.parse::<u32>().ok());
|
||||||
|
let index = number().unwrap_or(0);
|
||||||
|
let (weight, slant) = (number().unwrap_or(0), number().unwrap_or(0));
|
||||||
|
// A variable font's named instance is drawn by egui as the default one,
|
||||||
|
// which is upright and regular, whatever fontconfig says it matched.
|
||||||
|
let instance = index != fontconfig_face(index);
|
||||||
|
let face = SubtitleFace {
|
||||||
|
bold: !instance && weight >= FC_WEIGHT_BOLD,
|
||||||
|
italic: !instance && slant > 0,
|
||||||
|
};
|
||||||
|
path.is_file()
|
||||||
|
.then_some((path, fontconfig_face(index), face))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
const FC_WEIGHT_BOLD: u32 = 200;
|
||||||
|
|
||||||
|
/// A fontconfig pattern for the request. A family name is free text, and `-`,
|
||||||
|
/// `:`, `,` and `\\` mean something in a pattern, so those are escaped.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn fontconfig_pattern(request: &SubtitleRequest) -> String {
|
||||||
|
let mut pattern = String::new();
|
||||||
|
for character in request.family.chars() {
|
||||||
|
if matches!(character, '-' | ':' | ',' | '\\') {
|
||||||
|
pattern.push('\\');
|
||||||
|
}
|
||||||
|
pattern.push(character);
|
||||||
|
}
|
||||||
|
if request.bold {
|
||||||
|
pattern.push_str(&format!(":weight={FC_WEIGHT_BOLD}"));
|
||||||
|
}
|
||||||
|
if request.italic {
|
||||||
|
pattern.push_str(":slant=100");
|
||||||
|
}
|
||||||
|
pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elsewhere there is no asking which file a family is, and the preview
|
||||||
|
/// stays in the app's own font.
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn system_subtitle_font(_: &SubtitleRequest) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
|
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
|
||||||
@@ -316,6 +514,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The font families installed here, by name, for the subtitle font picker.
|
||||||
|
///
|
||||||
|
/// `None` until they have been read. The first call starts that on a thread
|
||||||
|
/// of its own and asks for a repaint when it is done: `fc-list` over a large
|
||||||
|
/// font collection takes long enough to be seen as a stalled frame.
|
||||||
|
pub fn font_families(ctx: &egui::Context) -> Option<&'static [String]> {
|
||||||
|
static FAMILIES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||||
|
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
if let Some(families) = FAMILIES.get() {
|
||||||
|
return Some(families);
|
||||||
|
}
|
||||||
|
if !STARTED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("font-families".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let _ = FAMILIES.set(system_font_families());
|
||||||
|
ctx.request_repaint();
|
||||||
|
});
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
tracing::debug!("font families thread: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn system_font_families() -> Vec<String> {
|
||||||
|
let Ok(output) = std::process::Command::new("fc-list")
|
||||||
|
.args([":", "family"])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
font_families_from_fc_list(&String::from_utf8_lossy(&output.stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elsewhere the picker takes a typed name only. mpv's font lookup there is
|
||||||
|
/// the system's own, so any installed family's name works.
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn system_font_families() -> Vec<String> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One family per line of `fc-list : family`, sorted and without repeats.
|
||||||
|
///
|
||||||
|
/// A line lists a family's names in every language it has, comma-separated,
|
||||||
|
/// and one family appears once per style. The first name is the one libass
|
||||||
|
/// matches on.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn font_families_from_fc_list(listing: &str) -> Vec<String> {
|
||||||
|
let mut families: Vec<String> = listing
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split(',').next())
|
||||||
|
.map(|family| family.trim().to_owned())
|
||||||
|
.filter(|family| !family.is_empty() && !family.starts_with('.'))
|
||||||
|
.collect();
|
||||||
|
families.sort_by_key(|family| family.to_lowercase());
|
||||||
|
families.dedup();
|
||||||
|
families
|
||||||
|
}
|
||||||
|
|
||||||
/// Every colour the app draws with, as egui wants them.
|
/// Every colour the app draws with, as egui wants them.
|
||||||
///
|
///
|
||||||
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
||||||
@@ -563,6 +823,11 @@ pub fn ramp_fill(
|
|||||||
/// scrim that starts opaque and lets the picture out, `true` for one that lets
|
/// scrim that starts opaque and lets the picture out, `true` for one that lets
|
||||||
/// the picture *into* the page — which is the one the title backdrop wants,
|
/// the picture *into* the page — which is the one the title backdrop wants,
|
||||||
/// since the page is below the band.
|
/// since the page is below the band.
|
||||||
|
///
|
||||||
|
/// The ramp itself is white and the colour is the shape's fill, which the GPU
|
||||||
|
/// multiplies in: one texture per direction, however many colours fade —
|
||||||
|
/// a banner tinted per title would otherwise build a ramp for every frame of
|
||||||
|
/// its cross-fade.
|
||||||
pub fn fade_shape(
|
pub fn fade_shape(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
@@ -570,8 +835,29 @@ pub fn fade_shape(
|
|||||||
direction: Direction,
|
direction: Direction,
|
||||||
arriving: bool,
|
arriving: bool,
|
||||||
) -> egui::Shape {
|
) -> egui::Shape {
|
||||||
let texture = alpha_ramp(ctx, color, direction, arriving);
|
let texture = alpha_ramp(ctx, Color32::WHITE, direction, arriving);
|
||||||
textured(rect, CornerRadius::ZERO, &texture)
|
let mut shape = egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, color);
|
||||||
|
shape.brush = Some(Arc::new(egui::epaint::Brush {
|
||||||
|
fill_texture_id: texture.id(),
|
||||||
|
uv: Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
|
}));
|
||||||
|
shape.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page colour tinted by a picture's colour, for the veils over that
|
||||||
|
/// picture: see [`pstr_core::appearance::mood_veil`]. The page colour itself
|
||||||
|
/// while the picture has not arrived.
|
||||||
|
pub fn mood_veil(mood: Option<Color32>) -> Color32 {
|
||||||
|
let palette = palette();
|
||||||
|
let Some(mood) = mood else {
|
||||||
|
return palette.background;
|
||||||
|
};
|
||||||
|
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
|
||||||
|
color(pstr_core::appearance::mood_veil(
|
||||||
|
rgb(palette.background),
|
||||||
|
rgb(mood),
|
||||||
|
palette.light,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one gradient that is not the accent: the top bar, which runs from the
|
/// The one gradient that is not the accent: the top bar, which runs from the
|
||||||
@@ -637,6 +923,43 @@ fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A soft highlight sweeping across a loading skeleton on a diagonal: what
|
||||||
|
/// says the shape is waiting for something rather than empty.
|
||||||
|
///
|
||||||
|
/// The phase runs off the clock rather than per tile, so every skeleton on
|
||||||
|
/// screen sweeps in step, as one surface. Asks for the next frame itself; a
|
||||||
|
/// skeleton is only ever on screen for a moment.
|
||||||
|
pub fn shimmer(painter: &egui::Painter, rect: Rect) {
|
||||||
|
let time = painter.ctx().input(|input| input.time);
|
||||||
|
let phase = (time / f64::from(motion::SHIMMER)).fract() as f32;
|
||||||
|
let span = rect.width() * 0.6;
|
||||||
|
let centre = rect.left() - span + phase * (rect.width() + 2.0 * span);
|
||||||
|
let skew = rect.height() * 0.4;
|
||||||
|
let highlight = if palette().light {
|
||||||
|
Color32::from_black_alpha(10)
|
||||||
|
} else {
|
||||||
|
Color32::from_white_alpha(12)
|
||||||
|
};
|
||||||
|
let mut mesh = egui::Mesh::default();
|
||||||
|
for (x, color) in [
|
||||||
|
(centre - span, Color32::TRANSPARENT),
|
||||||
|
(centre, highlight),
|
||||||
|
(centre + span, Color32::TRANSPARENT),
|
||||||
|
] {
|
||||||
|
mesh.colored_vertex(egui::pos2(x + skew, rect.top()), color);
|
||||||
|
mesh.colored_vertex(egui::pos2(x - skew, rect.bottom()), color);
|
||||||
|
}
|
||||||
|
for quad in 0..2 {
|
||||||
|
let i = quad * 2;
|
||||||
|
mesh.add_triangle(i, i + 1, i + 2);
|
||||||
|
mesh.add_triangle(i + 1, i + 3, i + 2);
|
||||||
|
}
|
||||||
|
painter
|
||||||
|
.with_clip_rect(rect.intersect(painter.clip_rect()))
|
||||||
|
.add(mesh);
|
||||||
|
painter.ctx().request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
/// The hover and press film on its own, for a control whose fill was painted by
|
/// The hover and press film on its own, for a control whose fill was painted by
|
||||||
/// somebody else — the selected tab, which sits on the pill.
|
/// somebody else — the selected tab, which sits on the pill.
|
||||||
pub fn veil_over(
|
pub fn veil_over(
|
||||||
@@ -881,6 +1204,15 @@ const RAMP_STOPS: usize = 64;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_font_family_is_listed_once_by_its_first_name() {
|
||||||
|
let listing = "Noto Sans,Noto Sans Regular\nDejaVu Sans\nnoto sans mono\nNoto Sans,Noto Sans Bold\n\n.LastResort\n";
|
||||||
|
assert_eq!(
|
||||||
|
font_families_from_fc_list(listing),
|
||||||
|
["DejaVu Sans", "Noto Sans", "noto sans mono"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
|
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
|
||||||
assert_eq!(fontconfig_face(262_144), 0);
|
assert_eq!(fontconfig_face(262_144), 0);
|
||||||
@@ -889,17 +1221,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_fallback_font_egui_cannot_read_is_left_out() {
|
fn a_system_font_egui_cannot_read_is_left_out() {
|
||||||
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
|
assert!(system_font_data(INTER.to_vec(), 0).is_some());
|
||||||
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
|
assert!(system_font_data(INTER.to_vec(), 4).is_none());
|
||||||
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
|
assert!(system_font_data(b"not a font".to_vec(), 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_font_is_asked_of_fontconfig_with_its_style_and_escaped() {
|
||||||
|
let request = |family: &str, bold, italic| SubtitleRequest {
|
||||||
|
family: family.to_owned(),
|
||||||
|
bold,
|
||||||
|
italic,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
fontconfig_pattern(&request("Noto Sans", false, false)),
|
||||||
|
"Noto Sans"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fontconfig_pattern(&request("Noto Sans", true, true)),
|
||||||
|
"Noto Sans:weight=200:slant=100"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fontconfig_pattern(&request("A-B:C,D\\E", false, true)),
|
||||||
|
"A\\-B\\:C\\,D\\\\E:slant=100"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_subtitle_preview_family_exists_before_any_font_has_loaded() {
|
||||||
|
let fonts = font_definitions(&SystemFonts::default());
|
||||||
|
assert_eq!(
|
||||||
|
fonts.families[&subtitle_family()],
|
||||||
|
fonts.families[&egui::FontFamily::Proportional]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 _;
|
||||||
|
|
||||||
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
|
for (name, bytes) in [
|
||||||
|
("regular", INTER),
|
||||||
|
("semibold", INTER_SEMIBOLD),
|
||||||
|
("display", INTER_DISPLAY),
|
||||||
|
] {
|
||||||
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
||||||
let shadowed: Vec<u32> = font
|
let shadowed: Vec<u32> = font
|
||||||
.charmap()
|
.charmap()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! The library page: what to watch, as a wall of stills.
|
//! The library page: what to watch, as a wall of stills.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use pstr_core::appearance::Tiles;
|
use pstr_core::appearance::Tiles;
|
||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
|
|
||||||
@@ -36,6 +38,17 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
crawl,
|
crawl,
|
||||||
offer_metadata,
|
offer_metadata,
|
||||||
} = shelves;
|
} = shelves;
|
||||||
|
let searching = !search.trim().is_empty();
|
||||||
|
let featured = if searching {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
featured(library, view, art.metadata, today())
|
||||||
|
};
|
||||||
|
// The panel leaves the top margin to this page, for the banner to start
|
||||||
|
// under the bar.
|
||||||
|
if featured.is_empty() {
|
||||||
|
ui.add_space(ui::PAGE_MARGIN.y);
|
||||||
|
}
|
||||||
if !loaded {
|
if !loaded {
|
||||||
return placeholder(ui);
|
return placeholder(ui);
|
||||||
}
|
}
|
||||||
@@ -50,24 +63,23 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let matches = pick(&view.matches);
|
let matches = pick(&view.matches);
|
||||||
let searching = !search.trim().is_empty();
|
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
if !featured.is_empty() {
|
||||||
|
hero(ui, art, &featured, &view.listed, actions);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
if !searching && offer_metadata {
|
if !searching && offer_metadata {
|
||||||
metadata_offer(ui, actions);
|
metadata_offer(ui, actions);
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
if !searching {
|
if !searching {
|
||||||
if let Some(title) = featured(library, view, art.metadata, today()) {
|
|
||||||
hero(ui, art, title, actions);
|
|
||||||
ui.add_space(theme::space::XL);
|
|
||||||
}
|
|
||||||
let resumable = pick(&view.resumable);
|
let resumable = pick(&view.resumable);
|
||||||
if !resumable.is_empty() {
|
if !resumable.is_empty() {
|
||||||
ui::section(ui, "Continue watching");
|
ui::section(ui, "Continue watching");
|
||||||
continue_row(ui, art, &resumable, actions);
|
continue_row(ui, art, &resumable, view, actions);
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
// Only over the whole library: under a filter, a shelf of
|
// Only over the whole library: under a filter, a shelf of
|
||||||
@@ -76,12 +88,21 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
if view.filter == Filter::All {
|
if view.filter == Filter::All {
|
||||||
for (label, indices) in &view.shelves {
|
for (label, indices) in &view.shelves {
|
||||||
ui::section(ui, label);
|
ui::section(ui, label);
|
||||||
shelf_row(ui, art, label, &pick(indices), tiles, actions);
|
shelf_row(ui, art, label, &pick(indices), view, tiles, actions);
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if searching && !view.found.is_empty() {
|
||||||
|
found(ui, library, &view.found, art, actions);
|
||||||
|
// Found as an episode or a person but not by name: no empty
|
||||||
|
// heading over no titles.
|
||||||
|
if matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui::section(
|
ui::section(
|
||||||
ui,
|
ui,
|
||||||
@@ -111,34 +132,158 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid(ui, art, &matches, view, tiles, actions);
|
grid(ui, art, &matches, view, tiles, true, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Days since the epoch: what the featured title turns over on.
|
/// What a search found besides titles, above the titles it found: the
|
||||||
|
/// people, studios and genres it names as chips, then episodes by name.
|
||||||
|
fn found(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
library: &Library,
|
||||||
|
found: &pstr_core::discover::Found,
|
||||||
|
art: &Art<'_>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let chips = |ui: &mut egui::Ui,
|
||||||
|
heading: &str,
|
||||||
|
facets: &[(pstr_core::discover::Facet, usize)],
|
||||||
|
actions: &mut Vec<Action>| {
|
||||||
|
if facets.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui::section(ui, heading);
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0);
|
||||||
|
for (facet, count) in facets {
|
||||||
|
if ui::widgets::link_chip(ui, &format!("{} · {count}", facet.name()))
|
||||||
|
.on_hover_text(format!("{}: {}", facet.kind(), plural(*count, "title")))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Goto(Page::Browse {
|
||||||
|
facet: facet.clone(),
|
||||||
|
from: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
};
|
||||||
|
chips(ui, "People and studios", &found.people, actions);
|
||||||
|
chips(ui, "Genres", &found.genres, actions);
|
||||||
|
|
||||||
|
if found.episodes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui::section(ui, "Episodes");
|
||||||
|
for hit in &found.episodes {
|
||||||
|
let Some(title) = library.get(&hit.title_key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(episode) = title.episodes().find(|episode| {
|
||||||
|
episode.node.link_id == hit.link_id && episode.node.share_id == hit.share_id
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let text = format!(
|
||||||
|
"{} {} · {} · {}",
|
||||||
|
egui_phosphor::regular::PLAY,
|
||||||
|
art.name(title),
|
||||||
|
hit.label,
|
||||||
|
hit.name
|
||||||
|
);
|
||||||
|
let response = ui
|
||||||
|
.add(egui::Button::selectable(false, text).frame_when_inactive(false))
|
||||||
|
.on_hover_text("Play this episode");
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(Action::Play(PlaybackTarget::from_node(
|
||||||
|
title,
|
||||||
|
&episode.node,
|
||||||
|
None,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a chip on a title page opened.
|
||||||
|
pub struct Browsing<'a> {
|
||||||
|
pub facet: &'a pstr_core::discover::Facet,
|
||||||
|
/// The title it was opened from, which is where back goes.
|
||||||
|
pub from: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every title sharing a genre, a studio or a director, as the library grid.
|
||||||
|
pub fn browse(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
library: &Library,
|
||||||
|
browsing: Browsing<'_>,
|
||||||
|
view: &LibraryView,
|
||||||
|
tiles: Tiles,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let Browsing { facet, from } = browsing;
|
||||||
|
let back = from
|
||||||
|
.and_then(|key| library.get(key))
|
||||||
|
.map_or("Library", |title| art.name(title))
|
||||||
|
.to_owned();
|
||||||
|
if ui::title::back_link(ui, &back).clicked() {
|
||||||
|
actions.push(Action::Back);
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let keys = facet.titles(&library.titles, art.metadata);
|
||||||
|
let titles: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label(ui::muted(facet.kind()));
|
||||||
|
ui.label(theme::Role::Display.rich(facet.name()).color(theme::text()));
|
||||||
|
ui.label(ui::muted(plural(titles.len(), "title")));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
if titles.is_empty() {
|
||||||
|
// A recrawl or a new match took the last one away.
|
||||||
|
ui.label(ui::muted("Nothing in the library has this any more."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid(ui, art, &titles, view, tiles, false, actions);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since the epoch: what the featured titles turn over on.
|
||||||
fn today() -> u64 {
|
fn today() -> u64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map_or(0, |since| since.as_secs() / 86_400)
|
.map_or(0, |since| since.as_secs() / 86_400)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The title the banner shows: whatever was watched last, so the top of the
|
/// How many titles the banner turns through.
|
||||||
/// page is one click from carrying on. With nothing part-watched, one of the
|
const FEATURED: usize = 5;
|
||||||
/// titles a provider has a backdrop for, turning over once a day — the same
|
|
||||||
/// one every time the page is opened today, rather than a new one per frame.
|
/// How long each title holds the banner before the next takes it, in seconds.
|
||||||
|
const HOLD: f64 = 8.0;
|
||||||
|
|
||||||
|
/// How long one title takes to give way to the next. Slower than artwork
|
||||||
|
/// arriving: this is the whole banner changing, and a quick one reads as a
|
||||||
|
/// cut.
|
||||||
|
const TURN: f32 = 0.6;
|
||||||
|
|
||||||
|
/// The titles the banner turns through: whatever was watched last first, so
|
||||||
|
/// the top of the page is one click from carrying on, then titles a provider
|
||||||
|
/// has a backdrop for, starting from a different one each day — the same
|
||||||
|
/// order every time the page is opened today, rather than a new one per frame.
|
||||||
fn featured<'a>(
|
fn featured<'a>(
|
||||||
library: &'a Library,
|
library: &'a Library,
|
||||||
view: &LibraryView,
|
view: &LibraryView,
|
||||||
metadata: &std::collections::HashMap<String, pstr_core::metadata::MetadataRecord>,
|
metadata: &std::collections::HashMap<String, pstr_core::metadata::MetadataRecord>,
|
||||||
day: u64,
|
day: u64,
|
||||||
) -> Option<&'a Title> {
|
) -> Vec<&'a Title> {
|
||||||
if let Some(title) = view
|
let mut picked: Vec<&Title> = view
|
||||||
.resumable
|
.resumable
|
||||||
.first()
|
.first()
|
||||||
.and_then(|&index| library.titles.get(index))
|
.and_then(|&index| library.titles.get(index))
|
||||||
{
|
.into_iter()
|
||||||
return Some(title);
|
.collect();
|
||||||
}
|
|
||||||
let pictured: Vec<&Title> = library
|
let pictured: Vec<&Title> = library
|
||||||
.titles
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
@@ -150,68 +295,136 @@ fn featured<'a>(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if pictured.is_empty() {
|
if pictured.is_empty() {
|
||||||
return None;
|
return picked;
|
||||||
}
|
}
|
||||||
pictured
|
let start = (day % pictured.len() as u64) as usize;
|
||||||
.get((day % pictured.len() as u64) as usize)
|
for title in pictured.iter().cycle().skip(start).take(pictured.len()) {
|
||||||
.copied()
|
if picked.len() == FEATURED {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !picked.iter().any(|already| already.key == title.key) {
|
||||||
|
picked.push(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picked
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The banner across the top of the library: one title, large, with what it
|
/// Which of the featured titles the banner is on, and since when. Kept in
|
||||||
/// is and a way to play it.
|
/// egui's memory: it is the banner's own business, and gone with the window.
|
||||||
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
|
#[derive(Clone, Copy)]
|
||||||
let height = (ui.ctx().content_rect().height() * 0.42).clamp(220.0, 360.0);
|
struct Turn {
|
||||||
let (rect, response) = ui.allocate_exact_size(
|
shown: usize,
|
||||||
|
/// The one it is turning from, while the cross-fade lasts.
|
||||||
|
leaving: Option<usize>,
|
||||||
|
/// `InputState::time` at the last turn.
|
||||||
|
since: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The banner across the top of the library: one title at a time, large, with
|
||||||
|
/// what it is and a way to play it, turning to the next every [`HOLD`]
|
||||||
|
/// seconds unless the pointer is over it.
|
||||||
|
///
|
||||||
|
/// Edge to edge rather than inside the page's margin, and square: a banner
|
||||||
|
/// in the margin with rounded corners read as one more card.
|
||||||
|
fn hero(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
titles: &[&Title],
|
||||||
|
listed: &HashSet<String>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let height = (ui.ctx().content_rect().height() * 0.5).clamp(280.0, 520.0);
|
||||||
|
let (column, _) = ui.allocate_exact_size(
|
||||||
egui::vec2(ui.available_width(), height),
|
egui::vec2(ui.available_width(), height),
|
||||||
egui::Sense::click(),
|
egui::Sense::hover(),
|
||||||
);
|
);
|
||||||
if !ui.is_rect_visible(rect) {
|
let rect = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(column.left() - ui::PAGE_MARGIN.x, column.top()),
|
||||||
|
egui::pos2(column.right() + ui::PAGE_MARGIN.x, column.bottom()),
|
||||||
|
);
|
||||||
|
if titles.is_empty() || !ui.is_rect_visible(rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let radius = egui::CornerRadius::same(theme::radius::LG);
|
let id = ui.id().with("hero");
|
||||||
let picture = art.wide(title);
|
let response = ui.interact(rect, id, egui::Sense::click());
|
||||||
let found = art
|
let now = ui.input(|input| input.time);
|
||||||
.metadata
|
let count = titles.len();
|
||||||
.get(&title.key)
|
let mut turn = ui.data(|data| data.get_temp::<Turn>(id)).unwrap_or(Turn {
|
||||||
.and_then(|record| record.metadata.clone());
|
shown: 0,
|
||||||
|
leaving: None,
|
||||||
|
since: now,
|
||||||
|
});
|
||||||
|
// The list is worked out again every frame, and can be shorter than the
|
||||||
|
// last time — a title finished, a match removed.
|
||||||
|
turn.shown %= count;
|
||||||
|
turn.leaving = turn.leaving.filter(|&index| index < count);
|
||||||
|
|
||||||
|
let hovered = ui.rect_contains_pointer(rect);
|
||||||
|
let elapsed = now - turn.since;
|
||||||
|
if hovered && elapsed > f64::from(TURN) {
|
||||||
|
// Held where it is while it is being looked at, and the count starts
|
||||||
|
// again once the pointer leaves rather than turning the moment it does.
|
||||||
|
turn.since = now - f64::from(TURN);
|
||||||
|
} else if count > 1 && elapsed >= HOLD {
|
||||||
|
turn = Turn {
|
||||||
|
shown: (turn.shown + 1) % count,
|
||||||
|
leaving: Some(turn.shown),
|
||||||
|
since: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let progress = theme::motion::ease_out(((now - turn.since) / f64::from(TURN)) as f32);
|
||||||
|
if progress >= 1.0 {
|
||||||
|
turn.leaving = None;
|
||||||
|
}
|
||||||
|
let title = titles[turn.shown];
|
||||||
|
|
||||||
let painter = ui.painter().with_clip_rect(rect);
|
let painter = ui.painter().with_clip_rect(rect);
|
||||||
painter.rect_filled(rect, radius, theme::card());
|
painter.rect_filled(rect, egui::CornerRadius::ZERO, theme::card());
|
||||||
let art_in = ui.ctx().animate_bool_with_time(
|
// A measure that reads as a column, not a line across a wide window.
|
||||||
response.id.with("art"),
|
let text_right = column.left() + (column.width() * 0.45).clamp(360.0, 720.0);
|
||||||
picture.is_some(),
|
let mut veil = None;
|
||||||
theme::motion::ARRIVE,
|
if let Some(leaving) = turn.leaving {
|
||||||
);
|
veil = Some(backdrop(
|
||||||
if art_in < 1.0 {
|
ui,
|
||||||
ui::placeholder(&painter, rect, "", 1.0 - art_in);
|
&painter,
|
||||||
}
|
art,
|
||||||
if let Some((texture, _)) = &picture {
|
titles[leaving],
|
||||||
painter.add(
|
|
||||||
egui::epaint::RectShape::filled(
|
|
||||||
rect,
|
rect,
|
||||||
radius,
|
text_right,
|
||||||
egui::Color32::WHITE.gamma_multiply(art_in),
|
1.0,
|
||||||
)
|
));
|
||||||
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
let arriving = if turn.leaving.is_some() {
|
||||||
|
progress
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let shown_veil = backdrop(ui, &painter, art, title, rect, text_right, arriving);
|
||||||
|
let veil = veil.map_or(shown_veil, |from| from.lerp_to_gamma(shown_veil, progress));
|
||||||
|
|
||||||
// The same two coats as the title page's band, for the same reason: a
|
// The same two coats as the title page's band, for the same reason: a
|
||||||
// still is arbitrary, and the text over it has to read whatever it is.
|
// still is arbitrary, and the text over it has to read whatever it is.
|
||||||
|
// Tinted with the picture's own colour, so each title's banner has a light
|
||||||
|
// of its own; the fade at the bottom is the page's, because that is what
|
||||||
|
// it hands over to.
|
||||||
if theme::ramps_on() {
|
if theme::ramps_on() {
|
||||||
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.25));
|
painter.rect_filled(rect, egui::CornerRadius::ZERO, veil.gamma_multiply(0.3));
|
||||||
let text_side = egui::Rect::from_min_max(
|
let text_side = egui::Rect::from_min_max(
|
||||||
rect.min,
|
rect.min,
|
||||||
egui::pos2(rect.left() + rect.width() * 0.7, rect.bottom()),
|
egui::pos2(
|
||||||
|
text_right + 0.5 * (rect.right() - text_right),
|
||||||
|
rect.bottom(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
painter.add(theme::fade_shape(
|
painter.add(theme::fade_shape(
|
||||||
ui.ctx(),
|
ui.ctx(),
|
||||||
text_side,
|
text_side,
|
||||||
theme::background(),
|
veil,
|
||||||
theme::Direction::Horizontal,
|
theme::Direction::Horizontal,
|
||||||
false,
|
false,
|
||||||
));
|
));
|
||||||
let bottom =
|
let bottom =
|
||||||
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 90.0), rect.max);
|
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 120.0), rect.max);
|
||||||
painter.add(theme::fade_shape(
|
painter.add(theme::fade_shape(
|
||||||
ui.ctx(),
|
ui.ctx(),
|
||||||
bottom,
|
bottom,
|
||||||
@@ -220,21 +433,140 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
true,
|
true,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.82));
|
painter.rect_filled(rect, egui::CornerRadius::ZERO, veil.gamma_multiply(0.82));
|
||||||
}
|
}
|
||||||
|
|
||||||
let inner = egui::Rect::from_min_max(
|
let inner = egui::Rect::from_min_max(
|
||||||
rect.min + egui::vec2(32.0, 0.0),
|
egui::pos2(column.left(), column.top() + theme::space::XL),
|
||||||
egui::pos2(
|
egui::pos2(text_right, column.bottom() - 40.0),
|
||||||
rect.left() + (rect.width() * 0.55).max(360.0),
|
|
||||||
rect.bottom() - 28.0,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
ui.scope_builder(
|
ui.scope_builder(
|
||||||
egui::UiBuilder::new()
|
egui::UiBuilder::new()
|
||||||
.max_rect(inner)
|
.max_rect(inner)
|
||||||
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|
||||||
|ui| {
|
|ui| {
|
||||||
|
ui.multiply_opacity(arriving);
|
||||||
|
hero_text(ui, art, title, listed.contains(&title.key), actions);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if count > 1
|
||||||
|
&& let Some(picked) = dots(ui, id, column, titles, turn.shown)
|
||||||
|
{
|
||||||
|
if picked != turn.shown {
|
||||||
|
turn = Turn {
|
||||||
|
shown: picked,
|
||||||
|
leaving: Some(turn.shown),
|
||||||
|
since: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if response.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, art.name(title)));
|
||||||
|
ui.data_mut(|data| data.insert_temp(id, turn));
|
||||||
|
|
||||||
|
if turn.leaving.is_some() {
|
||||||
|
ui.ctx().request_repaint();
|
||||||
|
} else if count > 1 && !hovered {
|
||||||
|
let left = (HOLD - (now - turn.since)).max(0.0);
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_secs_f64(left));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One title's picture across the banner, at `alpha`, and the colour its veils
|
||||||
|
/// are to be — the page's until the picture has arrived, then the picture's
|
||||||
|
/// own, eased in with it.
|
||||||
|
fn backdrop(
|
||||||
|
ui: &egui::Ui,
|
||||||
|
painter: &egui::Painter,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
rect: egui::Rect,
|
||||||
|
text_right: f32,
|
||||||
|
alpha: f32,
|
||||||
|
) -> egui::Color32 {
|
||||||
|
let picture = art.wide(title);
|
||||||
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
|
ui.id().with(("hero-art", &title.key)),
|
||||||
|
picture.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
|
if art_in < 1.0 {
|
||||||
|
ui::placeholder(painter, rect, "", alpha * (1.0 - art_in));
|
||||||
|
}
|
||||||
|
if let Some((texture, _)) = &picture {
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::ZERO,
|
||||||
|
egui::Color32::WHITE.gamma_multiply(alpha * art_in),
|
||||||
|
)
|
||||||
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no wide art the banner is the poster's wash, so the poster itself
|
||||||
|
// stands at the right, where wide art keeps its subject — and only where
|
||||||
|
// it clears the text.
|
||||||
|
if !art.has_wide_art(title)
|
||||||
|
&& let Some((poster, pstr_core::metadata::ArtShape::Portrait)) = art.poster(title)
|
||||||
|
{
|
||||||
|
const INSET: f32 = 40.0;
|
||||||
|
let height = rect.height() - 2.0 * INSET - 40.0;
|
||||||
|
let size = egui::vec2(height * 2.0 / 3.0, height);
|
||||||
|
let at = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(rect.right() - 2.0 * INSET - size.x, rect.top() + INSET),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
if at.left() > text_right + INSET {
|
||||||
|
painter.add(
|
||||||
|
theme::tile_shadow(alpha * art_in)
|
||||||
|
.as_shape(at, egui::CornerRadius::same(theme::radius::MD)),
|
||||||
|
);
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(
|
||||||
|
at,
|
||||||
|
egui::CornerRadius::same(theme::radius::MD),
|
||||||
|
egui::Color32::WHITE.gamma_multiply(alpha * art_in),
|
||||||
|
)
|
||||||
|
.with_texture(
|
||||||
|
poster.id(),
|
||||||
|
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
theme::background().lerp_to_gamma(theme::mood_veil(art.mood(title)), art_in)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the banner says about one title, from the bottom up: how far into it
|
||||||
|
/// the viewer is, the buttons, the synopsis, the facts and the name.
|
||||||
|
fn hero_text(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
on_list: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let found = art
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.clone());
|
||||||
|
if let Some(episode) = title.resume()
|
||||||
|
&& let Some(progress) = episode.progress()
|
||||||
|
{
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui::progress_bar(ui, progress as f32, 180.0);
|
||||||
|
if let Some(left) = time_left(episode) {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(ui::muted(left));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(episode) = title.next_up() {
|
if let Some(episode) = title.next_up() {
|
||||||
let label = match (title.resume(), episode.numbering()) {
|
let label = match (title.resume(), episode.numbering()) {
|
||||||
@@ -249,9 +581,12 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
}
|
}
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
if ui.button("More info").clicked() {
|
let more = format!("{} More info", egui_phosphor::regular::INFO);
|
||||||
|
if ui::tonal_button(ui, &more).clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui::list_button(ui, &title.key, on_list, actions);
|
||||||
});
|
});
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
|
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
|
||||||
@@ -274,23 +609,93 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
}
|
}
|
||||||
ui.label(ui::muted(facts.join(" · ")));
|
ui.label(ui::muted(facts.join(" · ")));
|
||||||
ui.add_space(theme::space::XXS);
|
ui.add_space(theme::space::XXS);
|
||||||
ui.add(
|
if ui::logo_heading(ui, art, title, 120.0).is_none() {
|
||||||
egui::Label::new(
|
// Two lines before it gives up: a long romanised name cut to one at
|
||||||
theme::Role::Display
|
// this size kept barely more than its first word.
|
||||||
.rich(art.name(title))
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
.strong()
|
art.name(title).to_owned(),
|
||||||
.color(theme::text()),
|
theme::Role::Hero.font(),
|
||||||
)
|
theme::text(),
|
||||||
.truncate(),
|
ui.available_width().min(640.0),
|
||||||
);
|
);
|
||||||
},
|
job.wrap.max_rows = 2;
|
||||||
);
|
job.wrap.overflow_character = Some('…');
|
||||||
|
ui.label(job);
|
||||||
if response.clicked() {
|
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "23 min left", for an episode part-way through. `None` when the file's
|
||||||
|
/// length is not known.
|
||||||
|
fn time_left(episode: &pstr_core::library::Episode) -> Option<String> {
|
||||||
|
let watch = episode.watch?;
|
||||||
|
let left = (watch.duration_secs? - watch.position_secs).max(0.0);
|
||||||
|
let minutes = (left / 60.0).ceil() as u64;
|
||||||
|
Some(if minutes >= 60 {
|
||||||
|
format!("{}h {}m left", minutes / 60, minutes % 60)
|
||||||
|
} else {
|
||||||
|
format!("{minutes} min left")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One dot per featured title at the banner's lower right, the one shown
|
||||||
|
/// drawn long. Returns the one clicked.
|
||||||
|
fn dots(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
id: egui::Id,
|
||||||
|
column: egui::Rect,
|
||||||
|
titles: &[&Title],
|
||||||
|
shown: usize,
|
||||||
|
) -> Option<usize> {
|
||||||
|
const DOT: f32 = 8.0;
|
||||||
|
const LONG: f32 = 24.0;
|
||||||
|
const GAP: f32 = 8.0;
|
||||||
|
let mut picked = None;
|
||||||
|
// Clear of the scroll bar, which floats over the column's right edge.
|
||||||
|
let mut right = column.right() - theme::space::L;
|
||||||
|
let centre = column.bottom() - 58.0;
|
||||||
|
// Right to left, so the row ends at the column's edge whatever its width.
|
||||||
|
for (index, title) in titles.iter().enumerate().rev() {
|
||||||
|
let width = ui.ctx().animate_value_with_time(
|
||||||
|
id.with(("dot", index)),
|
||||||
|
if index == shown { LONG } else { DOT },
|
||||||
|
theme::motion::STATE,
|
||||||
|
);
|
||||||
|
let dot = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(right - width, centre - DOT / 2.0),
|
||||||
|
egui::pos2(right, centre + DOT / 2.0),
|
||||||
|
);
|
||||||
|
right -= width + GAP;
|
||||||
|
let response = ui
|
||||||
|
.interact(
|
||||||
|
dot.expand2(egui::vec2(GAP / 2.0, 8.0)),
|
||||||
|
id.with(("dot-hit", index)),
|
||||||
|
egui::Sense::click(),
|
||||||
|
)
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||||
|
response.widget_info(|| {
|
||||||
|
egui::WidgetInfo::selected(
|
||||||
|
egui::WidgetType::RadioButton,
|
||||||
|
true,
|
||||||
|
index == shown,
|
||||||
|
&title.name,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let ink = if index == shown {
|
||||||
|
theme::text()
|
||||||
|
} else if response.hovered() {
|
||||||
|
theme::text().gamma_multiply(0.7)
|
||||||
|
} else {
|
||||||
|
theme::text().gamma_multiply(0.35)
|
||||||
|
};
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(dot, egui::CornerRadius::same(4), ink);
|
||||||
|
if response.clicked() {
|
||||||
|
picked = Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picked
|
||||||
|
}
|
||||||
|
|
||||||
/// The filter pills, the order and the grouping, at the right of the grid's
|
/// The filter pills, the order and the grouping, at the right of the grid's
|
||||||
/// heading.
|
/// heading.
|
||||||
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
|
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
|
||||||
@@ -348,14 +753,13 @@ fn shelf_row(
|
|||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
label: &str,
|
label: &str,
|
||||||
titles: &[&Title],
|
titles: &[&Title],
|
||||||
|
view: &LibraryView,
|
||||||
tiles: Tiles,
|
tiles: Tiles,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let (width, aspect) = tile_shape(tiles);
|
let (width, aspect) = tile_shape(tiles);
|
||||||
egui::ScrollArea::horizontal()
|
let now = crate::engine::unix_now();
|
||||||
.id_salt(("shelf", label))
|
ui::shelf(ui, ("shelf", label), |ui| {
|
||||||
.auto_shrink([false, true])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
for title in titles {
|
for title in titles {
|
||||||
@@ -370,12 +774,16 @@ fn shelf_row(
|
|||||||
name: art.name(title),
|
name: art.name(title),
|
||||||
subtitle: subtitle(title),
|
subtitle: subtitle(title),
|
||||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
badge: None,
|
badge: view
|
||||||
|
.badges
|
||||||
|
.get(&title.key)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| countdown(art, title, now)),
|
||||||
width,
|
width,
|
||||||
aspect,
|
aspect,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
tile_menu(&response, title, false, actions);
|
tile_menu(&response, title, false, view, actions);
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
@@ -384,6 +792,19 @@ fn shelf_row(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Ep 8 · in 2 d` on a show whose next episode is still to air.
|
||||||
|
fn countdown(art: &Art<'_>, title: &Title, now: i64) -> Option<String> {
|
||||||
|
let (episode, at) = art
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)?
|
||||||
|
.metadata
|
||||||
|
.as_ref()?
|
||||||
|
.details
|
||||||
|
.as_ref()?
|
||||||
|
.next_airing(now)?;
|
||||||
|
Some(pstr_core::news::countdown(episode, at, now))
|
||||||
|
}
|
||||||
|
|
||||||
/// The minimum width and the picture's shape of a tile.
|
/// The minimum width and the picture's shape of a tile.
|
||||||
fn tile_shape(tiles: Tiles) -> (f32, f32) {
|
fn tile_shape(tiles: Tiles) -> (f32, f32) {
|
||||||
match tiles {
|
match tiles {
|
||||||
@@ -397,12 +818,10 @@ fn continue_row(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
titles: &[&Title],
|
titles: &[&Title],
|
||||||
|
view: &LibraryView,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
egui::ScrollArea::horizontal()
|
ui::shelf(ui, "continue", |ui| {
|
||||||
.id_salt("continue")
|
|
||||||
.auto_shrink([false, true])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
for title in titles {
|
for title in titles {
|
||||||
let Some(episode) = title.continue_with() else {
|
let Some(episode) = title.continue_with() else {
|
||||||
@@ -433,7 +852,7 @@ fn continue_row(
|
|||||||
aspect: theme::CARD_ASPECT,
|
aspect: theme::CARD_ASPECT,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
tile_menu(&response, title, true, actions);
|
tile_menu(&response, title, true, view, actions);
|
||||||
|
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
@@ -444,12 +863,15 @@ fn continue_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every title, wrapped to the window.
|
/// Every title, wrapped to the window.
|
||||||
|
/// `titles` as a wall of tiles. `folding` says they are the library grid's
|
||||||
|
/// own, whose folded franchises `view` counts.
|
||||||
fn grid(
|
fn grid(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
titles: &[&Title],
|
titles: &[&Title],
|
||||||
view: &LibraryView,
|
view: &LibraryView,
|
||||||
tiles: Tiles,
|
tiles: Tiles,
|
||||||
|
folding: bool,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let (minimum, aspect) = tile_shape(tiles);
|
let (minimum, aspect) = tile_shape(tiles);
|
||||||
@@ -484,6 +906,7 @@ fn grid(
|
|||||||
let folded = view
|
let folded = view
|
||||||
.matches
|
.matches
|
||||||
.get(row * grid.columns + column)
|
.get(row * grid.columns + column)
|
||||||
|
.filter(|_| folding)
|
||||||
.and_then(|index| view.folded.get(index))
|
.and_then(|index| view.folded.get(index))
|
||||||
.copied();
|
.copied();
|
||||||
let response = ui::card(
|
let response = ui::card(
|
||||||
@@ -498,13 +921,18 @@ fn grid(
|
|||||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
// What a film is says itself in the subtitle below the
|
// What a film is says itself in the subtitle below the
|
||||||
// card; a second `Film` over the poster is noise. A
|
// card; a second `Film` over the poster is noise. A
|
||||||
// folded franchise says how much is under it.
|
// folded franchise says how much is under it, unless
|
||||||
badge: folded.map(|more| format!("+{more}")),
|
// something new arrived in it.
|
||||||
|
badge: view
|
||||||
|
.badges
|
||||||
|
.get(&title.key)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| folded.map(|more| format!("+{more}"))),
|
||||||
width: grid.width,
|
width: grid.width,
|
||||||
aspect,
|
aspect,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
tile_menu(&response, title, false, actions);
|
tile_menu(&response, title, false, view, actions);
|
||||||
|
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
@@ -523,6 +951,7 @@ fn tile_menu(
|
|||||||
response: &egui::Response,
|
response: &egui::Response,
|
||||||
title: &Title,
|
title: &Title,
|
||||||
continuing: bool,
|
continuing: bool,
|
||||||
|
view: &LibraryView,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
response.context_menu(|ui| {
|
response.context_menu(|ui| {
|
||||||
@@ -539,6 +968,20 @@ fn tile_menu(
|
|||||||
if ui.button("Open").clicked() {
|
if ui.button("Open").clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
|
let on_list = view.listed.contains(&title.key);
|
||||||
|
if ui
|
||||||
|
.button(if on_list {
|
||||||
|
"Remove from My List"
|
||||||
|
} else {
|
||||||
|
"Add to My List"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetOnList {
|
||||||
|
key: title.key.clone(),
|
||||||
|
on: !on_list,
|
||||||
|
});
|
||||||
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
let all_watched = title.watched_count() == title.episode_count();
|
let all_watched = title.watched_count() == title.episode_count();
|
||||||
@@ -639,6 +1082,7 @@ fn placeholder(ui: &mut egui::Ui) {
|
|||||||
egui::CornerRadius::same(theme::radius::MD),
|
egui::CornerRadius::same(theme::radius::MD),
|
||||||
theme::card(),
|
theme::card(),
|
||||||
);
|
);
|
||||||
|
theme::shimmer(ui.painter(), image);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(theme::CARD_GAP);
|
ui.add_space(theme::CARD_GAP);
|
||||||
@@ -756,12 +1200,67 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let metadata = std::collections::HashMap::new();
|
let metadata = std::collections::HashMap::new();
|
||||||
let mut view = LibraryView::default();
|
let mut view = LibraryView::default();
|
||||||
assert!(featured(&library, &view, &metadata, 0).is_none());
|
assert!(featured(&library, &view, &metadata, 0).is_empty());
|
||||||
view.resumable = vec![1];
|
view.resumable = vec![1];
|
||||||
assert_eq!(
|
let keys: Vec<&str> = featured(&library, &view, &metadata, 0)
|
||||||
featured(&library, &view, &metadata, 0).map(|title| title.key.as_str()),
|
.iter()
|
||||||
Some("second")
|
.map(|title| title.key.as_str())
|
||||||
);
|
.collect();
|
||||||
|
assert_eq!(keys, ["second"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pictured(key: &str) -> pstr_core::metadata::MetadataRecord {
|
||||||
|
use pstr_core::metadata::{MetadataRecord, ProviderId, TitleMetadata};
|
||||||
|
MetadataRecord {
|
||||||
|
title_key: key.into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: key.into(),
|
||||||
|
name: key.into(),
|
||||||
|
original_name: None,
|
||||||
|
overview: None,
|
||||||
|
year: None,
|
||||||
|
kind: TitleKind::Film,
|
||||||
|
poster_url: None,
|
||||||
|
backdrop_url: Some(format!("https://example.com/{key}.jpg")),
|
||||||
|
logo_url: None,
|
||||||
|
rating: None,
|
||||||
|
genres: Vec::new(),
|
||||||
|
episodes: None,
|
||||||
|
url: None,
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
fetched_at: 0,
|
||||||
|
manual: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_banner_turns_through_five_pictured_titles_after_the_one_being_watched() {
|
||||||
|
let titles: Vec<Title> = (0..8)
|
||||||
|
.map(|index| {
|
||||||
|
let mut one = title(TitleKind::Film, None);
|
||||||
|
one.key = format!("t{index}");
|
||||||
|
one
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let library = Library { titles };
|
||||||
|
let metadata: std::collections::HashMap<_, _> = (0..8)
|
||||||
|
.map(|index| (format!("t{index}"), pictured(&format!("t{index}"))))
|
||||||
|
.collect();
|
||||||
|
let mut view = LibraryView::default();
|
||||||
|
view.resumable = vec![5];
|
||||||
|
let keys = |day| -> Vec<String> {
|
||||||
|
featured(&library, &view, &metadata, day)
|
||||||
|
.iter()
|
||||||
|
.map(|title| title.key.clone())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!(keys(2), ["t5", "t2", "t3", "t4", "t6"]);
|
||||||
|
// The same order all day, and a different start the next.
|
||||||
|
assert_eq!(keys(2), keys(2));
|
||||||
|
assert_eq!(keys(3)[1], "t3");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ pub fn show(
|
|||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
ui.label(ui::muted(format!(
|
ui.label(ui::muted(format!(
|
||||||
"Everything {} answers with, in its own order — nothing is scored here.",
|
"Everything {} answer with, in their own order — nothing is scored here.",
|
||||||
provider.label()
|
pstr_meta::service::chain_label(provider)
|
||||||
)));
|
)));
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
@@ -279,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
|
|||||||
.texture(&key, || engine.request_poster(key.clone(), url))
|
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
|
/// `Film · 2017 · 26 episodes · ★ 8.2 · AniList`, with whatever the provider
|
||||||
/// simply absent.
|
/// left out simply absent. The provider last, because one search asks more
|
||||||
|
/// than one, and the same show can come back from each.
|
||||||
fn facts(found: &TitleMetadata) -> String {
|
fn facts(found: &TitleMetadata) -> String {
|
||||||
let mut parts: Vec<String> = vec![
|
let mut parts: Vec<String> = vec![
|
||||||
match found.kind {
|
match found.kind {
|
||||||
@@ -298,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String {
|
|||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
|
parts.push(found.provider.label().to_string());
|
||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
|
|||||||
use pstr_core::library::{Episode, Title};
|
use pstr_core::library::{Episode, Title};
|
||||||
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
|
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
|
||||||
|
|
||||||
|
use crate::app::Action;
|
||||||
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
|
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use widgets::Named;
|
use widgets::Named;
|
||||||
@@ -80,6 +81,17 @@ impl Art<'_> {
|
|||||||
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
|
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A provider's picture that belongs to no one title's tile — a cast
|
||||||
|
/// member's portrait — fetched like a poster the first time it is asked
|
||||||
|
/// for.
|
||||||
|
pub fn picture(&mut self, url: &str) -> Option<egui::TextureHandle> {
|
||||||
|
let key = format!("picture:{url}");
|
||||||
|
let engine = self.engine;
|
||||||
|
let url = url.to_owned();
|
||||||
|
self.posters
|
||||||
|
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||||
|
}
|
||||||
|
|
||||||
/// The name to show for `title`. See [`title_name`].
|
/// The name to show for `title`. See [`title_name`].
|
||||||
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
|
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
|
||||||
title_name(title, self.metadata, self.names)
|
title_name(title, self.metadata, self.names)
|
||||||
@@ -107,7 +119,9 @@ impl Art<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Art for a strip across the whole window, at full size: fanart, else
|
/// Art for a strip across the whole window, at full size: fanart, else
|
||||||
/// AniList's banner, else whatever [`Art::of`] has.
|
/// AniList's banner, else the poster blurred into a wash of its colours —
|
||||||
|
/// see [`crate::engine::Engine::request_ambient`] — else whatever
|
||||||
|
/// [`Art::of`] has.
|
||||||
///
|
///
|
||||||
/// Its own texture rather than the tile's: a 640 px tile picture
|
/// Its own texture rather than the tile's: a 640 px tile picture
|
||||||
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
|
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
|
||||||
@@ -128,10 +142,61 @@ impl Art<'_> {
|
|||||||
{
|
{
|
||||||
return Some((texture, ArtShape::Landscape));
|
return Some((texture, ArtShape::Landscape));
|
||||||
}
|
}
|
||||||
|
} else if let Some(url) = self.poster_url(title) {
|
||||||
|
let key = ambient_key(&title.key);
|
||||||
|
let engine = self.engine;
|
||||||
|
if let Some(texture) = self
|
||||||
|
.posters
|
||||||
|
.texture(&key, || engine.request_ambient(key.clone(), url))
|
||||||
|
{
|
||||||
|
return Some((texture, ArtShape::Landscape));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.of(title)
|
self.of(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The colour of whatever [`Art::wide`] is drawing for `title`, once it
|
||||||
|
/// has arrived — what that title's veils are tinted with. See
|
||||||
|
/// [`theme::mood_veil`].
|
||||||
|
pub fn mood(&self, title: &Title) -> Option<egui::Color32> {
|
||||||
|
[wide_key, ambient_key, poster_key]
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| key(&title.key))
|
||||||
|
// The tile's own picture last: what `wide` falls back on.
|
||||||
|
.chain(std::iter::once(title.key.clone()))
|
||||||
|
.find_map(|key| self.posters.mood(&key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `title` has a picture made for a strip across the window, rather
|
||||||
|
/// than [`Art::wide`] standing one in.
|
||||||
|
pub fn has_wide_art(&self, title: &Title) -> bool {
|
||||||
|
self.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.is_some_and(|metadata| metadata.wide_art().is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poster_url(&self, title: &Title) -> Option<String> {
|
||||||
|
self.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|metadata| metadata.poster_url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title's name as lettering, trimmed to it, when the provider has
|
||||||
|
/// one and it has arrived. Drawn over wide art in place of the name.
|
||||||
|
pub fn logo(&mut self, title: &Title) -> Option<egui::TextureHandle> {
|
||||||
|
let url = self
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|metadata| metadata.logo_url.clone())?;
|
||||||
|
let key = logo_key(&title.key);
|
||||||
|
let engine = self.engine;
|
||||||
|
self.posters
|
||||||
|
.texture(&key, || engine.request_logo(key.clone(), url))
|
||||||
|
}
|
||||||
|
|
||||||
/// The picture for a title, and how to fit it.
|
/// The picture for a title, and how to fit it.
|
||||||
///
|
///
|
||||||
/// Provider artwork first, then Proton's still, then nothing. That order is
|
/// Provider artwork first, then Proton's still, then nothing. That order is
|
||||||
@@ -162,6 +227,10 @@ impl Art<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The central panel's margin around every page. Anything drawn edge to edge —
|
||||||
|
/// the library's banner, the title page's band — reaches past it.
|
||||||
|
pub const PAGE_MARGIN: egui::Vec2 = egui::vec2(18.0, 12.0);
|
||||||
|
|
||||||
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
|
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
|
||||||
/// title key itself.
|
/// title key itself.
|
||||||
pub fn poster_key(title_key: &str) -> String {
|
pub fn poster_key(title_key: &str) -> String {
|
||||||
@@ -173,6 +242,61 @@ pub fn wide_key(title_key: &str) -> String {
|
|||||||
format!("wide:{title_key}")
|
format!("wide:{title_key}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The texture key of a title's poster blurred for a band.
|
||||||
|
pub fn ambient_key(title_key: &str) -> String {
|
||||||
|
format!("ambient:{title_key}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A title's logo in place of its name in Display type, when there is one to
|
||||||
|
/// draw; `None` leaves the caller to set the name as it does.
|
||||||
|
///
|
||||||
|
/// At most `max_height` tall and as wide as the space allows, up to 420 px —
|
||||||
|
/// a logo the width of the column shouts. A light palette keeps the name: a
|
||||||
|
/// clear logo is lettering made for dark art, white as often as not, and over
|
||||||
|
/// a near-white veil it is gone.
|
||||||
|
pub fn logo_heading(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
max_height: f32,
|
||||||
|
) -> Option<egui::Response> {
|
||||||
|
if theme::palette().light {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let texture = art.logo(title);
|
||||||
|
// From nothing rather than from the name it replaces: the name is gone the
|
||||||
|
// frame the logo is ready, and a logo easing in reads as it arriving.
|
||||||
|
let shown = ui.ctx().animate_bool_with_time(
|
||||||
|
ui.id().with(("logo", &title.key)),
|
||||||
|
texture.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
|
let texture = texture?;
|
||||||
|
let aspect = texture.aspect_ratio().max(0.1);
|
||||||
|
let width = ui.available_width().min(420.0).min(max_height * aspect);
|
||||||
|
let size = egui::vec2(width, width / aspect);
|
||||||
|
Some(
|
||||||
|
ui.add(
|
||||||
|
egui::Image::new((texture.id(), size)).tint(egui::Color32::WHITE.gamma_multiply(shown)),
|
||||||
|
)
|
||||||
|
.on_hover_text(art.name(title)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The texture key of a title's logo.
|
||||||
|
pub fn logo_key(title_key: &str) -> String {
|
||||||
|
format!("logo:{title_key}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
|
||||||
|
pub fn format_runtime(minutes: u32) -> String {
|
||||||
|
match (minutes / 60, minutes % 60) {
|
||||||
|
(0, minutes) => format!("{minutes} min"),
|
||||||
|
(hours, 0) => format!("{hours} h"),
|
||||||
|
(hours, minutes) => format!("{hours} h {minutes} min"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
|
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
|
||||||
/// the Unix time `at` was.
|
/// the Unix time `at` was.
|
||||||
pub fn ago(at: i64, now: i64) -> String {
|
pub fn ago(at: i64, now: i64) -> String {
|
||||||
@@ -206,6 +330,18 @@ pub fn format_speed(speed: f64) -> String {
|
|||||||
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
|
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `+0.3 s`, `−1.2 s` or `0 s`: a subtitle delay, signed so the direction
|
||||||
|
/// reads without knowing which way mpv counts.
|
||||||
|
pub fn format_delay(seconds: f64) -> String {
|
||||||
|
if seconds.abs() < 0.05 {
|
||||||
|
"0 s".to_owned()
|
||||||
|
} else if seconds > 0.0 {
|
||||||
|
format!("+{seconds:.1} s")
|
||||||
|
} else {
|
||||||
|
format!("−{:.1} s", -seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `1.4 GiB`, for what a file costs to watch.
|
/// `1.4 GiB`, for what a file costs to watch.
|
||||||
pub fn format_size(bytes: i64) -> String {
|
pub fn format_size(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||||
@@ -244,11 +380,53 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
|||||||
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The button beside the accent one: the same shape, in a film of the text
|
||||||
|
/// colour, so "Start over" next to "Resume" reads as the alternative rather
|
||||||
|
/// than as a second, unrelated kind of control.
|
||||||
|
pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// My List's button: `+ My List` to put the title on it, `✓ My List` once it
|
||||||
|
/// is, which takes it off again.
|
||||||
|
pub fn list_button(ui: &mut egui::Ui, key: &str, on_list: bool, actions: &mut Vec<Action>) {
|
||||||
|
let label = if on_list {
|
||||||
|
format!("{} My List", egui_phosphor::regular::CHECK)
|
||||||
|
} else {
|
||||||
|
format!("{} My List", egui_phosphor::regular::PLUS)
|
||||||
|
};
|
||||||
|
let response = tonal_button(ui, &label).on_hover_text(if on_list {
|
||||||
|
"On My List. Click to take it off"
|
||||||
|
} else {
|
||||||
|
"Put it on My List, to find it again on every device"
|
||||||
|
});
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(Action::SetOnList {
|
||||||
|
key: key.to_owned(),
|
||||||
|
on: !on_list,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A button laid over the picture: dark glass and white ink, whatever the
|
||||||
|
/// theme, because what is under it is a video frame and not the page.
|
||||||
|
pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
filled(ui, text, Fill::Glass, Vec2::new(0.0, 0.0))
|
||||||
|
}
|
||||||
|
|
||||||
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
|
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
|
||||||
///
|
///
|
||||||
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
|
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
|
||||||
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
|
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
|
||||||
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
///
|
||||||
|
/// Empty and with the caret in it, it offers the `recent` searches under it.
|
||||||
|
/// Picking one fills the box; the return says the viewer asked to forget them.
|
||||||
|
pub fn search_field(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
search: &mut String,
|
||||||
|
focus: bool,
|
||||||
|
recent: &[String],
|
||||||
|
) -> bool {
|
||||||
let id = egui::Id::new("library-search");
|
let id = egui::Id::new("library-search");
|
||||||
let width = 240.0;
|
let width = 240.0;
|
||||||
let height = ui.spacing().interact_size.y;
|
let height = ui.spacing().interact_size.y;
|
||||||
@@ -287,7 +465,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
|||||||
.id(id)
|
.id(id)
|
||||||
.frame(egui::Frame::NONE)
|
.frame(egui::Frame::NONE)
|
||||||
.hint_text(if has_focus {
|
.hint_text(if has_focus {
|
||||||
"Titles and filenames"
|
"Titles, episodes, people"
|
||||||
} else {
|
} else {
|
||||||
"Search Ctrl+F"
|
"Search Ctrl+F"
|
||||||
})
|
})
|
||||||
@@ -331,6 +509,56 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
|||||||
search.clear();
|
search.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept up while the pointer is over it as well as while the box has the
|
||||||
|
// caret: pressing on it takes the caret away before the click lands.
|
||||||
|
let popup = id.with("recent");
|
||||||
|
let over = ui
|
||||||
|
.data(|data| data.get_temp::<bool>(popup))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let mut forget = false;
|
||||||
|
if search.is_empty() && !recent.is_empty() && (response.has_focus() || over) {
|
||||||
|
let mut picked = None;
|
||||||
|
let shown = egui::Area::new(popup)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.fixed_pos(outer.left_bottom() + Vec2::new(0.0, 6.0))
|
||||||
|
.show(ui.ctx(), |ui| {
|
||||||
|
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||||
|
ui.set_width(width - 12.0);
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Caption
|
||||||
|
.rich("Recent searches")
|
||||||
|
.color(theme::muted()),
|
||||||
|
);
|
||||||
|
for query in recent {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::selectable(false, query.as_str())
|
||||||
|
.frame_when_inactive(false),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
picked = Some(query.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
forget = ui
|
||||||
|
.add(
|
||||||
|
egui::Button::selectable(false, muted("Clear recent searches"))
|
||||||
|
.frame_when_inactive(false),
|
||||||
|
)
|
||||||
|
.clicked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let over = shown.response.contains_pointer();
|
||||||
|
ui.data_mut(|data| data.insert_temp(popup, over && !forget && picked.is_none()));
|
||||||
|
if let Some(query) = picked {
|
||||||
|
*search = query;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.data_mut(|data| data.remove::<bool>(popup));
|
||||||
|
}
|
||||||
|
forget
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A thin bar in the accent, for how far something has got.
|
/// A thin bar in the accent, for how far something has got.
|
||||||
@@ -450,6 +678,10 @@ enum Fill {
|
|||||||
Ink,
|
Ink,
|
||||||
/// Nothing at rest, the hover colour under the pointer.
|
/// Nothing at rest, the hover colour under the pointer.
|
||||||
None,
|
None,
|
||||||
|
/// A film of the text colour over whatever is behind it.
|
||||||
|
Tonal,
|
||||||
|
/// Dark glass over the picture.
|
||||||
|
Glass,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A row of tabs with one accent pill that slides between them.
|
/// A row of tabs with one accent pill that slides between them.
|
||||||
@@ -570,6 +802,35 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
|||||||
}
|
}
|
||||||
theme::muted().lerp_to_gamma(theme::text(), hover)
|
theme::muted().lerp_to_gamma(theme::text(), hover)
|
||||||
}
|
}
|
||||||
|
Fill::Tonal => {
|
||||||
|
let film = if theme::palette().light {
|
||||||
|
Color32::from_black_alpha(16)
|
||||||
|
} else {
|
||||||
|
Color32::from_white_alpha(22)
|
||||||
|
};
|
||||||
|
ui.painter().rect_filled(rect, radius, film);
|
||||||
|
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||||
|
theme::text()
|
||||||
|
}
|
||||||
|
Fill::Glass => {
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(rect, radius, Color32::from_black_alpha(140));
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
Stroke::new(1.0, Color32::from_white_alpha(18)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||||
|
Color32::WHITE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A disabled control is drawn at part strength: the ink is what says what
|
||||||
|
// the button is, so that is what fades.
|
||||||
|
let ink = if ui.is_enabled() {
|
||||||
|
ink
|
||||||
|
} else {
|
||||||
|
ink.gamma_multiply(0.4)
|
||||||
};
|
};
|
||||||
|
|
||||||
let position = rect.center() - galley.size() / 2.0;
|
let position = rect.center() - galley.size() / 2.0;
|
||||||
@@ -642,18 +903,27 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
let resting = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
||||||
// Hover is a value, not a flag: the border, the lift and the shadow all
|
// Hover is a value, not a flag: the border, the lift and the shadow all
|
||||||
// ride it, so they arrive together rather than each snapping on its own.
|
// ride it, so they arrive together rather than each snapping on its own.
|
||||||
// egui drives this from wall-clock time, so it is the same speed whatever
|
// egui drives this from wall-clock time, so it is the same speed whatever
|
||||||
// the frame rate — and it needs no state kept here.
|
// the frame rate — and it needs no state kept here.
|
||||||
// Focus lights a tile the way the pointer does, so the keyboard can be
|
// Focus lifts a tile the way the pointer does, and adds a ring of its own,
|
||||||
// followed across the grid.
|
// so the keyboard can be followed across the grid.
|
||||||
let hover = ui.ctx().animate_bool_with_time(
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
response.id.with("hover"),
|
response.id.with("hover"),
|
||||||
response.hovered() || response.has_focus(),
|
response.hovered() || response.has_focus(),
|
||||||
theme::motion::HOVER,
|
theme::motion::HOVER,
|
||||||
);
|
);
|
||||||
|
let focus = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("focus"),
|
||||||
|
response.has_focus(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
// The picture grows towards the viewer into the gap around it, which is
|
||||||
|
// what says "this one" across a shelf of near-identical rectangles. The
|
||||||
|
// name under it stays put, so the row's text never wobbles.
|
||||||
|
let image_rect = resting.expand2(resting.size() * lift_per_side(width) * hover);
|
||||||
// The picture arrives whenever its download finishes, several frames after
|
// The picture arrives whenever its download finishes, several frames after
|
||||||
// the tile first drew. Fading it in over the placeholder turns a wall of
|
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||||
// letters popping into stills into one settle.
|
// letters popping into stills into one settle.
|
||||||
@@ -769,9 +1039,17 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if focus > 0.0 {
|
||||||
|
painter.rect_stroke(
|
||||||
|
image_rect.expand(3.0),
|
||||||
|
CornerRadius::same(theme::radius::MD + 3),
|
||||||
|
Stroke::new(2.0, theme::text().gamma_multiply(focus)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let text_rect = Rect::from_min_size(
|
let text_rect = Rect::from_min_size(
|
||||||
image_rect.left_bottom() + Vec2::new(0.0, 6.0),
|
resting.left_bottom() + Vec2::new(0.0, 6.0),
|
||||||
Vec2::new(width, text_height - 6.0),
|
Vec2::new(width, text_height - 6.0),
|
||||||
);
|
);
|
||||||
let painter = painter.with_clip_rect(text_rect);
|
let painter = painter.with_clip_rect(text_rect);
|
||||||
@@ -802,6 +1080,146 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every sideways row in the app: the shelves on the home page and the
|
||||||
|
/// franchise on a title's page.
|
||||||
|
///
|
||||||
|
/// What runs past either edge fades into the page rather than being cut off
|
||||||
|
/// by it, so a row says there is more without a scroll bar saying it. Under
|
||||||
|
/// the pointer, a round button at each edge with more beyond it pages the row
|
||||||
|
/// by most of its width: a mouse has no sideways wheel, and the bar was the
|
||||||
|
/// only other way across.
|
||||||
|
///
|
||||||
|
/// The row is padded by half a gap all round inside its scroll area, and the
|
||||||
|
/// area reaches the same half gap past the column on either side, so a lifted
|
||||||
|
/// tile is not clipped by the area and the tiles still line up with the
|
||||||
|
/// headings above them.
|
||||||
|
pub fn shelf<R>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
id_salt: impl std::hash::Hash + std::fmt::Debug,
|
||||||
|
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
|
) -> R {
|
||||||
|
let half = (theme::CARD_GAP / 2.0) as i8;
|
||||||
|
let id = ui.id().with(("shelf", &id_salt));
|
||||||
|
let page = id.with("page");
|
||||||
|
let pending: Option<f32> = ui.ctx().data_mut(|data| data.remove_temp(page));
|
||||||
|
let output = egui::Frame::new()
|
||||||
|
.outer_margin(egui::Margin {
|
||||||
|
left: -half,
|
||||||
|
right: -half,
|
||||||
|
top: -half,
|
||||||
|
bottom: -half,
|
||||||
|
})
|
||||||
|
.show(ui, |ui| {
|
||||||
|
egui::ScrollArea::horizontal()
|
||||||
|
.id_salt(id)
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(delta) = pending {
|
||||||
|
ui.scroll_with_delta_animation(
|
||||||
|
Vec2::new(delta, 0.0),
|
||||||
|
egui::style::ScrollAnimation::duration(SHELF_PAGE_SECONDS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
egui::Frame::new()
|
||||||
|
.inner_margin(egui::Margin::same(half))
|
||||||
|
.show(ui, add)
|
||||||
|
.inner
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
|
||||||
|
let rect = output.inner_rect;
|
||||||
|
let offset = output.state.offset.x;
|
||||||
|
let overflow = (output.content_size.x - rect.width()).max(0.0);
|
||||||
|
let ends = [
|
||||||
|
(offset > 1.0, false, egui_phosphor::regular::CARET_LEFT, 1.0),
|
||||||
|
(
|
||||||
|
offset < overflow - 1.0,
|
||||||
|
true,
|
||||||
|
egui_phosphor::regular::CARET_RIGHT,
|
||||||
|
-1.0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let pointer_in = ui.rect_contains_pointer(rect);
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
for (more, right, icon, direction) in ends {
|
||||||
|
if !more {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let strip = if right {
|
||||||
|
Rect::from_min_max(egui::pos2(rect.right() - SHELF_FADE, rect.top()), rect.max)
|
||||||
|
} else {
|
||||||
|
Rect::from_min_max(
|
||||||
|
rect.min,
|
||||||
|
egui::pos2(rect.left() + SHELF_FADE, rect.bottom()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
painter.add(theme::fade_shape(
|
||||||
|
ui.ctx(),
|
||||||
|
strip,
|
||||||
|
theme::background(),
|
||||||
|
theme::Direction::Horizontal,
|
||||||
|
right,
|
||||||
|
));
|
||||||
|
|
||||||
|
let button_id = id.with(right);
|
||||||
|
let shown = ui
|
||||||
|
.ctx()
|
||||||
|
.animate_bool_with_time(button_id, pointer_in, theme::motion::HOVER);
|
||||||
|
if shown == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let centre = egui::pos2(
|
||||||
|
if right {
|
||||||
|
rect.right() - SHELF_BUTTON / 2.0 - theme::space::M
|
||||||
|
} else {
|
||||||
|
rect.left() + SHELF_BUTTON / 2.0 + theme::space::M
|
||||||
|
},
|
||||||
|
rect.center().y,
|
||||||
|
);
|
||||||
|
let button = Rect::from_center_size(centre, Vec2::splat(SHELF_BUTTON));
|
||||||
|
let response = ui
|
||||||
|
.interact(button, button_id.with("button"), Sense::click())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||||
|
let response = response.named(if right { "Scroll right" } else { "Scroll left" });
|
||||||
|
let lit = if response.hovered() { 1.0 } else { 0.0 };
|
||||||
|
painter.circle_filled(
|
||||||
|
centre,
|
||||||
|
SHELF_BUTTON / 2.0,
|
||||||
|
Color32::from_black_alpha((150.0 + 60.0 * lit) as u8).gamma_multiply(shown),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
centre,
|
||||||
|
Align2::CENTER_CENTER,
|
||||||
|
icon,
|
||||||
|
egui::FontId::proportional(18.0),
|
||||||
|
Color32::WHITE.gamma_multiply(shown),
|
||||||
|
);
|
||||||
|
if response.clicked() {
|
||||||
|
let delta = direction * rect.width() * SHELF_PAGE;
|
||||||
|
ui.ctx().data_mut(|data| data.insert_temp(page, delta));
|
||||||
|
ui.ctx().request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How wide the fade at a shelf's edge is.
|
||||||
|
const SHELF_FADE: f32 = 56.0;
|
||||||
|
/// The diameter of a shelf's paging buttons.
|
||||||
|
const SHELF_BUTTON: f32 = 40.0;
|
||||||
|
/// How much of a shelf's width one page moves: most of it, so the tile that
|
||||||
|
/// was cut by the edge is whole on the next page.
|
||||||
|
const SHELF_PAGE: f32 = 0.85;
|
||||||
|
const SHELF_PAGE_SECONDS: f32 = 0.35;
|
||||||
|
|
||||||
|
/// How far a lifted tile grows past each edge, as a fraction of its size:
|
||||||
|
/// [`theme::TILE_LIFT`], but never so far that it reaches into the gap's other
|
||||||
|
/// half, which belongs to the neighbour. A wide window makes wide tiles.
|
||||||
|
fn lift_per_side(width: f32) -> f32 {
|
||||||
|
(theme::TILE_LIFT / 2.0).min((theme::CARD_GAP / 2.0 - 1.0) / width.max(1.0))
|
||||||
|
}
|
||||||
|
|
||||||
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
|
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
|
||||||
///
|
///
|
||||||
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
|
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
|
||||||
@@ -917,8 +1335,32 @@ pub fn columns_of(available: f32, minimum: f32) -> Grid {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_delay_reads_with_its_direction() {
|
||||||
|
assert_eq!(super::format_delay(0.3), "+0.3 s");
|
||||||
|
assert_eq!(super::format_delay(-1.2), "−1.2 s");
|
||||||
|
assert_eq!(super::format_delay(0.0000001), "0 s");
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lifted_tile_never_reaches_its_neighbour_however_wide_the_window() {
|
||||||
|
for available in [400.0, 1280.0, 1919.0, 2560.0, 3840.0] {
|
||||||
|
for minimum in [theme::CARD_WIDTH, theme::POSTER_WIDTH] {
|
||||||
|
let width = columns_of(available, minimum).width;
|
||||||
|
assert!(width * lift_per_side(width) < theme::CARD_GAP / 2.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_running_time_reads_in_hours_and_minutes() {
|
||||||
|
assert_eq!(format_runtime(24), "24 min");
|
||||||
|
assert_eq!(format_runtime(120), "2 h");
|
||||||
|
assert_eq!(format_runtime(124), "2 h 4 min");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn times_are_formatted_by_length() {
|
fn times_are_formatted_by_length() {
|
||||||
assert_eq!(format_time(0.0), "0:00");
|
assert_eq!(format_time(0.0), "0:00");
|
||||||
|
|||||||
@@ -72,6 +72,69 @@ const DRAWER_WIDTH: f32 = 380.0;
|
|||||||
/// The stills in the episode list.
|
/// The stills in the episode list.
|
||||||
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
|
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
|
||||||
|
|
||||||
|
/// How long the picture has to sit paused, with the mouse still, before the
|
||||||
|
/// controls give way to what is playing. Long enough that a pause to answer
|
||||||
|
/// the door is not one, short enough that coming back to it finds the name.
|
||||||
|
const RESTING_SECONDS: f64 = 5.0;
|
||||||
|
/// How long the pause screen takes to settle over the picture: slower than
|
||||||
|
/// the controls fade, because nobody is waiting on it.
|
||||||
|
const REST_FADE: f32 = 0.6;
|
||||||
|
|
||||||
|
/// The panels that open down the right of the picture. One at a time: each is
|
||||||
|
/// a list read at leisure, and two of them side by side would cover the film.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlayerPanel {
|
||||||
|
Episodes,
|
||||||
|
/// Audio and subtitles, and the subtitles' size, place and timing.
|
||||||
|
Tracks,
|
||||||
|
Speed,
|
||||||
|
Chapters,
|
||||||
|
Sleep,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sleep timer, as chosen or as it stands.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SleepChoice {
|
||||||
|
/// Stop at the end of the episode playing, rather than go on to the next.
|
||||||
|
EndOfEpisode,
|
||||||
|
/// Pause after this many minutes. As it stands, the minutes left, rounded
|
||||||
|
/// up.
|
||||||
|
Minutes(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The timers on offer, besides the end of the episode.
|
||||||
|
const SLEEP_MINUTES: [u32; 2] = [30, 60];
|
||||||
|
|
||||||
|
impl PlayerPanel {
|
||||||
|
fn heading(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Episodes => "Episodes",
|
||||||
|
Self::Tracks => "Audio and subtitles",
|
||||||
|
Self::Speed => "Playback speed",
|
||||||
|
Self::Chapters => "Chapters",
|
||||||
|
Self::Sleep => "Sleep timer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the title has an episode panel to offer, and which panel is out.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Panels {
|
||||||
|
episodes: bool,
|
||||||
|
open: Option<PlayerPanel>,
|
||||||
|
sleep: Option<SleepChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the pause screen says about what is playing.
|
||||||
|
pub struct About {
|
||||||
|
/// The title's name, as the library shows it.
|
||||||
|
pub name: String,
|
||||||
|
/// The title's logo, when its provider has one; its name otherwise.
|
||||||
|
pub logo: Option<egui::TextureHandle>,
|
||||||
|
/// What this episode is about, or failing that the whole title.
|
||||||
|
pub synopsis: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// State the page keeps between frames.
|
/// State the page keeps between frames.
|
||||||
///
|
///
|
||||||
/// Held by the app and passed in by reference, because one thing here *is*
|
/// Held by the app and passed in by reference, because one thing here *is*
|
||||||
@@ -89,6 +152,9 @@ pub struct Overlay {
|
|||||||
/// How tall the controls were the last time they were drawn, padding
|
/// How tall the controls were the last time they were drawn, padding
|
||||||
/// included. Measured rather than assumed — see [`CHROME_PAD_X`].
|
/// included. Measured rather than assumed — see [`CHROME_PAD_X`].
|
||||||
chrome_height: f32,
|
chrome_height: f32,
|
||||||
|
/// When the picture was last seen to pause, on egui's clock, for the
|
||||||
|
/// pause screen. `None` while it plays.
|
||||||
|
paused_since: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Overlay {
|
impl Default for Overlay {
|
||||||
@@ -97,6 +163,7 @@ impl Default for Overlay {
|
|||||||
last_movement: None,
|
last_movement: None,
|
||||||
last_position: None,
|
last_position: None,
|
||||||
chrome_height: CHROME_HEIGHT_GUESS + CHROME_PAD_TOP + CHROME_PAD_BOTTOM,
|
chrome_height: CHROME_HEIGHT_GUESS + CHROME_PAD_TOP + CHROME_PAD_BOTTOM,
|
||||||
|
paused_since: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,17 +181,21 @@ pub struct Chrome<'a> {
|
|||||||
pub up_next: Option<UpNextCard>,
|
pub up_next: Option<UpNextCard>,
|
||||||
/// Whether stepping to another episode is possible in either direction.
|
/// Whether stepping to another episode is possible in either direction.
|
||||||
pub neighbours: Neighbours,
|
pub neighbours: Neighbours,
|
||||||
/// The title's episodes, when it has more than one — what the list down
|
/// The title's episodes, when it has more than one — what the episode
|
||||||
/// the right of the picture shows.
|
/// panel shows.
|
||||||
pub episodes: Option<Episodes<'a>>,
|
pub episodes: Option<Episodes<'a>>,
|
||||||
|
/// Which panel is out down the right of the picture, if any.
|
||||||
|
pub panel: Option<PlayerPanel>,
|
||||||
|
/// What the pause screen shows, while the picture is paused.
|
||||||
|
pub about: Option<About>,
|
||||||
|
/// The sleep timer, when one is running.
|
||||||
|
pub sleep: Option<SleepChoice>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The playing title's episodes, for the list beside the picture.
|
/// The playing title's episodes, for the list beside the picture.
|
||||||
pub struct Episodes<'a> {
|
pub struct Episodes<'a> {
|
||||||
pub title: &'a pstr_core::library::Title,
|
pub title: &'a pstr_core::library::Title,
|
||||||
pub art: ui::Art<'a>,
|
pub art: ui::Art<'a>,
|
||||||
/// Whether the list is out.
|
|
||||||
pub open: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the end-of-episode card says, when there is one.
|
/// What the end-of-episode card says, when there is one.
|
||||||
@@ -187,6 +258,25 @@ impl Overlay {
|
|||||||
};
|
};
|
||||||
ctx.input(|input| input.time) - last_movement < IDLE_SECONDS
|
ctx.input(|input| input.time) - last_movement < IDLE_SECONDS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the picture has sat paused long enough, with nobody touching
|
||||||
|
/// the mouse, for the pause screen to take over from the controls.
|
||||||
|
fn resting(&mut self, ctx: &egui::Context, playback: &Playback) -> bool {
|
||||||
|
let now = ctx.input(|input| input.time);
|
||||||
|
if !playback.paused || !playback.loaded {
|
||||||
|
self.paused_since = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let since = *self.paused_since.get_or_insert(now);
|
||||||
|
let still = since.max(self.last_movement.unwrap_or(now));
|
||||||
|
let left = RESTING_SECONDS - (now - still);
|
||||||
|
if left > 0.0 {
|
||||||
|
// Nothing else causes a frame while a paused picture sits there.
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_secs_f64(left));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the player page shows while a file opens and there is no picture yet.
|
/// What the player page shows while a file opens and there is no picture yet.
|
||||||
@@ -219,6 +309,9 @@ pub fn show(
|
|||||||
up_next,
|
up_next,
|
||||||
neighbours,
|
neighbours,
|
||||||
mut episodes,
|
mut episodes,
|
||||||
|
panel,
|
||||||
|
about,
|
||||||
|
sleep,
|
||||||
} = chrome_state;
|
} = chrome_state;
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
@@ -260,10 +353,21 @@ pub fn show(
|
|||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
|
// The episode panel needs episodes; anything else asked for opens as is.
|
||||||
// The controls stay up while the list is out: it hangs off the button in
|
let panel = panel.filter(|panel| *panel != PlayerPanel::Episodes || episodes.is_some());
|
||||||
// the title strip, and is read at leisure.
|
if panel.is_some() {
|
||||||
let visible = overlay.visible(&ctx, playback) || drawer_open;
|
// Reading a panel is using the player: closing it should find the
|
||||||
|
// controls, not a pause screen that was waiting behind it.
|
||||||
|
overlay.last_movement = Some(ctx.input(|input| input.time));
|
||||||
|
}
|
||||||
|
let resting = panel.is_none() && overlay.resting(&ctx, playback);
|
||||||
|
let rest = ctx.animate_bool_with_time(ui.id().with("resting"), resting, REST_FADE);
|
||||||
|
if rest > 0.0 {
|
||||||
|
pause_screen(ui, playback, about.as_ref(), rect, rest);
|
||||||
|
}
|
||||||
|
// The controls stay up while a panel is out: it hangs off a button in
|
||||||
|
// them, and is read at leisure. They give way to the pause screen.
|
||||||
|
let visible = (overlay.visible(&ctx, playback) && !resting) || panel.is_some();
|
||||||
// Above the chrome when it is up, at the bottom corner when it is not —
|
// Above the chrome when it is up, at the bottom corner when it is not —
|
||||||
// and clear of it either way, which is what the measured height buys.
|
// and clear of it either way, which is what the measured height buys.
|
||||||
let float_above = if visible {
|
let float_above = if visible {
|
||||||
@@ -295,7 +399,11 @@ pub fn show(
|
|||||||
ui,
|
ui,
|
||||||
playback,
|
playback,
|
||||||
neighbours,
|
neighbours,
|
||||||
episodes.as_ref().map(|list| list.open),
|
Panels {
|
||||||
|
episodes: episodes.is_some(),
|
||||||
|
open: panel,
|
||||||
|
sleep,
|
||||||
|
},
|
||||||
rect,
|
rect,
|
||||||
overlay.chrome_height,
|
overlay.chrome_height,
|
||||||
actions,
|
actions,
|
||||||
@@ -303,9 +411,24 @@ pub fn show(
|
|||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
}
|
}
|
||||||
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
|
if let Some(panel) = panel {
|
||||||
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
|
side_panel(
|
||||||
} else {
|
&ctx,
|
||||||
|
panel,
|
||||||
|
rect,
|
||||||
|
overlay.chrome_height,
|
||||||
|
actions,
|
||||||
|
|ui, actions| match (panel, episodes.as_mut()) {
|
||||||
|
(PlayerPanel::Episodes, Some(list)) => episode_list(ui, list, playback, actions),
|
||||||
|
(PlayerPanel::Tracks, _) => track_list(ui, playback, actions),
|
||||||
|
(PlayerPanel::Speed, _) => speed_list(ui, playback, actions),
|
||||||
|
(PlayerPanel::Chapters, _) => chapter_list(ui, playback, actions),
|
||||||
|
(PlayerPanel::Sleep, _) => sleep_list(ui, sleep, actions),
|
||||||
|
(PlayerPanel::Episodes, None) => {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if panel != Some(PlayerPanel::Episodes) {
|
||||||
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
|
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
|
||||||
}
|
}
|
||||||
if !visible {
|
if !visible {
|
||||||
@@ -695,7 +818,7 @@ fn chrome(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
playback: &Playback,
|
playback: &Playback,
|
||||||
neighbours: Neighbours,
|
neighbours: Neighbours,
|
||||||
drawer: Option<bool>,
|
panels: Panels,
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
previous_height: f32,
|
previous_height: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
@@ -707,13 +830,8 @@ fn chrome(
|
|||||||
.max_rect(top.shrink2(Vec2::new(18.0, 14.0)))
|
.max_rect(top.shrink2(Vec2::new(18.0, 14.0)))
|
||||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||||
|ui| {
|
|ui| {
|
||||||
if ui
|
let library = format!("{} Library", egui_phosphor::regular::CARET_LEFT);
|
||||||
.add(
|
if ui::glass_button(ui, &library)
|
||||||
egui::Button::new(theme::Role::Label.rich("Library"))
|
|
||||||
.fill(Color32::from_black_alpha(160))
|
|
||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
|
||||||
.min_size(Vec2::new(96.0, 32.0)),
|
|
||||||
)
|
|
||||||
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -736,16 +854,13 @@ fn chrome(
|
|||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
|
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
|
||||||
if ui
|
if ui::glass_button(
|
||||||
.add(
|
ui,
|
||||||
egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
|
if fullscreen {
|
||||||
egui_phosphor::regular::CORNERS_IN
|
egui_phosphor::regular::CORNERS_IN
|
||||||
} else {
|
} else {
|
||||||
egui_phosphor::regular::CORNERS_OUT
|
egui_phosphor::regular::CORNERS_OUT
|
||||||
}))
|
},
|
||||||
.fill(Color32::from_black_alpha(160))
|
|
||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
|
||||||
)
|
)
|
||||||
.hint(if fullscreen {
|
.hint(if fullscreen {
|
||||||
"Leave fullscreen (F)"
|
"Leave fullscreen (F)"
|
||||||
@@ -756,49 +871,24 @@ fn chrome(
|
|||||||
{
|
{
|
||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
if let Some(open) = drawer {
|
if panels.episodes {
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
let fill = if open {
|
// In the accent while the list is out, so the button
|
||||||
theme::accent()
|
// that closes it is the one that says it is open.
|
||||||
|
let icon = egui_phosphor::regular::LIST_BULLETS;
|
||||||
|
let button = if panels.open == Some(PlayerPanel::Episodes) {
|
||||||
|
ui::accent_button(ui, icon)
|
||||||
} else {
|
} else {
|
||||||
Color32::from_black_alpha(160)
|
ui::glass_button(ui, icon)
|
||||||
};
|
};
|
||||||
let ink = if open {
|
if button.hint("Episodes (E)").clicked() {
|
||||||
theme::on_accent()
|
actions.push(Action::TogglePanel(PlayerPanel::Episodes));
|
||||||
} else {
|
|
||||||
Color32::WHITE
|
|
||||||
};
|
|
||||||
if ui
|
|
||||||
.add(
|
|
||||||
egui::Button::new(
|
|
||||||
theme::Role::Subhead
|
|
||||||
.rich(egui_phosphor::regular::LIST_BULLETS)
|
|
||||||
.color(ink),
|
|
||||||
)
|
|
||||||
.fill(fill)
|
|
||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
|
||||||
)
|
|
||||||
.hint("Episodes (E)")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::ToggleEpisodes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Esc leaves the page and keeps playing; this is the one-step
|
// Esc leaves the page and keeps playing; this is the one-step
|
||||||
// way to be done with the episode.
|
// way to be done with the episode.
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
if ui
|
if ui::glass_button(ui, egui_phosphor::regular::STOP)
|
||||||
.add(
|
|
||||||
egui::Button::new(
|
|
||||||
theme::Role::Subhead
|
|
||||||
.rich(egui_phosphor::regular::STOP)
|
|
||||||
.color(Color32::WHITE),
|
|
||||||
)
|
|
||||||
.fill(Color32::from_black_alpha(160))
|
|
||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
|
||||||
)
|
|
||||||
.hint("Stop")
|
.hint("Stop")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -833,7 +923,7 @@ fn chrome(
|
|||||||
.max_rect(content)
|
.max_rect(content)
|
||||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
.layout(egui::Layout::top_down(egui::Align::Min)),
|
||||||
|ui| {
|
|ui| {
|
||||||
ui::transport::full(ui, playback, neighbours, actions);
|
ui::transport::full(ui, playback, neighbours, panels.open, panels.sleep, actions);
|
||||||
ui.min_rect().height()
|
ui.min_rect().height()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -846,15 +936,15 @@ fn chrome(
|
|||||||
/// scrolls to the playing one once when it opens rather than every frame.
|
/// scrolls to the playing one once when it opens rather than every frame.
|
||||||
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
|
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
|
||||||
|
|
||||||
/// The title's episodes, down the right of the picture between the title
|
/// A panel down the right of the picture, between the title strip and the
|
||||||
/// strip and the controls. A season at a time, opened on the playing one.
|
/// controls: a heading, a way to close it, and `add` under them.
|
||||||
fn episode_drawer(
|
fn side_panel(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
list: &mut Episodes<'_>,
|
panel: PlayerPanel,
|
||||||
playback: &Playback,
|
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
chrome_height: f32,
|
chrome_height: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
|
add: impl FnOnce(&mut egui::Ui, &mut Vec<Action>),
|
||||||
) {
|
) {
|
||||||
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
|
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
|
||||||
let area = Rect::from_min_max(
|
let area = Rect::from_min_max(
|
||||||
@@ -864,6 +954,240 @@ fn episode_drawer(
|
|||||||
if area.height() < 160.0 {
|
if area.height() < 160.0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let margin = theme::space::L;
|
||||||
|
egui::Area::new(egui::Id::new("player-panel"))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.fixed_pos(area.min)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface().gamma_multiply(0.96))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::same(margin as i8))
|
||||||
|
.shadow(theme::tile_shadow(1.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(area.width() - 2.0 * margin);
|
||||||
|
ui.set_height(area.height() - 2.0 * margin);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Heading
|
||||||
|
.rich(panel.heading())
|
||||||
|
.color(theme::text()),
|
||||||
|
);
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::X)
|
||||||
|
.color(theme::muted()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.hint("Close (Esc)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::TogglePanel(panel));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
add(ui, actions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One choice in a panel: the whole row is the button, and the chosen one is
|
||||||
|
/// filled and ticked, so it reads from across the room.
|
||||||
|
fn option_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
selected: bool,
|
||||||
|
text: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
) -> egui::Response {
|
||||||
|
let (rect, response) =
|
||||||
|
ui.allocate_exact_size(Vec2::new(ui.available_width(), 36.0), Sense::click());
|
||||||
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
let painter = ui.painter();
|
||||||
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
|
if selected {
|
||||||
|
painter.rect_filled(rect, radius, theme::accent_dim());
|
||||||
|
} else if hover > 0.0 {
|
||||||
|
painter.rect_filled(rect, radius, theme::card_hover().gamma_multiply(hover));
|
||||||
|
}
|
||||||
|
let mut left = rect.left() + theme::space::L;
|
||||||
|
if let Some(detail) = detail {
|
||||||
|
let galley =
|
||||||
|
painter.layout_no_wrap(detail.to_owned(), theme::Role::Label.font(), theme::muted());
|
||||||
|
let width = galley.size().x;
|
||||||
|
painter.galley(
|
||||||
|
egui::pos2(left, rect.center().y - galley.size().y / 2.0),
|
||||||
|
galley,
|
||||||
|
theme::muted(),
|
||||||
|
);
|
||||||
|
left += width + theme::space::L;
|
||||||
|
}
|
||||||
|
let tick = 28.0;
|
||||||
|
let mut job = egui::text::LayoutJob::simple_singleline(
|
||||||
|
text.to_owned(),
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
theme::text(),
|
||||||
|
);
|
||||||
|
job.wrap.max_width = rect.right() - tick - left;
|
||||||
|
job.wrap.max_rows = 1;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
let galley = painter.layout_job(job);
|
||||||
|
painter.galley(
|
||||||
|
egui::pos2(left, rect.center().y - galley.size().y / 2.0),
|
||||||
|
galley,
|
||||||
|
theme::text(),
|
||||||
|
);
|
||||||
|
if selected {
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(rect.right() - theme::space::L, rect.center().y),
|
||||||
|
Align2::RIGHT_CENTER,
|
||||||
|
egui_phosphor::regular::CHECK,
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
theme::text(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.widget_info(|| {
|
||||||
|
egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, selected, text)
|
||||||
|
});
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading inside a panel, over the choices it names.
|
||||||
|
fn panel_section(ui: &mut egui::Ui, text: &str) {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Caption
|
||||||
|
.rich(text)
|
||||||
|
.family(theme::semibold())
|
||||||
|
.color(theme::muted()),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio and subtitle tracks, and what is worth changing about subtitles
|
||||||
|
/// mid-episode.
|
||||||
|
fn track_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
|
use pstr_player::TrackKind;
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("panel-tracks")
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
let audio: Vec<_> = playback.tracks_of(TrackKind::Audio).collect();
|
||||||
|
if !audio.is_empty() {
|
||||||
|
panel_section(ui, "Audio");
|
||||||
|
for track in &audio {
|
||||||
|
if option_row(ui, track.selected, &track.label(), None).clicked() {
|
||||||
|
actions.push(Action::SelectTrack {
|
||||||
|
kind: TrackKind::Audio,
|
||||||
|
id: Some(track.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let subtitles: Vec<_> = playback.tracks_of(TrackKind::Subtitle).collect();
|
||||||
|
panel_section(ui, "Subtitles");
|
||||||
|
// "Off" first, and always: it is the choice most often wanted, and
|
||||||
|
// the only one that is not in the file.
|
||||||
|
let off = playback.selected_track(TrackKind::Subtitle).is_none();
|
||||||
|
if option_row(ui, off, "Off", None).clicked() {
|
||||||
|
actions.push(Action::SelectTrack {
|
||||||
|
kind: TrackKind::Subtitle,
|
||||||
|
id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for track in &subtitles {
|
||||||
|
if option_row(ui, track.selected, &track.label(), None).clicked() {
|
||||||
|
actions.push(Action::SelectTrack {
|
||||||
|
kind: TrackKind::Subtitle,
|
||||||
|
id: Some(track.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !subtitles.is_empty() {
|
||||||
|
panel_section(ui, "Subtitle size, place and timing");
|
||||||
|
ui::transport::subtitle_adjustments(ui, playback, actions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The speeds `[` and `]` step through.
|
||||||
|
fn speed_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
|
for speed in ui::transport::SPEEDS {
|
||||||
|
let label = if speed == 1.0 {
|
||||||
|
"Normal".to_owned()
|
||||||
|
} else {
|
||||||
|
ui::format_speed(speed)
|
||||||
|
};
|
||||||
|
if option_row(ui, (playback.speed - speed).abs() < 1e-6, &label, None).clicked() {
|
||||||
|
actions.push(Action::SetSpeed(speed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sleep timers, and what the running one has left.
|
||||||
|
fn sleep_list(ui: &mut egui::Ui, sleep: Option<SleepChoice>, actions: &mut Vec<Action>) {
|
||||||
|
if let Some(SleepChoice::Minutes(left)) = sleep {
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Label
|
||||||
|
.rich(format!("Pausing in {left} min"))
|
||||||
|
.color(theme::muted()),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
if option_row(ui, sleep.is_none(), "Off", None).clicked() {
|
||||||
|
actions.push(Action::SetSleep(None));
|
||||||
|
}
|
||||||
|
let end = sleep == Some(SleepChoice::EndOfEpisode);
|
||||||
|
if option_row(ui, end, "End of this episode", None).clicked() {
|
||||||
|
actions.push(Action::SetSleep(Some(SleepChoice::EndOfEpisode)));
|
||||||
|
}
|
||||||
|
for minutes in SLEEP_MINUTES {
|
||||||
|
if option_row(ui, false, &format!("{minutes} minutes"), None).clicked() {
|
||||||
|
actions.push(Action::SetSleep(Some(SleepChoice::Minutes(minutes))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every chapter, with where it starts, the one playing marked.
|
||||||
|
fn chapter_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
|
let current = playback.chapter().map(|chapter| chapter.index);
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("panel-chapters")
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for chapter in &playback.chapters {
|
||||||
|
let start = ui::format_time(chapter.start);
|
||||||
|
if option_row(
|
||||||
|
ui,
|
||||||
|
current == Some(chapter.index),
|
||||||
|
&chapter.label(),
|
||||||
|
Some(&start),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Player(Command::SeekTo(chapter.start)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title's episodes, a season at a time, opened on the playing one.
|
||||||
|
fn episode_list(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
list: &mut Episodes<'_>,
|
||||||
|
playback: &Playback,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let ctx = ui.ctx().clone();
|
||||||
let title = list.title;
|
let title = list.title;
|
||||||
let target = &playback.target;
|
let target = &playback.target;
|
||||||
let is_playing = |episode: &pstr_core::library::Episode| {
|
let is_playing = |episode: &pstr_core::library::Episode| {
|
||||||
@@ -880,40 +1204,7 @@ fn episode_drawer(
|
|||||||
.unwrap_or(playing_season)
|
.unwrap_or(playing_season)
|
||||||
.min(title.seasons.len().saturating_sub(1));
|
.min(title.seasons.len().saturating_sub(1));
|
||||||
|
|
||||||
let margin = theme::space::L;
|
|
||||||
egui::Area::new(egui::Id::new("episode-drawer"))
|
|
||||||
.order(egui::Order::Foreground)
|
|
||||||
.fixed_pos(area.min)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
egui::Frame::new()
|
|
||||||
.fill(theme::surface().gamma_multiply(0.96))
|
|
||||||
.corner_radius(CornerRadius::same(theme::radius::LG))
|
|
||||||
.inner_margin(egui::Margin::same(margin as i8))
|
|
||||||
.shadow(theme::tile_shadow(1.0))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(area.width() - 2.0 * margin);
|
|
||||||
ui.set_height(area.height() - 2.0 * margin);
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
if ui
|
|
||||||
.add(
|
|
||||||
egui::Button::new(
|
|
||||||
theme::Role::Subhead
|
|
||||||
.rich(egui_phosphor::regular::X)
|
|
||||||
.color(theme::muted()),
|
|
||||||
)
|
|
||||||
.frame(false),
|
|
||||||
)
|
|
||||||
.hint("Close (E)")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::ToggleEpisodes);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if title.seasons.len() > 1 {
|
if title.seasons.len() > 1 {
|
||||||
ui.add_space(theme::space::XS);
|
|
||||||
let labels: Vec<(usize, String)> = title
|
let labels: Vec<(usize, String)> = title
|
||||||
.seasons
|
.seasons
|
||||||
.iter()
|
.iter()
|
||||||
@@ -932,8 +1223,8 @@ fn episode_drawer(
|
|||||||
ctx.data_mut(|data| data.insert_temp(season_id, picked));
|
ctx.data_mut(|data| data.insert_temp(season_id, picked));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
|
||||||
let Some(shown) = title.seasons.get(season) else {
|
let Some(shown) = title.seasons.get(season) else {
|
||||||
return;
|
return;
|
||||||
@@ -952,23 +1243,107 @@ fn episode_drawer(
|
|||||||
actions,
|
actions,
|
||||||
);
|
);
|
||||||
if is_playing(episode) {
|
if is_playing(episode) {
|
||||||
let scrolled = ctx.data(|data| {
|
let scrolled =
|
||||||
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
|
ctx.data(|data| data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED)));
|
||||||
});
|
|
||||||
if scrolled.as_deref() != Some(target.link_id.as_str()) {
|
if scrolled.as_deref() != Some(target.link_id.as_str()) {
|
||||||
row.scroll_to_me(Some(egui::Align::Center));
|
row.scroll_to_me(Some(egui::Align::Center));
|
||||||
ctx.data_mut(|data| {
|
ctx.data_mut(|data| {
|
||||||
data.insert_temp(
|
data.insert_temp(egui::Id::new(DRAWER_SCROLLED), target.link_id.clone())
|
||||||
egui::Id::new(DRAWER_SCROLLED),
|
|
||||||
target.link_id.clone(),
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
/// What takes over from the controls once the picture has sat paused: the
|
||||||
|
/// picture dimmed, and over it what is playing, the way a cinema's screen says
|
||||||
|
/// what the film is between showings.
|
||||||
|
fn pause_screen(ui: &egui::Ui, playback: &Playback, about: Option<&About>, rect: Rect, fade: f32) {
|
||||||
|
let painter = ui.painter();
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
CornerRadius::ZERO,
|
||||||
|
Color32::from_black_alpha((150.0 * fade) as u8),
|
||||||
|
);
|
||||||
|
// Darker down the left, where the words are, so they hold over any frame.
|
||||||
|
let side = Rect::from_min_size(rect.min, Vec2::new(rect.width() * 0.6, rect.height()));
|
||||||
|
let mut mesh = egui::Mesh::default();
|
||||||
|
let dark = Color32::from_black_alpha((120.0 * fade) as u8);
|
||||||
|
mesh.colored_vertex(side.left_top(), dark);
|
||||||
|
mesh.colored_vertex(side.right_top(), Color32::TRANSPARENT);
|
||||||
|
mesh.colored_vertex(side.left_bottom(), dark);
|
||||||
|
mesh.colored_vertex(side.right_bottom(), Color32::TRANSPARENT);
|
||||||
|
mesh.add_triangle(0, 1, 2);
|
||||||
|
mesh.add_triangle(1, 3, 2);
|
||||||
|
painter.add(egui::Shape::mesh(mesh));
|
||||||
|
|
||||||
|
let ink = |alpha: u8| Color32::from_white_alpha(alpha).gamma_multiply(fade);
|
||||||
|
let left = rect.left() + (rect.width() * 0.07).max(32.0);
|
||||||
|
let width = (rect.width() * 0.42).clamp(260.0, 620.0);
|
||||||
|
// Built from the bottom of the block up, so its lowest line sits at the
|
||||||
|
// same height whatever the synopsis runs to.
|
||||||
|
let mut y = rect.bottom() - rect.height() * 0.22;
|
||||||
|
|
||||||
|
if let Some(synopsis) = about.and_then(|about| about.synopsis.as_deref()) {
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
synopsis.to_owned(),
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
ink(215),
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 4;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
let galley = painter.layout_job(job);
|
||||||
|
y -= galley.size().y;
|
||||||
|
painter.galley(egui::pos2(left, y), galley, ink(215));
|
||||||
|
y -= theme::space::L;
|
||||||
|
}
|
||||||
|
let caption = playback.target.caption();
|
||||||
|
if !caption.is_empty() {
|
||||||
|
let galley = painter.layout(caption, theme::Role::Subhead.font(), ink(235), width);
|
||||||
|
y -= galley.size().y;
|
||||||
|
painter.galley(egui::pos2(left, y), galley, ink(235));
|
||||||
|
y -= theme::space::M;
|
||||||
|
}
|
||||||
|
match about.and_then(|about| about.logo.as_ref()) {
|
||||||
|
Some(logo) => {
|
||||||
|
let size = logo.size_vec2();
|
||||||
|
let scale = (width * 0.8 / size.x).min(110.0 / size.y);
|
||||||
|
let shown = size * scale;
|
||||||
|
y -= shown.y;
|
||||||
|
painter.image(
|
||||||
|
logo.id(),
|
||||||
|
Rect::from_min_size(egui::pos2(left, y), shown),
|
||||||
|
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
|
Color32::WHITE.gamma_multiply(fade),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let name = about.map_or(&playback.target.title_name, |about| &about.name);
|
||||||
|
let galley = painter.layout(name.clone(), theme::Role::Hero.font(), ink(255), width);
|
||||||
|
y -= galley.size().y;
|
||||||
|
painter.galley(egui::pos2(left, y), galley, ink(255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y -= theme::space::M;
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(left, y),
|
||||||
|
Align2::LEFT_BOTTOM,
|
||||||
|
"You're watching",
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
ink(190),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(
|
||||||
|
rect.right() - left + rect.left(),
|
||||||
|
rect.bottom() - rect.height() * 0.22,
|
||||||
|
),
|
||||||
|
Align2::RIGHT_BOTTOM,
|
||||||
|
"Paused",
|
||||||
|
theme::Role::Title.font(),
|
||||||
|
ink(200),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One episode in the list: its still, which plays it, and what it is.
|
/// One episode in the list: its still, which plays it, and what it is.
|
||||||
@@ -1010,8 +1385,9 @@ fn drawer_row(
|
|||||||
.unwrap_or_else(|| episode.detail().to_owned());
|
.unwrap_or_else(|| episode.detail().to_owned());
|
||||||
if let Some(numbering) = episode.numbering() {
|
if let Some(numbering) = episode.numbering() {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(numbering)
|
theme::Role::Caption
|
||||||
.monospace()
|
.rich(numbering)
|
||||||
|
.family(theme::semibold())
|
||||||
.color(theme::muted()),
|
.color(theme::muted()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1112,40 +1488,131 @@ fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
|
|||||||
painter.add(egui::Shape::mesh(mesh));
|
painter.add(egui::Shape::mesh(mesh));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a key just did, in a pill near the top of the picture, for a moment.
|
/// How long a [`Flash`] stays, and how much of that is fading out.
|
||||||
///
|
pub const FLASH_SECONDS: f64 = 0.9;
|
||||||
/// Returns whether it is still showing.
|
const FLASH_FADE: f64 = 0.25;
|
||||||
pub fn osd(ctx: &egui::Context, text: &str, at: f64) -> bool {
|
|
||||||
/// How long it stays, and how much of that is fading out.
|
|
||||||
const SECONDS: f64 = 0.9;
|
|
||||||
const FADE: f64 = 0.25;
|
|
||||||
|
|
||||||
let age = ctx.input(|input| input.time) - at;
|
/// What a key or a click just did, shown in the middle of the picture for a
|
||||||
if age >= SECONDS {
|
/// moment: "+30 s", "Volume 70%" with the level under it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Flash {
|
||||||
|
pub icon: &'static str,
|
||||||
|
pub text: String,
|
||||||
|
/// A level to draw as a bar under the text, from 0 to 1: the volume.
|
||||||
|
pub level: Option<f32>,
|
||||||
|
/// What a run of seeks in one direction adds up to, so that three
|
||||||
|
/// presses of → read "+90 s" rather than "+30 s" three times.
|
||||||
|
pub seek: Option<f64>,
|
||||||
|
/// When, on egui's clock.
|
||||||
|
pub at: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flash {
|
||||||
|
/// A seek of `seconds`, added to the one still showing when it went the
|
||||||
|
/// same way.
|
||||||
|
pub fn seek(previous: Option<&Flash>, seconds: f64, now: f64) -> Self {
|
||||||
|
let total = previous
|
||||||
|
.filter(|flash| now - flash.at < FLASH_SECONDS)
|
||||||
|
.and_then(|flash| flash.seek)
|
||||||
|
.filter(|total| total.signum() == seconds.signum())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
+ seconds;
|
||||||
|
Self {
|
||||||
|
icon: if total < 0.0 {
|
||||||
|
egui_phosphor::regular::CLOCK_COUNTER_CLOCKWISE
|
||||||
|
} else {
|
||||||
|
egui_phosphor::regular::CLOCK_CLOCKWISE
|
||||||
|
},
|
||||||
|
text: if total < 0.0 {
|
||||||
|
format!("− {:.0} s", -total)
|
||||||
|
} else {
|
||||||
|
format!("+ {total:.0} s")
|
||||||
|
},
|
||||||
|
level: None,
|
||||||
|
seek: Some(total),
|
||||||
|
at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw it over `rect`. Returns whether it is still showing.
|
||||||
|
pub fn show(&self, ctx: &egui::Context, rect: Rect) -> bool {
|
||||||
|
let age = ctx.input(|input| input.time) - self.at;
|
||||||
|
if age >= FLASH_SECONDS {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let opacity = ((SECONDS - age) / FADE).clamp(0.0, 1.0) as f32;
|
let opacity = ((FLASH_SECONDS - age) / FLASH_FADE).clamp(0.0, 1.0) as f32;
|
||||||
let painter = ctx.layer_painter(egui::LayerId::new(
|
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||||
egui::Order::Foreground,
|
egui::Order::Foreground,
|
||||||
egui::Id::new("osd"),
|
egui::Id::new("osd"),
|
||||||
));
|
));
|
||||||
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Title.font(), Color32::WHITE);
|
let ink = Color32::WHITE.gamma_multiply(opacity);
|
||||||
let size = galley.size() + Vec2::new(36.0, 18.0);
|
let icon = painter.layout_no_wrap(self.icon.to_owned(), theme::Role::Hero.font(), ink);
|
||||||
let screen = ctx.content_rect();
|
let text = painter.layout_no_wrap(self.text.clone(), theme::Role::Subhead.font(), ink);
|
||||||
let frame = Rect::from_center_size(
|
let bar = Vec2::new(120.0, 4.0);
|
||||||
egui::pos2(screen.center().x, screen.top() + TITLE_HEIGHT + 40.0),
|
let width = icon.size().x.max(text.size().x).max(bar.x) + 40.0;
|
||||||
size,
|
let height = icon.size().y
|
||||||
);
|
+ theme::space::S
|
||||||
|
+ text.size().y
|
||||||
|
+ if self.level.is_some() {
|
||||||
|
theme::space::L + bar.y
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
+ 32.0;
|
||||||
|
let frame = Rect::from_center_size(rect.center(), Vec2::new(width, height));
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
frame,
|
frame,
|
||||||
CornerRadius::same((size.y / 2.0) as u8),
|
CornerRadius::same(theme::radius::LG * 2),
|
||||||
Color32::from_black_alpha((190.0 * opacity) as u8),
|
Color32::from_black_alpha((170.0 * opacity) as u8),
|
||||||
);
|
);
|
||||||
painter.galley_with_override_text_color(
|
let mut y = frame.top() + 16.0;
|
||||||
frame.min + Vec2::new(18.0, 9.0),
|
let icon_height = icon.size().y;
|
||||||
galley,
|
painter.galley(
|
||||||
Color32::WHITE.gamma_multiply(opacity),
|
egui::pos2(frame.center().x - icon.size().x / 2.0, y),
|
||||||
|
icon,
|
||||||
|
ink,
|
||||||
);
|
);
|
||||||
|
y += icon_height + theme::space::S;
|
||||||
|
let text_height = text.size().y;
|
||||||
|
painter.galley(
|
||||||
|
egui::pos2(frame.center().x - text.size().x / 2.0, y),
|
||||||
|
text,
|
||||||
|
ink,
|
||||||
|
);
|
||||||
|
y += text_height + theme::space::L;
|
||||||
|
if let Some(level) = self.level {
|
||||||
|
let track = Rect::from_min_size(egui::pos2(frame.center().x - bar.x / 2.0, y), bar);
|
||||||
|
let radius = CornerRadius::same(theme::radius::BAR);
|
||||||
|
painter.rect_filled(
|
||||||
|
track,
|
||||||
|
radius,
|
||||||
|
Color32::from_white_alpha(60).gamma_multiply(opacity),
|
||||||
|
);
|
||||||
|
let mut filled = track;
|
||||||
|
filled.set_width(track.width() * level.clamp(0.0, 1.0));
|
||||||
|
painter.rect_filled(filled, radius, theme::accent().gamma_multiply(opacity));
|
||||||
|
}
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seeks_the_same_way_in_quick_succession_add_up_and_a_turn_or_a_pause_starts_again() {
|
||||||
|
let first = Flash::seek(None, 30.0, 10.0);
|
||||||
|
let second = Flash::seek(Some(&first), 30.0, 10.4);
|
||||||
|
assert_eq!(second.seek, Some(60.0));
|
||||||
|
assert_eq!(second.text, "+ 60 s");
|
||||||
|
|
||||||
|
let back = Flash::seek(Some(&second), -10.0, 10.6);
|
||||||
|
assert_eq!(back.seek, Some(-10.0));
|
||||||
|
assert_eq!(back.text, "− 10 s");
|
||||||
|
|
||||||
|
let later = Flash::seek(Some(&second), 30.0, 10.4 + FLASH_SECONDS);
|
||||||
|
assert_eq!(later.seek, Some(30.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||||
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::{Colour, PlaybackPrefs, SubtitleBackdrop, SubtitleStyle};
|
||||||
use pstr_player::LANGUAGES;
|
use pstr_player::LANGUAGES;
|
||||||
|
|
||||||
use crate::app::Action;
|
use crate::app::Action;
|
||||||
@@ -58,6 +58,7 @@ pub fn show(
|
|||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
appearance_form(ui, prefs.appearance, prefs.desktop, actions);
|
appearance_form(ui, prefs.appearance, prefs.desktop, actions);
|
||||||
playback_form(ui, prefs, actions);
|
playback_form(ui, prefs, actions);
|
||||||
|
subtitle_form(ui, &prefs.playback.subtitle_style, actions);
|
||||||
storage_form(ui, prefs, actions);
|
storage_form(ui, prefs, actions);
|
||||||
metadata_form(ui, settings, api_key, actions);
|
metadata_form(ui, settings, api_key, actions);
|
||||||
about(ui);
|
about(ui);
|
||||||
@@ -428,6 +429,368 @@ fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Acti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How subtitles look, with a sample line to judge it by.
|
||||||
|
///
|
||||||
|
/// Every change reaches a playing file as it is made, so the film itself is
|
||||||
|
/// the other preview. A styled track takes none of it unless it is asked to;
|
||||||
|
/// see [`SubtitleStyle`].
|
||||||
|
fn subtitle_form(ui: &mut egui::Ui, style: &SubtitleStyle, actions: &mut Vec<Action>) {
|
||||||
|
ui::widgets::settings_group(
|
||||||
|
ui,
|
||||||
|
"Subtitles",
|
||||||
|
Some(
|
||||||
|
"Changes show on the playing video straight away. Plain subtitles (SRT) take all of this; styled ones (ASS) keep their own look unless you restyle them below.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
let mut chosen = style.clone();
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
subtitle_preview(ui, &chosen);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
ui::widgets::settings_row(ui, "Size", None, |ui| {
|
||||||
|
percent_slider(
|
||||||
|
ui,
|
||||||
|
&mut chosen.scale,
|
||||||
|
SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Position",
|
||||||
|
Some("How far up the picture the line sits."),
|
||||||
|
|ui| subtitle_position_slider(ui, &mut chosen.position),
|
||||||
|
);
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Font",
|
||||||
|
Some(if cfg!(target_os = "linux") {
|
||||||
|
"Any font installed here."
|
||||||
|
} else {
|
||||||
|
"Any font installed here. The sample above is drawn in the app's own font."
|
||||||
|
}),
|
||||||
|
|ui| {
|
||||||
|
ui::widgets::toggle(ui, &mut chosen.italic, "Italic");
|
||||||
|
ui::widgets::toggle(ui, &mut chosen.bold, "Bold");
|
||||||
|
font_picker(ui, &mut chosen.font);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui::widgets::settings_row(ui, "Text colour", None, |ui| {
|
||||||
|
colour_button(ui, &mut chosen.colour);
|
||||||
|
});
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Background",
|
||||||
|
Some("A box reads on bright scenes; an outline hides less of the picture."),
|
||||||
|
|ui| {
|
||||||
|
let choices =
|
||||||
|
SubtitleBackdrop::ALL.map(|backdrop| (backdrop, backdrop.label()));
|
||||||
|
if let Some(backdrop) = ui::widgets::segmented(ui, chosen.backdrop, &choices) {
|
||||||
|
chosen.backdrop = backdrop;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match chosen.backdrop {
|
||||||
|
SubtitleBackdrop::Outline => {
|
||||||
|
ui::widgets::settings_row(ui, "Outline", None, |ui| {
|
||||||
|
colour_button(ui, &mut chosen.outline_colour);
|
||||||
|
ui.add(
|
||||||
|
egui::Slider::new(
|
||||||
|
&mut chosen.outline_size,
|
||||||
|
0.0..=SubtitleStyle::MAX_OUTLINE,
|
||||||
|
)
|
||||||
|
.step_by(0.05)
|
||||||
|
.fixed_decimals(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SubtitleBackdrop::Box => {
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Box colour",
|
||||||
|
Some("Its opacity is how much of the picture shows through."),
|
||||||
|
|ui| colour_button(ui, &mut chosen.box_colour),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui::widgets::settings_row(ui, "Shadow", Some("How far it falls. 0 is none."), |ui| {
|
||||||
|
colour_button(ui, &mut chosen.shadow_colour);
|
||||||
|
ui.add(
|
||||||
|
egui::Slider::new(&mut chosen.shadow_offset, 0.0..=SubtitleStyle::MAX_SHADOW)
|
||||||
|
.step_by(0.1)
|
||||||
|
.fixed_decimals(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Blur",
|
||||||
|
Some("Softens the edges. Not shown in the sample."),
|
||||||
|
|ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::Slider::new(&mut chosen.blur, 0.0..=SubtitleStyle::MAX_BLUR)
|
||||||
|
.step_by(0.1)
|
||||||
|
.fixed_decimals(1),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Restyle styled subtitles",
|
||||||
|
Some(
|
||||||
|
"Draw styled (ASS) dialogue in this style too, size and position included. Signs, songs and karaoke can come out wrong.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
ui::widgets::toggle(ui, &mut chosen.restyle_styled, "");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui::widgets::settings_row(ui, "Reset", Some("Back to mpv's own look."), |ui| {
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
*style != SubtitleStyle::default(),
|
||||||
|
egui::Button::new("Reset"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
chosen = SubtitleStyle::default();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if chosen != *style {
|
||||||
|
actions.push(Action::SetSubtitleStyle(chosen.sanitized()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scale as a percentage slider, 100% being mpv's own size.
|
||||||
|
pub(crate) fn percent_slider(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
scale: &mut f64,
|
||||||
|
range: std::ops::RangeInclusive<f64>,
|
||||||
|
) -> egui::Response {
|
||||||
|
let mut percent = *scale * 100.0;
|
||||||
|
let response = ui.add(
|
||||||
|
egui::Slider::new(&mut percent, range.start() * 100.0..=range.end() * 100.0)
|
||||||
|
.step_by(5.0)
|
||||||
|
.suffix("%")
|
||||||
|
.fixed_decimals(0),
|
||||||
|
);
|
||||||
|
*scale = percent / 100.0;
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the line sits, 0 at the top of the picture and 100 at the bottom —
|
||||||
|
/// shown the other way up, as height above the bottom edge, which is how
|
||||||
|
/// anyone would describe moving subtitles up.
|
||||||
|
pub(crate) fn subtitle_position_slider(ui: &mut egui::Ui, position: &mut f64) -> egui::Response {
|
||||||
|
let mut height = 100.0 - *position;
|
||||||
|
let response = ui.add(
|
||||||
|
egui::Slider::new(&mut height, 0.0..=90.0)
|
||||||
|
.step_by(1.0)
|
||||||
|
.suffix("% up")
|
||||||
|
.fixed_decimals(0),
|
||||||
|
);
|
||||||
|
*position = 100.0 - height;
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A swatch that opens a colour wheel with an opacity slider.
|
||||||
|
fn colour_button(ui: &mut egui::Ui, colour: &mut Colour) {
|
||||||
|
let [r, g, b, a] = colour.to_array();
|
||||||
|
let mut picked = egui::Color32::from_rgba_unmultiplied(r, g, b, a);
|
||||||
|
// The `Color32` form rather than `Hsva`, because egui remembers the hue
|
||||||
|
// behind it: rebuilt from RGB each frame, black and grey have none, and
|
||||||
|
// the wheel would snap back to red under the mouse.
|
||||||
|
let response = egui::color_picker::color_edit_button_srgba(
|
||||||
|
ui,
|
||||||
|
&mut picked,
|
||||||
|
egui::color_picker::Alpha::OnlyBlend,
|
||||||
|
);
|
||||||
|
// Only on a change: a round trip through premultiplied alpha can nudge
|
||||||
|
// an untouched colour by a step.
|
||||||
|
if response.changed() {
|
||||||
|
*colour = Colour::from(picked.to_srgba_unmultiplied());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The font: the installed families to pick from, and a box to type any name
|
||||||
|
/// into — the only way on a system whose families the app cannot list.
|
||||||
|
fn font_picker(ui: &mut egui::Ui, font: &mut Option<String>) {
|
||||||
|
let filter_id = egui::Id::new("subtitle-font-filter");
|
||||||
|
let mut filter: String = ui.data(|data| data.get_temp(filter_id)).unwrap_or_default();
|
||||||
|
let families = theme::font_families(ui.ctx());
|
||||||
|
egui::ComboBox::from_id_salt("subtitle-font")
|
||||||
|
.selected_text(font.as_deref().unwrap_or("Default"))
|
||||||
|
.width(200.0)
|
||||||
|
.height(320.0)
|
||||||
|
// Not on any click: the search box is clicked into.
|
||||||
|
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut filter)
|
||||||
|
.hint_text("Search or type a font name")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
)
|
||||||
|
.request_focus();
|
||||||
|
let typed = filter.trim();
|
||||||
|
if ui.selectable_label(font.is_none(), "Default").clicked() {
|
||||||
|
*font = None;
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
let wanted = typed.to_lowercase();
|
||||||
|
let mut exact = false;
|
||||||
|
for family in families.unwrap_or_default() {
|
||||||
|
if !family.to_lowercase().contains(&wanted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
exact |= family.eq_ignore_ascii_case(typed);
|
||||||
|
let label = egui::RichText::new(family.as_str());
|
||||||
|
if ui
|
||||||
|
.selectable_label(font.as_deref() == Some(family.as_str()), label)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
*font = Some(family.clone());
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if families.is_none() {
|
||||||
|
ui.label(ui::muted("Reading the installed fonts…"));
|
||||||
|
}
|
||||||
|
if !typed.is_empty() && !exact && ui.button(format!("Use “{typed}”")).clicked() {
|
||||||
|
*font = Some(typed.to_owned());
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.data_mut(|data| data.insert_temp(filter_id, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sample line, half on a bright sky and half on night, drawn in the
|
||||||
|
/// chosen style — so whether it reads on both is visible before a film is.
|
||||||
|
///
|
||||||
|
/// An approximation. The font is the face fontconfig picks for the chosen one,
|
||||||
|
/// as libass's is, but only on Linux; elsewhere, and while it loads, it is the
|
||||||
|
/// app's own. A bold or italic the face lacks is made up, as libass does, and
|
||||||
|
/// there is no blur. Proportions follow mpv's, where sizes are in pixels of a
|
||||||
|
/// 38-pixel font.
|
||||||
|
fn subtitle_preview(ui: &mut egui::Ui, style: &SubtitleStyle) {
|
||||||
|
const HEIGHT: f32 = 132.0;
|
||||||
|
/// mpv's `sub-font-size`, the unit every other size is relative to.
|
||||||
|
const MPV_FONT: f32 = 38.0;
|
||||||
|
let width = ui.available_width();
|
||||||
|
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, HEIGHT), egui::Sense::hover());
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||||
|
|
||||||
|
// Sky above haze on the left, a night street on the right.
|
||||||
|
let half = egui::Rect::from_min_max(rect.min, egui::pos2(rect.center().x, rect.max.y));
|
||||||
|
let mut sky = egui::Mesh::default();
|
||||||
|
sky.colored_vertex(half.left_top(), egui::Color32::from_rgb(0x8E, 0xC5, 0xF0));
|
||||||
|
sky.colored_vertex(half.right_top(), egui::Color32::from_rgb(0xB8, 0xDC, 0xF8));
|
||||||
|
sky.colored_vertex(
|
||||||
|
half.right_bottom(),
|
||||||
|
egui::Color32::from_rgb(0xFA, 0xF7, 0xEE),
|
||||||
|
);
|
||||||
|
sky.colored_vertex(
|
||||||
|
half.left_bottom(),
|
||||||
|
egui::Color32::from_rgb(0xF2, 0xF0, 0xE8),
|
||||||
|
);
|
||||||
|
sky.add_triangle(0, 1, 2);
|
||||||
|
sky.add_triangle(0, 2, 3);
|
||||||
|
painter.rect_filled(rect, radius, egui::Color32::from_rgb(0x14, 0x16, 0x22));
|
||||||
|
painter.add(sky);
|
||||||
|
|
||||||
|
let font_px = 22.0 * style.scale as f32;
|
||||||
|
let unit = font_px / MPV_FONT;
|
||||||
|
let face = theme::subtitle_face(
|
||||||
|
ui.ctx(),
|
||||||
|
style.font.as_deref().unwrap_or(SubtitleStyle::DEFAULT_FONT),
|
||||||
|
style.bold,
|
||||||
|
style.italic,
|
||||||
|
);
|
||||||
|
let (family, made_up_bold, made_up_italic) = match face {
|
||||||
|
Some(face) => (
|
||||||
|
theme::subtitle_family(),
|
||||||
|
style.bold && !face.bold,
|
||||||
|
style.italic && !face.italic,
|
||||||
|
),
|
||||||
|
None if style.bold => (theme::semibold(), false, style.italic),
|
||||||
|
None => (egui::FontFamily::Proportional, false, style.italic),
|
||||||
|
};
|
||||||
|
let job = egui::text::LayoutJob::single_section(
|
||||||
|
"Can you read this on both sides?".to_owned(),
|
||||||
|
egui::TextFormat {
|
||||||
|
font_id: egui::FontId::new(font_px, family),
|
||||||
|
color: egui::Color32::PLACEHOLDER,
|
||||||
|
italics: made_up_italic,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let galley = painter.layout_job(job);
|
||||||
|
let size = galley.size();
|
||||||
|
// Bottom of the line at `position` percent down, as mpv places it, kept
|
||||||
|
// inside the strip.
|
||||||
|
let margin = 8.0;
|
||||||
|
let bottom = rect.top() + HEIGHT * style.position as f32 / 100.0 - margin;
|
||||||
|
let top = bottom
|
||||||
|
.min(rect.bottom() - margin)
|
||||||
|
.max(rect.top() + margin + size.y)
|
||||||
|
- size.y;
|
||||||
|
let at = egui::pos2(rect.center().x - size.x / 2.0, top);
|
||||||
|
let colour = |colour: Colour| {
|
||||||
|
let [r, g, b, a] = colour.to_array();
|
||||||
|
egui::Color32::from_rgba_unmultiplied(r, g, b, a)
|
||||||
|
};
|
||||||
|
|
||||||
|
let shadow = style.shadow_offset as f32 * unit;
|
||||||
|
match style.backdrop {
|
||||||
|
SubtitleBackdrop::Box => {
|
||||||
|
let padding = 6.0 * unit;
|
||||||
|
let line = egui::Rect::from_min_size(at, size).expand(padding);
|
||||||
|
if shadow > 0.0 {
|
||||||
|
painter.rect_filled(
|
||||||
|
line.translate(egui::vec2(shadow, shadow)),
|
||||||
|
0.0,
|
||||||
|
colour(style.shadow_colour),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
painter.rect_filled(line, 0.0, colour(style.box_colour));
|
||||||
|
}
|
||||||
|
SubtitleBackdrop::Outline => {
|
||||||
|
let outline = style.outline_size as f32 * unit;
|
||||||
|
let rings = |offset: egui::Vec2, ink: egui::Color32| {
|
||||||
|
if outline <= 0.0 {
|
||||||
|
painter.galley_with_override_text_color(at + offset, galley.clone(), ink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for step in 0..8 {
|
||||||
|
let angle = step as f32 * std::f32::consts::TAU / 8.0;
|
||||||
|
let ring = egui::vec2(angle.cos(), angle.sin()) * outline;
|
||||||
|
painter.galley_with_override_text_color(
|
||||||
|
at + offset + ring,
|
||||||
|
galley.clone(),
|
||||||
|
ink,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if shadow > 0.0 {
|
||||||
|
rings(egui::vec2(shadow, shadow), colour(style.shadow_colour));
|
||||||
|
}
|
||||||
|
if outline > 0.0 {
|
||||||
|
rings(egui::Vec2::ZERO, colour(style.outline_colour));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if made_up_bold {
|
||||||
|
// libass thickens a face with no bold by a stroke of its own colour.
|
||||||
|
let thicken = (font_px / 40.0).max(0.5);
|
||||||
|
painter.galley_with_override_text_color(
|
||||||
|
at + egui::vec2(thicken, 0.0),
|
||||||
|
galley.clone(),
|
||||||
|
colour(style.colour),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
painter.galley_with_override_text_color(at, galley, colour(style.colour));
|
||||||
|
}
|
||||||
|
|
||||||
/// The streaming cache: how big it may get, and a way to empty it.
|
/// The streaming cache: how big it may get, and a way to empty it.
|
||||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||||
ui::widgets::settings_group(
|
ui::widgets::settings_group(
|
||||||
@@ -511,8 +874,8 @@ fn metadata_form(
|
|||||||
without this every tile is a pair of initials. Turning it on sends the titles \
|
without this every tile is a pair of initials. Turning it on sends the titles \
|
||||||
in your library — not your files, and nothing about what you have watched — to \
|
in your library — not your files, and nothing about what you have watched — to \
|
||||||
the provider you choose, over HTTPS, each time a new one appears. With AniList, \
|
the provider you choose, over HTTPS, each time a new one appears. With AniList, \
|
||||||
the ids it matched — not the titles — then go to ani.zip for episode names and \
|
a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \
|
||||||
wide artwork.",
|
not the titles — go to ani.zip for episode names and wide artwork.",
|
||||||
));
|
));
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
@@ -526,7 +889,7 @@ fn metadata_form(
|
|||||||
if settings.enabled {
|
if settings.enabled {
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(ui::muted("Provider"));
|
ui.label(ui::muted("Provider"));
|
||||||
for provider in ProviderId::ALL {
|
for provider in ProviderId::CHOOSABLE {
|
||||||
if ui
|
if ui
|
||||||
.radio(settings.provider == provider, provider.label())
|
.radio(settings.provider == provider, provider.label())
|
||||||
.on_hover_text(provider.description())
|
.on_hover_text(provider.description())
|
||||||
@@ -538,9 +901,17 @@ fn metadata_form(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.provider.needs_api_key() {
|
// TMDB's is the only key there is: the provider's with TMDB chosen,
|
||||||
|
// and with AniList, the art of films TVDB has none for.
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
if settings.provider.needs_api_key() {
|
||||||
|
ui.label(ui::muted("TMDB API key"));
|
||||||
|
} else {
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"TMDB API key, optional. With one, films get TMDB's backdrops and logos, \
|
||||||
|
asked for by the film's TMDB id alone.",
|
||||||
|
));
|
||||||
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(api_key)
|
egui::TextEdit::singleline(api_key)
|
||||||
@@ -550,7 +921,7 @@ fn metadata_form(
|
|||||||
);
|
);
|
||||||
if ui.button("Save key").clicked() {
|
if ui.button("Save key").clicked() {
|
||||||
actions.push(Action::SetApiKey {
|
actions.push(Action::SetApiKey {
|
||||||
provider: settings.provider,
|
provider: ProviderId::Tmdb,
|
||||||
key: std::mem::take(api_key),
|
key: std::mem::take(api_key),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -559,7 +930,6 @@ fn metadata_form(
|
|||||||
"Stored in your system keyring, never in a config file. Leave the box \
|
"Stored in your system keyring, never in a config file. Leave the box \
|
||||||
empty and save to forget it.",
|
empty and save to forget it.",
|
||||||
));
|
));
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("N / P", "Next and previous episode"),
|
("N / P", "Next and previous episode"),
|
||||||
("[ / ]", "Slower and faster"),
|
("[ / ]", "Slower and faster"),
|
||||||
("S", "Skip the opening or the credits"),
|
("S", "Skip the opening or the credits"),
|
||||||
|
("Z / X", "Subtitles 0.1 s earlier and later"),
|
||||||
("E", "The list of episodes"),
|
("E", "The list of episodes"),
|
||||||
("F or double-click", "Fullscreen"),
|
("F or double-click", "Fullscreen"),
|
||||||
("Esc", "Leave fullscreen, then the player"),
|
(
|
||||||
|
"Esc",
|
||||||
|
"Close a panel, leave fullscreen, then leave the player",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,15 +16,29 @@ pub struct OfflineView<'a> {
|
|||||||
pub files: &'a std::collections::HashSet<DownloadKey>,
|
pub files: &'a std::collections::HashSet<DownloadKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the title stands beyond its own files.
|
||||||
|
pub struct Context<'a> {
|
||||||
|
/// The keys of its whole franchise in release order, if it has one.
|
||||||
|
pub franchise: Option<&'a [String]>,
|
||||||
|
/// The keys of the titles most like it, best first.
|
||||||
|
pub similar: &'a [String],
|
||||||
|
pub on_list: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
library: &Library,
|
library: &Library,
|
||||||
key: &str,
|
key: &str,
|
||||||
franchise: Option<&[String]>,
|
context: Context<'_>,
|
||||||
offline: OfflineView<'_>,
|
offline: OfflineView<'_>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
|
let Context {
|
||||||
|
franchise,
|
||||||
|
similar,
|
||||||
|
on_list,
|
||||||
|
} = context;
|
||||||
let Some(title) = library.get(key) else {
|
let Some(title) = library.get(key) else {
|
||||||
// The catalog was replaced under the page — a recrawl that dropped this
|
// The catalog was replaced under the page — a recrawl that dropped this
|
||||||
// title. Nothing to show, so go back rather than draw an empty shell.
|
// title. Nothing to show, so go back rather than draw an empty shell.
|
||||||
@@ -32,7 +46,7 @@ pub fn show(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if back_link(ui).clicked() {
|
if back_link(ui, "Library").clicked() {
|
||||||
actions.push(Action::Goto(Page::Library));
|
actions.push(Action::Goto(Page::Library));
|
||||||
}
|
}
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
@@ -40,14 +54,21 @@ pub fn show(
|
|||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
header(ui, art, title, &offline, actions);
|
header(ui, art, title, on_list, &offline, actions);
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
|
|
||||||
if let Some(keys) = franchise {
|
if let Some(keys) = franchise {
|
||||||
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
|
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
|
||||||
if members.len() > 1 {
|
if members.len() > 1 {
|
||||||
ui::section(ui, "In this franchise");
|
ui::section(ui, "In this franchise");
|
||||||
franchise_row(ui, art, title, &members, actions);
|
cover_row(
|
||||||
|
ui,
|
||||||
|
art,
|
||||||
|
("franchise", &title.key),
|
||||||
|
Some(title),
|
||||||
|
&members,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +106,27 @@ pub fn show(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, label)| (index, label.as_str()))
|
.map(|(index, label)| (index, label.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
|
// A long-running show's row of pills ran off the page;
|
||||||
|
// past a handful it is a list to pick from instead.
|
||||||
|
let chosen = if choices.len() > SEASON_PILLS {
|
||||||
|
egui::ComboBox::from_id_salt(("season-list", &title.key))
|
||||||
|
.selected_text(labels[picked].as_str())
|
||||||
|
.height(360.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
let mut chosen = None;
|
||||||
|
for &(index, label) in &choices {
|
||||||
|
if ui.selectable_label(index == picked, label).clicked() {
|
||||||
|
chosen = Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen
|
||||||
|
})
|
||||||
|
.inner
|
||||||
|
.flatten()
|
||||||
|
} else {
|
||||||
|
ui::widgets::segmented(ui, picked, &choices)
|
||||||
|
};
|
||||||
|
if let Some(index) = chosen {
|
||||||
picked = index;
|
picked = index;
|
||||||
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
||||||
}
|
}
|
||||||
@@ -109,26 +150,138 @@ pub fn show(
|
|||||||
episode_row(ui, art, title, season, episode, &offline, actions);
|
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||||
}
|
}
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
let cast = art
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.map(|details| details.cast.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !cast.is_empty() {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui::section(ui, "Cast");
|
||||||
|
cast_row(ui, art, &title.key, &cast, actions);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under the episodes rather than over them: it is where to go
|
||||||
|
// once this one is done, not what the page is for.
|
||||||
|
let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect();
|
||||||
|
if !similar.is_empty() {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui::section(ui, "More like this");
|
||||||
|
cover_row(ui, art, ("similar", &title.key), None, &similar, actions);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every title of the franchise, in release order, as covers — this one
|
/// The characters, each a round portrait over their name and who voices
|
||||||
/// marked rather than left out, so the row says where it sits in the story.
|
/// them. A voice actor opens every title in the library they are in.
|
||||||
fn franchise_row(
|
fn cast_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
current: &Title,
|
key: &str,
|
||||||
|
cast: &[pstr_core::metadata::CastMember],
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 88.0;
|
||||||
|
ui::shelf(ui, ("cast", key), |ui| {
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::space::L;
|
||||||
|
for (i, member) in cast.iter().enumerate() {
|
||||||
|
let tile = ui.allocate_ui(egui::vec2(SIZE + 16.0, SIZE + 52.0), |ui| {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::hover());
|
||||||
|
let picture = member
|
||||||
|
.character_image
|
||||||
|
.as_deref()
|
||||||
|
.or(member.actor_image.as_deref())
|
||||||
|
.and_then(|url| art.picture(url));
|
||||||
|
let painter = ui.painter();
|
||||||
|
match picture {
|
||||||
|
Some(texture) => {
|
||||||
|
let shape = egui::epaint::RectShape::filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same((SIZE / 2.0) as u8),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
)
|
||||||
|
.with_texture(
|
||||||
|
texture.id(),
|
||||||
|
super::cover_uv(texture.size_vec2(), rect.size()),
|
||||||
|
);
|
||||||
|
painter.add(shape);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
painter.circle_filled(rect.center(), SIZE / 2.0, theme::card());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Caption
|
||||||
|
.rich(&member.character)
|
||||||
|
.color(theme::text()),
|
||||||
|
)
|
||||||
|
.truncate()
|
||||||
|
.selectable(false),
|
||||||
|
);
|
||||||
|
if let Some(actor) = &member.actor {
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(ui::muted(actor))
|
||||||
|
.truncate()
|
||||||
|
.selectable(false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rect
|
||||||
|
})
|
||||||
|
.inner
|
||||||
|
});
|
||||||
|
// The whole tile opens the actor's page, names included, not only the portrait.
|
||||||
|
let Some(actor) = &member.actor else { continue };
|
||||||
|
let response = ui
|
||||||
|
.interact(
|
||||||
|
tile.response.rect,
|
||||||
|
ui.id().with(("cast", i)),
|
||||||
|
egui::Sense::click(),
|
||||||
|
)
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text(format!("Everything in the library with {actor}"));
|
||||||
|
if response.hovered() {
|
||||||
|
ui.painter().circle_stroke(
|
||||||
|
tile.inner.center(),
|
||||||
|
SIZE / 2.0,
|
||||||
|
egui::Stroke::new(2.0, theme::accent()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Browse {
|
||||||
|
facet: pstr_core::discover::Facet::Person(actor.clone()),
|
||||||
|
from: Some(key.to_owned()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of covers: every title of the franchise in release order, with
|
||||||
|
/// `current` marked rather than left out, so the row says where it sits in
|
||||||
|
/// the story — or, with no `current`, the titles like this one.
|
||||||
|
fn cover_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
id: impl std::hash::Hash + std::fmt::Debug,
|
||||||
|
current: Option<&Title>,
|
||||||
members: &[&Title],
|
members: &[&Title],
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
egui::ScrollArea::horizontal()
|
ui::shelf(ui, id, |ui| {
|
||||||
.id_salt(("franchise", ¤t.key))
|
|
||||||
.auto_shrink([false, true])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
for member in members {
|
for member in members {
|
||||||
let here = member.key == current.key;
|
let here = current.is_some_and(|current| member.key == current.key);
|
||||||
let picture = art.poster(member);
|
let picture = art.poster(member);
|
||||||
let found = art
|
let found = art
|
||||||
.metadata
|
.metadata
|
||||||
@@ -192,9 +345,10 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// "← Library", as a link rather than a button: it is navigation, and a
|
/// "← Library", as a link rather than a button: it is navigation, and a
|
||||||
/// filled button beside the page's real actions competed with them.
|
/// filled button beside the page's real actions competed with them. `to`
|
||||||
fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
/// names where it goes.
|
||||||
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
|
pub(crate) fn back_link(ui: &mut egui::Ui, to: &str) -> egui::Response {
|
||||||
|
let text = theme::Role::Label.rich(format!("{} {to}", egui_phosphor::regular::ARROW_LEFT));
|
||||||
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
|
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
|
||||||
if response.hovered() {
|
if response.hovered() {
|
||||||
ui.painter().hline(
|
ui.painter().hline(
|
||||||
@@ -218,7 +372,7 @@ fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &m
|
|||||||
} else {
|
} else {
|
||||||
"Mark season watched"
|
"Mark season watched"
|
||||||
};
|
};
|
||||||
if ui.button(label).clicked() {
|
if ui::tonal_button(ui, label).clicked() {
|
||||||
actions.push(Action::MarkEpisodes {
|
actions.push(Action::MarkEpisodes {
|
||||||
key: title.key.clone(),
|
key: title.key.clone(),
|
||||||
links: season
|
links: season
|
||||||
@@ -272,6 +426,7 @@ fn header(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
title: &Title,
|
title: &Title,
|
||||||
|
on_list: bool,
|
||||||
offline: &OfflineView<'_>,
|
offline: &OfflineView<'_>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -323,7 +478,9 @@ fn header(
|
|||||||
// Text in a column a person can read, with the picture to the
|
// Text in a column a person can read, with the picture to the
|
||||||
// right of it rather than under it.
|
// right of it rather than under it.
|
||||||
ui.set_max_width(ui.available_width().min(820.0));
|
ui.set_max_width(ui.available_width().min(820.0));
|
||||||
ui.label(theme::Role::Display.rich(&shown).strong());
|
if ui::logo_heading(ui, art, title, 120.0).is_none() {
|
||||||
|
ui.label(theme::Role::Hero.rich(&shown).color(theme::text()));
|
||||||
|
}
|
||||||
// Whichever of the share's name and the provider's is not the one
|
// Whichever of the share's name and the provider's is not the one
|
||||||
// shown. Worth showing rather than only replacing: a viewer should
|
// shown. Worth showing rather than only replacing: a viewer should
|
||||||
// be able to tell what the match actually matched, and find the
|
// be able to tell what the match actually matched, and find the
|
||||||
@@ -346,6 +503,30 @@ fn header(
|
|||||||
}
|
}
|
||||||
ui.add_space(theme::space::XS);
|
ui.add_space(theme::space::XS);
|
||||||
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
||||||
|
// What the files are, rather than what the title is: set apart
|
||||||
|
// from the line above as small outlined marks, the way a box
|
||||||
|
// prints them.
|
||||||
|
//
|
||||||
|
// After where the title stands, which is the one fact here that
|
||||||
|
// changes week to week.
|
||||||
|
let quality = title.quality().badges();
|
||||||
|
let status = found
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.and_then(|details| status_text(details, now()));
|
||||||
|
if !quality.is_empty() || status.is_some() {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
||||||
|
if let Some((text, live)) = &status {
|
||||||
|
ui::widgets::status_chip(ui, text, *live);
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
}
|
||||||
|
for badge in quality {
|
||||||
|
ui::widgets::spec_badge(ui, badge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -372,8 +553,7 @@ fn header(
|
|||||||
}
|
}
|
||||||
// Beside the button it is the alternative to.
|
// Beside the button it is the alternative to.
|
||||||
if resuming
|
if resuming
|
||||||
&& ui
|
&& ui::tonal_button(ui, "Start over")
|
||||||
.button("Start over")
|
|
||||||
.on_hover_text("Play from the beginning")
|
.on_hover_text("Play from the beginning")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -383,18 +563,17 @@ fn header(
|
|||||||
None,
|
None,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
ui::list_button(ui, &title.key, on_list, actions);
|
||||||
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
||||||
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||||
if all_offline {
|
if all_offline {
|
||||||
if ui
|
if ui::tonal_button(ui, "Delete show")
|
||||||
.button("Delete show")
|
|
||||||
.on_hover_text("Delete the offline copies; keep the online source")
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
||||||
}
|
}
|
||||||
} else if ui
|
} else if ui::tonal_button(ui, "Download show")
|
||||||
.button("Download show")
|
|
||||||
.on_hover_text("Download every episode for disconnected playback")
|
.on_hover_text("Download every episode for disconnected playback")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -429,11 +608,13 @@ fn header(
|
|||||||
// wrongly. Offered whether or not anything matched: the two
|
// wrongly. Offered whether or not anything matched: the two
|
||||||
// cases it exists for are a title with no poster at all and a
|
// cases it exists for are a title with no poster at all and a
|
||||||
// title wearing someone else's.
|
// title wearing someone else's.
|
||||||
if ui
|
if ui::tonal_button(
|
||||||
.button(match &found {
|
ui,
|
||||||
|
match &found {
|
||||||
Some(_) => "Change match",
|
Some(_) => "Change match",
|
||||||
None => "Match…",
|
None => "Match…",
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.on_hover_text("Search the metadata provider and pick the entry yourself")
|
.on_hover_text("Search the metadata provider and pick the entry yourself")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -443,14 +624,14 @@ fn header(
|
|||||||
|
|
||||||
if let Some(found) = &found {
|
if let Some(found) = &found {
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
description(ui, found);
|
description(ui, found, &title.key, actions);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some((texture, index)) = backdrop {
|
if let Some((texture, index)) = backdrop {
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
band(ui, &texture, index);
|
band(ui, &texture, index, art.mood(title));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,10 +645,15 @@ fn header(
|
|||||||
/// page colour so text laid over it stays readable whatever the still happens to
|
/// page colour so text laid over it stays readable whatever the still happens to
|
||||||
/// be, and a fade into the page along the bottom so the band ends rather than
|
/// be, and a fade into the page along the bottom so the band ends rather than
|
||||||
/// stops.
|
/// stops.
|
||||||
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
|
fn band(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
texture: &egui::TextureHandle,
|
||||||
|
index: egui::layers::ShapeIdx,
|
||||||
|
mood: Option<egui::Color32>,
|
||||||
|
) {
|
||||||
// Full width of the window, not of the content: the panel's own margin is
|
// Full width of the window, not of the content: the panel's own margin is
|
||||||
// what the band has to reach past, or it reads as a picture in a box.
|
// what the band has to reach past, or it reads as a picture in a box.
|
||||||
const BLEED: f32 = 18.0;
|
const BLEED: f32 = super::PAGE_MARGIN.x;
|
||||||
let content = ui.min_rect();
|
let content = ui.min_rect();
|
||||||
let rect = egui::Rect::from_min_max(
|
let rect = egui::Rect::from_min_max(
|
||||||
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
|
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
|
||||||
@@ -501,7 +687,12 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
|||||||
//
|
//
|
||||||
// Heavier still when there is no fade coming, since then these are the only
|
// Heavier still when there is no fade coming, since then these are the only
|
||||||
// things standing between the text and the picture.
|
// things standing between the text and the picture.
|
||||||
|
//
|
||||||
|
// Both in the page colour tinted by the picture's own, so each title's
|
||||||
|
// page carries its light; the fade at the bottom stays the page's, since
|
||||||
|
// that is what it ends in.
|
||||||
let ramps = theme::ramps_on();
|
let ramps = theme::ramps_on();
|
||||||
|
let veil = theme::mood_veil(mood);
|
||||||
layers.push(
|
layers.push(
|
||||||
egui::epaint::RectShape::filled(
|
egui::epaint::RectShape::filled(
|
||||||
rect,
|
rect,
|
||||||
@@ -509,7 +700,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
|||||||
// Lighter with the ramps on: the horizontal one below is solid
|
// Lighter with the ramps on: the horizontal one below is solid
|
||||||
// where the text is, so this coat only has to calm the picture's
|
// where the text is, so this coat only has to calm the picture's
|
||||||
// right side, not make it disappear.
|
// right side, not make it disappear.
|
||||||
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
|
veil.gamma_multiply(if ramps { 0.72 } else { 0.94 }),
|
||||||
)
|
)
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
@@ -521,7 +712,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
|||||||
layers.push(theme::fade_shape(
|
layers.push(theme::fade_shape(
|
||||||
ui.ctx(),
|
ui.ctx(),
|
||||||
text_side,
|
text_side,
|
||||||
theme::background(),
|
veil,
|
||||||
theme::Direction::Horizontal,
|
theme::Direction::Horizontal,
|
||||||
// Solid at the left, where the poster and the text are.
|
// Solid at the left, where the poster and the text are.
|
||||||
false,
|
false,
|
||||||
@@ -548,17 +739,31 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What the provider had to say. Only ever drawn under a match.
|
/// What the provider had to say. Only ever drawn under a match.
|
||||||
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
///
|
||||||
let tags = found
|
/// Its genres, studios and director are chips that open every title in the
|
||||||
.details
|
/// library sharing them.
|
||||||
.as_ref()
|
fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mut Vec<Action>) {
|
||||||
|
use pstr_core::discover::Facet;
|
||||||
|
let browse = |facet: Facet| {
|
||||||
|
Action::Goto(Page::Browse {
|
||||||
|
facet,
|
||||||
|
from: Some(key.to_owned()),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let details = found.details.as_ref();
|
||||||
|
let tags = details
|
||||||
.map(|details| details.tags.as_slice())
|
.map(|details| details.tags.as_slice())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !found.genres.is_empty() || !tags.is_empty() {
|
if !found.genres.is_empty() || !tags.is_empty() {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 6.0;
|
ui.spacing_mut().item_spacing.x = 6.0;
|
||||||
for genre in &found.genres {
|
for genre in &found.genres {
|
||||||
ui::widgets::chip(ui, genre);
|
if ui::widgets::link_chip(ui, genre)
|
||||||
|
.on_hover_text(format!("Every {genre} title"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(browse(Facet::Genre(genre.clone())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// After the genres and quieter than them: a tag is the
|
// After the genres and quieter than them: a tag is the
|
||||||
// provider's community describing it, not what it is filed as.
|
// provider's community describing it, not what it is filed as.
|
||||||
@@ -568,6 +773,38 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
|||||||
});
|
});
|
||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
}
|
}
|
||||||
|
// Who made it, out of the grey line and onto chips, so the names lead
|
||||||
|
// somewhere.
|
||||||
|
let studios = details
|
||||||
|
.map(|details| &details.studios[..details.studios.len().min(2)])
|
||||||
|
.unwrap_or_default();
|
||||||
|
let directors = details
|
||||||
|
.map(|details| &details.directors[..details.directors.len().min(1)])
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !studios.is_empty() || !directors.is_empty() {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 6.0;
|
||||||
|
for (label, names, facet) in [
|
||||||
|
("Studio", studios, Facet::Studio as fn(String) -> Facet),
|
||||||
|
("Director", directors, Facet::Person),
|
||||||
|
] {
|
||||||
|
if names.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(label));
|
||||||
|
for name in names {
|
||||||
|
if ui::widgets::link_chip(ui, name)
|
||||||
|
.on_hover_text(format!("Everything in the library by {name}"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(browse(facet(name.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
}
|
||||||
if let Some(overview) = &found.overview {
|
if let Some(overview) = &found.overview {
|
||||||
// Four lines, then "More". A provider's synopsis runs to three
|
// Four lines, then "More". A provider's synopsis runs to three
|
||||||
// paragraphs on a long show, and all of them pushed the episodes —
|
// paragraphs on a long show, and all of them pushed the episodes —
|
||||||
@@ -605,13 +842,29 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(url) = &found.url {
|
let trailer = details
|
||||||
|
.and_then(|details| details.trailer.as_ref())
|
||||||
|
.and_then(|trailer| trailer.url());
|
||||||
|
if found.url.is_some() || trailer.is_some() {
|
||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// In the browser: the app itself never requests the trailer.
|
||||||
|
if let Some(trailer) = &trailer {
|
||||||
|
ui.hyperlink_to(
|
||||||
|
ui::muted(format!("{} Trailer", egui_phosphor::regular::FILM_STRIP)),
|
||||||
|
trailer,
|
||||||
|
)
|
||||||
|
.on_hover_text("Opens in your browser");
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
|
if let Some(url) = &found.url {
|
||||||
ui.hyperlink_to(
|
ui.hyperlink_to(
|
||||||
ui::muted(format!("More on {}", found.provider.label())),
|
ui::muted(format!("More on {}", found.provider.label())),
|
||||||
url,
|
url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grey line under the name: what the library knows, plus what the provider
|
/// The grey line under the name: what the library knows, plus what the provider
|
||||||
@@ -629,6 +882,15 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
if let Some(year) = title.year {
|
if let Some(year) = title.year {
|
||||||
parts.push(year.to_string());
|
parts.push(year.to_string());
|
||||||
}
|
}
|
||||||
|
// A film's running time is what it costs to start it. A series' is its
|
||||||
|
// episodes', which each row says.
|
||||||
|
if title.kind == TitleKind::Film
|
||||||
|
&& let Some(minutes) = found
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.and_then(|details| details.runtime)
|
||||||
|
{
|
||||||
|
parts.push(ui::format_runtime(minutes));
|
||||||
|
}
|
||||||
if title.kind == TitleKind::Series {
|
if title.kind == TitleKind::Series {
|
||||||
if title.seasons.len() > 1 {
|
if title.seasons.len() > 1 {
|
||||||
parts.push(ui::library::plural(title.seasons.len(), "season"));
|
parts.push(ui::library::plural(title.seasons.len(), "season"));
|
||||||
@@ -640,30 +902,12 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
parts.push(format!("{watched} watched"));
|
parts.push(format!("{watched} watched"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
|
if let Some(season) = found
|
||||||
if let Some(season) = details.season_label() {
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.and_then(|details| details.season_label())
|
||||||
|
{
|
||||||
parts.push(season);
|
parts.push(season);
|
||||||
}
|
}
|
||||||
parts.extend(details.studios.iter().take(2).cloned());
|
|
||||||
if let Some(director) = details.directors.first() {
|
|
||||||
parts.push(format!("dir. {director}"));
|
|
||||||
}
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map_or(0, |since| since.as_secs() as i64);
|
|
||||||
if let Some((episode, at)) = details.next_airing(now) {
|
|
||||||
let when = chrono::DateTime::from_timestamp(at, 0)
|
|
||||||
.map(|at| {
|
|
||||||
at.with_timezone(&chrono::Local)
|
|
||||||
.format("%a %-d %b, %H:%M")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
parts.push(format!("episode {episode} airs {when}"));
|
|
||||||
} else if details.is_airing() {
|
|
||||||
parts.push("airing".to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(found) = found {
|
if let Some(found) = found {
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
@@ -681,6 +925,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most seasons picked from a row of pills; past it, a list.
|
||||||
|
const SEASON_PILLS: usize = 5;
|
||||||
|
|
||||||
|
/// Unix seconds, now.
|
||||||
|
fn now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the status chip says — `Airing · episode 8 Sat 1 Nov, 14:00`,
|
||||||
|
/// `Finished` — and whether more is coming.
|
||||||
|
fn status_text(details: &pstr_core::metadata::TitleDetails, now: i64) -> Option<(String, bool)> {
|
||||||
|
let next = details.next_airing(now);
|
||||||
|
let label = details.status_label().or(next.map(|_| "Airing"))?;
|
||||||
|
let live = next.is_some() || details.is_airing();
|
||||||
|
Some(match next {
|
||||||
|
Some((episode, at)) => {
|
||||||
|
let when = chrono::DateTime::from_timestamp(at, 0)
|
||||||
|
.map(|at| {
|
||||||
|
at.with_timezone(&chrono::Local)
|
||||||
|
.format("%a %-d %b, %H:%M")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
(format!("{label} · episode {episode} {when}"), live)
|
||||||
|
}
|
||||||
|
None => (label.to_owned(), live),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// How big an episode's still is drawn.
|
/// How big an episode's still is drawn.
|
||||||
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
|
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
|
||||||
|
|
||||||
@@ -701,6 +976,7 @@ fn episode_row(
|
|||||||
found.name.clone(),
|
found.name.clone(),
|
||||||
found.overview.clone(),
|
found.overview.clone(),
|
||||||
found.air_date.clone(),
|
found.air_date.clone(),
|
||||||
|
found.runtime,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let watched = episode.is_watched();
|
let watched = episode.is_watched();
|
||||||
@@ -732,14 +1008,17 @@ fn episode_row(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let name = found
|
let name = found
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|(name, _, _)| name.clone())
|
.and_then(|(name, ..)| name.clone())
|
||||||
.unwrap_or_else(|| episode.detail().to_string());
|
.unwrap_or_else(|| episode.detail().to_string());
|
||||||
let mut job = egui::text::LayoutJob::default();
|
let mut job = egui::text::LayoutJob::default();
|
||||||
if !numbering.is_empty() {
|
if !numbering.is_empty() {
|
||||||
job.append(
|
job.append(
|
||||||
&format!("{numbering} "),
|
&format!("{numbering} "),
|
||||||
0.0,
|
0.0,
|
||||||
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
|
egui::TextFormat::simple(
|
||||||
|
egui::FontId::new(theme::Role::Label.size(), theme::semibold()),
|
||||||
|
theme::muted(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
job.append(
|
job.append(
|
||||||
@@ -762,9 +1041,10 @@ fn episode_row(
|
|||||||
ui.label(ui::muted(details(
|
ui.label(ui::muted(details(
|
||||||
episode,
|
episode,
|
||||||
found.as_ref().and_then(|f| f.2.as_deref()),
|
found.as_ref().and_then(|f| f.2.as_deref()),
|
||||||
|
found.as_ref().and_then(|f| f.3),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
if let Some((_, Some(overview), _)) = &found {
|
if let Some((_, Some(overview), ..)) = &found {
|
||||||
let mut job = egui::text::LayoutJob::simple(
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
overview.clone(),
|
overview.clone(),
|
||||||
theme::Role::Caption.font(),
|
theme::Role::Caption.font(),
|
||||||
@@ -836,9 +1116,9 @@ fn episode_row(
|
|||||||
ui.add_space(theme::space::XXS);
|
ui.add_space(theme::space::XXS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grey line under an episode's name: when it aired, how big it is, and
|
/// The grey line under an episode's name: when it aired, how long it runs,
|
||||||
/// where it would resume.
|
/// how big it is, and where it would resume.
|
||||||
fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
if let Some(at) = episode.resume_at() {
|
if let Some(at) = episode.resume_at() {
|
||||||
let left = episode
|
let left = episode
|
||||||
@@ -853,6 +1133,9 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
|||||||
if let Some(date) = air_date {
|
if let Some(date) = air_date {
|
||||||
parts.push(date.to_owned());
|
parts.push(date.to_owned());
|
||||||
}
|
}
|
||||||
|
if let Some(minutes) = runtime {
|
||||||
|
parts.push(ui::format_runtime(minutes));
|
||||||
|
}
|
||||||
match episode.node.size {
|
match episode.node.size {
|
||||||
// A file the share says is empty is one that will not play — an upload
|
// A file the share says is empty is one that will not play — an upload
|
||||||
// that never finished, usually. Worth saying on the row rather than
|
// that never finished, usually. Worth saying on the row rather than
|
||||||
@@ -888,12 +1171,20 @@ pub(crate) fn still(
|
|||||||
painter.rect_filled(rect, radius, theme::card_hover());
|
painter.rect_filled(rect, radius, theme::card_hover());
|
||||||
// Asked for only once the row is on screen: a season of twenty-five is
|
// Asked for only once the row is on screen: a season of twenty-five is
|
||||||
// twenty-five requests, and most of them are never scrolled to.
|
// twenty-five requests, and most of them are never scrolled to.
|
||||||
if let Some(texture) = art.still(&title.key, episode) {
|
let texture = art.still(&title.key, episode);
|
||||||
|
// Faded in like a tile's art, so a season's stills settle rather than pop.
|
||||||
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("art"),
|
||||||
|
texture.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
|
if let Some(texture) = texture {
|
||||||
let tint = if watched {
|
let tint = if watched {
|
||||||
egui::Color32::from_gray(150)
|
egui::Color32::from_gray(150)
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::WHITE
|
egui::Color32::WHITE
|
||||||
};
|
}
|
||||||
|
.gamma_multiply(art_in);
|
||||||
painter.add(
|
painter.add(
|
||||||
egui::epaint::RectShape::filled(rect, radius, tint)
|
egui::epaint::RectShape::filled(rect, radius, tint)
|
||||||
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||||
@@ -954,6 +1245,21 @@ pub(crate) fn still(
|
|||||||
theme::accent_fill(&painter, played, radius, 0.0);
|
theme::accent_fill(&painter, played, radius, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seen, said on the picture as well as by the mark at the row's end: a
|
||||||
|
// season is scanned by its stills, and a dimmed one alone read as one that
|
||||||
|
// had not loaded.
|
||||||
|
if watched {
|
||||||
|
let corner = rect.right_top() + egui::vec2(-14.0, 14.0);
|
||||||
|
painter.circle_filled(corner, 9.0, theme::accent());
|
||||||
|
painter.text(
|
||||||
|
corner,
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
egui_phosphor::regular::CHECK,
|
||||||
|
theme::Role::Micro.font(),
|
||||||
|
theme::on_accent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if response
|
if response
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
.on_hover_text(if episode.resume_at().is_some() {
|
.on_hover_text(if episode.resume_at().is_some() {
|
||||||
@@ -1037,3 +1343,36 @@ fn key_of(episode: &Episode) -> DownloadKey {
|
|||||||
link_id: episode.node.link_id.clone(),
|
link_id: episode.node.link_id.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn details(status: &str, next: Option<(u32, i64)>) -> pstr_core::metadata::TitleDetails {
|
||||||
|
pstr_core::metadata::TitleDetails {
|
||||||
|
status: Some(status.to_owned()),
|
||||||
|
next_episode: next.map(|(episode, _)| episode),
|
||||||
|
next_airing_at: next.map(|(_, at)| at),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_status_chip_says_when_the_next_episode_airs_and_only_while_it_is_ahead() {
|
||||||
|
let (text, live) = status_text(&details("RELEASING", Some((8, 2_000))), 1_000).unwrap();
|
||||||
|
assert!(text.starts_with("Airing · episode 8 "), "{text}");
|
||||||
|
assert!(live);
|
||||||
|
// Fetched a while ago: the date has passed, so it says nothing of it.
|
||||||
|
let (text, live) = status_text(&details("RELEASING", Some((8, 500))), 1_000).unwrap();
|
||||||
|
assert_eq!(text, "Airing");
|
||||||
|
assert!(live);
|
||||||
|
assert_eq!(
|
||||||
|
status_text(&details("FINISHED", None), 1_000),
|
||||||
|
Some(("Finished".to_owned(), false))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_text(&pstr_core::metadata::TitleDetails::default(), 0),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,14 @@
|
|||||||
//! and neither mutates: this module draws.
|
//! and neither mutates: this module draws.
|
||||||
|
|
||||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
|
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
|
||||||
|
use pstr_core::prefs::SubtitleStyle;
|
||||||
use pstr_player::{MAX_VOLUME, Track, TrackKind};
|
use pstr_player::{MAX_VOLUME, Track, TrackKind};
|
||||||
|
|
||||||
use crate::app::{Action, Adjacent, Page};
|
use crate::app::{Action, Adjacent, Page};
|
||||||
use crate::playback::{Command, Playback};
|
use crate::playback::{Command, Playback};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use crate::ui::player::{PlayerPanel, SleepChoice};
|
||||||
use crate::ui::widgets::Named;
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
||||||
@@ -41,6 +43,9 @@ const ROW_HEIGHT_FULL: f32 = 44.0;
|
|||||||
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
|
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
|
||||||
const ROW_HEIGHT_MINI: f32 = 34.0;
|
const ROW_HEIGHT_MINI: f32 = 34.0;
|
||||||
|
|
||||||
|
/// The speeds on offer, and the steps `[` and `]` walk.
|
||||||
|
pub const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
|
||||||
|
|
||||||
/// Whether there is a file before and after this one in the same title.
|
/// Whether there is a file before and after this one in the same title.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct Neighbours {
|
pub struct Neighbours {
|
||||||
@@ -53,6 +58,8 @@ pub fn full(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
playback: &Playback,
|
playback: &Playback,
|
||||||
neighbours: Neighbours,
|
neighbours: Neighbours,
|
||||||
|
panel: Option<PlayerPanel>,
|
||||||
|
sleep: Option<SleepChoice>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
seek_bar(ui, playback, actions, 6.0);
|
seek_bar(ui, playback, actions, 6.0);
|
||||||
@@ -98,13 +105,11 @@ pub fn full(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right cluster: everything that is a choice rather than a move.
|
// Right cluster: everything that is a choice rather than a move. Each
|
||||||
|
// opens its panel down the right of the picture rather than a menu
|
||||||
|
// under the pointer: a list of twelve chapters is read, not aimed at.
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
track_menus(ui, playback, actions);
|
panel_buttons(ui, playback, panel, sleep, actions);
|
||||||
ui.add_space(theme::space::M);
|
|
||||||
speed_menu(ui, playback, actions);
|
|
||||||
ui.add_space(theme::space::M);
|
|
||||||
chapter_menu(ui, playback, actions);
|
|
||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
volume(ui, playback, actions);
|
volume(ui, playback, actions);
|
||||||
});
|
});
|
||||||
@@ -470,6 +475,8 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
|
|||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
track_options(ui, &subtitles, TrackKind::Subtitle, actions);
|
track_options(ui, &subtitles, TrackKind::Subtitle, actions);
|
||||||
|
ui.separator();
|
||||||
|
subtitle_adjustments(ui, playback, actions);
|
||||||
})
|
})
|
||||||
.response
|
.response
|
||||||
.on_hover_text("Subtitle track");
|
.on_hover_text("Subtitle track");
|
||||||
@@ -489,6 +496,49 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is worth changing mid-episode without a trip to Settings: the size and
|
||||||
|
/// place of the line, and its timing for a release that is out of step.
|
||||||
|
pub fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
|
let mut style = playback.subtitle_style.clone();
|
||||||
|
egui::Grid::new("subtitle-adjustments")
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([theme::space::M, theme::space::S])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label("Size");
|
||||||
|
ui::settings::percent_slider(
|
||||||
|
ui,
|
||||||
|
&mut style.scale,
|
||||||
|
SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE,
|
||||||
|
);
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("Position");
|
||||||
|
ui::settings::subtitle_position_slider(ui, &mut style.position);
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("Delay");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("−").on_hover_text("Earlier (Z)").clicked() {
|
||||||
|
actions.push(Action::NudgeSubtitleDelay(-0.1));
|
||||||
|
}
|
||||||
|
ui.label(ui::format_delay(playback.subtitle_delay));
|
||||||
|
if ui.button("+").on_hover_text("Later (X)").clicked() {
|
||||||
|
actions.push(Action::NudgeSubtitleDelay(0.1));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add_enabled(playback.subtitle_delay != 0.0, egui::Button::new("Reset"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ResetSubtitleDelay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
if style != playback.subtitle_style {
|
||||||
|
actions.push(Action::SetSubtitleStyle(style.sanitized()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
|
fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
|
||||||
for track in tracks {
|
for track in tracks {
|
||||||
if ui.selectable_label(track.selected, track.label()).clicked() {
|
if ui.selectable_label(track.selected, track.label()).clicked() {
|
||||||
@@ -501,54 +551,73 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How fast it plays. Says so on the button only when it is not normal speed,
|
/// The buttons that open the player's panels, right to left: tracks, speed,
|
||||||
/// because that is the one case worth noticing at a glance.
|
/// chapters, sleep. Each shows only when there is a choice to make, and says
|
||||||
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
/// what is chosen when that is worth noticing at a glance.
|
||||||
let label = if (playback.speed - 1.0).abs() < 1e-6 {
|
fn panel_buttons(
|
||||||
"Speed".to_owned()
|
ui: &mut egui::Ui,
|
||||||
|
playback: &Playback,
|
||||||
|
open: Option<PlayerPanel>,
|
||||||
|
sleep: Option<SleepChoice>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| {
|
||||||
|
let response = if open == Some(panel) {
|
||||||
|
ui::accent_button(ui, &text)
|
||||||
} else {
|
} else {
|
||||||
format!("Speed · {}", ui::format_speed(playback.speed))
|
ui::glass_button(ui, &text)
|
||||||
};
|
};
|
||||||
ui.menu_button(label, |ui| {
|
if response.on_hover_text(hint).clicked() {
|
||||||
// The same steps `[` and `]` walk.
|
actions.push(Action::TogglePanel(panel));
|
||||||
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
|
|
||||||
if ui
|
|
||||||
.selectable_label(
|
|
||||||
(playback.speed - speed).abs() < 1e-6,
|
|
||||||
ui::format_speed(speed),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::SetSpeed(speed));
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.response
|
|
||||||
.on_hover_text("Playback speed ([ and ])");
|
|
||||||
}
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
};
|
||||||
|
|
||||||
/// Jump to a chapter. Only drawn for a file that has more than one, which for
|
let subtitles = playback.tracks_of(TrackKind::Subtitle).count();
|
||||||
/// most releases means anime and not much else.
|
let audio = playback.tracks_of(TrackKind::Audio).count();
|
||||||
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
if subtitles > 0 || audio > 1 {
|
||||||
if playback.chapters.len() < 2 {
|
let shown = match playback.selected_track(TrackKind::Subtitle) {
|
||||||
return;
|
Some(track) => short(track),
|
||||||
|
None if subtitles > 0 => "Off".to_owned(),
|
||||||
|
None => playback
|
||||||
|
.selected_track(TrackKind::Audio)
|
||||||
|
.map(short)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
button(
|
||||||
|
ui,
|
||||||
|
PlayerPanel::Tracks,
|
||||||
|
format!("{} {shown}", egui_phosphor::regular::SUBTITLES),
|
||||||
|
"Audio and subtitles",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let current = playback.chapter().map(|chapter| chapter.index);
|
let speed = if (playback.speed - 1.0).abs() < 1e-6 {
|
||||||
ui.menu_button("Chapters", |ui| {
|
egui_phosphor::regular::GAUGE.to_owned()
|
||||||
for chapter in &playback.chapters {
|
} else {
|
||||||
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label());
|
format!(
|
||||||
if ui
|
"{} {}",
|
||||||
.selectable_label(current == Some(chapter.index), label)
|
egui_phosphor::regular::GAUGE,
|
||||||
.clicked()
|
ui::format_speed(playback.speed)
|
||||||
{
|
)
|
||||||
actions.push(Action::Player(Command::SeekTo(chapter.start)));
|
};
|
||||||
ui.close();
|
button(ui, PlayerPanel::Speed, speed, "Playback speed ([ and ])");
|
||||||
|
// Only for a file with more than one, which for most releases means anime
|
||||||
|
// and not much else.
|
||||||
|
if playback.chapters.len() > 1 {
|
||||||
|
button(
|
||||||
|
ui,
|
||||||
|
PlayerPanel::Chapters,
|
||||||
|
egui_phosphor::regular::LIST_NUMBERS.to_owned(),
|
||||||
|
"Chapters",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
let moon = egui_phosphor::regular::MOON;
|
||||||
})
|
let sleep = match sleep {
|
||||||
.response
|
None => moon.to_owned(),
|
||||||
.on_hover_text("Chapters");
|
Some(SleepChoice::EndOfEpisode) => format!("{moon} End"),
|
||||||
|
Some(SleepChoice::Minutes(left)) => format!("{moon} {left} min"),
|
||||||
|
};
|
||||||
|
button(ui, PlayerPanel::Sleep, sleep, "Sleep timer");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A track in as few words as fit on a button: what distinguishes it from the
|
/// A track in as few words as fit on a button: what distinguishes it from the
|
||||||
|
|||||||
@@ -390,6 +390,52 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
|
|||||||
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fact about the files rather than the title — 4K, HDR, Dual audio — as
|
||||||
|
/// a small outlined mark: quieter than a [`chip`], which is something the
|
||||||
|
/// title is filed under.
|
||||||
|
pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
egui::Frame::new()
|
||||||
|
.stroke(egui::Stroke::new(1.0, theme::muted().gamma_multiply(0.7)))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::BADGE))
|
||||||
|
.inner_margin(egui::Margin::symmetric(5, 1))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Micro
|
||||||
|
.rich(text)
|
||||||
|
.family(theme::semibold())
|
||||||
|
.color(theme::muted()),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a title stands — `Airing · episode 8 Sat 1 Nov, 14:00`, `Finished` —
|
||||||
|
/// as a [`chip`] with a dot in front: the accent while more is coming, muted
|
||||||
|
/// once it is all out.
|
||||||
|
pub fn status_chip(ui: &mut egui::Ui, text: &str, live: bool) -> egui::Response {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card_hover().gamma_multiply(0.8))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
||||||
|
let (dot, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
|
||||||
|
ui.painter().circle_filled(
|
||||||
|
dot.center(),
|
||||||
|
3.0,
|
||||||
|
if live {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
}
|
||||||
|
|
||||||
/// A small rounded label: a genre, a year, a fact.
|
/// A small rounded label: a genre, a year, a fact.
|
||||||
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
@@ -401,3 +447,29 @@ pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
|||||||
})
|
})
|
||||||
.response
|
.response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A [`chip`] that opens something: lighter under the pointer, with a ring
|
||||||
|
/// when keyboard focus is on it.
|
||||||
|
pub fn link_chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
let mut prepared = egui::Frame::new()
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||||
|
.begin(ui);
|
||||||
|
prepared
|
||||||
|
.content_ui
|
||||||
|
.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||||
|
let response = prepared
|
||||||
|
.allocate_space(ui)
|
||||||
|
.interact(egui::Sense::click())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||||
|
prepared.frame.fill = if response.hovered() {
|
||||||
|
theme::card_hover()
|
||||||
|
} else {
|
||||||
|
theme::card_hover().gamma_multiply(0.8)
|
||||||
|
};
|
||||||
|
if response.has_focus() {
|
||||||
|
prepared.frame.stroke = egui::Stroke::new(1.5, theme::accent());
|
||||||
|
}
|
||||||
|
prepared.paint(ui);
|
||||||
|
response
|
||||||
|
}
|
||||||
@@ -546,15 +546,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
settings.provider.label(),
|
settings.provider.label(),
|
||||||
settings.provider.description()
|
settings.provider.description()
|
||||||
);
|
);
|
||||||
if settings.provider.needs_api_key() {
|
let stored_key = pstr_meta::settings::api_key(ProviderId::Tmdb).is_some();
|
||||||
println!(
|
println!(
|
||||||
"api key: {}",
|
"tmdb key: {}",
|
||||||
match pstr_meta::settings::api_key(settings.provider) {
|
match (stored_key, settings.provider.needs_api_key()) {
|
||||||
Some(_) => "stored in the credential store",
|
(true, true) => "stored in the credential store",
|
||||||
None => "not set — run `pstr metadata set --api-key`",
|
(true, false) => "stored — used for the art of films TVDB has none for",
|
||||||
|
(false, true) => "not set — run `pstr metadata set --api-key`",
|
||||||
|
(false, false) => "not set (optional, for film art)",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||||
let stored = catalog.all_metadata()?;
|
let stored = catalog.all_metadata()?;
|
||||||
@@ -575,10 +576,14 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
api_key,
|
api_key,
|
||||||
} => {
|
} => {
|
||||||
if let Some(name) = provider {
|
if let Some(name) = provider {
|
||||||
settings.provider = ProviderId::parse(&name).ok_or_else(|| {
|
settings.provider = ProviderId::parse(&name)
|
||||||
|
.filter(|provider| ProviderId::CHOOSABLE.contains(provider))
|
||||||
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"unknown provider {name:?}; try one of: {}",
|
"unknown provider {name:?}; try one of: {}",
|
||||||
ProviderId::ALL.map(|provider| provider.as_str()).join(", ")
|
ProviderId::CHOOSABLE
|
||||||
|
.map(|provider| provider.as_str())
|
||||||
|
.join(", ")
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
@@ -590,12 +595,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if api_key {
|
if api_key {
|
||||||
let key = rpassword::prompt_password(format!(
|
// TMDB's is the only key there is: the provider with TMDB
|
||||||
"{} API key (empty to forget it): ",
|
// chosen, and film art with AniList.
|
||||||
settings.provider.label()
|
let key = rpassword::prompt_password("TMDB API key (empty to forget it): ")
|
||||||
))
|
|
||||||
.context("read the API key")?;
|
.context("read the API key")?;
|
||||||
pstr_meta::settings::set_api_key(settings.provider, &key)
|
pstr_meta::settings::set_api_key(ProviderId::Tmdb, &key)
|
||||||
.context("store the API key")?;
|
.context("store the API key")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
// `Engine::set_metadata_config`.
|
// `Engine::set_metadata_config`.
|
||||||
if off || settings.enabled {
|
if off || settings.enabled {
|
||||||
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||||
|
let chain = pstr_meta::service::chain_of(settings.provider);
|
||||||
let stale = catalog
|
let stale = catalog
|
||||||
.all_metadata()?
|
.all_metadata()?
|
||||||
.values()
|
.values()
|
||||||
.any(|record| record.provider != settings.provider);
|
.any(|record| !chain.contains(&record.provider));
|
||||||
if off || stale {
|
if off || stale {
|
||||||
catalog.clear_metadata()?;
|
catalog.clear_metadata()?;
|
||||||
println!("cleared the stored answers");
|
println!("cleared the stored answers");
|
||||||
@@ -627,13 +632,13 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"lookups are off — run `pstr metadata set --on` first.\n\
|
"lookups are off — run `pstr metadata set --on` first.\n\
|
||||||
Matching sends the titles in your catalog to {}.",
|
Matching sends the titles in your catalog to {}.",
|
||||||
settings.provider.label()
|
pstr_meta::service::chain_label(settings.provider)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let service = pstr_meta::MetadataService::new(
|
let service = pstr_meta::MetadataService::new(
|
||||||
&settings,
|
&settings,
|
||||||
pstr_meta::settings::api_key(settings.provider),
|
pstr_meta::settings::api_key(ProviderId::Tmdb),
|
||||||
)
|
)
|
||||||
.context("build the metadata provider")?;
|
.context("build the metadata provider")?;
|
||||||
|
|
||||||
@@ -646,8 +651,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
library.titles,
|
library.titles,
|
||||||
&stored,
|
&stored,
|
||||||
&enriched,
|
&enriched,
|
||||||
settings.provider,
|
&service.chain(),
|
||||||
service.has_details(),
|
|
||||||
force,
|
force,
|
||||||
);
|
);
|
||||||
if let Some(limit) = limit {
|
if let Some(limit) = limit {
|
||||||
@@ -661,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
println!(
|
println!(
|
||||||
"looking up {} titles against {}…",
|
"looking up {} titles against {}…",
|
||||||
pending.len(),
|
pending.len(),
|
||||||
settings.provider.label()
|
service.label()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Serial, not concurrent: both providers rate-limit by the minute,
|
// Serial, not concurrent: both providers rate-limit by the minute,
|
||||||
@@ -691,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
Some(found) => {
|
Some(found) => {
|
||||||
matched += 1;
|
matched += 1;
|
||||||
println!(
|
println!(
|
||||||
" {name:<40} → {} ({})",
|
" {name:<40} → {} ({}){}",
|
||||||
found.name,
|
found.name,
|
||||||
found
|
found
|
||||||
.year
|
.year
|
||||||
.map(|year| year.to_string())
|
.map(|year| year.to_string())
|
||||||
.unwrap_or_else(|| "-".into())
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
if found.provider == service.provider() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" on {}", found.provider.label())
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => println!(" {name:<40} → no match"),
|
None => println!(" {name:<40} → no match"),
|
||||||
@@ -717,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
);
|
);
|
||||||
catalog.set_enrichment(
|
catalog.set_enrichment(
|
||||||
&outcome.title_key,
|
&outcome.title_key,
|
||||||
settings.provider,
|
outcome.provider,
|
||||||
unix_now(),
|
unix_now(),
|
||||||
enrichment,
|
enrichment,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -280,6 +280,81 @@ pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
|
|||||||
/// a tab, a play glyph, never body copy.
|
/// a tab, a play glyph, never body copy.
|
||||||
pub const MIN_CONTRAST: f32 = 3.0;
|
pub const MIN_CONTRAST: f32 = 3.0;
|
||||||
|
|
||||||
|
/// The colour a picture leaves behind: its pixels averaged, with the vivid
|
||||||
|
/// ones counting for more. `None` for a picture with nothing opaque in it.
|
||||||
|
///
|
||||||
|
/// Weighted rather than a plain mean, because a plain mean of almost any
|
||||||
|
/// still is a brownish grey — the sky and the shadows cancel out, and the one
|
||||||
|
/// red coat that makes the poster what it is barely moves the sum. Each pixel
|
||||||
|
/// counts once plus [`MOOD_CHROMA_WEIGHT`] times its chroma, so a saturated
|
||||||
|
/// patch pulls the result towards itself without a single pixel deciding it.
|
||||||
|
///
|
||||||
|
/// Takes whatever pixels the caller has, in any order and at any size: every
|
||||||
|
/// client hands over a picture already squeezed small, so this is a few
|
||||||
|
/// hundred values, not a frame.
|
||||||
|
pub fn mood(pixels: impl IntoIterator<Item = (Rgb, u8)>) -> Option<Rgb> {
|
||||||
|
let (mut r, mut g, mut b, mut total) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
|
||||||
|
for (pixel, alpha) in pixels {
|
||||||
|
// Half transparent and below is a logo's anti-aliasing or a poster's
|
||||||
|
// rounded corner, not the picture.
|
||||||
|
if alpha < 128 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let high = pixel.r.max(pixel.g).max(pixel.b);
|
||||||
|
let low = pixel.r.min(pixel.g).min(pixel.b);
|
||||||
|
let weight = 1.0 + MOOD_CHROMA_WEIGHT * f32::from(high - low) / 255.0;
|
||||||
|
r += weight * f32::from(pixel.r);
|
||||||
|
g += weight * f32::from(pixel.g);
|
||||||
|
b += weight * f32::from(pixel.b);
|
||||||
|
total += weight;
|
||||||
|
}
|
||||||
|
(total > 0.0).then(|| {
|
||||||
|
let channel = |sum: f32| (sum / total).round().clamp(0.0, 255.0) as u8;
|
||||||
|
Rgb::new(channel(r), channel(g), channel(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much more a fully saturated pixel counts towards [`mood`] than a grey
|
||||||
|
/// one.
|
||||||
|
pub const MOOD_CHROMA_WEIGHT: f32 = 4.0;
|
||||||
|
|
||||||
|
/// The brightest channel a mood is brought down to before it tints a dark
|
||||||
|
/// palette. Any brighter and the veil stops being the page with a hint of the
|
||||||
|
/// picture in it, and text over it loses the contrast the veil is there for.
|
||||||
|
pub const MOOD_DEPTH: u8 = 60;
|
||||||
|
|
||||||
|
/// How far a dark palette's veil moves from the page colour towards the mood.
|
||||||
|
pub const MOOD_DARK: f32 = 0.55;
|
||||||
|
|
||||||
|
/// The same for a light palette — much less, because a light page tinted as
|
||||||
|
/// hard reads as a coloured page rather than a lit one.
|
||||||
|
pub const MOOD_LIGHT: f32 = 0.12;
|
||||||
|
|
||||||
|
/// The page colour, tinted by a picture's [`mood`]: what the veils over a
|
||||||
|
/// title's backdrop are painted in, so each title's banner carries its own
|
||||||
|
/// light rather than the same flat colour.
|
||||||
|
///
|
||||||
|
/// On a dark palette the mood is first darkened to [`MOOD_DEPTH`] — only its
|
||||||
|
/// hue and saturation are wanted, never its brightness — and the page moves
|
||||||
|
/// [`MOOD_DARK`] of the way to it. A light palette moves [`MOOD_LIGHT`] of the
|
||||||
|
/// way to the mood as it is.
|
||||||
|
pub fn mood_veil(background: Rgb, mood: Rgb, light: bool) -> Rgb {
|
||||||
|
if light {
|
||||||
|
return mix(background, mood, MOOD_LIGHT);
|
||||||
|
}
|
||||||
|
let high = mood.r.max(mood.g).max(mood.b);
|
||||||
|
if high == 0 {
|
||||||
|
return mix(background, mood, MOOD_DARK);
|
||||||
|
}
|
||||||
|
let scale = f32::from(MOOD_DEPTH.min(high)) / f32::from(high);
|
||||||
|
let deep = |channel: u8| (f32::from(channel) * scale).round() as u8;
|
||||||
|
mix(
|
||||||
|
background,
|
||||||
|
Rgb::new(deep(mood.r), deep(mood.g), deep(mood.b)),
|
||||||
|
MOOD_DARK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Every colour a client draws with, resolved from a [`Flavor`] and an
|
/// Every colour a client draws with, resolved from a [`Flavor`] and an
|
||||||
/// [`Accent`].
|
/// [`Accent`].
|
||||||
///
|
///
|
||||||
@@ -627,6 +702,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_of_one_colour_has_that_colour_as_its_mood() {
|
||||||
|
let teal = Rgb::new(20, 140, 150);
|
||||||
|
assert_eq!(mood(std::iter::repeat_n((teal, 255), 16)), Some(teal));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vivid_patch_outweighs_as_much_grey_in_the_mood() {
|
||||||
|
let red = Rgb::new(220, 30, 30);
|
||||||
|
let grey = Rgb::new(120, 120, 120);
|
||||||
|
let found = mood([(red, 255), (grey, 255)]).unwrap();
|
||||||
|
assert!(found.r > 180, "{found:?}");
|
||||||
|
assert!(found.g < 60 && found.b < 60, "{found:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transparent_pixels_do_not_count_towards_the_mood() {
|
||||||
|
let blue = Rgb::new(30, 60, 200);
|
||||||
|
assert_eq!(
|
||||||
|
mood([(blue, 255), (Rgb::new(255, 255, 255), 0)]),
|
||||||
|
Some(blue)
|
||||||
|
);
|
||||||
|
assert_eq!(mood([(blue, 10)]), None);
|
||||||
|
assert_eq!(mood(std::iter::empty()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dark_veil_takes_the_moods_hue_but_never_its_brightness() {
|
||||||
|
let background = Rgb::new(11, 11, 15);
|
||||||
|
let veil = mood_veil(background, Rgb::new(255, 240, 40), false);
|
||||||
|
// Warmer than the page…
|
||||||
|
assert!(veil.r > background.r && veil.g > background.g, "{veil:?}");
|
||||||
|
// …and still dark enough to be a page under white text.
|
||||||
|
assert!(luminance(veil) < 0.05, "{veil:?}");
|
||||||
|
assert!(contrast(veil, Rgb::new(255, 255, 255)) > 12.0, "{veil:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_light_veil_moves_only_a_little_way_to_the_mood() {
|
||||||
|
let background = Rgb::new(239, 241, 245);
|
||||||
|
let veil = mood_veil(background, Rgb::new(200, 20, 20), true);
|
||||||
|
assert!(veil.r >= veil.g, "{veil:?}");
|
||||||
|
assert!(luminance(veil) > 0.6, "{veil:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_first_run_gets_the_palette_the_app_shipped_with() {
|
fn a_first_run_gets_the_palette_the_app_shipped_with() {
|
||||||
let appearance = Appearance::default();
|
let appearance = Appearance::default();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use crate::catalog::CatalogNode;
|
use crate::catalog::CatalogNode;
|
||||||
use crate::library::{Library, Title, TitleKind, sort_name};
|
use crate::library::{Library, Title, TitleKind, sort_name};
|
||||||
use crate::metadata::{MetadataRecord, TitleNames};
|
use crate::metadata::{MetadataRecord, TitleNames};
|
||||||
|
use crate::mylist::MyList;
|
||||||
|
|
||||||
/// What order a grid is in.
|
/// What order a grid is in.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -66,11 +67,14 @@ pub enum Filter {
|
|||||||
Unwatched,
|
Unwatched,
|
||||||
/// With at least one episode kept on this device.
|
/// With at least one episode kept on this device.
|
||||||
Downloaded,
|
Downloaded,
|
||||||
|
/// On My List.
|
||||||
|
MyList,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Filter {
|
impl Filter {
|
||||||
pub const ALL: [Self; 6] = [
|
pub const ALL: [Self; 7] = [
|
||||||
Self::All,
|
Self::All,
|
||||||
|
Self::MyList,
|
||||||
Self::Series,
|
Self::Series,
|
||||||
Self::Films,
|
Self::Films,
|
||||||
Self::Watching,
|
Self::Watching,
|
||||||
@@ -86,12 +90,18 @@ impl Filter {
|
|||||||
Self::Watching => "Watching",
|
Self::Watching => "Watching",
|
||||||
Self::Unwatched => "Not started",
|
Self::Unwatched => "Not started",
|
||||||
Self::Downloaded => "Downloaded",
|
Self::Downloaded => "Downloaded",
|
||||||
|
Self::MyList => "My List",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the grid shows `title`. `offline` says whether a file is kept
|
/// Whether the grid shows `title`. `offline` says whether a file is kept
|
||||||
/// on this device, which only the front end knows.
|
/// on this device, which only the front end knows.
|
||||||
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool {
|
pub fn admits(
|
||||||
|
self,
|
||||||
|
title: &Title,
|
||||||
|
offline: impl Fn(&CatalogNode) -> bool,
|
||||||
|
list: &MyList,
|
||||||
|
) -> bool {
|
||||||
let watched = title.watched_count();
|
let watched = title.watched_count();
|
||||||
match self {
|
match self {
|
||||||
Self::All => true,
|
Self::All => true,
|
||||||
@@ -102,6 +112,7 @@ impl Filter {
|
|||||||
}
|
}
|
||||||
Self::Unwatched => watched == 0 && title.resume().is_none(),
|
Self::Unwatched => watched == 0 && title.resume().is_none(),
|
||||||
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
|
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
|
||||||
|
Self::MyList => list.contains(&title.key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,6 +323,7 @@ mod tests {
|
|||||||
kind: TitleKind::Series,
|
kind: TitleKind::Series,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: Some(rating),
|
rating: Some(rating),
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
@@ -454,12 +466,18 @@ mod tests {
|
|||||||
let film = title("f", "Film", TitleKind::Film);
|
let film = title("f", "Film", TitleKind::Film);
|
||||||
let series = title("s", "Series", TitleKind::Series);
|
let series = title("s", "Series", TitleKind::Series);
|
||||||
let none = |_: &CatalogNode| false;
|
let none = |_: &CatalogNode| false;
|
||||||
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none));
|
let list = MyList::default();
|
||||||
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none));
|
assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list));
|
||||||
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none));
|
assert!(
|
||||||
|
Filter::Films.admits(&film, none, &list) && !Filter::Films.admits(&series, none, &list)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
Filter::Series.admits(&series, none, &list)
|
||||||
|
&& !Filter::Series.admits(&film, none, &list)
|
||||||
|
);
|
||||||
// Nothing watched of either: not started, and not being watched.
|
// Nothing watched of either: not started, and not being watched.
|
||||||
assert!(Filter::Unwatched.admits(&film, none));
|
assert!(Filter::Unwatched.admits(&film, none, &list));
|
||||||
assert!(!Filter::Watching.admits(&film, none));
|
assert!(!Filter::Watching.admits(&film, none, &list));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -485,7 +503,18 @@ mod tests {
|
|||||||
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
|
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
|
||||||
let show = &library.titles[0];
|
let show = &library.titles[0];
|
||||||
|
|
||||||
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b"));
|
let list = MyList::default();
|
||||||
assert!(!Filter::Downloaded.admits(show, |_| false));
|
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b", &list));
|
||||||
|
assert!(!Filter::Downloaded.admits(show, |_| false, &list));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_my_list_filter_admits_only_what_is_on_the_list() {
|
||||||
|
let film = title("f", "Film", TitleKind::Film);
|
||||||
|
let series = title("s", "Series", TitleKind::Series);
|
||||||
|
let mut list = MyList::default();
|
||||||
|
list.add("s", 1);
|
||||||
|
assert!(Filter::MyList.admits(&series, |_| false, &list));
|
||||||
|
assert!(!Filter::MyList.admits(&film, |_| false, &list));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 = 12;
|
pub const SCHEMA_VERSION: i64 = 14;
|
||||||
|
|
||||||
const MIGRATION_V1: &str = r#"
|
const MIGRATION_V1: &str = r#"
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
@@ -242,6 +242,29 @@ CREATE TABLE sidecars (
|
|||||||
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
|
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Title logos and running times.
|
||||||
|
///
|
||||||
|
/// ani.zip sent both all along and neither was kept. Every AniList title is
|
||||||
|
/// marked un-enriched so the next match run asks for them; nothing is deleted,
|
||||||
|
/// and the stored episodes and art stay on screen until the answer replaces
|
||||||
|
/// them. The title runtime lives in `details`, which is refetched by
|
||||||
|
/// `TitleDetails::revision` rather than blanked here.
|
||||||
|
const MIGRATION_V13: &str = r#"
|
||||||
|
ALTER TABLE title_metadata ADD COLUMN logo_url TEXT;
|
||||||
|
ALTER TABLE episode_metadata ADD COLUMN runtime INTEGER;
|
||||||
|
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Each episode's MyAnimeList id, for AniSkip.
|
||||||
|
///
|
||||||
|
/// ani.zip sends it with every entry and it was not kept. As with V13, every
|
||||||
|
/// AniList title is marked un-enriched so the next match run fills it in, and
|
||||||
|
/// nothing is deleted meanwhile.
|
||||||
|
const MIGRATION_V14: &str = r#"
|
||||||
|
ALTER TABLE episode_metadata ADD COLUMN mal_id INTEGER;
|
||||||
|
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
|
||||||
|
"#;
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -497,6 +520,18 @@ impl Catalog {
|
|||||||
tx.pragma_update(None, "user_version", 12)?;
|
tx.pragma_update(None, "user_version", 12)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
if version < 13 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V13)?;
|
||||||
|
tx.pragma_update(None, "user_version", 13)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
if version < 14 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V14)?;
|
||||||
|
tx.pragma_update(None, "user_version", 14)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1072,7 @@ impl Catalog {
|
|||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
||||||
manual, details, chain
|
manual, details, chain, logo_url
|
||||||
FROM title_metadata",
|
FROM title_metadata",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], row_to_metadata)?;
|
let rows = statement.query_map([], row_to_metadata)?;
|
||||||
@@ -1058,7 +1093,7 @@ impl Catalog {
|
|||||||
.query_row(
|
.query_row(
|
||||||
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
|
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
|
||||||
url, manual, details, chain
|
url, manual, details, chain, logo_url
|
||||||
FROM title_metadata WHERE title_key = ?1",
|
FROM title_metadata WHERE title_key = ?1",
|
||||||
params![title_key],
|
params![title_key],
|
||||||
row_to_metadata,
|
row_to_metadata,
|
||||||
@@ -1103,9 +1138,9 @@ impl Catalog {
|
|||||||
&"INSERT INTO title_metadata (
|
&"INSERT INTO title_metadata (
|
||||||
title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
||||||
manual, details
|
manual, details, logo_url
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
|
||||||
?17, ?18)
|
?17, ?18, ?19)
|
||||||
ON CONFLICT (title_key) DO UPDATE SET
|
ON CONFLICT (title_key) DO UPDATE SET
|
||||||
provider = excluded.provider,
|
provider = excluded.provider,
|
||||||
matched = excluded.matched,
|
matched = excluded.matched,
|
||||||
@@ -1120,6 +1155,9 @@ impl Catalog {
|
|||||||
backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url,
|
backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url,
|
||||||
title_metadata.backdrop_url)
|
title_metadata.backdrop_url)
|
||||||
ELSE excluded.backdrop_url END,
|
ELSE excluded.backdrop_url END,
|
||||||
|
logo_url = CASE WHEN same THEN COALESCE(excluded.logo_url,
|
||||||
|
title_metadata.logo_url)
|
||||||
|
ELSE excluded.logo_url END,
|
||||||
rating = excluded.rating,
|
rating = excluded.rating,
|
||||||
genres = excluded.genres,
|
genres = excluded.genres,
|
||||||
episodes = excluded.episodes,
|
episodes = excluded.episodes,
|
||||||
@@ -1153,6 +1191,7 @@ impl Catalog {
|
|||||||
data.and_then(|data| data.url.as_deref()),
|
data.and_then(|data| data.url.as_deref()),
|
||||||
record.manual as i64,
|
record.manual as i64,
|
||||||
details,
|
details,
|
||||||
|
data.and_then(|data| data.logo_url.as_deref()),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1220,7 +1259,8 @@ impl Catalog {
|
|||||||
replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?;
|
replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE title_metadata
|
"UPDATE title_metadata
|
||||||
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5
|
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5,
|
||||||
|
logo_url = COALESCE(?6, logo_url)
|
||||||
WHERE title_key = ?1 AND provider = ?2",
|
WHERE title_key = ?1 AND provider = ?2",
|
||||||
params![
|
params![
|
||||||
title_key,
|
title_key,
|
||||||
@@ -1228,6 +1268,7 @@ impl Catalog {
|
|||||||
fetched_at,
|
fetched_at,
|
||||||
enrichment.backdrop_url.as_deref(),
|
enrichment.backdrop_url.as_deref(),
|
||||||
enrichment.chain.join("\n"),
|
enrichment.chain.join("\n"),
|
||||||
|
enrichment.logo_url.as_deref(),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
@@ -1241,7 +1282,7 @@ impl Catalog {
|
|||||||
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
|
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
|
||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, season, number, name, overview, still_url, air_date,
|
"SELECT title_key, season, number, name, overview, still_url, air_date,
|
||||||
absolute, entry_season, entry_number
|
absolute, entry_season, entry_number, runtime, mal_id
|
||||||
FROM episode_metadata",
|
FROM episode_metadata",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], |row| {
|
let rows = statement.query_map([], |row| {
|
||||||
@@ -1259,6 +1300,8 @@ impl Catalog {
|
|||||||
overview: row.get(4)?,
|
overview: row.get(4)?,
|
||||||
still_url: row.get(5)?,
|
still_url: row.get(5)?,
|
||||||
air_date: row.get(6)?,
|
air_date: row.get(6)?,
|
||||||
|
runtime: row.get(10)?,
|
||||||
|
mal_id: row.get(11)?,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
@@ -1365,8 +1408,8 @@ fn replace_episodes(
|
|||||||
"INSERT OR REPLACE INTO episode_metadata (
|
"INSERT OR REPLACE INTO episode_metadata (
|
||||||
title_key, season, number, provider, fetched_at,
|
title_key, season, number, provider, fetched_at,
|
||||||
name, overview, still_url, air_date,
|
name, overview, still_url, air_date,
|
||||||
absolute, entry_season, entry_number
|
absolute, entry_season, entry_number, runtime, mal_id
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||||
)?;
|
)?;
|
||||||
for episode in episodes {
|
for episode in episodes {
|
||||||
insert.execute(params![
|
insert.execute(params![
|
||||||
@@ -1382,6 +1425,8 @@ fn replace_episodes(
|
|||||||
episode.absolute,
|
episode.absolute,
|
||||||
episode.entry_season,
|
episode.entry_season,
|
||||||
episode.entry_number,
|
episode.entry_number,
|
||||||
|
episode.runtime,
|
||||||
|
episode.mal_id,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1432,6 +1477,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
|
|||||||
kind: parse_kind(row.get::<_, Option<String>>(9)?.as_deref()),
|
kind: parse_kind(row.get::<_, Option<String>>(9)?.as_deref()),
|
||||||
poster_url: row.get(10)?,
|
poster_url: row.get(10)?,
|
||||||
backdrop_url: row.get(11)?,
|
backdrop_url: row.get(11)?,
|
||||||
|
logo_url: row.get(19)?,
|
||||||
rating: row.get(12)?,
|
rating: row.get(12)?,
|
||||||
genres: genres
|
genres: genres
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -1549,7 +1595,9 @@ pub fn from_node(share_id: &str, node: &proton_drive_rs::Node) -> CatalogNode {
|
|||||||
/// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their
|
/// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their
|
||||||
/// contents group under the series above them;
|
/// contents group under the series above them;
|
||||||
/// - whatever the filename claimed as a title, when it differs, is kept as the
|
/// - whatever the filename claimed as a title, when it differs, is kept as the
|
||||||
/// episode's own name — with the redundant series prefix stripped.
|
/// episode's own name — with the redundant series prefix stripped;
|
||||||
|
/// - **a folder of films is one title per film**, not one title: see
|
||||||
|
/// [`split_films`].
|
||||||
///
|
///
|
||||||
/// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg`
|
/// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg`
|
||||||
/// alongside the video, and a catalog that offers those as episodes is worse
|
/// alongside the video, and a catalog that offers those as episodes is worse
|
||||||
@@ -1562,12 +1610,18 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
|||||||
.map(|node| (node.uid.link_id.as_str(), node))
|
.map(|node| (node.uid.link_id.as_str(), node))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
nodes
|
let mut origins = Vec::new();
|
||||||
|
let mut rows: Vec<CatalogNode> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|node| !node.is_folder() && naming::is_video_file(&node.name))
|
.filter(|node| !node.is_folder() && naming::is_video_file(&node.name))
|
||||||
.map(|node| {
|
.map(|node| {
|
||||||
let mut row = from_node(share_id, node);
|
let mut row = from_node(share_id, node);
|
||||||
let context = ancestry(node, &by_link);
|
let context = ancestry(node, &by_link);
|
||||||
|
origins.push(Origin {
|
||||||
|
own: row.parsed.clone(),
|
||||||
|
folder: context.title_folder.clone(),
|
||||||
|
through_container: context.through_container,
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(title) = context.title {
|
if let Some(title) = context.title {
|
||||||
// The filename's own title, if it had one and it differs, is the
|
// The filename's own title, if it had one and it differs, is the
|
||||||
@@ -1601,7 +1655,75 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
|||||||
}
|
}
|
||||||
row
|
row
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
split_films(&mut rows, &origins);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a row's title came from, kept for [`split_films`].
|
||||||
|
struct Origin {
|
||||||
|
/// What the filename alone parsed as, before the folder overrode it.
|
||||||
|
own: ParsedName,
|
||||||
|
/// The link id of the folder that named the row.
|
||||||
|
folder: Option<String>,
|
||||||
|
through_container: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give each film in a folder of films its own title.
|
||||||
|
///
|
||||||
|
/// `Rebuild of Evangelion/` holding `Evangelion - 1.11 You Are (Not)
|
||||||
|
/// Alone.mkv` and three more is four films, each its own entry at every
|
||||||
|
/// provider. Titled by the folder they are one series of four unnumbered
|
||||||
|
/// files, matched as whichever single film the folder's name reads most like —
|
||||||
|
/// and no single film is right.
|
||||||
|
///
|
||||||
|
/// Only when every file in the folder says so, because the same shape with one
|
||||||
|
/// file out of place is a series:
|
||||||
|
///
|
||||||
|
/// - none is numbered, by its name or by a season folder;
|
||||||
|
/// - none is under a grouping folder, because `Neon Genesis Evangelion/Movies/`
|
||||||
|
/// is the series' films, and they belong on its page;
|
||||||
|
/// - each names itself, differently from the others, and not by the folder's
|
||||||
|
/// name — `Mushishi - The Green Seat.mkv` under `Mushishi/` is an episode
|
||||||
|
/// of that show whose number is missing, not a film.
|
||||||
|
fn split_films(rows: &mut [CatalogNode], origins: &[Origin]) {
|
||||||
|
use crate::library::title_key;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut by_folder: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||||
|
for (index, origin) in origins.iter().enumerate() {
|
||||||
|
if let Some(folder) = &origin.folder {
|
||||||
|
by_folder.entry(folder.as_str()).or_default().push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for members in by_folder.into_values() {
|
||||||
|
if members.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut keys = HashSet::new();
|
||||||
|
let films = members.iter().all(|&index| {
|
||||||
|
let (row, origin) = (&rows[index], &origins[index]);
|
||||||
|
let own = &origin.own.title;
|
||||||
|
!origin.through_container
|
||||||
|
&& !origin.own.is_episode()
|
||||||
|
&& origin.own.season.is_none()
|
||||||
|
&& row.parsed.season.is_none()
|
||||||
|
&& !own.is_empty()
|
||||||
|
&& strip_prefix_title(own, &row.parsed.title) == *own
|
||||||
|
&& !own.eq_ignore_ascii_case(&row.parsed.title)
|
||||||
|
&& keys.insert(title_key(own))
|
||||||
|
});
|
||||||
|
if !films {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for index in members {
|
||||||
|
let row = &mut rows[index];
|
||||||
|
let own = &origins[index].own;
|
||||||
|
row.parsed.title = own.title.clone();
|
||||||
|
row.parsed.episode_title = own.episode_title.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pair each subtitle file in a crawl with the video it is for.
|
/// Pair each subtitle file in a crawl with the video it is for.
|
||||||
@@ -1784,6 +1906,10 @@ fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct Ancestry {
|
struct Ancestry {
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
|
/// The link id of the folder [`Ancestry::title`] came from.
|
||||||
|
title_folder: Option<String>,
|
||||||
|
/// Whether a grouping folder sat between the file and that one.
|
||||||
|
through_container: bool,
|
||||||
season: Option<u32>,
|
season: Option<u32>,
|
||||||
year: Option<u32>,
|
year: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -1822,10 +1948,13 @@ fn ancestry(
|
|||||||
|
|
||||||
match crate::naming::classify_folder(&ancestor.name) {
|
match crate::naming::classify_folder(&ancestor.name) {
|
||||||
FolderRole::Season(season) => found.season = found.season.or(Some(season)),
|
FolderRole::Season(season) => found.season = found.season.or(Some(season)),
|
||||||
FolderRole::Container => {}
|
FolderRole::Container => {
|
||||||
|
found.through_container |= found.title.is_none();
|
||||||
|
}
|
||||||
FolderRole::Title(parsed) => {
|
FolderRole::Title(parsed) => {
|
||||||
if found.title.is_none() && !parsed.title.is_empty() {
|
if found.title.is_none() && !parsed.title.is_empty() {
|
||||||
found.title = Some(parsed.title.clone());
|
found.title = Some(parsed.title.clone());
|
||||||
|
found.title_folder = Some(link_id.clone());
|
||||||
found.year = found.year.or(parsed.year);
|
found.year = found.year.or(parsed.year);
|
||||||
// `Game of Thrones - Season 1` names both; the season it
|
// `Game of Thrones - Season 1` names both; the season it
|
||||||
// states belongs to the files under it.
|
// states belongs to the files under it.
|
||||||
@@ -1898,6 +2027,7 @@ mod tests {
|
|||||||
kind: TitleKind::Series,
|
kind: TitleKind::Series,
|
||||||
poster_url: Some("https://example.invalid/p.jpg".into()),
|
poster_url: Some("https://example.invalid/p.jpg".into()),
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: Some(8.4),
|
rating: Some(8.4),
|
||||||
genres: vec!["Action".into(), "Drama".into()],
|
genres: vec!["Action".into(), "Drama".into()],
|
||||||
episodes: Some(25),
|
episodes: Some(25),
|
||||||
@@ -1993,6 +2123,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.expect("store episodes");
|
.expect("store episodes");
|
||||||
@@ -2085,6 +2217,8 @@ mod tests {
|
|||||||
overview: Some("Ed and Al…".into()),
|
overview: Some("Ed and Al…".into()),
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: Some("2010-11-14".into()),
|
air_date: Some("2010-11-14".into()),
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
},
|
},
|
||||||
EpisodeMetadata {
|
EpisodeMetadata {
|
||||||
season: Some(1),
|
season: Some(1),
|
||||||
@@ -2096,6 +2230,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: Some("still.jpg".into()),
|
still_url: Some("still.jpg".into()),
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
catalog
|
catalog
|
||||||
@@ -2138,6 +2274,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: Some(24),
|
||||||
|
mal_id: None,
|
||||||
};
|
};
|
||||||
catalog
|
catalog
|
||||||
.set_episode_metadata(
|
.set_episode_metadata(
|
||||||
@@ -2181,6 +2319,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.expect("store episodes");
|
.expect("store episodes");
|
||||||
@@ -2237,6 +2377,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.expect("store episodes");
|
.expect("store episodes");
|
||||||
@@ -2253,16 +2395,18 @@ mod tests {
|
|||||||
assert!(catalog.all_episode_metadata().expect("read").is_empty());
|
assert!(catalog.all_episode_metadata().expect("read").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enrichment adds a backdrop when it found one and otherwise leaves the
|
/// Enrichment adds a backdrop or a logo when it found one and otherwise
|
||||||
/// match's own alone; a new match makes the title un-enriched again.
|
/// leaves the match's own alone; a new match makes the title un-enriched
|
||||||
|
/// again, and refreshing the same one keeps both.
|
||||||
#[test]
|
#[test]
|
||||||
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() {
|
fn enrichment_keeps_the_art_it_did_not_replace_and_a_different_match_resets_it() {
|
||||||
let mut catalog = Catalog::in_memory().expect("open");
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
let record = MetadataRecord {
|
let record = MetadataRecord {
|
||||||
title_key: "attack on titan".into(),
|
title_key: "attack on titan".into(),
|
||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
metadata: Some(TitleMetadata {
|
metadata: Some(TitleMetadata {
|
||||||
backdrop_url: Some("old.jpg".into()),
|
backdrop_url: Some("old.jpg".into()),
|
||||||
|
logo_url: Some("old.png".into()),
|
||||||
..found()
|
..found()
|
||||||
}),
|
}),
|
||||||
fetched_at: 1,
|
fetched_at: 1,
|
||||||
@@ -2274,8 +2418,9 @@ mod tests {
|
|||||||
.metadata("attack on titan")
|
.metadata("attack on titan")
|
||||||
.expect("read")
|
.expect("read")
|
||||||
.and_then(|record| record.metadata)
|
.and_then(|record| record.metadata)
|
||||||
.and_then(|found| found.backdrop_url)
|
.map(|found| (found.backdrop_url, found.logo_url))
|
||||||
};
|
};
|
||||||
|
let art = |backdrop: &str, logo: &str| Some((Some(backdrop.into()), Some(logo.into())));
|
||||||
|
|
||||||
catalog
|
catalog
|
||||||
.set_enrichment(
|
.set_enrichment(
|
||||||
@@ -2285,16 +2430,26 @@ mod tests {
|
|||||||
&Enrichment::default(),
|
&Enrichment::default(),
|
||||||
)
|
)
|
||||||
.expect("enrich");
|
.expect("enrich");
|
||||||
assert_eq!(backdrop(&catalog).as_deref(), Some("old.jpg"));
|
assert_eq!(backdrop(&catalog), art("old.jpg", "old.png"));
|
||||||
|
|
||||||
let enrichment = Enrichment {
|
let enrichment = Enrichment {
|
||||||
backdrop_url: Some("fanart.jpg".into()),
|
backdrop_url: Some("fanart.jpg".into()),
|
||||||
|
logo_url: Some("clearlogo.png".into()),
|
||||||
..Enrichment::default()
|
..Enrichment::default()
|
||||||
};
|
};
|
||||||
catalog
|
catalog
|
||||||
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
|
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
|
||||||
.expect("enrich again");
|
.expect("enrich again");
|
||||||
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg"));
|
assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
|
||||||
|
|
||||||
|
// A refresh of the same entry answers with no art of its own.
|
||||||
|
catalog
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
metadata: Some(found()),
|
||||||
|
..record.clone()
|
||||||
|
})
|
||||||
|
.expect("refresh");
|
||||||
|
assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
|
||||||
|
|
||||||
catalog
|
catalog
|
||||||
.set_metadata(&MetadataRecord {
|
.set_metadata(&MetadataRecord {
|
||||||
@@ -2331,8 +2486,11 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
}],
|
}],
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
chain: Vec::new(),
|
chain: Vec::new(),
|
||||||
};
|
};
|
||||||
catalog
|
catalog
|
||||||
@@ -2390,6 +2548,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: Some(air_date.into()),
|
air_date: Some(air_date.into()),
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
};
|
};
|
||||||
let enrichment = Enrichment {
|
let enrichment = Enrichment {
|
||||||
episodes: vec![
|
episodes: vec![
|
||||||
@@ -2397,6 +2557,7 @@ mod tests {
|
|||||||
episode(2, None, "2026-10-09"),
|
episode(2, None, "2026-10-09"),
|
||||||
],
|
],
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
chain: Vec::new(),
|
chain: Vec::new(),
|
||||||
};
|
};
|
||||||
catalog
|
catalog
|
||||||
@@ -2455,8 +2616,11 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
}],
|
}],
|
||||||
backdrop_url: Some("fanart.jpg".into()),
|
backdrop_url: Some("fanart.jpg".into()),
|
||||||
|
logo_url: None,
|
||||||
chain: vec!["457".into(), "20595".into()],
|
chain: vec!["457".into(), "20595".into()],
|
||||||
};
|
};
|
||||||
catalog
|
catalog
|
||||||
@@ -2616,6 +2780,63 @@ mod tests {
|
|||||||
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
|
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schema v13 asks every AniList title to be enriched again, for the
|
||||||
|
/// logos and running times it never kept — and deletes nothing: the
|
||||||
|
/// match, its details and its episode names stay on screen meanwhile.
|
||||||
|
#[test]
|
||||||
|
fn upgrading_to_v13_re_enriches_anilist_titles_and_keeps_everything_they_had() {
|
||||||
|
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,
|
||||||
|
MIGRATION_V12,
|
||||||
|
] {
|
||||||
|
conn.execute_batch(migration).expect("migrate");
|
||||||
|
}
|
||||||
|
conn.pragma_update(None, "user_version", 12)
|
||||||
|
.expect("set version");
|
||||||
|
conn.execute_batch(
|
||||||
|
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, remote_id,
|
||||||
|
name, kind, backdrop_url, manual, details, enriched_at)
|
||||||
|
VALUES ('aot', 'anilist', 1, 1, '16498', 'Attack on Titan', 'series',
|
||||||
|
'fanart.jpg', 1, '{\"format\":\"TV\"}', 5),
|
||||||
|
('got', 'tmdb', 1, 1, '1399', 'Game of Thrones', 'series',
|
||||||
|
'backdrop.jpg', 0, NULL, 5);
|
||||||
|
INSERT INTO episode_metadata (title_key, season, number, provider, fetched_at, name)
|
||||||
|
VALUES ('aot', 1, 1, 'anilist', 1, 'To You, in 2000 Years');",
|
||||||
|
)
|
||||||
|
.expect("seed");
|
||||||
|
|
||||||
|
let catalog = Catalog::from_connection(conn).expect("upgrade");
|
||||||
|
let records = catalog.all_metadata().expect("read");
|
||||||
|
assert!(records["aot"].manual);
|
||||||
|
let aot = records["aot"].metadata.as_ref().expect("still matched");
|
||||||
|
assert_eq!(aot.backdrop_url.as_deref(), Some("fanart.jpg"));
|
||||||
|
assert_eq!(aot.logo_url, None);
|
||||||
|
let details = aot.details.as_ref().expect("details kept");
|
||||||
|
assert_eq!(details.format.as_deref(), Some("TV"));
|
||||||
|
assert!(!details.is_current());
|
||||||
|
|
||||||
|
let ages = catalog.enrichment_ages().expect("ages");
|
||||||
|
assert!(!ages.contains_key("aot"));
|
||||||
|
assert!(ages.contains_key("got"));
|
||||||
|
let episodes = catalog.all_episode_metadata().expect("read");
|
||||||
|
assert_eq!(
|
||||||
|
episodes["aot"].get(Some(1), 1).and_then(|e| e.runtime),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(episodes["aot"].len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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]
|
||||||
@@ -2714,6 +2935,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.expect("store");
|
.expect("store");
|
||||||
@@ -3081,6 +3304,126 @@ mod tests {
|
|||||||
assert_eq!(row(&film, "film").parsed.title, "Millennium Actress");
|
assert_eq!(row(&film, "film").parsed.title, "Millennium Actress");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **What the Rebuild of Evangelion films were matched as.** A folder of
|
||||||
|
/// four films titled by the folder is one series, matched as a single film
|
||||||
|
/// that none of the four is. Each names itself, so each is its own title.
|
||||||
|
#[test]
|
||||||
|
fn a_folder_of_unnumbered_films_is_one_title_per_film() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("rebuild", Some("root"), "Rebuild of Evangelion"),
|
||||||
|
video(
|
||||||
|
"one",
|
||||||
|
"rebuild",
|
||||||
|
"Evangelion - 1.11 You Are (Not) Alone.mkv",
|
||||||
|
),
|
||||||
|
video(
|
||||||
|
"two",
|
||||||
|
"rebuild",
|
||||||
|
"Evangelion - 2.22 You Can (Not) Advance..mkv",
|
||||||
|
),
|
||||||
|
video(
|
||||||
|
"three",
|
||||||
|
"rebuild",
|
||||||
|
"Evangelion - 3.33 You Can (Not) Redo..mkv",
|
||||||
|
),
|
||||||
|
video(
|
||||||
|
"four",
|
||||||
|
"rebuild",
|
||||||
|
"Evangelion - 3.0+1.01 Thrice Upon a Time.mkv",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let rows = build_rows("share", &nodes);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
row(&rows, "one").parsed.title,
|
||||||
|
"Evangelion - 1.11 You Are Alone"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row(&rows, "four").parsed.title,
|
||||||
|
"Evangelion - 3.0+1.01 Thrice Upon a Time"
|
||||||
|
);
|
||||||
|
for film in &rows {
|
||||||
|
assert_eq!(film.parsed.episode, None);
|
||||||
|
assert_eq!(film.parsed.episode_title, None, "a film is not an episode");
|
||||||
|
}
|
||||||
|
|
||||||
|
let library = crate::library::Library::build(rows, &HashMap::new());
|
||||||
|
assert_eq!(library.titles.len(), 4);
|
||||||
|
assert!(
|
||||||
|
library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.all(|title| title.kind == TitleKind::Film)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One numbered file says the folder is a show, and the unnumbered ones are
|
||||||
|
/// its specials.
|
||||||
|
#[test]
|
||||||
|
fn a_folder_with_a_numbered_episode_in_it_is_not_split() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("show", Some("root"), "Promised Neverland"),
|
||||||
|
video("e1", "show", "[Group] Yakusoku no Neverland - 01.mkv"),
|
||||||
|
video("film", "show", "Some Recap Film.mkv"),
|
||||||
|
video("other", "show", "Another Special.mkv"),
|
||||||
|
];
|
||||||
|
let rows = build_rows("share", &nodes);
|
||||||
|
for file in &rows {
|
||||||
|
assert_eq!(file.parsed.title, "Promised Neverland");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Films under `Movies/` are the series' films, and stay on its page.
|
||||||
|
#[test]
|
||||||
|
fn films_under_a_grouping_folder_stay_with_the_series() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("eva", Some("root"), "Neon Genesis Evangelion"),
|
||||||
|
folder("movies", Some("eva"), "Movies"),
|
||||||
|
video("a", "movies", "Death and Rebirth.mkv"),
|
||||||
|
video("b", "movies", "The End of Evangelion.mkv"),
|
||||||
|
];
|
||||||
|
let rows = build_rows("share", &nodes);
|
||||||
|
for file in &rows {
|
||||||
|
assert_eq!(file.parsed.title, "Neon Genesis Evangelion");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files that repeat the folder's name are that show's episodes with their
|
||||||
|
/// numbers missing, not films of their own.
|
||||||
|
#[test]
|
||||||
|
fn unnumbered_files_named_after_their_folder_stay_one_title() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("mushi", Some("root"), "Mushishi"),
|
||||||
|
video("a", "mushi", "Mushishi - The Green Seat.mkv"),
|
||||||
|
video("b", "mushi", "Mushishi - The Light of the Eyelid.mkv"),
|
||||||
|
];
|
||||||
|
let rows = build_rows("share", &nodes);
|
||||||
|
assert_eq!(row(&rows, "a").parsed.title, "Mushishi");
|
||||||
|
assert_eq!(
|
||||||
|
row(&rows, "a").parsed.episode_title.as_deref(),
|
||||||
|
Some("The Green Seat")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two files that name themselves the same thing are not two films.
|
||||||
|
#[test]
|
||||||
|
fn files_whose_own_names_collide_are_not_split() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("film", Some("root"), "Akira"),
|
||||||
|
video("a", "film", "[GroupA] Neo Tokyo.mkv"),
|
||||||
|
video("b", "film", "Neo.Tokyo.mkv"),
|
||||||
|
];
|
||||||
|
let rows = build_rows("share", &nodes);
|
||||||
|
for file in &rows {
|
||||||
|
assert_eq!(file.parsed.title, "Akira");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The share root names the *share*, not a series — using it would label
|
/// The share root names the *share*, not a series — using it would label
|
||||||
/// every top-level file identically.
|
/// every top-level file identically.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -294,6 +294,116 @@ pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
|
|||||||
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
|
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stretch of a file someone has marked as its opening or its ending, for
|
||||||
|
/// a file whose own chapters do not say: AniSkip's answer, in the file's
|
||||||
|
/// seconds.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct SkipRange {
|
||||||
|
/// [`ChapterRole::Opening`] or [`ChapterRole::Ending`].
|
||||||
|
pub role: ChapterRole,
|
||||||
|
pub start: f64,
|
||||||
|
pub end: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkipRange {
|
||||||
|
/// What the chapter cut for it is called, which is also what [`roles`]
|
||||||
|
/// reads it back as.
|
||||||
|
fn label(self) -> Option<&'static str> {
|
||||||
|
match self.role {
|
||||||
|
ChapterRole::Opening => Some("Opening"),
|
||||||
|
ChapterRole::Ending => Some("Ending"),
|
||||||
|
ChapterRole::Preview | ChapterRole::Content => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How close two chapter starts may be before they are the same place.
|
||||||
|
const SAME_PLACE_SECONDS: f64 = 1.0;
|
||||||
|
|
||||||
|
/// The file's chapters with `ranges` cut into them, as chapters named
|
||||||
|
/// `Opening` and `Ending` — so that skipping, skipping on its own, the up-next
|
||||||
|
/// countdown and the marks on the seek bar all work for a file that came with
|
||||||
|
/// no such chapters, through the same code as one that did.
|
||||||
|
///
|
||||||
|
/// **The file wins.** When its own chapters already mark an opening or an
|
||||||
|
/// ending, it is returned as it is: what the release says about its own cut
|
||||||
|
/// beats what a viewer of some other release said. A file chapter that starts
|
||||||
|
/// inside a range is dropped, since it would split the range in two; one that
|
||||||
|
/// starts where a range ends gives that place its name. Every chapter is
|
||||||
|
/// numbered again from zero, which is what the players key "already skipped"
|
||||||
|
/// by.
|
||||||
|
pub fn with_skip_ranges(
|
||||||
|
chapters: &[Chapter],
|
||||||
|
ranges: &[SkipRange],
|
||||||
|
duration: Option<f64>,
|
||||||
|
) -> Vec<Chapter> {
|
||||||
|
let own = roles(chapters, duration);
|
||||||
|
if ranges.is_empty()
|
||||||
|
|| own
|
||||||
|
.iter()
|
||||||
|
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending))
|
||||||
|
{
|
||||||
|
return chapters.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ranges: Vec<SkipRange> = ranges
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|range| range.label().is_some() && range.start >= 0.0 && range.end > range.start)
|
||||||
|
.collect();
|
||||||
|
ranges.sort_by(|a, b| a.start.total_cmp(&b.start));
|
||||||
|
// Two answers that overlap cannot both be right; the earlier one stays.
|
||||||
|
let mut kept: Vec<SkipRange> = Vec::with_capacity(ranges.len());
|
||||||
|
for range in ranges {
|
||||||
|
if kept.last().is_none_or(|last| range.start >= last.end) {
|
||||||
|
kept.push(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_at = |position: f64| {
|
||||||
|
chapter_at(chapters, position).and_then(|index| chapters[index].title.clone())
|
||||||
|
};
|
||||||
|
let mut points: Vec<(f64, Option<String>)> = Vec::new();
|
||||||
|
for range in &kept {
|
||||||
|
points.push((range.start, range.label().map(str::to_owned)));
|
||||||
|
let ends_the_file =
|
||||||
|
duration.is_some_and(|duration| range.end >= duration - SAME_PLACE_SECONDS);
|
||||||
|
if !ends_the_file {
|
||||||
|
points.push((range.end, title_at(range.end)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let near_a_point = |start: f64, points: &[(f64, Option<String>)]| {
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.any(|(at, _)| (at - start).abs() < SAME_PLACE_SECONDS)
|
||||||
|
};
|
||||||
|
let inside_a_range = |start: f64| {
|
||||||
|
kept.iter()
|
||||||
|
.any(|range| start > range.start && start < range.end)
|
||||||
|
};
|
||||||
|
let from_ranges = points.clone();
|
||||||
|
for chapter in chapters {
|
||||||
|
if !near_a_point(chapter.start, &from_ranges) && !inside_a_range(chapter.start) {
|
||||||
|
points.push((chapter.start, chapter.title.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Whatever comes before the first mark is a place in the file too.
|
||||||
|
if !points.iter().any(|(at, _)| *at < SAME_PLACE_SECONDS) {
|
||||||
|
points.push((0.0, None));
|
||||||
|
}
|
||||||
|
points.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
|
||||||
|
points
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (start, title))| Chapter {
|
||||||
|
index: index as i64,
|
||||||
|
title,
|
||||||
|
start,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -480,4 +590,123 @@ mod tests {
|
|||||||
// and a button that seeks to zero would be worse than no button.
|
// and a button that seeks to zero would be worse than no button.
|
||||||
assert_eq!(chapter_end(&chapters, 1, None), None);
|
assert_eq!(chapter_end(&chapters, 1, None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn range(role: ChapterRole, start: f64, end: f64) -> SkipRange {
|
||||||
|
SkipRange { role, start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape(chapters: &[Chapter]) -> Vec<(i64, Option<&str>, f64)> {
|
||||||
|
chapters
|
||||||
|
.iter()
|
||||||
|
.map(|chapter| (chapter.index, chapter.title.as_deref(), chapter.start))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_with_no_chapters_gets_its_opening_and_ending_as_chapters_that_skip() {
|
||||||
|
let ranges = [
|
||||||
|
range(ChapterRole::Opening, 90.0, 180.0),
|
||||||
|
range(ChapterRole::Ending, 1300.0, 1390.0),
|
||||||
|
];
|
||||||
|
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
|
||||||
|
assert_eq!(
|
||||||
|
shape(&chapters),
|
||||||
|
vec![
|
||||||
|
(0, None, 0.0),
|
||||||
|
(1, Some("Opening"), 90.0),
|
||||||
|
(2, None, 180.0),
|
||||||
|
(3, Some("Ending"), 1300.0),
|
||||||
|
(4, None, 1390.0),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let roles = roles(&chapters, Some(1420.0));
|
||||||
|
assert_eq!(roles[1], ChapterRole::Opening);
|
||||||
|
assert_eq!(roles[3], ChapterRole::Ending);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_files_own_opening_beats_anything_aniskip_says() {
|
||||||
|
let own = vec![
|
||||||
|
Chapter {
|
||||||
|
index: 0,
|
||||||
|
title: Some("Prologue".into()),
|
||||||
|
start: 0.0,
|
||||||
|
},
|
||||||
|
Chapter {
|
||||||
|
index: 1,
|
||||||
|
title: Some("OP".into()),
|
||||||
|
start: 60.0,
|
||||||
|
},
|
||||||
|
Chapter {
|
||||||
|
index: 2,
|
||||||
|
title: Some("Part A".into()),
|
||||||
|
start: 150.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let ranges = [range(ChapterRole::Opening, 70.0, 160.0)];
|
||||||
|
assert_eq!(with_skip_ranges(&own, &ranges, Some(1420.0)), own);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unnamed_chapters_inside_a_range_give_way_and_one_at_its_end_keeps_its_name() {
|
||||||
|
let own = vec![
|
||||||
|
Chapter {
|
||||||
|
index: 0,
|
||||||
|
title: None,
|
||||||
|
start: 0.0,
|
||||||
|
},
|
||||||
|
Chapter {
|
||||||
|
index: 1,
|
||||||
|
title: None,
|
||||||
|
start: 89.6,
|
||||||
|
},
|
||||||
|
Chapter {
|
||||||
|
index: 2,
|
||||||
|
title: None,
|
||||||
|
start: 120.0,
|
||||||
|
},
|
||||||
|
Chapter {
|
||||||
|
index: 3,
|
||||||
|
title: Some("Part A".into()),
|
||||||
|
start: 180.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let ranges = [range(ChapterRole::Opening, 90.0, 180.0)];
|
||||||
|
let chapters = with_skip_ranges(&own, &ranges, Some(1420.0));
|
||||||
|
assert_eq!(
|
||||||
|
shape(&chapters),
|
||||||
|
vec![
|
||||||
|
(0, None, 0.0),
|
||||||
|
(1, Some("Opening"), 90.0),
|
||||||
|
(2, Some("Part A"), 180.0)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ending_that_runs_to_the_end_of_the_file_adds_no_chapter_after_it() {
|
||||||
|
let ranges = [range(ChapterRole::Ending, 1330.0, 1419.8)];
|
||||||
|
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
|
||||||
|
assert_eq!(
|
||||||
|
shape(&chapters),
|
||||||
|
vec![(0, None, 0.0), (1, Some("Ending"), 1330.0)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
credits_start(&chapters, &roles(&chapters, Some(1420.0))),
|
||||||
|
Some(1330.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlapping_answers_keep_the_earlier_one() {
|
||||||
|
let ranges = [
|
||||||
|
range(ChapterRole::Opening, 0.0, 90.0),
|
||||||
|
range(ChapterRole::Ending, 60.0, 150.0),
|
||||||
|
];
|
||||||
|
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
|
||||||
|
assert_eq!(
|
||||||
|
shape(&chapters),
|
||||||
|
vec![(0, Some("Opening"), 0.0), (1, None, 90.0)]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
//! Getting from one title to the next: "More like this", the "Because you
|
||||||
|
//! watched" row, and the pages a genre, studio or person chip opens.
|
||||||
|
//!
|
||||||
|
//! All of it is read off what the provider already said about each title —
|
||||||
|
//! genres, tags, studios, directors, format — so none of it costs a request,
|
||||||
|
//! and it only ever offers what the library holds.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::library::Title;
|
||||||
|
use crate::metadata::{MetadataRecord, TitleMetadata, TitleNames};
|
||||||
|
use crate::shelves::Shelf;
|
||||||
|
|
||||||
|
/// What a chip on a title page stands for, and the page it opens.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Facet {
|
||||||
|
Genre(String),
|
||||||
|
Studio(String),
|
||||||
|
/// A director, or a voice actor in the cast.
|
||||||
|
Person(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Facet {
|
||||||
|
/// The name on the chip, and the heading of its page.
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Genre(name) | Self::Studio(name) | Self::Person(name) => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of thing the name is, said over the heading.
|
||||||
|
pub fn kind(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Genre(_) => "Genre",
|
||||||
|
Self::Studio(_) => "Studio",
|
||||||
|
Self::Person(_) => "Person",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a title the provider described as `found` belongs on the page.
|
||||||
|
pub fn admits(&self, found: &TitleMetadata) -> bool {
|
||||||
|
let details = found.details.as_ref();
|
||||||
|
match self {
|
||||||
|
Self::Genre(name) => found.genres.iter().any(|genre| genre == name),
|
||||||
|
Self::Studio(name) => {
|
||||||
|
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
|
||||||
|
}
|
||||||
|
Self::Person(name) => details.is_some_and(|details| {
|
||||||
|
details.directors.iter().any(|person| person == name)
|
||||||
|
|| details
|
||||||
|
.cast
|
||||||
|
.iter()
|
||||||
|
.any(|member| member.actor.as_deref() == Some(name.as_str()))
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys of the titles in `titles` the page lists, in library order.
|
||||||
|
pub fn titles(
|
||||||
|
&self,
|
||||||
|
titles: &[Title],
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
titles
|
||||||
|
.iter()
|
||||||
|
.filter(|title| {
|
||||||
|
metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.is_some_and(|found| self.admits(found))
|
||||||
|
})
|
||||||
|
.map(|title| title.key.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode a search found by the name the provider gave it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EpisodeMatch {
|
||||||
|
pub title_key: String,
|
||||||
|
pub share_id: String,
|
||||||
|
pub link_id: String,
|
||||||
|
/// `S01E05`, as the row everywhere else says it.
|
||||||
|
pub label: String,
|
||||||
|
/// The provider's name for it.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a search found besides titles: episodes by name, and the people,
|
||||||
|
/// studios and genres whose names hold the query, each with how many titles
|
||||||
|
/// in the library carry it.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Found {
|
||||||
|
pub episodes: Vec<EpisodeMatch>,
|
||||||
|
/// Directors and studios.
|
||||||
|
pub people: Vec<(Facet, usize)>,
|
||||||
|
pub genres: Vec<(Facet, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Found {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.episodes.is_empty() && self.people.is_empty() && self.genres.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many episodes a search lists: past this the query is too loose for a
|
||||||
|
/// list of episodes to help.
|
||||||
|
const EPISODES: usize = 12;
|
||||||
|
|
||||||
|
/// Everything but titles that `query` finds. Nothing for a query under two
|
||||||
|
/// letters, which would hold half of every name.
|
||||||
|
pub fn find(
|
||||||
|
library: &crate::library::Library,
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
guides: &HashMap<String, crate::metadata::EpisodeGuide>,
|
||||||
|
query: &str,
|
||||||
|
) -> Found {
|
||||||
|
let needle = query.trim().to_lowercase();
|
||||||
|
if needle.chars().count() < 2 {
|
||||||
|
return Found::default();
|
||||||
|
}
|
||||||
|
let holds = |name: &str| name.to_lowercase().contains(&needle);
|
||||||
|
|
||||||
|
let mut episodes = Vec::new();
|
||||||
|
'titles: for title in &library.titles {
|
||||||
|
let Some(guide) = guides.get(&title.key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for episode in title.episodes() {
|
||||||
|
let node = &episode.node;
|
||||||
|
let Some(name) = node
|
||||||
|
.parsed
|
||||||
|
.episode
|
||||||
|
.and_then(|number| guide.get(node.parsed.season, number))
|
||||||
|
.and_then(|found| found.name.as_deref())
|
||||||
|
.filter(|name| holds(name))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
episodes.push(EpisodeMatch {
|
||||||
|
title_key: title.key.clone(),
|
||||||
|
share_id: node.share_id.clone(),
|
||||||
|
link_id: node.link_id.clone(),
|
||||||
|
label: episode.numbering().unwrap_or_else(|| episode.label()),
|
||||||
|
name: name.to_owned(),
|
||||||
|
});
|
||||||
|
if episodes.len() == EPISODES {
|
||||||
|
break 'titles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut people: HashMap<Facet, usize> = HashMap::new();
|
||||||
|
let mut genres: HashMap<Facet, usize> = HashMap::new();
|
||||||
|
for title in &library.titles {
|
||||||
|
let Some(found) = metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for genre in found.genres.iter().filter(|genre| holds(genre)) {
|
||||||
|
*genres.entry(Facet::Genre(genre.clone())).or_default() += 1;
|
||||||
|
}
|
||||||
|
let Some(details) = &found.details else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// One count per title, however many characters a voice actor has
|
||||||
|
// in it.
|
||||||
|
let made: HashSet<Facet> = details
|
||||||
|
.directors
|
||||||
|
.iter()
|
||||||
|
.chain(
|
||||||
|
details
|
||||||
|
.cast
|
||||||
|
.iter()
|
||||||
|
.filter_map(|member| member.actor.as_ref()),
|
||||||
|
)
|
||||||
|
.map(|name| Facet::Person(name.clone()))
|
||||||
|
.chain(
|
||||||
|
details
|
||||||
|
.studios
|
||||||
|
.iter()
|
||||||
|
.map(|name| Facet::Studio(name.clone())),
|
||||||
|
)
|
||||||
|
.filter(|facet| holds(facet.name()))
|
||||||
|
.collect();
|
||||||
|
for facet in made {
|
||||||
|
*people.entry(facet).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Most titles first, then by name, so the order does not change from one
|
||||||
|
// keystroke to the next.
|
||||||
|
let ranked = |found: HashMap<Facet, usize>| {
|
||||||
|
let mut found: Vec<(Facet, usize)> = found.into_iter().collect();
|
||||||
|
found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name())));
|
||||||
|
found
|
||||||
|
};
|
||||||
|
Found {
|
||||||
|
episodes,
|
||||||
|
people: ranked(people),
|
||||||
|
genres: ranked(genres),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much one shared trait says two titles are alike. A director is close
|
||||||
|
/// to a signature; a genre is one of three or four on every entry.
|
||||||
|
const DIRECTOR: u32 = 4;
|
||||||
|
const STUDIO: u32 = 3;
|
||||||
|
const GENRE: u32 = 2;
|
||||||
|
const TAG: u32 = 1;
|
||||||
|
const FORMAT: u32 = 1;
|
||||||
|
|
||||||
|
/// How many of a title's tags count. A provider ranks them, and the tail of
|
||||||
|
/// the list ("Male Protagonist", "Primarily Teen Cast") is on half of
|
||||||
|
/// everything.
|
||||||
|
const TAGS: usize = 8;
|
||||||
|
|
||||||
|
/// The least score a title needs to be offered: one director, or a studio and
|
||||||
|
/// a tag, or two genres. One genre in common is not a recommendation.
|
||||||
|
const MIN_SCORE: u32 = 4;
|
||||||
|
|
||||||
|
/// What a title is, for comparing: only the parts that score.
|
||||||
|
struct Traits<'a> {
|
||||||
|
genres: HashSet<&'a str>,
|
||||||
|
tags: HashSet<&'a str>,
|
||||||
|
studios: HashSet<&'a str>,
|
||||||
|
directors: HashSet<&'a str>,
|
||||||
|
format: Option<&'a str>,
|
||||||
|
popularity: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Traits<'a> {
|
||||||
|
fn of(found: &'a TitleMetadata) -> Self {
|
||||||
|
let details = found.details.as_ref();
|
||||||
|
let strings = |list: Option<&'a Vec<String>>, take: usize| -> HashSet<&'a str> {
|
||||||
|
list.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.take(take)
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
genres: found.genres.iter().map(String::as_str).collect(),
|
||||||
|
tags: strings(details.map(|details| &details.tags), TAGS),
|
||||||
|
studios: strings(details.map(|details| &details.studios), usize::MAX),
|
||||||
|
directors: strings(details.map(|details| &details.directors), usize::MAX),
|
||||||
|
format: details.and_then(|details| details.format.as_deref()),
|
||||||
|
popularity: details.and_then(|details| details.popularity).unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score(&self, other: &Self) -> u32 {
|
||||||
|
let shared = |a: &HashSet<&str>, b: &HashSet<&str>| a.intersection(b).count() as u32;
|
||||||
|
let traits = DIRECTOR * shared(&self.directors, &other.directors)
|
||||||
|
+ STUDIO * shared(&self.studios, &other.studios)
|
||||||
|
+ GENRE * shared(&self.genres, &other.genres)
|
||||||
|
+ TAG * shared(&self.tags, &other.tags);
|
||||||
|
// Only on top of something else: a TV series is like every other one.
|
||||||
|
if traits > 0 && self.format.is_some() && self.format == other.format {
|
||||||
|
traits + FORMAT
|
||||||
|
} else {
|
||||||
|
traits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Up to `limit` keys of the titles most like `key`, best first.
|
||||||
|
///
|
||||||
|
/// Never its own franchise — that is the row above it — and one title per
|
||||||
|
/// other franchise, its best scoring: three films of one story are one
|
||||||
|
/// suggestion, not three.
|
||||||
|
pub fn more_like(
|
||||||
|
key: &str,
|
||||||
|
titles: &[Title],
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
ranked(key, titles, metadata, |_| true, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row seeded by the title most recently watched to the end: titles like
|
||||||
|
/// it the viewer has not started. `None` when nothing is finished, or nothing
|
||||||
|
/// is like it.
|
||||||
|
pub fn because_you_watched(
|
||||||
|
titles: &[Title],
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
names: TitleNames,
|
||||||
|
limit: usize,
|
||||||
|
) -> Option<Shelf> {
|
||||||
|
let seed = titles
|
||||||
|
.iter()
|
||||||
|
.filter(|title| {
|
||||||
|
title.episode_count() > 0
|
||||||
|
&& title.watched_count() == title.episode_count()
|
||||||
|
&& metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.is_some_and(|record| record.metadata.is_some())
|
||||||
|
})
|
||||||
|
.max_by_key(|title| title.last_played())?;
|
||||||
|
let keys = ranked(
|
||||||
|
&seed.key,
|
||||||
|
titles,
|
||||||
|
metadata,
|
||||||
|
|title| title.last_played() == 0,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
(!keys.is_empty()).then(|| Shelf {
|
||||||
|
label: format!(
|
||||||
|
"Because you watched {}",
|
||||||
|
crate::browse::display_name(seed, metadata, names)
|
||||||
|
),
|
||||||
|
keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ranked(
|
||||||
|
key: &str,
|
||||||
|
titles: &[Title],
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
admit: impl Fn(&Title) -> bool,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let found = |title: &Title| {
|
||||||
|
metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
};
|
||||||
|
let Some(seed) = titles
|
||||||
|
.iter()
|
||||||
|
.find(|title| title.key == key)
|
||||||
|
.and_then(found)
|
||||||
|
.map(Traits::of)
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let refs: Vec<&Title> = titles.iter().collect();
|
||||||
|
let franchise_of: Vec<usize> = {
|
||||||
|
let mut of = vec![0; titles.len()];
|
||||||
|
for (at, franchise) in crate::franchise::group(&refs, metadata)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
for member in franchise.members {
|
||||||
|
of[member] = at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
of
|
||||||
|
};
|
||||||
|
let own = titles
|
||||||
|
.iter()
|
||||||
|
.position(|title| title.key == key)
|
||||||
|
.map(|at| franchise_of[at]);
|
||||||
|
|
||||||
|
// The best of each franchise: (score, popularity, index).
|
||||||
|
let mut best: HashMap<usize, (u32, u32, usize)> = HashMap::new();
|
||||||
|
for (index, title) in titles.iter().enumerate() {
|
||||||
|
if Some(franchise_of[index]) == own || !admit(title) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(traits) = found(title).map(Traits::of) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let score = seed.score(&traits);
|
||||||
|
if score < MIN_SCORE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let candidate = (score, traits.popularity, index);
|
||||||
|
best.entry(franchise_of[index])
|
||||||
|
.and_modify(|held| {
|
||||||
|
if (candidate.0, candidate.1) > (held.0, held.1) {
|
||||||
|
*held = candidate;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(candidate);
|
||||||
|
}
|
||||||
|
let mut picked: Vec<(u32, u32, usize)> = best.into_values().collect();
|
||||||
|
// Best score, then most followed, then library order for a stable row.
|
||||||
|
picked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
|
||||||
|
picked
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|(_, _, index)| titles[index].key.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::catalog::{CatalogNode, WatchState};
|
||||||
|
use crate::library::{Library, TitleKind};
|
||||||
|
use crate::metadata::{ProviderId, TitleDetails};
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
key: &'static str,
|
||||||
|
genres: &'static [&'static str],
|
||||||
|
studio: &'static str,
|
||||||
|
director: &'static str,
|
||||||
|
related: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(entry: &Entry) -> (String, MetadataRecord) {
|
||||||
|
(
|
||||||
|
entry.key.into(),
|
||||||
|
MetadataRecord {
|
||||||
|
title_key: entry.key.into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: entry.key.into(),
|
||||||
|
name: entry.key.into(),
|
||||||
|
original_name: None,
|
||||||
|
overview: None,
|
||||||
|
year: None,
|
||||||
|
kind: TitleKind::Series,
|
||||||
|
poster_url: None,
|
||||||
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
|
rating: None,
|
||||||
|
genres: entry.genres.iter().map(|genre| genre.to_string()).collect(),
|
||||||
|
episodes: None,
|
||||||
|
url: None,
|
||||||
|
details: Some(TitleDetails {
|
||||||
|
studios: vec![entry.studio.into()],
|
||||||
|
directors: vec![entry.director.into()],
|
||||||
|
related: entry.related.iter().map(|id| id.to_string()).collect(),
|
||||||
|
format: Some("TV".into()),
|
||||||
|
..TitleDetails::default()
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
fetched_at: 0,
|
||||||
|
manual: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(key: &str) -> CatalogNode {
|
||||||
|
let name = format!("{key} - 01.mkv");
|
||||||
|
CatalogNode {
|
||||||
|
share_id: "s".into(),
|
||||||
|
link_id: key.into(),
|
||||||
|
volume_id: "v".into(),
|
||||||
|
parent_link_id: None,
|
||||||
|
name: name.clone(),
|
||||||
|
is_folder: false,
|
||||||
|
media_type: Some("video/x-matroska".into()),
|
||||||
|
size: Some(1),
|
||||||
|
active_revision_id: Some("r".into()),
|
||||||
|
parsed: crate::naming::parse(&name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRIES: [Entry; 5] = [
|
||||||
|
Entry {
|
||||||
|
key: "frieren",
|
||||||
|
genres: &["Adventure", "Drama", "Fantasy"],
|
||||||
|
studio: "Madhouse",
|
||||||
|
director: "Keiichirou Saitou",
|
||||||
|
related: &[],
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
key: "frieren film",
|
||||||
|
genres: &["Adventure", "Drama", "Fantasy"],
|
||||||
|
studio: "Madhouse",
|
||||||
|
director: "Keiichirou Saitou",
|
||||||
|
related: &["frieren"],
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
key: "bocchi",
|
||||||
|
genres: &["Comedy", "Music"],
|
||||||
|
studio: "CloverWorks",
|
||||||
|
director: "Keiichirou Saitou",
|
||||||
|
related: &[],
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
key: "mushishi",
|
||||||
|
genres: &["Adventure", "Drama", "Fantasy", "Mystery"],
|
||||||
|
studio: "Artland",
|
||||||
|
director: "Hiroshi Nagahama",
|
||||||
|
related: &[],
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
key: "lucky star",
|
||||||
|
genres: &["Comedy"],
|
||||||
|
studio: "Kyoto Animation",
|
||||||
|
director: "Yasuhiro Takemoto",
|
||||||
|
related: &[],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn library(watched: &[&str]) -> (Library, HashMap<String, MetadataRecord>) {
|
||||||
|
let watch: HashMap<(String, String), WatchState> = watched
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(at, key)| {
|
||||||
|
(
|
||||||
|
("s".to_owned(), key.to_string()),
|
||||||
|
WatchState {
|
||||||
|
position_secs: 0.0,
|
||||||
|
duration_secs: Some(1_440.0),
|
||||||
|
watched: true,
|
||||||
|
updated_at: 100 + at as i64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let library = Library::build(
|
||||||
|
ENTRIES.iter().map(|entry| file(entry.key)).collect(),
|
||||||
|
&watch,
|
||||||
|
);
|
||||||
|
// The library's keys are the folder names, normalised; the fixture's
|
||||||
|
// already are.
|
||||||
|
(library, ENTRIES.iter().map(record).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn more_like_this_leaves_out_the_franchise_and_ranks_a_shared_director_and_genres() {
|
||||||
|
let (library, metadata) = library(&[]);
|
||||||
|
let similar = more_like("frieren", &library.titles, &metadata, 10);
|
||||||
|
// Mushishi shares three genres, Bocchi only the director; both the
|
||||||
|
// format.
|
||||||
|
// Lucky Star shares nothing and its film is Frieren's own franchise.
|
||||||
|
assert_eq!(similar, ["mushishi", "bocchi"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_genre_in_common_is_not_a_recommendation() {
|
||||||
|
let (library, metadata) = library(&[]);
|
||||||
|
assert!(more_like("lucky star", &library.titles, &metadata, 10).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn because_you_watched_follows_the_last_finished_title_and_skips_what_was_started() {
|
||||||
|
let (library, metadata) = library(&["mushishi", "frieren"]);
|
||||||
|
let row =
|
||||||
|
because_you_watched(&library.titles, &metadata, TitleNames::default(), 10).unwrap();
|
||||||
|
assert_eq!(row.label, "Because you watched frieren");
|
||||||
|
// Mushishi is like it, but already watched.
|
||||||
|
assert_eq!(row.keys, ["bocchi"]);
|
||||||
|
assert!(
|
||||||
|
because_you_watched(&library.titles, &HashMap::new(), TitleNames::default(), 10)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_search_finds_episodes_by_name_and_the_people_and_genres_it_names() {
|
||||||
|
let (library, metadata) = library(&[]);
|
||||||
|
let guide = crate::metadata::EpisodeGuide::new(vec![crate::metadata::EpisodeMetadata {
|
||||||
|
season: None,
|
||||||
|
number: 1,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
|
name: Some("The Journey's End".into()),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
|
}]);
|
||||||
|
let guides = HashMap::from([("frieren".to_owned(), guide)]);
|
||||||
|
|
||||||
|
let found = find(&library, &metadata, &guides, "journey");
|
||||||
|
assert_eq!(found.episodes.len(), 1);
|
||||||
|
assert_eq!(found.episodes[0].title_key, "frieren");
|
||||||
|
assert_eq!(found.episodes[0].name, "The Journey's End");
|
||||||
|
|
||||||
|
let found = find(&library, &metadata, &guides, "saitou");
|
||||||
|
assert_eq!(
|
||||||
|
found.people,
|
||||||
|
[(Facet::Person("Keiichirou Saitou".into()), 3)]
|
||||||
|
);
|
||||||
|
let found = find(&library, &metadata, &guides, "com");
|
||||||
|
assert_eq!(found.genres, [(Facet::Genre("Comedy".into()), 2)]);
|
||||||
|
assert!(find(&library, &metadata, &guides, "c").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_facet_page_lists_the_titles_that_carry_it() {
|
||||||
|
let (library, metadata) = library(&[]);
|
||||||
|
let person = Facet::Person("Keiichirou Saitou".into());
|
||||||
|
assert_eq!(
|
||||||
|
person.titles(&library.titles, &metadata).len(),
|
||||||
|
3,
|
||||||
|
"both Frieren titles and Bocchi"
|
||||||
|
);
|
||||||
|
assert_eq!(person.kind(), "Person");
|
||||||
|
let genre = Facet::Genre("Comedy".into());
|
||||||
|
let mut comedy = genre.titles(&library.titles, &metadata);
|
||||||
|
comedy.sort();
|
||||||
|
assert_eq!(comedy, ["bocchi", "lucky star"]);
|
||||||
|
assert!(
|
||||||
|
Facet::Studio("MAPPA".into())
|
||||||
|
.titles(&library.titles, &metadata)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,11 +85,14 @@ impl Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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()
|
||||||
}
|
}
|
||||||
@@ -127,4 +130,10 @@ mod tests {
|
|||||||
"io: client error (SendRequest): connection closed before message completed"
|
"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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +177,7 @@ mod tests {
|
|||||||
kind: TitleKind::Series,
|
kind: TitleKind::Series,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
|
|||||||
@@ -11,14 +11,18 @@ pub mod browse;
|
|||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod chapters;
|
pub mod chapters;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod discover;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod franchise;
|
pub mod franchise;
|
||||||
pub mod language;
|
pub mod language;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
|
pub mod mylist;
|
||||||
pub mod naming;
|
pub mod naming;
|
||||||
|
pub mod news;
|
||||||
pub mod prefs;
|
pub mod prefs;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod recent;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod shelves;
|
pub mod shelves;
|
||||||
pub mod subtitles;
|
pub mod subtitles;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::catalog::{CatalogNode, WatchState};
|
use crate::catalog::{CatalogNode, WatchState};
|
||||||
|
use crate::naming;
|
||||||
|
|
||||||
/// A file has been watched once playback passed this fraction of it. Short of
|
/// A file has been watched once playback passed this fraction of it. Short of
|
||||||
/// 1.0 because nobody sits through the credits, and a title that never reaches
|
/// 1.0 because nobody sits through the credits, and a title that never reaches
|
||||||
@@ -158,6 +159,34 @@ impl Title {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What most of its files' names say about their quality — see
|
||||||
|
/// [`naming::quality`]. A fact holds when more than half the files state
|
||||||
|
/// it, so one 720p special does not mark a 1080p show down.
|
||||||
|
pub fn quality(&self) -> naming::Quality {
|
||||||
|
let stated: Vec<naming::Quality> = self
|
||||||
|
.episodes()
|
||||||
|
.map(|episode| naming::quality(&episode.node.name))
|
||||||
|
.collect();
|
||||||
|
let most = |holds: &dyn Fn(&naming::Quality) -> bool| {
|
||||||
|
stated.iter().filter(|quality| holds(quality)).count() * 2 > stated.len()
|
||||||
|
};
|
||||||
|
// The height most files have, if most have the same one.
|
||||||
|
let mut heights: HashMap<u32, usize> = HashMap::new();
|
||||||
|
for height in stated.iter().filter_map(|quality| quality.height) {
|
||||||
|
*heights.entry(height).or_default() += 1;
|
||||||
|
}
|
||||||
|
let height = heights
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, files)| files * 2 > stated.len())
|
||||||
|
.map(|(height, _)| height);
|
||||||
|
naming::Quality {
|
||||||
|
height,
|
||||||
|
hdr: most(&|quality| quality.hdr),
|
||||||
|
ten_bit: most(&|quality| quality.ten_bit),
|
||||||
|
dual_audio: most(&|quality| quality.dual_audio),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn watched_count(&self) -> usize {
|
pub fn watched_count(&self) -> usize {
|
||||||
self.episodes().filter(|e| e.is_watched()).count()
|
self.episodes().filter(|e| e.is_watched()).count()
|
||||||
}
|
}
|
||||||
@@ -567,6 +596,36 @@ mod tests {
|
|||||||
assert_eq!(title.preceding("s1", "a"), None);
|
assert_eq!(title.preceding("s1", "a"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One odd file out — a 720p special, a release with no dub — does not
|
||||||
|
/// change what the title as a whole is said to be.
|
||||||
|
#[test]
|
||||||
|
fn a_title_states_the_quality_most_of_its_files_have() {
|
||||||
|
let library = Library::build(
|
||||||
|
vec![
|
||||||
|
node(
|
||||||
|
"s1",
|
||||||
|
"a",
|
||||||
|
"Show - S01E01 [1080p][Dual-Audio].mkv",
|
||||||
|
Some(1),
|
||||||
|
Some(1),
|
||||||
|
),
|
||||||
|
node(
|
||||||
|
"s1",
|
||||||
|
"b",
|
||||||
|
"Show - S01E02 [1080p][Dual-Audio].mkv",
|
||||||
|
Some(1),
|
||||||
|
Some(2),
|
||||||
|
),
|
||||||
|
node("s1", "c", "Show - S01E03 [720p].mkv", Some(1), Some(3)),
|
||||||
|
],
|
||||||
|
&HashMap::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
library.titles[0].quality().badges(),
|
||||||
|
["1080p", "Dual audio"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_from_another_share_is_not_this_titles_neighbour() {
|
fn a_file_from_another_share_is_not_this_titles_neighbour() {
|
||||||
let library = Library::build(
|
let library = Library::build(
|
||||||
|
|||||||
@@ -39,15 +39,23 @@ pub enum ProviderId {
|
|||||||
AniList,
|
AniList,
|
||||||
/// The Movie Database. Film and television, needs a free API key.
|
/// The Movie Database. Film and television, needs a free API key.
|
||||||
Tmdb,
|
Tmdb,
|
||||||
|
/// TVmaze. Television, no API key. Never chosen on its own: it answers
|
||||||
|
/// for what AniList has not got — see `pstr_meta::service`.
|
||||||
|
TvMaze,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderId {
|
impl ProviderId {
|
||||||
pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb];
|
/// Every provider a stored row can name.
|
||||||
|
pub const ALL: [Self; 3] = [Self::AniList, Self::Tmdb, Self::TvMaze];
|
||||||
|
|
||||||
|
/// The ones a viewer picks between in the settings.
|
||||||
|
pub const CHOOSABLE: [Self; 2] = [Self::AniList, Self::Tmdb];
|
||||||
|
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::AniList => "anilist",
|
Self::AniList => "anilist",
|
||||||
Self::Tmdb => "tmdb",
|
Self::Tmdb => "tmdb",
|
||||||
|
Self::TvMaze => "tvmaze",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +64,7 @@ impl ProviderId {
|
|||||||
match self {
|
match self {
|
||||||
Self::AniList => "AniList",
|
Self::AniList => "AniList",
|
||||||
Self::Tmdb => "TMDB",
|
Self::Tmdb => "TMDB",
|
||||||
|
Self::TvMaze => "TVmaze",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +73,11 @@ impl ProviderId {
|
|||||||
match self {
|
match self {
|
||||||
Self::AniList => {
|
Self::AniList => {
|
||||||
"Anime, with no API key and no account. Episode names and wide art come from \
|
"Anime, with no API key and no account. Episode names and wide art come from \
|
||||||
ani.zip, which is sent the matched ids."
|
ani.zip, which is sent the matched ids. Shows AniList does not have are \
|
||||||
|
looked up on TVmaze."
|
||||||
}
|
}
|
||||||
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
|
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
|
||||||
|
Self::TvMaze => "Television, with no API key and no account.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +115,11 @@ pub struct TitleMetadata {
|
|||||||
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
|
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
|
||||||
/// grid cannot simply prefer one shape.
|
/// grid cannot simply prefer one shape.
|
||||||
pub backdrop_url: Option<String>,
|
pub backdrop_url: Option<String>,
|
||||||
|
/// The title's name as its own lettering: a transparent PNG, drawn over
|
||||||
|
/// wide art in place of the name in type. Found by enrichment, like the
|
||||||
|
/// backdrop, and padded by its source — see `pstr-app`'s `trim_alpha`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub logo_url: Option<String>,
|
||||||
/// Out of 10, however the provider scores.
|
/// Out of 10, however the provider scores.
|
||||||
pub rating: Option<f32>,
|
pub rating: Option<f32>,
|
||||||
pub genres: Vec<String>,
|
pub genres: Vec<String>,
|
||||||
@@ -161,6 +177,20 @@ pub struct TitleDetails {
|
|||||||
/// side stories, recaps, retellings. What the library groups a franchise
|
/// side stories, recaps, retellings. What the library groups a franchise
|
||||||
/// by; see `crate::franchise`.
|
/// by; see `crate::franchise`.
|
||||||
pub related: Vec<String>,
|
pub related: Vec<String>,
|
||||||
|
/// Minutes: an episode's length for a series, the whole running time for
|
||||||
|
/// a film.
|
||||||
|
pub runtime: Option<u32>,
|
||||||
|
/// The main characters and then the supporting ones, each with who voices
|
||||||
|
/// them in the original language.
|
||||||
|
pub cast: Vec<CastMember>,
|
||||||
|
/// Where the provider says its trailer is.
|
||||||
|
pub trailer: Option<Trailer>,
|
||||||
|
/// Which [`TitleDetails::REVISION`] wrote this. A stored record from an
|
||||||
|
/// older one lacks a field a newer build asks for, and is fetched again
|
||||||
|
/// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a
|
||||||
|
/// migration: until the refetch lands, the old details still group the
|
||||||
|
/// library and fill its shelves.
|
||||||
|
pub revision: u32,
|
||||||
/// The entries enrichment walked through to find the title's episodes —
|
/// The entries enrichment walked through to find the title's episodes —
|
||||||
/// its own sequels, by provider id. Stored apart from the rest, because
|
/// its own sequels, by provider id. Stored apart from the rest, because
|
||||||
/// it is enrichment's answer rather than the match's.
|
/// it is enrichment's answer rather than the match's.
|
||||||
@@ -168,7 +198,63 @@ pub struct TitleDetails {
|
|||||||
pub chain: Vec<String>,
|
pub chain: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One character, and who voices them.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CastMember {
|
||||||
|
pub character: String,
|
||||||
|
/// The character's portrait.
|
||||||
|
pub character_image: Option<String>,
|
||||||
|
/// `MAIN`, `SUPPORTING` or `BACKGROUND`.
|
||||||
|
pub role: Option<String>,
|
||||||
|
/// The voice actor in the original language.
|
||||||
|
pub actor: Option<String>,
|
||||||
|
pub actor_image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trailer, as the provider names it: a site and that site's id for it.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Trailer {
|
||||||
|
/// `youtube` or `dailymotion`.
|
||||||
|
pub site: String,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Trailer {
|
||||||
|
/// The trailer's page, to open in the browser. The app itself never
|
||||||
|
/// requests it. `None` for a site this build does not know.
|
||||||
|
pub fn url(&self) -> Option<String> {
|
||||||
|
let id: String = self
|
||||||
|
.id
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
|
||||||
|
.collect();
|
||||||
|
if id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match self.site.to_ascii_lowercase().as_str() {
|
||||||
|
"youtube" => Some(format!("https://www.youtube.com/watch?v={id}")),
|
||||||
|
"dailymotion" => Some(format!("https://www.dailymotion.com/video/{id}")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TitleDetails {
|
impl TitleDetails {
|
||||||
|
/// What a provider that fills these in today writes into
|
||||||
|
/// [`TitleDetails::revision`]. Raise it with a field that existing
|
||||||
|
/// records should be fetched again for. `1` added
|
||||||
|
/// [`TitleDetails::runtime`], `2` [`TitleDetails::cast`] and
|
||||||
|
/// [`TitleDetails::trailer`].
|
||||||
|
pub const REVISION: u32 = 2;
|
||||||
|
|
||||||
|
/// Whether these were written by this build's [`TitleDetails::REVISION`]
|
||||||
|
/// or a later one.
|
||||||
|
pub fn is_current(&self) -> bool {
|
||||||
|
self.revision >= Self::REVISION
|
||||||
|
}
|
||||||
|
|
||||||
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
|
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
|
||||||
pub fn format_label(&self) -> Option<&'static str> {
|
pub fn format_label(&self) -> Option<&'static str> {
|
||||||
Some(match self.format.as_deref()? {
|
Some(match self.format.as_deref()? {
|
||||||
@@ -201,6 +287,20 @@ impl TitleDetails {
|
|||||||
self.status.as_deref() == Some("RELEASING")
|
self.status.as_deref() == Some("RELEASING")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`TitleDetails::status`] said for a person: `Airing`, `Finished`,
|
||||||
|
/// `Upcoming`, `On hiatus` or `Cancelled`. `None` for a status nobody
|
||||||
|
/// filled in, or one this build does not know.
|
||||||
|
pub fn status_label(&self) -> Option<&'static str> {
|
||||||
|
Some(match self.status.as_deref()? {
|
||||||
|
"RELEASING" => "Airing",
|
||||||
|
"FINISHED" => "Finished",
|
||||||
|
"NOT_YET_RELEASED" => "Upcoming",
|
||||||
|
"HIATUS" => "On hiatus",
|
||||||
|
"CANCELLED" => "Cancelled",
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The next episode and when it airs, when that is still after `now`.
|
/// The next episode and when it airs, when that is still after `now`.
|
||||||
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
|
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
|
||||||
let at = self.next_airing_at.filter(|at| *at > now)?;
|
let at = self.next_airing_at.filter(|at| *at > now)?;
|
||||||
@@ -317,6 +417,14 @@ pub struct EpisodeMetadata {
|
|||||||
/// sorted on, and a provider that answers `2009` should not be a parse
|
/// sorted on, and a provider that answers `2009` should not be a parse
|
||||||
/// error.
|
/// error.
|
||||||
pub air_date: Option<String>,
|
pub air_date: Option<String>,
|
||||||
|
/// Minutes.
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime: Option<u32>,
|
||||||
|
/// The provider entry's id on MyAnimeList, which is what AniSkip knows
|
||||||
|
/// openings and endings by. With `entry_number`, it names this episode
|
||||||
|
/// there.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mal_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What enriching a matched title found: its episodes, and art the search
|
/// What enriching a matched title found: its episodes, and art the search
|
||||||
@@ -331,6 +439,9 @@ pub struct Enrichment {
|
|||||||
/// A 16:9 picture of the whole title, when one was found. `None` leaves
|
/// A 16:9 picture of the whole title, when one was found. `None` leaves
|
||||||
/// whatever backdrop the match already had.
|
/// whatever backdrop the match already had.
|
||||||
pub backdrop_url: Option<String>,
|
pub backdrop_url: Option<String>,
|
||||||
|
/// The title's logo, when one was found. `None` leaves whatever the match
|
||||||
|
/// already had, as with the backdrop.
|
||||||
|
pub logo_url: Option<String>,
|
||||||
/// Every provider entry the episodes were taken from, the match's own
|
/// Every provider entry the episodes were taken from, the match's own
|
||||||
/// included. See [`TitleDetails::chain`].
|
/// included. See [`TitleDetails::chain`].
|
||||||
pub chain: Vec<String>,
|
pub chain: Vec<String>,
|
||||||
@@ -496,8 +607,13 @@ pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
|
|||||||
|
|
||||||
impl MetadataRecord {
|
impl MetadataRecord {
|
||||||
/// Whether this answer is still worth using rather than asking again.
|
/// Whether this answer is still worth using rather than asking again.
|
||||||
pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool {
|
///
|
||||||
if self.provider != provider {
|
/// `chain` is the providers asked, in order. A match from any of them
|
||||||
|
/// stands. A miss is stored under the last one asked, and only stands for
|
||||||
|
/// a chain that ends there: one an earlier provider stored was never
|
||||||
|
/// followed by the providers after it.
|
||||||
|
pub fn is_fresh(&self, now: i64, chain: &[ProviderId]) -> bool {
|
||||||
|
if !chain.contains(&self.provider) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// A hand-picked match does not go stale: nothing a re-ask could learn
|
// A hand-picked match does not go stale: nothing a re-ask could learn
|
||||||
@@ -507,8 +623,10 @@ impl MetadataRecord {
|
|||||||
}
|
}
|
||||||
let ttl = if self.metadata.is_some() {
|
let ttl = if self.metadata.is_some() {
|
||||||
MATCH_TTL_SECS
|
MATCH_TTL_SECS
|
||||||
} else {
|
} else if chain.last() == Some(&self.provider) {
|
||||||
MISS_TTL_SECS
|
MISS_TTL_SECS
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
now.saturating_sub(self.fetched_at) < ttl
|
now.saturating_sub(self.fetched_at) < ttl
|
||||||
}
|
}
|
||||||
@@ -539,6 +657,57 @@ impl Default for MetadataConfig {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_trailer_opens_on_its_own_site_and_an_unknown_site_or_odd_id_opens_nothing() {
|
||||||
|
let trailer = |site: &str, id: &str| Trailer {
|
||||||
|
site: site.into(),
|
||||||
|
id: id.into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
trailer("youtube", "tR8YH0G67Rk").url().as_deref(),
|
||||||
|
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
trailer("dailymotion", "x7tgad0").url().as_deref(),
|
||||||
|
Some("https://www.dailymotion.com/video/x7tgad0")
|
||||||
|
);
|
||||||
|
assert_eq!(trailer("vimeo", "1").url(), None);
|
||||||
|
// Whatever the provider sent, only an id's own characters reach the
|
||||||
|
// address.
|
||||||
|
assert_eq!(
|
||||||
|
trailer("youtube", "a&b=c").url().as_deref(),
|
||||||
|
Some("https://www.youtube.com/watch?v=abc")
|
||||||
|
);
|
||||||
|
assert_eq!(trailer("youtube", "&?").url(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn details_stored_before_the_cast_existed_still_read() {
|
||||||
|
let old = r#"{"studios":["MAPPA"],"revision":1}"#;
|
||||||
|
let details: TitleDetails = serde_json::from_str(old).unwrap();
|
||||||
|
assert!(details.cast.is_empty());
|
||||||
|
assert_eq!(details.trailer, None);
|
||||||
|
assert!(!details.is_current(), "fetched again for the cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
|
||||||
|
let status = |value: &str| {
|
||||||
|
TitleDetails {
|
||||||
|
status: Some(value.to_owned()),
|
||||||
|
..TitleDetails::default()
|
||||||
|
}
|
||||||
|
.status_label()
|
||||||
|
};
|
||||||
|
assert_eq!(status("RELEASING"), Some("Airing"));
|
||||||
|
assert_eq!(status("FINISHED"), Some("Finished"));
|
||||||
|
assert_eq!(status("NOT_YET_RELEASED"), Some("Upcoming"));
|
||||||
|
assert_eq!(status("HIATUS"), Some("On hiatus"));
|
||||||
|
assert_eq!(status("CANCELLED"), Some("Cancelled"));
|
||||||
|
assert_eq!(status("SOMETHING_NEW"), None);
|
||||||
|
assert_eq!(TitleDetails::default().status_label(), None);
|
||||||
|
}
|
||||||
|
|
||||||
fn metadata() -> TitleMetadata {
|
fn metadata() -> TitleMetadata {
|
||||||
TitleMetadata {
|
TitleMetadata {
|
||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
@@ -550,6 +719,7 @@ mod tests {
|
|||||||
kind: TitleKind::Series,
|
kind: TitleKind::Series,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: Some(26),
|
episodes: Some(26),
|
||||||
@@ -569,6 +739,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,13 +924,13 @@ mod tests {
|
|||||||
fetched_at: now - MISS_TTL_SECS - 1,
|
fetched_at: now - MISS_TTL_SECS - 1,
|
||||||
manual: false,
|
manual: false,
|
||||||
};
|
};
|
||||||
assert!(!miss.is_fresh(now, ProviderId::AniList));
|
assert!(!miss.is_fresh(now, &[ProviderId::AniList]));
|
||||||
|
|
||||||
let matched = MetadataRecord {
|
let matched = MetadataRecord {
|
||||||
metadata: Some(metadata()),
|
metadata: Some(metadata()),
|
||||||
..miss.clone()
|
..miss.clone()
|
||||||
};
|
};
|
||||||
assert!(matched.is_fresh(now, ProviderId::AniList));
|
assert!(matched.is_fresh(now, &[ProviderId::AniList]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the viewer picked by hand outlives every TTL: it is not an answer
|
/// What the viewer picked by hand outlives every TTL: it is not an answer
|
||||||
@@ -772,9 +944,9 @@ mod tests {
|
|||||||
fetched_at: 0,
|
fetched_at: 0,
|
||||||
manual: true,
|
manual: true,
|
||||||
};
|
};
|
||||||
assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList));
|
assert!(record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::AniList]));
|
||||||
// But it is still one provider's id, and it means nothing to the other.
|
// But it is still one provider's id, and it means nothing to the other.
|
||||||
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::Tmdb));
|
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::Tmdb]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switching provider must re-ask rather than trust the other one's answer —
|
/// Switching provider must re-ask rather than trust the other one's answer —
|
||||||
@@ -788,6 +960,32 @@ mod tests {
|
|||||||
fetched_at: 1_000_000_000,
|
fetched_at: 1_000_000_000,
|
||||||
manual: false,
|
manual: false,
|
||||||
};
|
};
|
||||||
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
|
assert!(!record.is_fresh(1_000_000_000, &[ProviderId::Tmdb]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AniList's miss was stored before TVmaze followed it, and so says
|
||||||
|
/// nothing about what TVmaze has; TVmaze's miss, last in the chain, does.
|
||||||
|
/// A match from either stands.
|
||||||
|
#[test]
|
||||||
|
fn a_miss_only_stands_when_the_last_provider_asked_stored_it() {
|
||||||
|
let chain = [ProviderId::AniList, ProviderId::TvMaze];
|
||||||
|
let now = 1_000_000_000;
|
||||||
|
let miss = |provider| MetadataRecord {
|
||||||
|
title_key: "game of thrones".into(),
|
||||||
|
provider,
|
||||||
|
metadata: None,
|
||||||
|
fetched_at: now,
|
||||||
|
manual: false,
|
||||||
|
};
|
||||||
|
assert!(!miss(ProviderId::AniList).is_fresh(now, &chain));
|
||||||
|
assert!(miss(ProviderId::TvMaze).is_fresh(now, &chain));
|
||||||
|
assert!(miss(ProviderId::AniList).is_fresh(now, &[ProviderId::AniList]));
|
||||||
|
|
||||||
|
let found = |provider| MetadataRecord {
|
||||||
|
metadata: Some(metadata()),
|
||||||
|
..miss(provider)
|
||||||
|
};
|
||||||
|
assert!(found(ProviderId::AniList).is_fresh(now, &chain));
|
||||||
|
assert!(found(ProviderId::TvMaze).is_fresh(now, &chain));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! My List: the titles the viewer put aside to watch, kept in
|
||||||
|
//! `my-list.json` beside the other settings.
|
||||||
|
//!
|
||||||
|
//! Keyed by title key, like a hand-picked match, so a title keeps its place on
|
||||||
|
//! the list across a recrawl and on every device that has the same share. A
|
||||||
|
//! title that leaves the library stays on the list, unseen, and is back on it
|
||||||
|
//! when the share brings it back.
|
||||||
|
//!
|
||||||
|
//! It travels with the profile, one `list/<title key>` register per title
|
||||||
|
//! ([`crate::profile`]): adding a title on the phone puts it on the laptop's
|
||||||
|
//! list, and taking it off on one takes it off on all of them.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::{AppDirs, read_json, write_json};
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// The titles on the list, by key, with when each was put there in Unix
|
||||||
|
/// seconds.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MyList {
|
||||||
|
#[serde(default)]
|
||||||
|
titles: BTreeMap<String, i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyList {
|
||||||
|
pub fn contains(&self, key: &str) -> bool {
|
||||||
|
self.titles.contains_key(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.titles.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `key` was put on the list, if it is on it.
|
||||||
|
pub fn added_at(&self, key: &str) -> Option<i64> {
|
||||||
|
self.titles.get(key).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `key` on the list at `at`. One already there keeps its place.
|
||||||
|
pub fn add(&mut self, key: &str, at: i64) {
|
||||||
|
self.titles.entry(key.to_owned()).or_insert(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, key: &str) {
|
||||||
|
self.titles.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `key` on the list or take it off, saying which.
|
||||||
|
pub fn toggle(&mut self, key: &str, at: i64) -> bool {
|
||||||
|
if self.contains(key) {
|
||||||
|
self.remove(key);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.add(key, at);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every key with when it was added, in key order.
|
||||||
|
pub fn entries(&self) -> impl Iterator<Item = (&str, i64)> {
|
||||||
|
self.titles.iter().map(|(key, at)| (key.as_str(), *at))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys, most recently added first: the order the row is in.
|
||||||
|
pub fn newest_first(&self) -> Vec<&str> {
|
||||||
|
let mut keys: Vec<(&str, i64)> = self.entries().collect();
|
||||||
|
keys.sort_by_key(|(_, at)| std::cmp::Reverse(*at));
|
||||||
|
keys.into_iter().map(|(key, _)| key).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set `key`'s entry as another device had it: on the list since `at`,
|
||||||
|
/// or, with `None`, off it.
|
||||||
|
pub(crate) fn set(&mut self, key: &str, at: Option<i64>) {
|
||||||
|
match at {
|
||||||
|
Some(at) => {
|
||||||
|
self.titles.insert(key.to_owned(), at);
|
||||||
|
}
|
||||||
|
None => self.remove(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn file(dirs: &AppDirs) -> PathBuf {
|
||||||
|
dirs.config.join("my-list.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list, empty when there is no file yet.
|
||||||
|
pub fn load(dirs: &AppDirs) -> Result<MyList> {
|
||||||
|
Ok(read_json::<MyList>(&file(dirs))?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(dirs: &AppDirs, list: &MyList) -> Result<()> {
|
||||||
|
write_json(&file(dirs), list)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_added_twice_keeps_the_time_it_was_first_added() {
|
||||||
|
let mut list = MyList::default();
|
||||||
|
list.add("frieren", 10);
|
||||||
|
list.add("frieren", 20);
|
||||||
|
assert_eq!(list.added_at("frieren"), Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggling_puts_a_title_on_the_list_and_takes_it_off_again() {
|
||||||
|
let mut list = MyList::default();
|
||||||
|
assert!(list.toggle("frieren", 10));
|
||||||
|
assert!(list.contains("frieren"));
|
||||||
|
assert!(!list.toggle("frieren", 20));
|
||||||
|
assert!(list.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_row_shows_the_most_recently_added_title_first() {
|
||||||
|
let mut list = MyList::default();
|
||||||
|
list.add("a", 30);
|
||||||
|
list.add("b", 10);
|
||||||
|
list.add("c", 20);
|
||||||
|
assert_eq!(list.newest_first(), ["a", "c", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_list_survives_a_save_and_a_load() {
|
||||||
|
let root = std::env::temp_dir().join(format!("pstr-mylist-{}", std::process::id()));
|
||||||
|
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
|
||||||
|
assert!(load(&dirs).unwrap().is_empty());
|
||||||
|
let mut list = MyList::default();
|
||||||
|
list.add("one piece", 5);
|
||||||
|
save(&dirs, &list).unwrap();
|
||||||
|
assert_eq!(load(&dirs).unwrap(), list);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,9 @@ pub struct ParsedName {
|
|||||||
/// Release year, from a parenthesised or bracketed four-digit group.
|
/// Release year, from a parenthesised or bracketed four-digit group.
|
||||||
pub year: Option<u32>,
|
pub year: Option<u32>,
|
||||||
/// The episode's own name, when the filename states one after the
|
/// The episode's own name, when the filename states one after the
|
||||||
/// numbering (`S01E01-Mother and Children`). Never the quality and codec
|
/// numbering (`S01E01-Mother and Children`, `S01E01.Winter.Is.Coming.2160p`).
|
||||||
/// tags that follow numbering in scene releases — see [`episode_title`].
|
/// Never the quality and codec tags that follow numbering in scene
|
||||||
|
/// releases — see [`episode_title`].
|
||||||
pub episode_title: Option<String>,
|
pub episode_title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,17 +99,21 @@ struct NumberingMatch {
|
|||||||
|
|
||||||
/// The episode's own name out of what followed the numbering.
|
/// The episode's own name out of what followed the numbering.
|
||||||
///
|
///
|
||||||
/// Only taken when a dash introduces it. Scene releases separate their quality,
|
/// Taken when a dash introduces it, or from a scene release's dotted words up
|
||||||
/// source and codec tags with the same character as their title words (`.`), so
|
/// to its first release tag. Scene releases separate their quality, source and
|
||||||
/// without that requirement `Show.Name.S02E05.1080p.WEB-DL.x265` would yield an
|
/// codec tags with the same character as their title words (`.`), so the tag
|
||||||
/// "episode title" of `1080p WEB-DL x265`.
|
/// is what ends the name: `Show.S01E01.Winter.Is.Coming.2160p.BluRay` names
|
||||||
|
/// its episode, `Show.Name.S02E05.1080p.WEB-DL.x265` does not, and a dotted
|
||||||
|
/// run that no tag ends is not trusted to be a name at all.
|
||||||
fn episode_title(tail: &str) -> Option<String> {
|
fn episode_title(tail: &str) -> Option<String> {
|
||||||
let rest = tail.trim_start();
|
let rest = tail.trim_start();
|
||||||
let rest = rest
|
let cleaned = match rest
|
||||||
.strip_prefix('-')
|
.strip_prefix('-')
|
||||||
.or_else(|| rest.strip_prefix('\u{2013}'))?;
|
.or_else(|| rest.strip_prefix('\u{2013}'))
|
||||||
|
{
|
||||||
let cleaned = clean_title(rest, None);
|
Some(rest) => clean_title(rest, None),
|
||||||
|
None => dotted_title(rest.strip_prefix('.')?)?,
|
||||||
|
};
|
||||||
let cleaned = cleaned.trim().to_string();
|
let cleaned = cleaned.trim().to_string();
|
||||||
if cleaned.is_empty() {
|
if cleaned.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -117,6 +122,110 @@ fn episode_title(tail: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Winter.Is.Coming.2160p.UHD…` → `Winter Is Coming`.
|
||||||
|
fn dotted_title(rest: &str) -> Option<String> {
|
||||||
|
let words: Vec<&str> = rest.split('.').collect();
|
||||||
|
let end = words.iter().position(|word| is_release_tag(word))?;
|
||||||
|
Some(words[..end].join(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a dotted word is a release's quality, source or codec rather than
|
||||||
|
/// part of a name.
|
||||||
|
fn is_release_tag(word: &str) -> bool {
|
||||||
|
const TAGS: &[&str] = &[
|
||||||
|
"4k", "uhd", "bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "web", "web-dl",
|
||||||
|
"webdl", "webrip", "hdtv", "dvdrip", "hdrip", "x264", "x265", "h264", "h265", "hevc",
|
||||||
|
"avc", "hdr", "hdr10", "dv", "10bit", "10-bit", "proper", "repack", "amzn", "nf", "dsnp",
|
||||||
|
"hmax", "atvp",
|
||||||
|
];
|
||||||
|
let word = word.to_ascii_lowercase();
|
||||||
|
let resolution = word.strip_suffix('p').is_some_and(|digits| {
|
||||||
|
(3..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
});
|
||||||
|
resolution || TAGS.contains(&word.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a release name says about its picture and sound.
|
||||||
|
///
|
||||||
|
/// Read for the facts line of a title's page, and nothing else: no release
|
||||||
|
/// group's name is ever taken, and a name that says nothing yields the default.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct Quality {
|
||||||
|
/// Lines of picture: `2160` for `2160p`, `4K` or `UHD`.
|
||||||
|
pub height: Option<u32>,
|
||||||
|
/// `HDR`, `HDR10`, `HDR10+` or Dolby Vision.
|
||||||
|
pub hdr: bool,
|
||||||
|
pub ten_bit: bool,
|
||||||
|
/// Two audio languages, as anime releases mark a dub beside the original.
|
||||||
|
pub dual_audio: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Quality {
|
||||||
|
/// The short labels to show, in a fixed order: `4K`, `HDR`, `10-bit`,
|
||||||
|
/// `Dual audio`.
|
||||||
|
pub fn badges(&self) -> Vec<&'static str> {
|
||||||
|
let resolution = self.height.map(|height| match height {
|
||||||
|
2160.. => "4K",
|
||||||
|
1440.. => "1440p",
|
||||||
|
1080.. => "1080p",
|
||||||
|
720.. => "720p",
|
||||||
|
_ => "SD",
|
||||||
|
});
|
||||||
|
resolution
|
||||||
|
.into_iter()
|
||||||
|
.chain(self.hdr.then_some("HDR"))
|
||||||
|
.chain(self.ten_bit.then_some("10-bit"))
|
||||||
|
.chain(self.dual_audio.then_some("Dual audio"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Quality`] a file name states.
|
||||||
|
///
|
||||||
|
/// Words are taken apart on everything but letters, digits and `+`, so
|
||||||
|
/// `[1080p][10-bit]`, `.10bit.` and `(BD 1080p HEVC 10bit Dual Audio)` read
|
||||||
|
/// the same.
|
||||||
|
pub fn quality(file_name: &str) -> Quality {
|
||||||
|
let lower = strip_extension(file_name).to_ascii_lowercase();
|
||||||
|
let words: Vec<&str> = lower
|
||||||
|
.split(|c: char| !c.is_ascii_alphanumeric() && c != '+')
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.collect();
|
||||||
|
let mut quality = Quality::default();
|
||||||
|
for (index, word) in words.iter().enumerate() {
|
||||||
|
let next = words.get(index + 1).copied();
|
||||||
|
let height = match *word {
|
||||||
|
"4k" | "uhd" => Some(2160),
|
||||||
|
_ => resolution(word),
|
||||||
|
};
|
||||||
|
if let Some(height) = height {
|
||||||
|
quality.height = quality.height.max(Some(height));
|
||||||
|
}
|
||||||
|
match (*word, next) {
|
||||||
|
("hdr" | "hdr10" | "hdr10+" | "dv" | "dovi", _) | ("dolby", Some("vision")) => {
|
||||||
|
quality.hdr = true;
|
||||||
|
}
|
||||||
|
("10bit" | "10bits" | "hi10" | "hi10p", _) | ("10", Some("bit" | "bits")) => {
|
||||||
|
quality.ten_bit = true;
|
||||||
|
}
|
||||||
|
("dualaudio", _) | ("dual" | "multi", Some("audio")) => quality.dual_audio = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quality
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1080` out of `1080p`, and out of `bd1080p`, which some groups write.
|
||||||
|
fn resolution(word: &str) -> Option<u32> {
|
||||||
|
let digits = word
|
||||||
|
.strip_suffix('p')?
|
||||||
|
.trim_start_matches(|c: char| c.is_ascii_alphabetic());
|
||||||
|
if !(3..=4).contains(&digits.len()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
digits.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop a trailing extension, but only one that looks like an extension.
|
/// Drop a trailing extension, but only one that looks like an extension.
|
||||||
///
|
///
|
||||||
/// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the
|
/// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the
|
||||||
@@ -539,6 +648,9 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
|
|||||||
if is_token_char(tail) && tail != Some(b'v') {
|
if is_token_char(tail) && tail != Some(b'v') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if is_decimal_tail(text, after) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
found = Some(NumberingMatch {
|
found = Some(NumberingMatch {
|
||||||
numbering: Numbering {
|
numbering: Numbering {
|
||||||
season: None,
|
season: None,
|
||||||
@@ -551,6 +663,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the number that ended at `at` goes on as a decimal: `.11` in
|
||||||
|
/// `Evangelion - 1.11 You Are (Not) Alone`, `.0` in `3.0+1.01`.
|
||||||
|
///
|
||||||
|
/// That is a version of a film, not episode 1 of anything, and reading it as
|
||||||
|
/// one turns a folder of four films into a series of episodes 1, 2, 3 and 3.
|
||||||
|
/// One or two digits only: `- 05.1080p` is episode 5 and a resolution.
|
||||||
|
fn is_decimal_tail(text: &str, at: usize) -> bool {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
if bytes.get(at) != Some(&b'.') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let digits = bytes[at + 1..]
|
||||||
|
.iter()
|
||||||
|
.take_while(|byte| byte.is_ascii_digit())
|
||||||
|
.count();
|
||||||
|
(1..=2).contains(&digits) && !is_token_char(bytes.get(at + 1 + digits).copied())
|
||||||
|
}
|
||||||
|
|
||||||
/// Step over an `E` / `EP` episode marker and any separator after it.
|
/// Step over an `E` / `EP` episode marker and any separator after it.
|
||||||
///
|
///
|
||||||
/// Returns `at` unchanged when there is none, so a bare number and a marked one
|
/// Returns `at` unchanged when there is none, so a bare number and a marked one
|
||||||
@@ -630,9 +760,18 @@ fn is_token_start(text: &str, at: usize) -> bool {
|
|||||||
/// Normalize the title span: separators to spaces, then trim the debris that
|
/// Normalize the title span: separators to spaces, then trim the debris that
|
||||||
/// sits between the title and whatever followed it.
|
/// sits between the title and whatever followed it.
|
||||||
fn clean_title(raw: &str, year: Option<u32>) -> String {
|
fn clean_title(raw: &str, year: Option<u32>) -> String {
|
||||||
|
// A dot between two digits is a decimal point, not a separator: `1.11`
|
||||||
|
// is the film's name, `1 11` is two numbers.
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
let mut text: String = raw
|
let mut text: String = raw
|
||||||
.chars()
|
.char_indices()
|
||||||
.map(|c| match c {
|
.map(|(at, c)| match c {
|
||||||
|
'.' if at > 0
|
||||||
|
&& bytes[at - 1].is_ascii_digit()
|
||||||
|
&& bytes.get(at + 1).is_some_and(u8::is_ascii_digit) =>
|
||||||
|
{
|
||||||
|
'.'
|
||||||
|
}
|
||||||
'.' | '_' => ' ',
|
'.' | '_' => ' ',
|
||||||
other => other,
|
other => other,
|
||||||
})
|
})
|
||||||
@@ -856,6 +995,46 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **What the four Rebuild of Evangelion films parsed as.** ` - 1.11` read
|
||||||
|
/// as episode 1, so the folder became a series of episodes 1, 2, 3 and 3.
|
||||||
|
/// A decimal is a version of the film, and the name keeps it.
|
||||||
|
#[test]
|
||||||
|
fn a_decimal_after_the_dash_is_part_of_a_films_name_not_an_episode() {
|
||||||
|
assert_eq!(
|
||||||
|
parsed("Evangelion - 1.11 You Are (Not) Alone.mkv"),
|
||||||
|
("Evangelion - 1.11 You Are Alone".into(), None, None, None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed("Evangelion - 3.0+1.01 Thrice Upon a Time.mkv"),
|
||||||
|
(
|
||||||
|
"Evangelion - 3.0+1.01 Thrice Upon a Time".into(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recap numbered between two episodes is not a second copy of the one
|
||||||
|
/// before it.
|
||||||
|
#[test]
|
||||||
|
fn a_half_episode_is_not_the_episode_before_it() {
|
||||||
|
assert_eq!(
|
||||||
|
parsed("[Group] Show Name - 12.5 [1080p].mkv"),
|
||||||
|
("Show Name - 12.5".into(), None, None, None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decimal rule stops at two digits, so a resolution glued on with a
|
||||||
|
/// dot leaves the episode number alone.
|
||||||
|
#[test]
|
||||||
|
fn a_resolution_after_a_dot_does_not_hide_the_episode() {
|
||||||
|
assert_eq!(
|
||||||
|
parsed("Show Name - 05.1080p.mkv"),
|
||||||
|
("Show Name".into(), None, Some(5), None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A version suffix still names the same episode.
|
/// A version suffix still names the same episode.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_versioned_fansub_episode_keeps_its_number() {
|
fn a_versioned_fansub_episode_keeps_its_number() {
|
||||||
@@ -1042,6 +1221,55 @@ mod tests {
|
|||||||
assert_eq!(p.title, "Show Name");
|
assert_eq!(p.title, "Show Name");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A scene release names its episode in dotted words before its tags, and
|
||||||
|
/// the first tag is where the name ends.
|
||||||
|
#[test]
|
||||||
|
fn a_dotted_episode_name_before_the_release_tags_is_captured() {
|
||||||
|
let p = parse(
|
||||||
|
"Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.10bit.HDR.x265-GRP.mkv",
|
||||||
|
);
|
||||||
|
assert_eq!(p.title, "Game of Thrones");
|
||||||
|
assert_eq!((p.season, p.episode), (Some(1), Some(1)));
|
||||||
|
assert_eq!(p.episode_title.as_deref(), Some("Winter Is Coming"));
|
||||||
|
|
||||||
|
let p = parse("Game.of.Thrones.S06E05.The.Door.1080p.WEB-DL.mkv");
|
||||||
|
assert_eq!(p.episode_title.as_deref(), Some("The Door"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without a tag to end it, a dotted run could be a group's name or
|
||||||
|
/// anything else, and is not taken.
|
||||||
|
#[test]
|
||||||
|
fn a_dotted_run_no_tag_ends_is_not_an_episode_name() {
|
||||||
|
assert_eq!(parse("Show.S01E01.Pilot.mkv").episode_title, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scene_release_states_4k_hdr_and_10_bit() {
|
||||||
|
let stated =
|
||||||
|
quality("Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.x265.10bit.HDR.mkv");
|
||||||
|
assert_eq!(stated.badges(), ["4K", "HDR", "10-bit"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fansub_release_states_its_tags_in_brackets() {
|
||||||
|
let bracketed =
|
||||||
|
quality("[Group] Show - S01E01 [1080p][BD][x265][10-bit][Dual-Audio][1A2B3C4D].mkv");
|
||||||
|
assert_eq!(bracketed.badges(), ["1080p", "10-bit", "Dual audio"]);
|
||||||
|
let spaced = quality("Film (BD 1080p HEVC 10bit Dual Audio).mkv");
|
||||||
|
assert_eq!(spaced.badges(), ["1080p", "10-bit", "Dual audio"]);
|
||||||
|
assert_eq!(
|
||||||
|
quality("[Group] Show - 01 [BD1080p].mkv").height,
|
||||||
|
Some(1080)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most of a real library: a group, a number and nothing else to say.
|
||||||
|
#[test]
|
||||||
|
fn a_name_with_no_tags_states_nothing() {
|
||||||
|
assert_eq!(quality("[Group] Bleach - S17E01.mkv"), Quality::default());
|
||||||
|
assert!(Quality::default().badges().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// A library that names its files with nothing but the episode marker.
|
/// A library that names its files with nothing but the episode marker.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bare_episode_marker_is_read_as_an_episode() {
|
fn a_bare_episode_marker_is_read_as_an_episode() {
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
//! What arrived since the viewer last looked: the **New** and **3 new**
|
||||||
|
//! badges, the "New episodes" and "Recently added" rows, and the countdown on
|
||||||
|
//! an airing show's tile.
|
||||||
|
//!
|
||||||
|
//! All of it is read off [`crate::catalog::Catalog::first_seen`], the time a
|
||||||
|
//! crawl first found each file. A file the first crawl of its share found is
|
||||||
|
//! not new, however recent that crawl: it was there before this device looked,
|
||||||
|
//! and a share added today would otherwise badge its whole library.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::library::{Episode, Title, TitleKind};
|
||||||
|
use crate::shelves::Shelf;
|
||||||
|
|
||||||
|
/// How long a title that arrived stays **New**, unless it is started first.
|
||||||
|
pub const NEW_FOR: i64 = 14 * 86_400;
|
||||||
|
|
||||||
|
/// What a tile says about what arrived.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Badge {
|
||||||
|
/// A title that arrived in the last [`NEW_FOR`] and was never played.
|
||||||
|
New,
|
||||||
|
/// A series being watched, with this many unwatched episodes that arrived
|
||||||
|
/// after it was last played.
|
||||||
|
Episodes(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Badge {
|
||||||
|
pub fn label(self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::New => "New".to_owned(),
|
||||||
|
Self::Episodes(count) => format!("{count} new"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When each file arrived, read against when its share was first crawled.
|
||||||
|
pub struct Arrivals<'a> {
|
||||||
|
first_seen: &'a HashMap<(String, String), i64>,
|
||||||
|
/// The earliest sighting in each share: its first crawl.
|
||||||
|
first_crawl: HashMap<&'a str, i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Arrivals<'a> {
|
||||||
|
pub fn new(first_seen: &'a HashMap<(String, String), i64>) -> Self {
|
||||||
|
let mut first_crawl: HashMap<&str, i64> = HashMap::new();
|
||||||
|
for ((share, _), seen) in first_seen {
|
||||||
|
first_crawl
|
||||||
|
.entry(share.as_str())
|
||||||
|
.and_modify(|first| *first = (*first).min(*seen))
|
||||||
|
.or_insert(*seen);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
first_seen,
|
||||||
|
first_crawl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `episode`'s file arrived, if a crawl after its share's first one
|
||||||
|
/// found it.
|
||||||
|
pub fn arrived(&self, episode: &Episode) -> Option<i64> {
|
||||||
|
let node = &episode.node;
|
||||||
|
let seen = *self
|
||||||
|
.first_seen
|
||||||
|
.get(&(node.share_id.clone(), node.link_id.clone()))?;
|
||||||
|
let first = self.first_crawl.get(node.share_id.as_str())?;
|
||||||
|
(seen > *first).then_some(seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The badge for `title`, if it has one.
|
||||||
|
pub fn badge(&self, title: &Title, now: i64) -> Option<Badge> {
|
||||||
|
let played = title.last_played();
|
||||||
|
if played == 0 {
|
||||||
|
// New only if every file of it arrived: a show the first crawl
|
||||||
|
// found is not new because a crawl since added an episode.
|
||||||
|
let arrived: Option<Vec<i64>> = title
|
||||||
|
.episodes()
|
||||||
|
.map(|episode| self.arrived(episode))
|
||||||
|
.collect();
|
||||||
|
let first = arrived?.into_iter().min()?;
|
||||||
|
return (now - first < NEW_FOR).then_some(Badge::New);
|
||||||
|
}
|
||||||
|
if title.kind != TitleKind::Series {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let count = title
|
||||||
|
.episodes()
|
||||||
|
.filter(|episode| !episode.is_watched())
|
||||||
|
.filter(|episode| self.arrived(episode).is_some_and(|at| at > played))
|
||||||
|
.count();
|
||||||
|
(count > 0).then_some(Badge::Episodes(count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `title` last gained a file that counts as arrived.
|
||||||
|
fn newest(&self, title: &Title) -> Option<i64> {
|
||||||
|
title
|
||||||
|
.episodes()
|
||||||
|
.filter_map(|episode| self.arrived(episode))
|
||||||
|
.max()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every badge in `titles`, by key.
|
||||||
|
pub fn badges(
|
||||||
|
titles: &[Title],
|
||||||
|
first_seen: &HashMap<(String, String), i64>,
|
||||||
|
now: i64,
|
||||||
|
) -> HashMap<String, Badge> {
|
||||||
|
let arrivals = Arrivals::new(first_seen);
|
||||||
|
titles
|
||||||
|
.iter()
|
||||||
|
.filter_map(|title| Some((title.key.clone(), arrivals.badge(title, now)?)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two rows of what arrived, newest arrival first. `None` for a row with
|
||||||
|
/// nothing in it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Rows {
|
||||||
|
/// "New episodes": the shows being watched that gained episodes.
|
||||||
|
pub episodes: Option<Shelf>,
|
||||||
|
/// "Recently added": the titles that are [`Badge::New`].
|
||||||
|
pub added: Option<Shelf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rows(titles: &[Title], first_seen: &HashMap<(String, String), i64>, now: i64) -> Rows {
|
||||||
|
let arrivals = Arrivals::new(first_seen);
|
||||||
|
let mut episodes: Vec<(&Title, i64)> = Vec::new();
|
||||||
|
let mut added: Vec<(&Title, i64)> = Vec::new();
|
||||||
|
for title in titles {
|
||||||
|
let (Some(badge), Some(newest)) = (arrivals.badge(title, now), arrivals.newest(title))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match badge {
|
||||||
|
Badge::Episodes(_) => episodes.push((title, newest)),
|
||||||
|
Badge::New => added.push((title, newest)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let shelf = |label: &str, mut row: Vec<(&Title, i64)>| {
|
||||||
|
row.sort_by_key(|(_, newest)| std::cmp::Reverse(*newest));
|
||||||
|
(!row.is_empty()).then(|| Shelf {
|
||||||
|
label: label.to_owned(),
|
||||||
|
keys: row
|
||||||
|
.into_iter()
|
||||||
|
.map(|(title, _)| title.key.clone())
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
Rows {
|
||||||
|
episodes: shelf("New episodes", episodes),
|
||||||
|
added: shelf("Recently added", added),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an airing show's tile says about its next episode: `Ep 8 · in 2 d`,
|
||||||
|
/// `Ep 8 · in 5 h`, `Ep 8 · soon`.
|
||||||
|
pub fn countdown(episode: u32, at: i64, now: i64) -> String {
|
||||||
|
let left = (at - now).max(0);
|
||||||
|
let when = match left {
|
||||||
|
0..3_600 => "soon".to_owned(),
|
||||||
|
3_600..86_400 => format!("in {} h", left / 3_600),
|
||||||
|
_ => format!("in {} d", left / 86_400),
|
||||||
|
};
|
||||||
|
format!("Ep {episode} · {when}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::catalog::{CatalogNode, WatchState};
|
||||||
|
use crate::library::Library;
|
||||||
|
|
||||||
|
const DAY: i64 = 86_400;
|
||||||
|
|
||||||
|
fn file(share: &str, link: &str, title: &str, episode: u32) -> CatalogNode {
|
||||||
|
CatalogNode {
|
||||||
|
share_id: share.into(),
|
||||||
|
link_id: link.into(),
|
||||||
|
volume_id: "v".into(),
|
||||||
|
parent_link_id: None,
|
||||||
|
name: format!("{title} - {episode:02}.mkv"),
|
||||||
|
is_folder: false,
|
||||||
|
media_type: Some("video/x-matroska".into()),
|
||||||
|
size: Some(1),
|
||||||
|
active_revision_id: Some("r".into()),
|
||||||
|
parsed: crate::naming::parse(&format!("{title} - {episode:02}.mkv")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watched(at: i64) -> WatchState {
|
||||||
|
WatchState {
|
||||||
|
position_secs: 1_400.0,
|
||||||
|
duration_secs: Some(1_440.0),
|
||||||
|
watched: true,
|
||||||
|
updated_at: at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One share whose first crawl at day 1 found Frieren 1–2, and Dandadan 1
|
||||||
|
/// arriving at day 20 next to Frieren 3.
|
||||||
|
fn share(frieren_played: Option<i64>) -> (Library, HashMap<(String, String), i64>) {
|
||||||
|
let files = vec![
|
||||||
|
file("s", "f1", "Frieren", 1),
|
||||||
|
file("s", "f2", "Frieren", 2),
|
||||||
|
file("s", "f3", "Frieren", 3),
|
||||||
|
file("s", "d1", "Dandadan", 1),
|
||||||
|
];
|
||||||
|
let mut watch = HashMap::new();
|
||||||
|
if let Some(at) = frieren_played {
|
||||||
|
watch.insert(("s".to_owned(), "f1".to_owned()), watched(at));
|
||||||
|
watch.insert(("s".to_owned(), "f2".to_owned()), watched(at));
|
||||||
|
}
|
||||||
|
let seen = [("f1", 1), ("f2", 1), ("f3", 20), ("d1", 20)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(link, day)| (("s".to_owned(), link.to_owned()), day * DAY))
|
||||||
|
.collect();
|
||||||
|
(Library::build(files, &watch), seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named<'a>(library: &'a Library, name: &str) -> &'a Title {
|
||||||
|
library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.find(|title| title.name == name)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_the_first_crawl_of_a_share_found_is_never_new() {
|
||||||
|
let (library, seen) = share(None);
|
||||||
|
let arrivals = Arrivals::new(&seen);
|
||||||
|
let frieren = named(&library, "Frieren");
|
||||||
|
assert_eq!(arrivals.badge(frieren, 21 * DAY), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_that_arrived_is_new_for_two_weeks_unless_it_is_started() {
|
||||||
|
let (library, seen) = share(None);
|
||||||
|
let arrivals = Arrivals::new(&seen);
|
||||||
|
let dandadan = named(&library, "Dandadan");
|
||||||
|
assert_eq!(arrivals.badge(dandadan, 21 * DAY), Some(Badge::New));
|
||||||
|
assert_eq!(arrivals.badge(dandadan, 20 * DAY + NEW_FOR), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_show_being_watched_counts_the_episodes_that_arrived_after_the_last_watch() {
|
||||||
|
let (library, seen) = share(Some(10 * DAY));
|
||||||
|
let arrivals = Arrivals::new(&seen);
|
||||||
|
let frieren = named(&library, "Frieren");
|
||||||
|
assert_eq!(arrivals.badge(frieren, 21 * DAY), Some(Badge::Episodes(1)));
|
||||||
|
assert_eq!(Badge::Episodes(1).label(), "1 new");
|
||||||
|
|
||||||
|
// Watched after episode 3 arrived: nothing is new to the viewer.
|
||||||
|
let (library, seen) = share(Some(25 * DAY));
|
||||||
|
let arrivals = Arrivals::new(&seen);
|
||||||
|
assert_eq!(arrivals.badge(named(&library, "Frieren"), 26 * DAY), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_rows_hold_new_episodes_and_recent_arrivals_and_leave_out_an_empty_one() {
|
||||||
|
let (library, seen) = share(Some(10 * DAY));
|
||||||
|
let found = rows(&library.titles, &seen, 21 * DAY);
|
||||||
|
let labels: Vec<(&str, Vec<&str>)> = [&found.episodes, &found.added]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|row| {
|
||||||
|
let names = row
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| {
|
||||||
|
library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.find(|title| &title.key == key)
|
||||||
|
.unwrap()
|
||||||
|
.name
|
||||||
|
.as_str()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(row.label.as_str(), names)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
labels,
|
||||||
|
[
|
||||||
|
("New episodes", vec!["Frieren"]),
|
||||||
|
("Recently added", vec!["Dandadan"])
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let (library, seen) = share(None);
|
||||||
|
let found = rows(&library.titles, &seen, 21 * DAY);
|
||||||
|
assert!(found.episodes.is_none());
|
||||||
|
assert_eq!(found.added.unwrap().label, "Recently added");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_countdown_says_days_then_hours_then_soon() {
|
||||||
|
assert_eq!(countdown(8, 3 * DAY + 100, 0), "Ep 8 · in 3 d");
|
||||||
|
assert_eq!(countdown(8, 5 * 3_600 + 10, 0), "Ep 8 · in 5 h");
|
||||||
|
assert_eq!(countdown(8, 600, 0), "Ep 8 · soon");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,211 @@ pub struct PlaybackPrefs {
|
|||||||
/// everything at 1.25, and a speed that reset every episode would be a
|
/// everything at 1.25, and a speed that reset every episode would be a
|
||||||
/// setting nobody could use.
|
/// setting nobody could use.
|
||||||
pub speed: f64,
|
pub speed: f64,
|
||||||
|
/// How plain subtitles look. Kept with the rest because it is the same
|
||||||
|
/// kind of choice: made once, for every file after.
|
||||||
|
pub subtitle_style: SubtitleStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How subtitles are drawn.
|
||||||
|
///
|
||||||
|
/// Every default is mpv's own, so a viewer who never opens the section sees
|
||||||
|
/// what any mpv shows. Plain (SRT) tracks take all of it. A styled (ASS) track
|
||||||
|
/// was typeset by somebody, its signs placed and coloured by hand, and painting
|
||||||
|
/// over that undoes the work — so by default it takes none of this, and moves
|
||||||
|
/// only to stay clear of the player's controls. All of it reaches a styled
|
||||||
|
/// track only with [`Self::restyle_styled`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SubtitleStyle {
|
||||||
|
/// Relative to mpv's own size, which is itself relative to the video's
|
||||||
|
/// height — so one setting reads the same on a phone and a television.
|
||||||
|
pub scale: f64,
|
||||||
|
/// A font family name. `None` is mpv's `sans-serif`. A name this device
|
||||||
|
/// does not have falls back the same way, which is what makes it safe to
|
||||||
|
/// sync.
|
||||||
|
pub font: Option<String>,
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
pub colour: Colour,
|
||||||
|
pub backdrop: SubtitleBackdrop,
|
||||||
|
pub outline_colour: Colour,
|
||||||
|
/// In mpv's scaled pixels, as `sub-outline-size`.
|
||||||
|
pub outline_size: f64,
|
||||||
|
/// The box's colour, its alpha the box's opacity. Only with
|
||||||
|
/// [`SubtitleBackdrop::Box`].
|
||||||
|
pub box_colour: Colour,
|
||||||
|
pub shadow_colour: Colour,
|
||||||
|
/// How far the shadow falls; 0 is none, which is mpv's default.
|
||||||
|
pub shadow_offset: f64,
|
||||||
|
/// How soft the edges are, 0–20.
|
||||||
|
pub blur: f64,
|
||||||
|
/// Where the line sits, as a percentage of the picture's height from the
|
||||||
|
/// top: 100 is the bottom edge, mpv's usual place.
|
||||||
|
pub position: f64,
|
||||||
|
/// Whether styled (ASS) tracks are drawn in this style too, size and
|
||||||
|
/// position included. Off by default: it turns signs and karaoke into
|
||||||
|
/// dialogue-looking text.
|
||||||
|
pub restyle_styled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubtitleStyle {
|
||||||
|
/// The family a style with no font of its own is set in: mpv's default,
|
||||||
|
/// which fontconfig resolves to the system's sans-serif face.
|
||||||
|
pub const DEFAULT_FONT: &str = "sans-serif";
|
||||||
|
pub const MIN_SCALE: f64 = 0.5;
|
||||||
|
pub const MAX_SCALE: f64 = 2.5;
|
||||||
|
pub const MAX_OUTLINE: f64 = 10.0;
|
||||||
|
pub const MAX_SHADOW: f64 = 10.0;
|
||||||
|
pub const MAX_BLUR: f64 = 20.0;
|
||||||
|
|
||||||
|
/// The same style with every number brought back into range.
|
||||||
|
///
|
||||||
|
/// A hand-edited or synced file can hold anything, and mpv refuses an
|
||||||
|
/// out-of-range value outright — which would cost the rest of the style.
|
||||||
|
pub fn sanitized(mut self) -> Self {
|
||||||
|
let defaults = Self::default();
|
||||||
|
let clamp = |value: f64, default: f64, min: f64, max: f64| {
|
||||||
|
if value.is_finite() {
|
||||||
|
value.clamp(min, max)
|
||||||
|
} else {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.scale = clamp(self.scale, defaults.scale, Self::MIN_SCALE, Self::MAX_SCALE);
|
||||||
|
self.outline_size = clamp(
|
||||||
|
self.outline_size,
|
||||||
|
defaults.outline_size,
|
||||||
|
0.0,
|
||||||
|
Self::MAX_OUTLINE,
|
||||||
|
);
|
||||||
|
self.shadow_offset = clamp(
|
||||||
|
self.shadow_offset,
|
||||||
|
defaults.shadow_offset,
|
||||||
|
0.0,
|
||||||
|
Self::MAX_SHADOW,
|
||||||
|
);
|
||||||
|
self.blur = clamp(self.blur, defaults.blur, 0.0, Self::MAX_BLUR);
|
||||||
|
self.position = clamp(self.position, defaults.position, 0.0, 100.0);
|
||||||
|
self.font = self
|
||||||
|
.font
|
||||||
|
.map(|font| font.trim().to_owned())
|
||||||
|
.filter(|font| !font.is_empty());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubtitleStyle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
scale: 1.0,
|
||||||
|
font: None,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
colour: Colour::rgba(0xFF, 0xFF, 0xFF, 0xFF),
|
||||||
|
backdrop: SubtitleBackdrop::Outline,
|
||||||
|
outline_colour: Colour::rgba(0, 0, 0, 0xFF),
|
||||||
|
outline_size: 1.65,
|
||||||
|
// About seventy percent black.
|
||||||
|
box_colour: Colour::rgba(0, 0, 0, 0xB0),
|
||||||
|
shadow_colour: Colour::rgba(0, 0, 0, 0xAF),
|
||||||
|
shadow_offset: 0.0,
|
||||||
|
blur: 0.0,
|
||||||
|
position: 100.0,
|
||||||
|
restyle_styled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What keeps the text readable against the picture.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum SubtitleBackdrop {
|
||||||
|
/// An outline round each letter: mpv's own, and the default.
|
||||||
|
#[default]
|
||||||
|
Outline,
|
||||||
|
/// A box behind each line. Uglier, and the only thing that holds up on
|
||||||
|
/// snow, sky and white walls.
|
||||||
|
Box,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubtitleBackdrop {
|
||||||
|
pub const ALL: [Self; 2] = [Self::Outline, Self::Box];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Outline => "Outline",
|
||||||
|
Self::Box => "Box",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A colour with opacity, written `#RRGGBBAA` the way CSS writes one.
|
||||||
|
///
|
||||||
|
/// Not mpv's order: mpv reads `#AARRGGBB`, which nobody hand-editing a file
|
||||||
|
/// would guess. [`Self::to_mpv`] does the translation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(try_from = "String", into = "String")]
|
||||||
|
pub struct Colour {
|
||||||
|
pub r: u8,
|
||||||
|
pub g: u8,
|
||||||
|
pub b: u8,
|
||||||
|
pub a: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Colour {
|
||||||
|
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
|
||||||
|
Self { r, g, b, a }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#RRGGBB` or `#RRGGBBAA`, the `#` optional. Six digits are opaque.
|
||||||
|
pub fn parse(text: &str) -> Option<Self> {
|
||||||
|
let hex = text.trim().strip_prefix('#').unwrap_or(text.trim());
|
||||||
|
if !(hex.len() == 6 || hex.len() == 8) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let byte = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
|
||||||
|
let a = if hex.len() == 8 { byte(6)? } else { 0xFF };
|
||||||
|
Some(Self::rgba(byte(0)?, byte(2)?, byte(4)?, a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As mpv's colour options take it.
|
||||||
|
pub fn to_mpv(self) -> String {
|
||||||
|
format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_array(self) -> [u8; 4] {
|
||||||
|
[self.r, self.g, self.b, self.a]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<[u8; 4]> for Colour {
|
||||||
|
fn from([r, g, b, a]: [u8; 4]) -> Self {
|
||||||
|
Self::rgba(r, g, b, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Colour {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"#{:02X}{:02X}{:02X}{:02X}",
|
||||||
|
self.r, self.g, self.b, self.a
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Colour> for String {
|
||||||
|
fn from(colour: Colour) -> Self {
|
||||||
|
colour.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Colour {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(text: String) -> std::result::Result<Self, Self::Error> {
|
||||||
|
Self::parse(&text).ok_or_else(|| format!("not a colour: {text:?}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlaybackPrefs {
|
impl Default for PlaybackPrefs {
|
||||||
@@ -73,6 +278,7 @@ impl Default for PlaybackPrefs {
|
|||||||
autoplay_next: true,
|
autoplay_next: true,
|
||||||
auto_skip: false,
|
auto_skip: false,
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
|
subtitle_style: SubtitleStyle::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +299,7 @@ impl PlaybackPrefs {
|
|||||||
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
|
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
|
||||||
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
|
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
|
||||||
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
||||||
|
self.subtitle_style = self.subtitle_style.sanitized();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +356,16 @@ mod tests {
|
|||||||
autoplay_next: false,
|
autoplay_next: false,
|
||||||
auto_skip: true,
|
auto_skip: true,
|
||||||
speed: 1.5,
|
speed: 1.5,
|
||||||
|
subtitle_style: SubtitleStyle {
|
||||||
|
scale: 1.5,
|
||||||
|
font: Some("Noto Sans".into()),
|
||||||
|
italic: true,
|
||||||
|
colour: Colour::rgba(0xFF, 0xE6, 0x00, 0xFF),
|
||||||
|
backdrop: SubtitleBackdrop::Box,
|
||||||
|
box_colour: Colour::rgba(0x10, 0x10, 0x10, 0x80),
|
||||||
|
restyle_styled: true,
|
||||||
|
..SubtitleStyle::default()
|
||||||
|
},
|
||||||
};
|
};
|
||||||
save(&dirs, &prefs).unwrap();
|
save(&dirs, &prefs).unwrap();
|
||||||
assert_eq!(load(&dirs).unwrap(), prefs);
|
assert_eq!(load(&dirs).unwrap(), prefs);
|
||||||
@@ -156,6 +373,46 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&temp).ok();
|
std::fs::remove_dir_all(&temp).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_from_before_subtitle_styles_loads_with_the_default_style() {
|
||||||
|
let prefs: PlaybackPrefs =
|
||||||
|
serde_json::from_str(r#"{"volume": 60.0, "subtitles": true, "speed": 1.0}"#).unwrap();
|
||||||
|
assert_eq!(prefs.subtitle_style, SubtitleStyle::default());
|
||||||
|
assert_eq!(prefs.volume, 60.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_colour_is_written_rgba_and_handed_to_mpv_as_argb() {
|
||||||
|
let colour = Colour::parse("#FFE60080").unwrap();
|
||||||
|
assert_eq!(colour, Colour::rgba(0xFF, 0xE6, 0x00, 0x80));
|
||||||
|
assert_eq!(colour.to_string(), "#FFE60080");
|
||||||
|
assert_eq!(colour.to_mpv(), "#80FFE600");
|
||||||
|
assert_eq!(
|
||||||
|
Colour::parse("ffe600"),
|
||||||
|
Some(Colour::rgba(0xFF, 0xE6, 0, 0xFF))
|
||||||
|
);
|
||||||
|
assert_eq!(Colour::parse("#FFE6"), None);
|
||||||
|
assert_eq!(Colour::parse("#GGGGGG"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_style_out_of_range_is_brought_back_into_it() {
|
||||||
|
let style = SubtitleStyle {
|
||||||
|
scale: 40.0,
|
||||||
|
outline_size: -3.0,
|
||||||
|
blur: f64::NAN,
|
||||||
|
position: 400.0,
|
||||||
|
font: Some(" ".into()),
|
||||||
|
..SubtitleStyle::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(style.scale, SubtitleStyle::MAX_SCALE);
|
||||||
|
assert_eq!(style.outline_size, 0.0);
|
||||||
|
assert_eq!(style.blur, 0.0);
|
||||||
|
assert_eq!(style.position, 100.0);
|
||||||
|
assert_eq!(style.font, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
|
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
|
||||||
let prefs = PlaybackPrefs {
|
let prefs = PlaybackPrefs {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! The rest of what makes the app look the same on every device: the shares,
|
//! The rest of what makes the app look the same on every device: the shares,
|
||||||
//! the settings, the hand-picked metadata matches and the per-show track
|
//! the settings, the hand-picked metadata matches, the per-show track choices
|
||||||
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
|
//! and My List. Synced next to watch history by [`crate::sync`], in the same file.
|
||||||
//!
|
//!
|
||||||
//! ## Registers
|
//! ## Registers
|
||||||
//!
|
//!
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
|
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
|
||||||
//! last changed. Merging is the same rule as watch history — the newer
|
//! last changed. Merging is the same rule as watch history — the newer
|
||||||
//! `updated_at` wins, per key — so it is idempotent and order-free across any
|
//! `updated_at` wins, per key — so it is idempotent and order-free across any
|
||||||
//! number of devices. A removed share is a register with no value, so a
|
//! number of devices. A removed share, or a title taken off My List, is a
|
||||||
//! removal travels like any other change.
|
//! register with no value, so a removal travels like any other change.
|
||||||
//!
|
//!
|
||||||
//! ## Noticing changes
|
//! ## Noticing changes
|
||||||
//!
|
//!
|
||||||
@@ -56,6 +56,7 @@ use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
|
|||||||
const SHARE: &str = "share/";
|
const SHARE: &str = "share/";
|
||||||
const TRACKS: &str = "tracks/";
|
const TRACKS: &str = "tracks/";
|
||||||
const MATCH: &str = "match/";
|
const MATCH: &str = "match/";
|
||||||
|
const LIST: &str = "list/";
|
||||||
|
|
||||||
/// The settings files that travel, by register, and the fields in each that
|
/// The settings files that travel, by register, and the fields in each that
|
||||||
/// stay on the device that wrote them.
|
/// stay on the device that wrote them.
|
||||||
@@ -98,6 +99,12 @@ struct SyncedShare {
|
|||||||
folder: Option<AccountFolder>,
|
folder: Option<AccountFolder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A title on My List.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
struct SyncedListEntry {
|
||||||
|
added_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// A match the viewer picked by hand. Automatic ones are not synced: every
|
/// A match the viewer picked by hand. Automatic ones are not synced: every
|
||||||
/// device with enrichment on finds them again, and they expire anyway.
|
/// device with enrichment on finds them again, and they expire anyway.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -123,6 +130,8 @@ pub struct ProfileChanges {
|
|||||||
pub settings: bool,
|
pub settings: bool,
|
||||||
/// Hand-picked matches and per-show track choices taken.
|
/// Hand-picked matches and per-show track choices taken.
|
||||||
pub titles: usize,
|
pub titles: usize,
|
||||||
|
/// Whether My List changed.
|
||||||
|
pub list: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileChanges {
|
impl ProfileChanges {
|
||||||
@@ -154,6 +163,8 @@ struct Observed {
|
|||||||
/// Whether the share list itself could be read. Without it, a share
|
/// Whether the share list itself could be read. Without it, a share
|
||||||
/// missing from `values` says nothing about whether it was removed.
|
/// missing from `values` says nothing about whether it was removed.
|
||||||
shares_listed: bool,
|
shares_listed: bool,
|
||||||
|
/// The same for My List.
|
||||||
|
list_read: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_file(dirs: &AppDirs) -> PathBuf {
|
fn state_file(dirs: &AppDirs) -> PathBuf {
|
||||||
@@ -248,6 +259,18 @@ impl Profile<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match crate::mylist::load(self.dirs()) {
|
||||||
|
Ok(list) => {
|
||||||
|
observed.list_read = true;
|
||||||
|
for (title, added_at) in list.entries() {
|
||||||
|
if let Ok(value) = serde_json::to_value(SyncedListEntry { added_at }) {
|
||||||
|
observed.values.insert(format!("{LIST}{title}"), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => tracing::warn!(%error, "My List was not synced"),
|
||||||
|
}
|
||||||
|
|
||||||
let catalog = self.catalog.lock();
|
let catalog = self.catalog.lock();
|
||||||
if let Ok(tracks) = catalog.all_title_track_prefs() {
|
if let Ok(tracks) = catalog.all_title_track_prefs() {
|
||||||
for (title, prefs) in tracks {
|
for (title, prefs) in tracks {
|
||||||
@@ -320,6 +343,19 @@ impl Profile<'_> {
|
|||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if let Some(title) = key.strip_prefix(LIST) {
|
||||||
|
let added_at = value
|
||||||
|
.map(parse::<SyncedListEntry>)
|
||||||
|
.transpose()?
|
||||||
|
.map(|entry| entry.added_at);
|
||||||
|
let mut list = crate::mylist::load(self.dirs())?;
|
||||||
|
if list.added_at(title) != added_at {
|
||||||
|
list.set(title, added_at);
|
||||||
|
crate::mylist::save(self.dirs(), &list)?;
|
||||||
|
changes.list = true;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if key == TMDB_KEY {
|
if key == TMDB_KEY {
|
||||||
match value.and_then(Value::as_str) {
|
match value.and_then(Value::as_str) {
|
||||||
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
|
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
|
||||||
@@ -428,9 +464,10 @@ impl Profile<'_> {
|
|||||||
/// Record what changed on this device since the last sync, stamped `now`.
|
/// Record what changed on this device since the last sync, stamped `now`.
|
||||||
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
|
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
|
||||||
for (key, register) in registers.iter_mut() {
|
for (key, register) in registers.iter_mut() {
|
||||||
|
let removable = (key.starts_with(SHARE) && observed.shares_listed)
|
||||||
|
|| (key.starts_with(LIST) && observed.list_read);
|
||||||
if register.value.is_some()
|
if register.value.is_some()
|
||||||
&& key.starts_with(SHARE)
|
&& removable
|
||||||
&& observed.shares_listed
|
|
||||||
&& !observed.values.contains_key(key)
|
&& !observed.values.contains_key(key)
|
||||||
&& !observed.unknown.contains(key)
|
&& !observed.unknown.contains(key)
|
||||||
{
|
{
|
||||||
@@ -747,6 +784,7 @@ pub(crate) mod tests {
|
|||||||
kind: crate::library::TitleKind::Series,
|
kind: crate::library::TitleKind::Series,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
@@ -788,6 +826,58 @@ pub(crate) mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_put_on_my_list_on_one_device_is_on_the_list_of_another() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
laptop.sync(&[], 1);
|
||||||
|
let mut list = crate::mylist::MyList::default();
|
||||||
|
list.add("frieren", 5);
|
||||||
|
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 10);
|
||||||
|
|
||||||
|
let (_, changes) = phone.sync(&[&sent], 20);
|
||||||
|
|
||||||
|
assert!(changes.list);
|
||||||
|
let taken = crate::mylist::load(&phone.store.dirs).unwrap();
|
||||||
|
assert_eq!(taken.added_at("frieren"), Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_taken_off_my_list_on_one_device_leaves_every_list() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
let mut list = crate::mylist::MyList::default();
|
||||||
|
list.add("frieren", 5);
|
||||||
|
list.add("one piece", 6);
|
||||||
|
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 10);
|
||||||
|
let (phone_sent, _) = phone.sync(&[&sent], 20);
|
||||||
|
|
||||||
|
let mut list = crate::mylist::load(&phone.store.dirs).unwrap();
|
||||||
|
list.remove("frieren");
|
||||||
|
crate::mylist::save(&phone.store.dirs, &list).unwrap();
|
||||||
|
let (phone_sent, _) = phone.sync(&[&sent, &phone_sent], 30);
|
||||||
|
let (sent, changes) = laptop.sync(&[&phone_sent], 40);
|
||||||
|
|
||||||
|
assert!(changes.list);
|
||||||
|
let left = crate::mylist::load(&laptop.store.dirs).unwrap();
|
||||||
|
assert!(!left.contains("frieren"));
|
||||||
|
assert!(left.contains("one piece"));
|
||||||
|
|
||||||
|
// Put back later, it is back everywhere: the removal is older.
|
||||||
|
let mut list = left;
|
||||||
|
list.add("frieren", 50);
|
||||||
|
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[&sent], 50);
|
||||||
|
phone.sync(&[&sent], 60);
|
||||||
|
assert!(
|
||||||
|
crate::mylist::load(&phone.store.dirs)
|
||||||
|
.unwrap()
|
||||||
|
.contains("frieren")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_synced_share_that_names_another_id_is_refused() {
|
fn a_synced_share_that_names_another_id_is_refused() {
|
||||||
let phone = Device::new("phone");
|
let phone = Device::new("phone");
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! The last few searches, offered again under an empty search box.
|
||||||
|
//!
|
||||||
|
//! Kept on this device only, in `recent-searches.json` beside the other
|
||||||
|
//! settings, and never synced: what someone typed is not a setting, and
|
||||||
|
//! another device's list is not what they were just looking for.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::{AppDirs, read_json, write_json};
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// How many searches are kept.
|
||||||
|
pub const KEEP: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RecentSearches {
|
||||||
|
/// Newest first.
|
||||||
|
#[serde(default)]
|
||||||
|
pub queries: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecentSearches {
|
||||||
|
/// Put `query` first, saying whether the list changed. The same search
|
||||||
|
/// in other letter case moves rather than repeats, and one under two
|
||||||
|
/// letters is not kept.
|
||||||
|
pub fn remember(&mut self, query: &str) -> bool {
|
||||||
|
let query = query.trim();
|
||||||
|
if query.chars().count() < 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.queries.first().map(String::as_str) == Some(query) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let lower = query.to_lowercase();
|
||||||
|
self.queries.retain(|kept| kept.to_lowercase() != lower);
|
||||||
|
self.queries.insert(0, query.to_owned());
|
||||||
|
self.queries.truncate(KEEP);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(dirs: &AppDirs) -> PathBuf {
|
||||||
|
dirs.config.join("recent-searches.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list, empty when there is no file yet.
|
||||||
|
pub fn load(dirs: &AppDirs) -> Result<RecentSearches> {
|
||||||
|
Ok(read_json::<RecentSearches>(&file(dirs))?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(dirs: &AppDirs, recent: &RecentSearches) -> Result<()> {
|
||||||
|
write_json(&file(dirs), recent)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_search_repeated_moves_to_the_front_instead_of_appearing_twice() {
|
||||||
|
let mut recent = RecentSearches::default();
|
||||||
|
assert!(recent.remember("frieren"));
|
||||||
|
assert!(recent.remember("kon"));
|
||||||
|
assert!(recent.remember("Frieren "));
|
||||||
|
assert_eq!(recent.queries, ["Frieren", "kon"]);
|
||||||
|
assert!(!recent.remember("Frieren"));
|
||||||
|
assert!(!recent.remember("x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_newest_searches_are_kept() {
|
||||||
|
let mut recent = RecentSearches::default();
|
||||||
|
for at in 0..KEEP + 3 {
|
||||||
|
recent.remember(&format!("search {at}"));
|
||||||
|
}
|
||||||
|
assert_eq!(recent.queries.len(), KEEP);
|
||||||
|
assert_eq!(recent.queries[0], format!("search {}", KEEP + 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,46 @@ pub fn pick(
|
|||||||
picked
|
picked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many titles the "Because you watched" row offers.
|
||||||
|
const BECAUSE: usize = 12;
|
||||||
|
|
||||||
|
/// Every row above the library grid, in the order all three apps show them:
|
||||||
|
/// new episodes of what is being watched, My List, what was recently added,
|
||||||
|
/// what is like the last thing finished, then up to `limit` [`pick`]ed
|
||||||
|
/// shelves.
|
||||||
|
pub fn home(
|
||||||
|
titles: &[Title],
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
names: crate::metadata::TitleNames,
|
||||||
|
list: &crate::mylist::MyList,
|
||||||
|
first_seen: &HashMap<(String, String), i64>,
|
||||||
|
now: i64,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<Shelf> {
|
||||||
|
let news = crate::news::rows(titles, first_seen, now);
|
||||||
|
let present: std::collections::HashSet<&str> =
|
||||||
|
titles.iter().map(|title| title.key.as_str()).collect();
|
||||||
|
let keys: Vec<String> = list
|
||||||
|
.newest_first()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|key| present.contains(key))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect();
|
||||||
|
let mine = (!keys.is_empty()).then(|| Shelf {
|
||||||
|
label: "My List".into(),
|
||||||
|
keys,
|
||||||
|
});
|
||||||
|
news.episodes
|
||||||
|
.into_iter()
|
||||||
|
.chain(mine)
|
||||||
|
.chain(news.added)
|
||||||
|
.chain(crate::discover::because_you_watched(
|
||||||
|
titles, metadata, names, BECAUSE,
|
||||||
|
))
|
||||||
|
.chain(pick(titles, metadata, limit))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -168,6 +208,7 @@ mod tests {
|
|||||||
kind: TitleKind::Film,
|
kind: TitleKind::Film,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: genres.iter().map(|genre| genre.to_string()).collect(),
|
genres: genres.iter().map(|genre| genre.to_string()).collect(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
@@ -258,4 +299,25 @@ mod tests {
|
|||||||
assert_eq!(shelves[0].label, "Airing now");
|
assert_eq!(shelves[0].label, "Airing now");
|
||||||
assert_eq!(shelves[0].keys, vec!["a"]);
|
assert_eq!(shelves[0].keys, vec!["a"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn my_list_is_a_row_of_what_the_library_holds_newest_first() {
|
||||||
|
let titles = vec![title("a"), title("b")];
|
||||||
|
let mut list = crate::mylist::MyList::default();
|
||||||
|
list.add("a", 1);
|
||||||
|
list.add("b", 3);
|
||||||
|
list.add("gone", 5);
|
||||||
|
let rows = home(
|
||||||
|
&titles,
|
||||||
|
&HashMap::new(),
|
||||||
|
Default::default(),
|
||||||
|
&list,
|
||||||
|
&HashMap::new(),
|
||||||
|
10,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].label, "My List");
|
||||||
|
assert_eq!(rows[0].keys, ["b", "a"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,8 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use pstr_core::library::{self, TitleKind};
|
use pstr_core::library::{self, TitleKind};
|
||||||
use pstr_core::metadata::{
|
use pstr_core::metadata::{
|
||||||
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
|
CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
|
||||||
|
Trailer,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ use crate::error::{Error, Result};
|
|||||||
use crate::limiter::RateLimiter;
|
use crate::limiter::RateLimiter;
|
||||||
use crate::matching::{Candidate, Query};
|
use crate::matching::{Candidate, Query};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
|
use crate::tmdb::{Tmdb, TmdbId};
|
||||||
|
|
||||||
const ENDPOINT: &str = "https://graphql.anilist.co";
|
const ENDPOINT: &str = "https://graphql.anilist.co";
|
||||||
|
|
||||||
@@ -59,7 +61,9 @@ const SCORE_SCALE: f32 = 10.0;
|
|||||||
/// Everything one entry is asked for, by search and by id alike.
|
/// Everything one entry is asked for, by search and by id alike.
|
||||||
///
|
///
|
||||||
/// The relations are the franchise's — see `pstr_core::franchise` — and the
|
/// The relations are the franchise's — see `pstr_core::franchise` — and the
|
||||||
/// staff are only the most relevant few, which is where a director sits.
|
/// staff are only the most relevant few, which is where a director sits. The
|
||||||
|
/// characters are the first ten by role, main ones first, each with their
|
||||||
|
/// Japanese voice actor.
|
||||||
macro_rules! entry_fragment {
|
macro_rules! entry_fragment {
|
||||||
() => {
|
() => {
|
||||||
r#"
|
r#"
|
||||||
@@ -77,11 +81,20 @@ fragment entry on Media {
|
|||||||
genres
|
genres
|
||||||
tags { name rank isMediaSpoiler }
|
tags { name rank isMediaSpoiler }
|
||||||
episodes
|
episodes
|
||||||
|
duration
|
||||||
format
|
format
|
||||||
status
|
status
|
||||||
siteUrl
|
siteUrl
|
||||||
studios(isMain: true) { nodes { name } }
|
studios(isMain: true) { nodes { name } }
|
||||||
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
|
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
|
||||||
|
characters(sort: [ROLE, RELEVANCE, ID], perPage: 10) {
|
||||||
|
edges {
|
||||||
|
role
|
||||||
|
node { name { full } image { medium } }
|
||||||
|
voiceActors(language: JAPANESE, sort: [RELEVANCE, ID]) { name { full } image { medium } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailer { id site }
|
||||||
nextAiringEpisode { episode airingAt }
|
nextAiringEpisode { episode airingAt }
|
||||||
relations { edges { relationType node { id type } } }
|
relations { edges { relationType node { id type } } }
|
||||||
}
|
}
|
||||||
@@ -158,16 +171,20 @@ pub struct AniList {
|
|||||||
/// Shared by every lookup in flight — see [`crate::limiter`].
|
/// Shared by every lookup in flight — see [`crate::limiter`].
|
||||||
limiter: RateLimiter,
|
limiter: RateLimiter,
|
||||||
anizip: AniZip,
|
anizip: AniZip,
|
||||||
|
/// Asked by id for the art TVDB has not got, when a TMDB key is stored.
|
||||||
|
tmdb: Option<Tmdb>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AniList {
|
impl AniList {
|
||||||
/// `language` is the one episode names are wanted in, where ani.zip has
|
/// `language` is the one episode names are wanted in, where ani.zip has
|
||||||
/// them in it.
|
/// them in it. `tmdb` is asked for the art of entries TVDB has none for —
|
||||||
pub fn new(http: reqwest::Client, language: String) -> Self {
|
/// see [`Tmdb::art`].
|
||||||
|
pub fn new(http: reqwest::Client, language: String, tmdb: Option<Tmdb>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
anizip: AniZip::new(http.clone(), language),
|
anizip: AniZip::new(http.clone(), language),
|
||||||
http,
|
http,
|
||||||
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||||
|
tmdb,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +220,8 @@ impl Provider for AniList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The episodes of every entry the library's files are spread across, and
|
/// The episodes of every entry the library's files are spread across, and
|
||||||
/// the series' fanart.
|
/// the series' fanart and logo — from TMDB, by the id ani.zip gives, for
|
||||||
|
/// an entry TVDB has no art for.
|
||||||
///
|
///
|
||||||
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
|
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
|
||||||
/// four of them, Mushoku Tensei's first is two — so a title's match is
|
/// four of them, Mushoku Tensei's first is two — so a title's match is
|
||||||
@@ -234,6 +252,8 @@ impl Provider for AniList {
|
|||||||
// The same ids as `visited`, in the order they were read.
|
// The same ids as `visited`, in the order they were read.
|
||||||
let mut chain = Vec::new();
|
let mut chain = Vec::new();
|
||||||
let mut series_entries = 0;
|
let mut series_entries = 0;
|
||||||
|
// The match's own: a sequel's art is not the title's.
|
||||||
|
let mut tmdb_id: Option<TmdbId> = None;
|
||||||
let mut next = Some(Link {
|
let mut next = Some(Link {
|
||||||
id: first,
|
id: first,
|
||||||
series: found.kind == TitleKind::Series,
|
series: found.kind == TitleKind::Series,
|
||||||
@@ -246,9 +266,15 @@ impl Provider for AniList {
|
|||||||
series_entries
|
series_entries
|
||||||
});
|
});
|
||||||
let mapping = self.anizip.mapping(link.id, entry_season).await?;
|
let mapping = self.anizip.mapping(link.id, entry_season).await?;
|
||||||
|
if chain.len() == 1 {
|
||||||
|
tmdb_id = mapping.tmdb;
|
||||||
|
}
|
||||||
if enrichment.backdrop_url.is_none() {
|
if enrichment.backdrop_url.is_none() {
|
||||||
enrichment.backdrop_url = mapping.fanart;
|
enrichment.backdrop_url = mapping.fanart;
|
||||||
}
|
}
|
||||||
|
if enrichment.logo_url.is_none() {
|
||||||
|
enrichment.logo_url = mapping.logo;
|
||||||
|
}
|
||||||
// Where two entries both list an episode, the earlier one — the
|
// Where two entries both list an episode, the earlier one — the
|
||||||
// one closer to the match — keeps it.
|
// one closer to the match — keeps it.
|
||||||
enrichment.episodes.extend(
|
enrichment.episodes.extend(
|
||||||
@@ -264,6 +290,14 @@ impl Provider for AniList {
|
|||||||
next = self.sequel(link.id, &visited).await?;
|
next = self.sequel(link.id, &visited).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let (Some(tmdb), Some(id)) = (&self.tmdb, tmdb_id)
|
||||||
|
&& (enrichment.backdrop_url.is_none() || enrichment.logo_url.is_none())
|
||||||
|
{
|
||||||
|
let art = tmdb.art_or_nothing(id).await;
|
||||||
|
enrichment.backdrop_url = enrichment.backdrop_url.or(art.backdrop);
|
||||||
|
enrichment.logo_url = enrichment.logo_url.or(art.logo);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"{}: {} episodes over {} entries",
|
"{}: {} episodes over {} entries",
|
||||||
found.name,
|
found.name,
|
||||||
@@ -308,7 +342,6 @@ impl AniList {
|
|||||||
Ok(next_in_chain(edges, visited))
|
Ok(next_in_chain(edges, visited))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One GraphQL requestimpl AniList {
|
|
||||||
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
||||||
/// status and an `errors` array under a 200 — folded into one.
|
/// status and an `errors` array under a 200 — folded into one.
|
||||||
///
|
///
|
||||||
@@ -379,6 +412,12 @@ impl AniList {
|
|||||||
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only
|
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only
|
||||||
/// after the name as written found nothing, which is what keeps it from
|
/// after the name as written found nothing, which is what keeps it from
|
||||||
/// broadening a search that was already working.
|
/// broadening a search that was already working.
|
||||||
|
///
|
||||||
|
/// A film's version number is the same kind of miss. `Evangelion - 1.11 You
|
||||||
|
/// Are (Not) Alone` is the file of a film AniList calls `Evangelion: 1.0 You
|
||||||
|
/// Are (Not) Alone`, and `1.11` is not a word of that name — so the last
|
||||||
|
/// fallback asks without it, and leaves telling the films apart to the scoring,
|
||||||
|
/// which still sees the whole name.
|
||||||
fn search_terms(name: &str) -> Vec<String> {
|
fn search_terms(name: &str) -> Vec<String> {
|
||||||
let mut terms = vec![name.to_string()];
|
let mut terms = vec![name.to_string()];
|
||||||
|
|
||||||
@@ -386,9 +425,31 @@ fn search_terms(name: &str) -> Vec<String> {
|
|||||||
if depossessed != name {
|
if depossessed != name {
|
||||||
terms.push(depossessed);
|
terms.push(depossessed);
|
||||||
}
|
}
|
||||||
|
let unversioned = without_version_numbers(name);
|
||||||
|
if !unversioned.is_empty() && !terms.contains(&unversioned) {
|
||||||
|
terms.push(unversioned);
|
||||||
|
}
|
||||||
terms
|
terms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Evangelion - 1.11 You Are Alone` → `Evangelion - You Are Alone`.
|
||||||
|
///
|
||||||
|
/// A version is digits with a dot in them (`1.11`, `3.0+1.01`); a bare number
|
||||||
|
/// is left alone, because in `Kaijuu 8-gou` or `86` it is the name.
|
||||||
|
fn without_version_numbers(name: &str) -> String {
|
||||||
|
name.split(' ')
|
||||||
|
.filter(|word| {
|
||||||
|
let versioned = word.contains('.')
|
||||||
|
&& word.chars().any(|c| c.is_ascii_digit())
|
||||||
|
&& word
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '+'));
|
||||||
|
!versioned
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
/// `Heavens Feel` → `Heaven Feel`.
|
/// `Heavens Feel` → `Heaven Feel`.
|
||||||
///
|
///
|
||||||
/// Only words long enough to still mean something without it, and only where
|
/// Only words long enough to still mean something without it, and only where
|
||||||
@@ -532,6 +593,8 @@ struct Media {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tags: Vec<Tag>,
|
tags: Vec<Tag>,
|
||||||
episodes: Option<u32>,
|
episodes: Option<u32>,
|
||||||
|
/// Minutes per episode, or the film's running time.
|
||||||
|
duration: Option<u32>,
|
||||||
format: Option<String>,
|
format: Option<String>,
|
||||||
season: Option<String>,
|
season: Option<String>,
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
@@ -539,6 +602,8 @@ struct Media {
|
|||||||
site_url: Option<String>,
|
site_url: Option<String>,
|
||||||
studios: Option<Studios>,
|
studios: Option<Studios>,
|
||||||
staff: Option<Staff>,
|
staff: Option<Staff>,
|
||||||
|
characters: Option<Characters>,
|
||||||
|
trailer: Option<AniTrailer>,
|
||||||
#[serde(rename = "nextAiringEpisode")]
|
#[serde(rename = "nextAiringEpisode")]
|
||||||
next_airing_episode: Option<NextAiring>,
|
next_airing_episode: Option<NextAiring>,
|
||||||
relations: Option<Relations>,
|
relations: Option<Relations>,
|
||||||
@@ -584,6 +649,32 @@ struct StaffEdge {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Person {
|
struct Person {
|
||||||
name: PersonName,
|
name: PersonName,
|
||||||
|
image: Option<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Image {
|
||||||
|
medium: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Characters {
|
||||||
|
#[serde(default)]
|
||||||
|
edges: Vec<CharacterEdge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CharacterEdge {
|
||||||
|
role: Option<String>,
|
||||||
|
node: Option<Person>,
|
||||||
|
#[serde(rename = "voiceActors", default)]
|
||||||
|
voice_actors: Vec<Person>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AniTrailer {
|
||||||
|
id: Option<String>,
|
||||||
|
site: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -706,6 +797,37 @@ impl Media {
|
|||||||
.collect(),
|
.collect(),
|
||||||
popularity: self.popularity,
|
popularity: self.popularity,
|
||||||
status: self.status,
|
status: self.status,
|
||||||
|
runtime: self.duration.filter(|minutes| *minutes > 0),
|
||||||
|
cast: self
|
||||||
|
.characters
|
||||||
|
.map(|characters| {
|
||||||
|
characters
|
||||||
|
.edges
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|edge| {
|
||||||
|
let character = edge.node?;
|
||||||
|
let actor = edge.voice_actors.into_iter().next();
|
||||||
|
Some(CastMember {
|
||||||
|
character: character.name.full?,
|
||||||
|
character_image: character.image.and_then(|image| image.medium),
|
||||||
|
role: edge.role,
|
||||||
|
actor_image: actor
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|actor| actor.image.as_ref())
|
||||||
|
.and_then(|image| image.medium.clone()),
|
||||||
|
actor: actor.and_then(|actor| actor.name.full),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
trailer: self.trailer.and_then(|trailer| {
|
||||||
|
Some(Trailer {
|
||||||
|
site: trailer.site?,
|
||||||
|
id: trailer.id?,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
revision: TitleDetails::REVISION,
|
||||||
next_episode: self
|
next_episode: self
|
||||||
.next_airing_episode
|
.next_airing_episode
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -746,6 +868,8 @@ impl Media {
|
|||||||
// backdrop is ani.zip's fanart, found when the match is
|
// backdrop is ani.zip's fanart, found when the match is
|
||||||
// enriched.
|
// enriched.
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
// ani.zip's too, like the fanart.
|
||||||
|
logo_url: None,
|
||||||
rating: self.average_score.map(|score| score / SCORE_SCALE),
|
rating: self.average_score.map(|score| score / SCORE_SCALE),
|
||||||
genres: self.genres,
|
genres: self.genres,
|
||||||
episodes: self.episodes,
|
episodes: self.episodes,
|
||||||
@@ -775,7 +899,7 @@ fn kind_of(format: Option<&str>) -> TitleKind {
|
|||||||
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
|
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
|
||||||
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become
|
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become
|
||||||
/// nothing and `<br>` becomes the line break it stands for.
|
/// nothing and `<br>` becomes the line break it stands for.
|
||||||
fn strip_markup(text: &str) -> String {
|
pub(crate) fn strip_markup(text: &str) -> String {
|
||||||
let text = text
|
let text = text
|
||||||
.replace("<br>", "\n")
|
.replace("<br>", "\n")
|
||||||
.replace("<br/>", "\n")
|
.replace("<br/>", "\n")
|
||||||
@@ -836,6 +960,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **What three of the four Rebuild films matched as: nothing.** AniList
|
||||||
|
/// requires every word, and a film's version number is not one of its
|
||||||
|
/// title's.
|
||||||
|
#[test]
|
||||||
|
fn a_films_version_number_is_searched_for_again_without_it() {
|
||||||
|
assert_eq!(
|
||||||
|
search_terms("Evangelion - 1.11 You Are Alone"),
|
||||||
|
vec![
|
||||||
|
"Evangelion - 1.11 You Are Alone".to_string(),
|
||||||
|
"Evangelion - You Are Alone".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
search_terms("Evangelion - 3.0+1.01 Thrice Upon a Time")[1],
|
||||||
|
"Evangelion - Thrice Upon a Time"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
search_terms("Kaijuu 8-gou"),
|
||||||
|
vec!["Kaijuu 8-gou"],
|
||||||
|
"a number that is part of the name stays"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Byte slicing, on names that are not ASCII.
|
/// Byte slicing, on names that are not ASCII.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_multi_byte_name_is_not_cut_inside_a_character() {
|
fn a_multi_byte_name_is_not_cut_inside_a_character() {
|
||||||
@@ -894,6 +1041,51 @@ mod tests {
|
|||||||
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
|
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cast_comes_with_its_voice_actors_and_the_trailer_with_its_site() {
|
||||||
|
let media: Media = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"id": 154587,
|
||||||
|
"title": {"romaji": "Sousou no Frieren", "english": null, "native": null},
|
||||||
|
"characters": {"edges": [
|
||||||
|
{"role": "MAIN",
|
||||||
|
"node": {"name": {"full": "Frieren"}, "image": {"medium": "frieren.png"}},
|
||||||
|
"voiceActors": [{"name": {"full": "Atsumi Tanezaki"},
|
||||||
|
"image": {"medium": "tanezaki.png"}}]},
|
||||||
|
{"role": "SUPPORTING",
|
||||||
|
"node": {"name": {"full": "Himmel"}, "image": null},
|
||||||
|
"voiceActors": []},
|
||||||
|
{"role": "BACKGROUND", "node": {"name": {"full": null}}, "voiceActors": []}
|
||||||
|
]},
|
||||||
|
"trailer": {"id": "tR8YH0G67Rk", "site": "youtube"}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("parse");
|
||||||
|
let details = media.into_candidate().metadata.details.expect("details");
|
||||||
|
assert_eq!(
|
||||||
|
details.cast,
|
||||||
|
[
|
||||||
|
CastMember {
|
||||||
|
character: "Frieren".into(),
|
||||||
|
character_image: Some("frieren.png".into()),
|
||||||
|
role: Some("MAIN".into()),
|
||||||
|
actor: Some("Atsumi Tanezaki".into()),
|
||||||
|
actor_image: Some("tanezaki.png".into()),
|
||||||
|
},
|
||||||
|
// No voice actor yet is still a character; no name is not.
|
||||||
|
CastMember {
|
||||||
|
character: "Himmel".into(),
|
||||||
|
role: Some("SUPPORTING".into()),
|
||||||
|
..CastMember::default()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
details.trailer.and_then(|trailer| trailer.url()).as_deref(),
|
||||||
|
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
|
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
|
||||||
RelationEdge {
|
RelationEdge {
|
||||||
relation_type: Some(relation.into()),
|
relation_type: Some(relation.into()),
|
||||||
@@ -971,6 +1163,8 @@ mod tests {
|
|||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
air_date: None,
|
air_date: None,
|
||||||
|
runtime: None,
|
||||||
|
mal_id: None,
|
||||||
};
|
};
|
||||||
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
|
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
|
||||||
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
|
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! AniSkip: where an episode's opening and ending are, as its viewers marked
|
||||||
|
//! them.
|
||||||
|
//!
|
||||||
|
//! For the files that come with no chapters saying so — which is most of what
|
||||||
|
//! is not a fansub release. Asked once per episode, when it starts playing, by
|
||||||
|
//! MyAnimeList id and episode number, with the file's length so the answer is
|
||||||
|
//! one marked against a cut of the same length. Nothing is stored: the answer
|
||||||
|
//! is the player's for as long as the file is open. Like ani.zip it needs no
|
||||||
|
//! key and no account, and it learns nothing about the library that the match
|
||||||
|
//! did not already send AniList.
|
||||||
|
|
||||||
|
use pstr_core::chapters::{ChapterRole, SkipRange};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
const ENDPOINT: &str = "https://api.aniskip.com/v2/skip-times";
|
||||||
|
|
||||||
|
/// Where `episode` of the MyAnimeList entry `mal_id` has its opening and its
|
||||||
|
/// ending, in a cut `length` seconds long. Empty when nobody has marked it.
|
||||||
|
pub async fn skip_times(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
mal_id: u32,
|
||||||
|
episode: u32,
|
||||||
|
length: f64,
|
||||||
|
) -> Result<Vec<SkipRange>> {
|
||||||
|
let response = http
|
||||||
|
.get(format!("{ENDPOINT}/{mal_id}/{episode}"))
|
||||||
|
.query(&[
|
||||||
|
("types", "op"),
|
||||||
|
("types", "ed"),
|
||||||
|
("episodeLength", &format!("{length:.0}")),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
// "No skip times found" comes back as a 404 with a body saying so.
|
||||||
|
if status.as_u16() == 404 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(Error::Http(format!("AniSkip answered {status}")));
|
||||||
|
}
|
||||||
|
let payload: Payload = response.json().await?;
|
||||||
|
Ok(payload.ranges())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct Payload {
|
||||||
|
#[serde(default)]
|
||||||
|
found: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
results: Vec<Skip>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Skip {
|
||||||
|
interval: Interval,
|
||||||
|
skip_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Interval {
|
||||||
|
start_time: f64,
|
||||||
|
end_time: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Payload {
|
||||||
|
fn ranges(self) -> Vec<SkipRange> {
|
||||||
|
if !self.found {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.results
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|skip| {
|
||||||
|
let role = match skip.skip_type.as_str() {
|
||||||
|
"op" => ChapterRole::Opening,
|
||||||
|
"ed" => ChapterRole::Ending,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(skip.interval.end_time > skip.interval.start_time).then_some(SkipRange {
|
||||||
|
role,
|
||||||
|
start: skip.interval.start_time.max(0.0),
|
||||||
|
end: skip.interval.end_time,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_answer_parses_into_an_opening_and_an_ending() {
|
||||||
|
let payload: Payload = serde_json::from_str(
|
||||||
|
r#"{"found":true,"results":[
|
||||||
|
{"interval":{"startTime":1.35,"endTime":91.35},"skipType":"op",
|
||||||
|
"skipId":"9a85542c","episodeLength":1469.979},
|
||||||
|
{"interval":{"startTime":1367,"endTime":1459},"skipType":"ed",
|
||||||
|
"skipId":"f6f905e7","episodeLength":1471}],
|
||||||
|
"message":"Successfully found skip times","statusCode":200}"#,
|
||||||
|
)
|
||||||
|
.expect("parse");
|
||||||
|
assert_eq!(
|
||||||
|
payload.ranges(),
|
||||||
|
vec![
|
||||||
|
SkipRange {
|
||||||
|
role: ChapterRole::Opening,
|
||||||
|
start: 1.35,
|
||||||
|
end: 91.35,
|
||||||
|
},
|
||||||
|
SkipRange {
|
||||||
|
role: ChapterRole::Ending,
|
||||||
|
start: 1367.0,
|
||||||
|
end: 1459.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_found_and_kinds_not_asked_for_parse_to_nothing() {
|
||||||
|
let none: Payload = serde_json::from_str(
|
||||||
|
r#"{"found":false,"results":[],"message":"No skip times found","statusCode":404}"#,
|
||||||
|
)
|
||||||
|
.expect("parse");
|
||||||
|
assert!(none.ranges().is_empty());
|
||||||
|
|
||||||
|
let recap: Payload = serde_json::from_str(
|
||||||
|
r#"{"found":true,"results":[{"interval":{"startTime":0,"endTime":60},"skipType":"recap"}]}"#,
|
||||||
|
)
|
||||||
|
.expect("parse");
|
||||||
|
assert!(recap.ranges().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::limiter::RateLimiter;
|
use crate::limiter::RateLimiter;
|
||||||
|
use crate::tmdb::TmdbId;
|
||||||
|
|
||||||
const ENDPOINT: &str = "https://api.ani.zip/mappings";
|
const ENDPOINT: &str = "https://api.ani.zip/mappings";
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ pub struct Mapping {
|
|||||||
pub episodes: Vec<EpisodeMetadata>,
|
pub episodes: Vec<EpisodeMetadata>,
|
||||||
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
|
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
|
||||||
pub fanart: Option<String>,
|
pub fanart: Option<String>,
|
||||||
|
/// TVDB's clear logo: the series' name as lettering, a transparent PNG.
|
||||||
|
pub logo: Option<String>,
|
||||||
|
/// The same entry on TMDB, where TVDB has none of the films.
|
||||||
|
pub tmdb: Option<TmdbId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AniZip {
|
pub struct AniZip {
|
||||||
@@ -90,6 +95,34 @@ struct Payload {
|
|||||||
episodes: std::collections::BTreeMap<String, Episode>,
|
episodes: std::collections::BTreeMap<String, Episode>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
images: Vec<Image>,
|
images: Vec<Image>,
|
||||||
|
mappings: Option<Ids>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entry's ids elsewhere.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Ids {
|
||||||
|
/// AniList's format, upper-cased: `TV`, `MOVIE`, `OVA`…
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "lenient_number")]
|
||||||
|
themoviedb_id: Option<u32>,
|
||||||
|
/// The same entry on MyAnimeList, which AniSkip is keyed by.
|
||||||
|
#[serde(default, deserialize_with = "lenient_number")]
|
||||||
|
mal_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ids {
|
||||||
|
/// The TMDB entry, where its kind says which list the id is from. An OVA
|
||||||
|
/// or a special is filed either way on TMDB, so it is left alone rather
|
||||||
|
/// than guessed.
|
||||||
|
fn tmdb(&self) -> Option<TmdbId> {
|
||||||
|
let id = self.themoviedb_id?;
|
||||||
|
match self.kind.as_deref()? {
|
||||||
|
"MOVIE" => Some(TmdbId::Movie(id)),
|
||||||
|
"TV" | "TV_SHORT" | "ONA" => Some(TmdbId::Tv(id)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -108,6 +141,12 @@ struct Episode {
|
|||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
air_date: Option<String>,
|
air_date: Option<String>,
|
||||||
|
/// TVDB's running time, in minutes.
|
||||||
|
#[serde(default, deserialize_with = "lenient_number")]
|
||||||
|
runtime: Option<u32>,
|
||||||
|
/// AniDB's, which is there when TVDB's is not.
|
||||||
|
#[serde(default, deserialize_with = "lenient_number")]
|
||||||
|
length: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -119,11 +158,16 @@ struct Image {
|
|||||||
|
|
||||||
impl Payload {
|
impl Payload {
|
||||||
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
|
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
|
||||||
let fanart = self
|
let image = |kind: &str| {
|
||||||
.images
|
self.images
|
||||||
.into_iter()
|
.iter()
|
||||||
.find(|image| image.cover_type.as_deref() == Some("Fanart"))
|
.find(|image| image.cover_type.as_deref() == Some(kind))
|
||||||
.and_then(|image| image.url);
|
.and_then(|image| text(image.url.clone()))
|
||||||
|
};
|
||||||
|
let fanart = image("Fanart");
|
||||||
|
let logo = image("Clearlogo");
|
||||||
|
let tmdb = self.mappings.as_ref().and_then(Ids::tmdb);
|
||||||
|
let mal_id = self.mappings.as_ref().and_then(|ids| ids.mal_id);
|
||||||
|
|
||||||
let episodes = self
|
let episodes = self
|
||||||
.episodes
|
.episodes
|
||||||
@@ -150,11 +194,21 @@ impl Payload {
|
|||||||
overview: text(episode.overview).or_else(|| text(episode.summary)),
|
overview: text(episode.overview).or_else(|| text(episode.summary)),
|
||||||
still_url: text(episode.image),
|
still_url: text(episode.image),
|
||||||
air_date: text(episode.air_date),
|
air_date: text(episode.air_date),
|
||||||
|
runtime: episode
|
||||||
|
.runtime
|
||||||
|
.or(episode.length)
|
||||||
|
.filter(|minutes| *minutes > 0),
|
||||||
|
mal_id,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Mapping { episodes, fanart }
|
Mapping {
|
||||||
|
episodes,
|
||||||
|
fanart,
|
||||||
|
logo,
|
||||||
|
tmdb,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +260,16 @@ mod tests {
|
|||||||
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
|
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
|
||||||
"overview": "Titans appear inside Wall Rose.",
|
"overview": "Titans appear inside Wall Rose.",
|
||||||
"image": "https://artworks.thetvdb.com/still.jpg",
|
"image": "https://artworks.thetvdb.com/still.jpg",
|
||||||
"airDate": "2017-04-01"},
|
"airDate": "2017-04-01", "runtime": 24, "length": 25},
|
||||||
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
|
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
|
||||||
"title": {"en": "TBA", "x-jat": "Ilse no Techou"},
|
"title": {"en": "TBA", "x-jat": "Ilse no Techou"},
|
||||||
"summary": "An OVA."},
|
"summary": "An OVA.", "length": "30"},
|
||||||
"2": {"episode": "2", "title": {"en": "Unplaced"}}
|
"2": {"episode": "2", "title": {"en": "Unplaced"}}
|
||||||
},
|
},
|
||||||
"images": [
|
"images": [
|
||||||
{"coverType": "Banner", "url": "banner.jpg"},
|
{"coverType": "Banner", "url": "banner.jpg"},
|
||||||
{"coverType": "Fanart", "url": "fanart.jpg"}
|
{"coverType": "Fanart", "url": "fanart.jpg"},
|
||||||
|
{"coverType": "Clearlogo", "url": "clearlogo.png"}
|
||||||
]
|
]
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
@@ -241,6 +296,8 @@ mod tests {
|
|||||||
Some("Titans appear inside Wall Rose.")
|
Some("Titans appear inside Wall Rose.")
|
||||||
);
|
);
|
||||||
assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
|
assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
|
||||||
|
// TVDB's running time wins over AniDB's.
|
||||||
|
assert_eq!(first.runtime, Some(24));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A special is numbered in strings and filed under season zero, and no
|
/// A special is numbered in strings and filed under season zero, and no
|
||||||
@@ -260,6 +317,8 @@ mod tests {
|
|||||||
assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
|
assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
|
||||||
// AniDB's summary stands in for TVDB's missing overview.
|
// AniDB's summary stands in for TVDB's missing overview.
|
||||||
assert_eq!(special.overview.as_deref(), Some("An OVA."));
|
assert_eq!(special.overview.as_deref(), Some("An OVA."));
|
||||||
|
// And AniDB's length for TVDB's missing running time.
|
||||||
|
assert_eq!(special.runtime, Some(30));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -285,6 +344,11 @@ mod tests {
|
|||||||
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
|
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_clear_logo_is_kept() {
|
||||||
|
assert_eq!(mapping(None).logo.as_deref(), Some("clearlogo.png"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
|
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
|
||||||
let mapping = serde_json::from_str::<Payload>("{}")
|
let mapping = serde_json::from_str::<Payload>("{}")
|
||||||
@@ -292,5 +356,44 @@ mod tests {
|
|||||||
.into_mapping("en", Some(1));
|
.into_mapping("en", Some(1));
|
||||||
assert!(mapping.episodes.is_empty());
|
assert!(mapping.episodes.is_empty());
|
||||||
assert_eq!(mapping.fanart, None);
|
assert_eq!(mapping.fanart, None);
|
||||||
|
assert_eq!(mapping.logo, None);
|
||||||
|
assert_eq!(mapping.tmdb, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paprika: a film with no TVDB art at all, and a TMDB id written as a
|
||||||
|
/// string.
|
||||||
|
#[test]
|
||||||
|
fn a_films_tmdb_id_is_kept_as_a_film() {
|
||||||
|
let mapping = serde_json::from_str::<Payload>(
|
||||||
|
r#"{"images": [], "mappings": {"type": "MOVIE", "themoviedb_id": "4977"}}"#,
|
||||||
|
)
|
||||||
|
.expect("parse")
|
||||||
|
.into_mapping("en", None);
|
||||||
|
assert_eq!(mapping.tmdb, Some(TmdbId::Movie(4977)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_episode_carries_its_entrys_myanimelist_id() {
|
||||||
|
let mapping = serde_json::from_str::<Payload>(
|
||||||
|
r#"{"episodes": {"1": {"seasonNumber": 1, "episodeNumber": 1}},
|
||||||
|
"mappings": {"type": "TV", "mal_id": 52991}}"#,
|
||||||
|
)
|
||||||
|
.expect("parse")
|
||||||
|
.into_mapping("en", Some(1));
|
||||||
|
assert_eq!(mapping.episodes[0].mal_id, Some(52991));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_series_tmdb_id_is_kept_and_a_specials_is_not() {
|
||||||
|
let tmdb = |kind: &str| {
|
||||||
|
serde_json::from_str::<Payload>(&format!(
|
||||||
|
r#"{{"mappings": {{"type": "{kind}", "themoviedb_id": 209867}}}}"#
|
||||||
|
))
|
||||||
|
.expect("parse")
|
||||||
|
.into_mapping("en", Some(1))
|
||||||
|
.tmdb
|
||||||
|
};
|
||||||
|
assert_eq!(tmdb("TV"), Some(TmdbId::Tv(209867)));
|
||||||
|
assert_eq!(tmdb("OVA"), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
|
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
|
||||||
//! (anilist | tmdb) │
|
//! (anilist, then tvmaze | tmdb) │
|
||||||
//! ▼
|
//! ▼
|
||||||
//! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
|
//! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
|
||||||
//! (episodes, fanart) (anilist relations + ani.zip | tmdb)
|
//! (episodes, fanart) (anilist relations + ani.zip | tvmaze | tmdb)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Three things are worth knowing before touching any of it:
|
//! Three things are worth knowing before touching any of it:
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
//! without either depending on this crate.
|
//! without either depending on this crate.
|
||||||
|
|
||||||
pub mod anilist;
|
pub mod anilist;
|
||||||
|
pub mod aniskip;
|
||||||
pub mod anizip;
|
pub mod anizip;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
@@ -37,6 +38,7 @@ pub mod provider;
|
|||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod tmdb;
|
pub mod tmdb;
|
||||||
|
pub mod tvmaze;
|
||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use matching::{Candidate, Query};
|
pub use matching::{Candidate, Query};
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ mod tests {
|
|||||||
kind,
|
kind,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
|
|||||||
@@ -5,8 +5,17 @@
|
|||||||
//! │ miss / stale
|
//! │ miss / stale
|
||||||
//! ▼
|
//! ▼
|
||||||
//! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord
|
//! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord
|
||||||
|
//! │ no match
|
||||||
|
//! ▼
|
||||||
|
//! next provider in the chain, the same way
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! The providers are asked in a chain: AniList, then TVmaze for what AniList
|
||||||
|
//! has not got. The first match wins and is stored under the provider that
|
||||||
|
//! gave it, so a library of anime with one live-action show in it is matched
|
||||||
|
//! against both without anyone choosing per title — and everything after a
|
||||||
|
//! match is asked of that match's own provider.
|
||||||
|
//!
|
||||||
//! Two rules govern what gets written back, and they are the difference between
|
//! Two rules govern what gets written back, and they are the difference between
|
||||||
//! a library that settles down and one that hammers a third party forever:
|
//! a library that settles down and one that hammers a third party forever:
|
||||||
//!
|
//!
|
||||||
@@ -25,7 +34,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use pstr_core::library::{Title, TitleKind, title_key};
|
use pstr_core::library::{Title, TitleKind, title_key};
|
||||||
use pstr_core::metadata::{
|
use pstr_core::metadata::{
|
||||||
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleDetails,
|
||||||
|
TitleMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::anilist::AniList;
|
use crate::anilist::AniList;
|
||||||
@@ -33,6 +43,7 @@ use crate::error::{Error, Result};
|
|||||||
use crate::matching::{self, Query};
|
use crate::matching::{self, Query};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::tmdb::Tmdb;
|
use crate::tmdb::Tmdb;
|
||||||
|
use crate::tvmaze::TvMaze;
|
||||||
|
|
||||||
/// How long to wait on a provider.
|
/// How long to wait on a provider.
|
||||||
///
|
///
|
||||||
@@ -43,10 +54,11 @@ const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|||||||
/// Identifies this client to the providers, which both ask for it.
|
/// Identifies this client to the providers, which both ask for it.
|
||||||
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
|
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
/// The configured provider, resolved.
|
/// One provider in the chain, resolved.
|
||||||
enum Source {
|
enum Source {
|
||||||
AniList(AniList),
|
AniList(AniList),
|
||||||
Tmdb(Tmdb),
|
Tmdb(Tmdb),
|
||||||
|
TvMaze(TvMaze),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Source {
|
impl Source {
|
||||||
@@ -54,6 +66,7 @@ impl Source {
|
|||||||
match self {
|
match self {
|
||||||
Self::AniList(provider) => provider.id(),
|
Self::AniList(provider) => provider.id(),
|
||||||
Self::Tmdb(provider) => provider.id(),
|
Self::Tmdb(provider) => provider.id(),
|
||||||
|
Self::TvMaze(provider) => provider.id(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,78 +74,153 @@ impl Source {
|
|||||||
match self {
|
match self {
|
||||||
Self::AniList(provider) => provider.search(query).await,
|
Self::AniList(provider) => provider.search(query).await,
|
||||||
Self::Tmdb(provider) => provider.search(query).await,
|
Self::Tmdb(provider) => provider.search(query).await,
|
||||||
|
Self::TvMaze(provider) => provider.search(query).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entry a stored match names, fetched again by its id. `None` from a
|
/// The entry a stored match names, fetched again by its id. `None` from a
|
||||||
/// provider that has no details to add.
|
/// provider that has no details to add — see [`gives_details`].
|
||||||
async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
|
async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
|
||||||
match self {
|
match self {
|
||||||
Self::AniList(provider) => provider.entry(id).await,
|
Self::AniList(provider) => provider.entry(id).await,
|
||||||
|
Self::TvMaze(provider) => provider.entry(id).await,
|
||||||
Self::Tmdb(_) => Ok(None),
|
Self::Tmdb(_) => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether [`Source::entry`] has anything to give.
|
|
||||||
fn has_details(&self) -> bool {
|
|
||||||
matches!(self, Self::AniList(_))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
match self {
|
match self {
|
||||||
Self::AniList(provider) => provider.enrich(title, found).await,
|
Self::AniList(provider) => provider.enrich(title, found).await,
|
||||||
Self::Tmdb(provider) => provider.enrich(title, found).await,
|
Self::Tmdb(provider) => provider.enrich(title, found).await,
|
||||||
|
Self::TvMaze(provider) => provider.enrich(title, found).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metadata lookups, against one provider.
|
/// The providers asked when the viewer chose `chosen`, in order.
|
||||||
|
///
|
||||||
|
/// AniList is followed by TVmaze, which has the television AniList does not
|
||||||
|
/// and needs no key either. TMDB has that television itself, so it is asked
|
||||||
|
/// alone. A stored answer from a provider outside the chain is the previous
|
||||||
|
/// choice's, and is re-asked.
|
||||||
|
pub fn chain_of(chosen: ProviderId) -> &'static [ProviderId] {
|
||||||
|
match chosen {
|
||||||
|
ProviderId::AniList => &[ProviderId::AniList, ProviderId::TvMaze],
|
||||||
|
ProviderId::Tmdb => &[ProviderId::Tmdb],
|
||||||
|
ProviderId::TvMaze => &[ProviderId::TvMaze],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`chain_of`] `chosen`, for a sentence: `AniList and TVmaze`.
|
||||||
|
pub fn chain_label(chosen: ProviderId) -> String {
|
||||||
|
chain_of(chosen)
|
||||||
|
.iter()
|
||||||
|
.map(|provider| provider.label())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `provider` fills in [`TitleMetadata::details`], and so whether a
|
||||||
|
/// match it gave without them, or with an older revision of them, is worth
|
||||||
|
/// fetching again.
|
||||||
|
fn gives_details(provider: ProviderId) -> bool {
|
||||||
|
matches!(provider, ProviderId::AniList | ProviderId::TvMaze)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata lookups, against a chain of providers.
|
||||||
///
|
///
|
||||||
/// Cheap to clone — the HTTP client inside is a connection pool that wants
|
/// Cheap to clone — the HTTP client inside is a connection pool that wants
|
||||||
/// sharing, not duplicating.
|
/// sharing, not duplicating.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct MetadataService {
|
pub struct MetadataService {
|
||||||
source: Arc<Source>,
|
/// In the order they are asked. Never empty.
|
||||||
|
sources: Arc<[Source]>,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MetadataService {
|
impl MetadataService {
|
||||||
/// Build a service for `config`.
|
/// Build a service for `config`, asking [`chain_of`] its provider.
|
||||||
///
|
///
|
||||||
/// `api_key` is only consulted for a provider that needs one; for AniList it
|
/// `tmdb_key` is the stored TMDB key, if there is one. TMDB as the provider
|
||||||
/// is ignored, and passing `None` there is not an error.
|
/// cannot do without it; AniList uses one for the art of films TVDB has
|
||||||
pub fn new(config: &MetadataConfig, api_key: Option<String>) -> Result<Self> {
|
/// none for, and does without it otherwise.
|
||||||
|
pub fn new(config: &MetadataConfig, tmdb_key: Option<String>) -> Result<Self> {
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
.user_agent(USER_AGENT)
|
.user_agent(USER_AGENT)
|
||||||
.timeout(TIMEOUT)
|
.timeout(TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
.map_err(Error::Network)?;
|
.map_err(Error::Network)?;
|
||||||
|
|
||||||
let source = match config.provider {
|
let tmdb_key = tmdb_key.filter(|key| !key.trim().is_empty());
|
||||||
ProviderId::AniList => {
|
let tmdb = || {
|
||||||
Source::AniList(AniList::new(http.clone(), config.language.clone()))
|
tmdb_key
|
||||||
}
|
.clone()
|
||||||
ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
|
.map(|key| Tmdb::new(http.clone(), key, config.language.clone()))
|
||||||
http.clone(),
|
.ok_or(Error::MissingApiKey(ProviderId::Tmdb))
|
||||||
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
|
|
||||||
config.language.clone(),
|
|
||||||
)),
|
|
||||||
};
|
};
|
||||||
|
let sources = chain_of(config.provider)
|
||||||
|
.iter()
|
||||||
|
.map(|provider| {
|
||||||
|
Ok(match provider {
|
||||||
|
ProviderId::AniList => Source::AniList(AniList::new(
|
||||||
|
http.clone(),
|
||||||
|
config.language.clone(),
|
||||||
|
tmdb().ok(),
|
||||||
|
)),
|
||||||
|
ProviderId::Tmdb => Source::Tmdb(tmdb()?),
|
||||||
|
ProviderId::TvMaze => Source::TvMaze(TvMaze::new(http.clone())),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
source: Arc::new(source),
|
sources: sources.into(),
|
||||||
http,
|
http,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn provider(&self) -> ProviderId {
|
/// Where AniSkip says an episode's opening and ending are. See
|
||||||
self.source.id()
|
/// [`crate::aniskip`]; on the service so that it is only ever asked with
|
||||||
|
/// enrichment on, like every other source.
|
||||||
|
pub async fn skip_times(
|
||||||
|
&self,
|
||||||
|
mal_id: u32,
|
||||||
|
episode: u32,
|
||||||
|
length: f64,
|
||||||
|
) -> Result<Vec<pstr_core::chapters::SkipRange>> {
|
||||||
|
crate::aniskip::skip_times(&self.http, mal_id, episode, length).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the provider about one title.
|
/// The provider the viewer chose: the first one asked.
|
||||||
|
pub fn provider(&self) -> ProviderId {
|
||||||
|
self.sources[0].id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every provider asked, in order. What [`plan`] and
|
||||||
|
/// [`MetadataRecord::is_fresh`] judge a stored answer against.
|
||||||
|
pub fn chain(&self) -> Vec<ProviderId> {
|
||||||
|
self.sources.iter().map(Source::id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chain, for a status line: `AniList and TVmaze`.
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
chain_label(self.provider())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(&self, provider: ProviderId) -> Result<&Source> {
|
||||||
|
self.sources
|
||||||
|
.iter()
|
||||||
|
.find(|source| source.id() == provider)
|
||||||
|
.ok_or_else(|| Error::Config(format!("{} is not in use", provider.label())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the chain about one title, stopping at the first match.
|
||||||
///
|
///
|
||||||
/// `Ok(None)` is a real answer — nothing matched well enough — and the
|
/// `Ok(None)` is a real answer — nothing matched well enough anywhere —
|
||||||
/// caller should store it. An `Err` is not, and the caller should not.
|
/// and the caller should store it. An `Err` is not, and the caller should
|
||||||
|
/// not: a provider that could not be asked has not said it lacks the
|
||||||
|
/// title, and settling for the next one's answer would store a guess in
|
||||||
|
/// its place.
|
||||||
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
|
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
|
||||||
let mut query = Query::new(title.name.clone(), title.year, title.kind);
|
let mut query = Query::new(title.name.clone(), title.year, title.kind);
|
||||||
// Nothing in the files numbered itself, so the kind came from counting
|
// Nothing in the files numbered itself, so the kind came from counting
|
||||||
@@ -140,34 +228,48 @@ impl MetadataService {
|
|||||||
if !title.states_its_numbering() {
|
if !title.states_its_numbering() {
|
||||||
query = query.with_guessed_kind();
|
query = query.with_guessed_kind();
|
||||||
}
|
}
|
||||||
let candidates = self.source.search(&query).await?;
|
for source in self.sources.iter() {
|
||||||
|
let candidates = source.search(&query).await?;
|
||||||
let found = candidates.len();
|
let found = candidates.len();
|
||||||
let best = matching::best(&query, candidates);
|
let best = matching::best(&query, candidates);
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"{}: {found} candidates from {}, {}",
|
"{}: {found} candidates from {}, {}",
|
||||||
title.name,
|
title.name,
|
||||||
self.provider().label(),
|
source.id().label(),
|
||||||
match &best {
|
match &best {
|
||||||
Some(found) => format!("matched {:?}", found.name),
|
Some(found) => format!("matched {:?}", found.name),
|
||||||
None => "no match".to_string(),
|
None => "no match".to_string(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
Ok(best)
|
if best.is_some() {
|
||||||
|
return Ok(best);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same lookup, as a record ready to store — misses included.
|
/// The same lookup, as a record ready to store — misses included.
|
||||||
|
///
|
||||||
|
/// A match is stored under the provider that gave it; a miss under the
|
||||||
|
/// last one asked, which is what makes it stand — see
|
||||||
|
/// [`MetadataRecord::is_fresh`].
|
||||||
pub async fn record(&self, title: &Title) -> Result<MetadataRecord> {
|
pub async fn record(&self, title: &Title) -> Result<MetadataRecord> {
|
||||||
let metadata = self.lookup(title).await?;
|
let metadata = self.lookup(title).await?;
|
||||||
Ok(MetadataRecord {
|
Ok(MetadataRecord {
|
||||||
title_key: title_key(&title.name),
|
title_key: title_key(&title.name),
|
||||||
provider: self.provider(),
|
provider: metadata
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| self.last(), |found| found.provider),
|
||||||
metadata,
|
metadata,
|
||||||
fetched_at: now(),
|
fetched_at: now(),
|
||||||
manual: false,
|
manual: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn last(&self) -> ProviderId {
|
||||||
|
self.sources[self.sources.len() - 1].id()
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the provider thinks `name` might be, unscored and in its own
|
/// Everything the provider thinks `name` might be, unscored and in its own
|
||||||
/// order.
|
/// order.
|
||||||
///
|
///
|
||||||
@@ -182,39 +284,57 @@ impl MetadataService {
|
|||||||
/// Takes a kind because the providers key their search on it, and no year:
|
/// Takes a kind because the providers key their search on it, and no year:
|
||||||
/// the point of a hand search is that the library's own guesses are what
|
/// the point of a hand search is that the library's own guesses are what
|
||||||
/// went wrong.
|
/// went wrong.
|
||||||
|
///
|
||||||
|
/// Every provider in the chain is asked, the first one's answers first,
|
||||||
|
/// and each answer says which provider gave it. One that fails leaves its
|
||||||
|
/// answers out; only when all of them fail is that an error.
|
||||||
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> {
|
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> {
|
||||||
let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
|
let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
|
||||||
if query.name.is_empty() {
|
if query.name.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let candidates = self.source.search(&query).await?;
|
let mut options = Vec::new();
|
||||||
|
let mut failure = None;
|
||||||
|
for source in self.sources.iter() {
|
||||||
|
match source.search(&query).await {
|
||||||
|
Ok(candidates) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"hand search {:?}: {} candidates from {}",
|
"hand search {:?}: {} candidates from {}",
|
||||||
query.name,
|
query.name,
|
||||||
candidates.len(),
|
candidates.len(),
|
||||||
self.provider().label()
|
source.id().label()
|
||||||
);
|
);
|
||||||
Ok(candidates
|
options.extend(candidates.into_iter().map(|candidate| candidate.metadata));
|
||||||
.into_iter()
|
}
|
||||||
.map(|candidate| candidate.metadata)
|
Err(error) => {
|
||||||
.collect())
|
tracing::warn!("hand search on {}: {error}", source.id().label());
|
||||||
|
failure.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match failure {
|
||||||
|
Some(error) if options.is_empty() => Err(error),
|
||||||
|
_ => Ok(options),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A record for an entry the viewer picked themselves.
|
/// A record for an entry the viewer picked themselves, under the provider
|
||||||
|
/// it came from.
|
||||||
///
|
///
|
||||||
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run
|
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run
|
||||||
/// — including a forced one — from undoing it.
|
/// — including a forced one — from undoing it.
|
||||||
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
|
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
|
||||||
MetadataRecord {
|
MetadataRecord {
|
||||||
title_key,
|
title_key,
|
||||||
provider: self.provider(),
|
provider: found.provider,
|
||||||
metadata: Some(found),
|
metadata: Some(found),
|
||||||
fetched_at: now(),
|
fetched_at: now(),
|
||||||
manual: true,
|
manual: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The episodes of a matched title, and any art its match lacked.
|
/// The episodes of a matched title, and any art its match lacked, from
|
||||||
|
/// the provider that matched it.
|
||||||
///
|
///
|
||||||
/// Only ever called for a title that already has a match, because the
|
/// Only ever called for a title that already has a match, because the
|
||||||
/// provider's own id is what it takes — there is no second search here, and
|
/// provider's own id is what it takes — there is no second search here, and
|
||||||
@@ -224,12 +344,12 @@ impl MetadataService {
|
|||||||
/// un-enriched and the next match run asks again. An empty answer is an
|
/// un-enriched and the next match run asks again. An empty answer is an
|
||||||
/// answer, and is — see `Catalog::set_enrichment`.
|
/// answer, and is — see `Catalog::set_enrichment`.
|
||||||
pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
let enrichment = self.source.enrich(title, found).await?;
|
let enrichment = self.source(found.provider)?.enrich(title, found).await?;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"{}: {} episodes from {}{}",
|
"{}: {} episodes from {}{}",
|
||||||
title.name,
|
title.name,
|
||||||
enrichment.episodes.len(),
|
enrichment.episodes.len(),
|
||||||
self.provider().label(),
|
found.provider.label(),
|
||||||
if enrichment.backdrop_url.is_some() {
|
if enrichment.backdrop_url.is_some() {
|
||||||
", with a backdrop"
|
", with a backdrop"
|
||||||
} else {
|
} else {
|
||||||
@@ -261,8 +381,9 @@ pub enum Work {
|
|||||||
Match(Title),
|
Match(Title),
|
||||||
/// It is already matched; only its enrichment is missing or old.
|
/// It is already matched; only its enrichment is missing or old.
|
||||||
Enrich(Title, Box<TitleMetadata>),
|
Enrich(Title, Box<TitleMetadata>),
|
||||||
/// It is matched, but stored before [`TitleMetadata::details`] existed:
|
/// It is matched, but stored before [`TitleMetadata::details`] existed or
|
||||||
/// fetch the same entry again by id — never searched for, so a match the
|
/// by an older [`TitleDetails::REVISION`] of them: fetch the same entry
|
||||||
|
/// again by id — never searched for, so a match the
|
||||||
/// viewer picked by hand stays theirs — and enrich it too if `enrich`.
|
/// viewer picked by hand stays theirs — and enrich it too if `enrich`.
|
||||||
Refresh {
|
Refresh {
|
||||||
title: Title,
|
title: Title,
|
||||||
@@ -283,6 +404,9 @@ impl Work {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Outcome {
|
pub struct Outcome {
|
||||||
pub title_key: String,
|
pub title_key: String,
|
||||||
|
/// The provider the title is matched to, and so the one
|
||||||
|
/// [`Outcome::enrichment`] is stored under.
|
||||||
|
pub provider: ProviderId,
|
||||||
/// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it —
|
/// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it —
|
||||||
/// misses included.
|
/// misses included.
|
||||||
pub record: Option<MetadataRecord>,
|
pub record: Option<MetadataRecord>,
|
||||||
@@ -298,44 +422,46 @@ pub struct Outcome {
|
|||||||
/// already has a good match but has not been enriched — or was enriched longer
|
/// already has a good match but has not been enriched — or was enriched longer
|
||||||
/// ago than a match is trusted — needs enriching only, and searching for it
|
/// ago than a match is trusted — needs enriching only, and searching for it
|
||||||
/// again would ask the provider something it has already answered. One whose
|
/// again would ask the provider something it has already answered. One whose
|
||||||
/// match predates its details is fetched again by id, which is one request
|
/// match predates its details, or this build's revision of them, is fetched
|
||||||
/// and decides nothing.
|
/// again by id, which is one request and decides nothing.
|
||||||
///
|
///
|
||||||
/// A hand-picked entry is never searched for again, not even when `force`d —
|
/// A hand-picked entry is never searched for again, not even when `force`d —
|
||||||
/// that means "the automatic answers are wrong", and re-deciding the one title
|
/// that means "the automatic answers are wrong", and re-deciding the one title
|
||||||
/// the viewer already fixed by hand is the opposite of what they asked for. It
|
/// the viewer already fixed by hand is the opposite of what they asked for. It
|
||||||
/// is still enriched and refreshed.
|
/// is still enriched and refreshed, and `force` enriches it again.
|
||||||
///
|
///
|
||||||
/// `details` is whether the provider has [`TitleMetadata::details`] to give
|
/// `chain` is [`MetadataService::chain`]. Enriching and refreshing go to the
|
||||||
/// — see [`MetadataService::has_details`].
|
/// provider each title is matched to.
|
||||||
pub fn plan(
|
pub fn plan(
|
||||||
titles: Vec<Title>,
|
titles: Vec<Title>,
|
||||||
stored: &HashMap<String, MetadataRecord>,
|
stored: &HashMap<String, MetadataRecord>,
|
||||||
enriched: &HashMap<String, (ProviderId, i64)>,
|
enriched: &HashMap<String, (ProviderId, i64)>,
|
||||||
provider: ProviderId,
|
chain: &[ProviderId],
|
||||||
details: bool,
|
|
||||||
force: bool,
|
force: bool,
|
||||||
) -> Vec<Work> {
|
) -> Vec<Work> {
|
||||||
let now = now();
|
let now = now();
|
||||||
let mut work = Vec::new();
|
let mut work = Vec::new();
|
||||||
for title in titles {
|
for title in titles {
|
||||||
let record = stored.get(&title.key);
|
let record = stored.get(&title.key);
|
||||||
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
|
let pinned = record.is_some_and(|record| record.manual && chain.contains(&record.provider));
|
||||||
if !pinned && (force || !is_usable(record, provider)) {
|
if !pinned && (force || !is_usable(record, chain)) {
|
||||||
work.push(Work::Match(title));
|
work.push(Work::Match(title));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(record) = record.filter(|record| record.metadata.is_some()) else {
|
let Some(record) = record.filter(|record| record.metadata.is_some()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| {
|
// Forced, a pinned title is enriched again even when it is fresh: art
|
||||||
*asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS
|
// a newly saved key reaches is the usual reason to force.
|
||||||
|
let fresh = !force
|
||||||
|
&& enriched.get(&title.key).is_some_and(|(asked, at)| {
|
||||||
|
*asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS
|
||||||
});
|
});
|
||||||
let stale_details = details
|
let stale_details = gives_details(record.provider)
|
||||||
&& record
|
&& record
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|found| found.details.is_none());
|
.is_some_and(|found| !found.details.as_ref().is_some_and(TitleDetails::is_current));
|
||||||
if stale_details {
|
if stale_details {
|
||||||
work.push(Work::Refresh {
|
work.push(Work::Refresh {
|
||||||
title,
|
title,
|
||||||
@@ -350,12 +476,6 @@ pub fn plan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MetadataService {
|
impl MetadataService {
|
||||||
/// Whether this provider fills in [`TitleMetadata::details`], and so
|
|
||||||
/// whether a match stored without them is worth fetching again.
|
|
||||||
pub fn has_details(&self) -> bool {
|
|
||||||
self.source.has_details()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Do one piece of [`Work`].
|
/// Do one piece of [`Work`].
|
||||||
///
|
///
|
||||||
/// A failed search is an `Err` and nothing is stored. A failed enrichment
|
/// A failed search is an `Err` and nothing is stored. A failed enrichment
|
||||||
@@ -373,12 +493,14 @@ impl MetadataService {
|
|||||||
};
|
};
|
||||||
Ok(Outcome {
|
Ok(Outcome {
|
||||||
title_key: record.title_key.clone(),
|
title_key: record.title_key.clone(),
|
||||||
|
provider: record.provider,
|
||||||
record: Some(record),
|
record: Some(record),
|
||||||
enrichment,
|
enrichment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Work::Enrich(title, found) => Ok(Outcome {
|
Work::Enrich(title, found) => Ok(Outcome {
|
||||||
title_key: title.key.clone(),
|
title_key: title.key.clone(),
|
||||||
|
provider: found.provider,
|
||||||
record: None,
|
record: None,
|
||||||
enrichment: Some(self.enrich(title, found).await?),
|
enrichment: Some(self.enrich(title, found).await?),
|
||||||
}),
|
}),
|
||||||
@@ -390,18 +512,22 @@ impl MetadataService {
|
|||||||
let Some(stored) = &record.metadata else {
|
let Some(stored) = &record.metadata else {
|
||||||
return Ok(Outcome {
|
return Ok(Outcome {
|
||||||
title_key: title.key.clone(),
|
title_key: title.key.clone(),
|
||||||
|
provider: record.provider,
|
||||||
record: None,
|
record: None,
|
||||||
enrichment: None,
|
enrichment: None,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// An id the provider no longer has keeps what was stored, with
|
// An id the provider no longer has keeps what was stored, its
|
||||||
// empty details so it is not asked about on every run.
|
// details marked current so it is not asked about on every run.
|
||||||
let found = self
|
let found = self
|
||||||
.source
|
.source(stored.provider)?
|
||||||
.entry(&stored.remote_id)
|
.entry(&stored.remote_id)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or_else(|| TitleMetadata {
|
.unwrap_or_else(|| TitleMetadata {
|
||||||
details: Some(Default::default()),
|
details: Some(TitleDetails {
|
||||||
|
revision: TitleDetails::REVISION,
|
||||||
|
..stored.details.clone().unwrap_or_default()
|
||||||
|
}),
|
||||||
..stored.clone()
|
..stored.clone()
|
||||||
});
|
});
|
||||||
let enrichment = match enrich {
|
let enrichment = match enrich {
|
||||||
@@ -410,6 +536,7 @@ impl MetadataService {
|
|||||||
};
|
};
|
||||||
Ok(Outcome {
|
Ok(Outcome {
|
||||||
title_key: title.key.clone(),
|
title_key: title.key.clone(),
|
||||||
|
provider: record.provider,
|
||||||
record: Some(MetadataRecord {
|
record: Some(MetadataRecord {
|
||||||
metadata: Some(found),
|
metadata: Some(found),
|
||||||
fetched_at: now(),
|
fetched_at: now(),
|
||||||
@@ -432,6 +559,7 @@ impl MetadataService {
|
|||||||
let enrichment = self.enrich_or_log(title, &found).await;
|
let enrichment = self.enrich_or_log(title, &found).await;
|
||||||
Outcome {
|
Outcome {
|
||||||
title_key: title.key.clone(),
|
title_key: title.key.clone(),
|
||||||
|
provider: found.provider,
|
||||||
record: Some(self.chosen(title.key.clone(), found)),
|
record: Some(self.chosen(title.key.clone(), found)),
|
||||||
enrichment,
|
enrichment,
|
||||||
}
|
}
|
||||||
@@ -445,9 +573,9 @@ impl MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a stored answer can be used as-is.
|
/// Whether a stored answer can be used as-is by `chain`.
|
||||||
pub fn is_usable(record: Option<&MetadataRecord>, provider: ProviderId) -> bool {
|
pub fn is_usable(record: Option<&MetadataRecord>, chain: &[ProviderId]) -> bool {
|
||||||
record.is_some_and(|record| record.is_fresh(now(), provider))
|
record.is_some_and(|record| record.is_fresh(now(), chain))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> i64 {
|
fn now() -> i64 {
|
||||||
@@ -469,6 +597,8 @@ mod tests {
|
|||||||
..MetadataConfig::default()
|
..MetadataConfig::default()
|
||||||
};
|
};
|
||||||
assert!(MetadataService::new(&anilist, None).is_ok());
|
assert!(MetadataService::new(&anilist, None).is_ok());
|
||||||
|
// A stored TMDB key is used for art, not demanded.
|
||||||
|
assert!(MetadataService::new(&anilist, Some("key".into())).is_ok());
|
||||||
|
|
||||||
let tmdb = MetadataConfig {
|
let tmdb = MetadataConfig {
|
||||||
provider: ProviderId::Tmdb,
|
provider: ProviderId::Tmdb,
|
||||||
@@ -506,17 +636,34 @@ mod tests {
|
|||||||
kind: TitleKind::Series,
|
kind: TitleKind::Series,
|
||||||
poster_url: None,
|
poster_url: None,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
rating: None,
|
rating: None,
|
||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
url: None,
|
url: None,
|
||||||
details: None,
|
details: Some(TitleDetails {
|
||||||
|
revision: TitleDetails::REVISION,
|
||||||
|
..TitleDetails::default()
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
fetched_at: now(),
|
fetched_at: now(),
|
||||||
manual,
|
manual,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`matched`], stored before there were details.
|
||||||
|
fn without_details(key: &str, manual: bool, provider: ProviderId) -> MetadataRecord {
|
||||||
|
let mut record = matched(key, manual);
|
||||||
|
record.provider = provider;
|
||||||
|
if let Some(found) = record.metadata.as_mut() {
|
||||||
|
found.provider = provider;
|
||||||
|
found.details = None;
|
||||||
|
}
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANILIST: &[ProviderId] = &[ProviderId::AniList, ProviderId::TvMaze];
|
||||||
|
|
||||||
fn kinds(work: &[Work]) -> Vec<(&str, &str)> {
|
fn kinds(work: &[Work]) -> Vec<(&str, &str)> {
|
||||||
work.iter()
|
work.iter()
|
||||||
.map(|work| match work {
|
.map(|work| match work {
|
||||||
@@ -545,14 +692,7 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec();
|
let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec();
|
||||||
|
|
||||||
let work = plan(
|
let work = plan(titles, &stored, &enriched, ANILIST, false);
|
||||||
titles,
|
|
||||||
&stored,
|
|
||||||
&enriched,
|
|
||||||
ProviderId::AniList,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
kinds(&work),
|
kinds(&work),
|
||||||
vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
|
vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
|
||||||
@@ -569,14 +709,7 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let titles = ["pinned", "automatic"].map(title).to_vec();
|
let titles = ["pinned", "automatic"].map(title).to_vec();
|
||||||
|
|
||||||
let work = plan(
|
let work = plan(titles, &stored, &HashMap::new(), ANILIST, true);
|
||||||
titles,
|
|
||||||
&stored,
|
|
||||||
&HashMap::new(),
|
|
||||||
ProviderId::AniList,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
kinds(&work),
|
kinds(&work),
|
||||||
vec![("pinned", "enrich"), ("automatic", "match")]
|
vec![("pinned", "enrich"), ("automatic", "match")]
|
||||||
@@ -584,23 +717,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A match stored before details existed is fetched again by id — a
|
/// A match stored before details existed is fetched again by id — a
|
||||||
/// pinned one included, and without being searched for — and only by a
|
/// pinned one included, and without being searched for — and only from a
|
||||||
/// provider that has details to give.
|
/// provider that has details to give.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_match_without_details_is_refreshed_rather_than_searched_for() {
|
fn a_match_without_details_is_refreshed_rather_than_searched_for() {
|
||||||
let stored = HashMap::from([
|
let records = |provider| {
|
||||||
("pinned".to_string(), matched("pinned", true)),
|
HashMap::from([
|
||||||
("automatic".to_string(), matched("automatic", false)),
|
(
|
||||||
]);
|
"pinned".to_string(),
|
||||||
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]);
|
without_details("pinned", true, provider),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"automatic".to_string(),
|
||||||
|
without_details("automatic", false, provider),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
};
|
||||||
|
let enriched = |provider| HashMap::from([("pinned".to_string(), (provider, now()))]);
|
||||||
let titles = ["pinned", "automatic"].map(title).to_vec();
|
let titles = ["pinned", "automatic"].map(title).to_vec();
|
||||||
|
|
||||||
let work = plan(
|
let work = plan(
|
||||||
titles.clone(),
|
titles.clone(),
|
||||||
&stored,
|
&records(ProviderId::AniList),
|
||||||
&enriched,
|
&enriched(ProviderId::AniList),
|
||||||
ProviderId::AniList,
|
ANILIST,
|
||||||
true,
|
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -612,17 +752,89 @@ mod tests {
|
|||||||
|
|
||||||
let work = plan(
|
let work = plan(
|
||||||
titles,
|
titles,
|
||||||
&stored,
|
&records(ProviderId::Tmdb),
|
||||||
&enriched,
|
&enriched(ProviderId::Tmdb),
|
||||||
ProviderId::AniList,
|
&[ProviderId::Tmdb],
|
||||||
false,
|
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
|
assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A record from the other provider is never usable — switching provider has
|
/// A title AniList had nothing for is asked of TVmaze at once, rather than
|
||||||
/// to re-ask, including about titles the previous one found nothing for.
|
/// waiting out AniList's stored miss; a match from TVmaze is enriched by
|
||||||
|
/// TVmaze, and a hand-picked one stays pinned — re-enriched when forced,
|
||||||
|
/// never searched for.
|
||||||
|
#[test]
|
||||||
|
fn titles_are_planned_against_the_provider_each_is_matched_to() {
|
||||||
|
let miss = MetadataRecord {
|
||||||
|
metadata: None,
|
||||||
|
..matched("anilist miss", false)
|
||||||
|
};
|
||||||
|
let tvmaze = |key: &str, manual| {
|
||||||
|
let mut record = matched(key, manual);
|
||||||
|
record.provider = ProviderId::TvMaze;
|
||||||
|
if let Some(found) = record.metadata.as_mut() {
|
||||||
|
found.provider = ProviderId::TvMaze;
|
||||||
|
}
|
||||||
|
record
|
||||||
|
};
|
||||||
|
let stored = HashMap::from([
|
||||||
|
("anilist miss".to_string(), miss),
|
||||||
|
("on tvmaze".to_string(), tvmaze("on tvmaze", false)),
|
||||||
|
("pinned".to_string(), tvmaze("pinned", true)),
|
||||||
|
]);
|
||||||
|
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::TvMaze, now()))]);
|
||||||
|
let titles = ["anilist miss", "on tvmaze", "pinned"].map(title).to_vec();
|
||||||
|
|
||||||
|
let work = plan(titles.clone(), &stored, &enriched, ANILIST, false);
|
||||||
|
assert_eq!(
|
||||||
|
kinds(&work),
|
||||||
|
vec![("anilist miss", "match"), ("on tvmaze", "enrich")]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forced, the pinned title is not searched for, but is enriched again
|
||||||
|
// although it is fresh.
|
||||||
|
let work = plan(titles, &stored, &enriched, ANILIST, true);
|
||||||
|
assert_eq!(
|
||||||
|
kinds(&work),
|
||||||
|
vec![
|
||||||
|
("anilist miss", "match"),
|
||||||
|
("on tvmaze", "match"),
|
||||||
|
("pinned", "enrich")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Details from an older build are refetched by id, and kept meanwhile;
|
||||||
|
/// details this build wrote are not asked for again.
|
||||||
|
#[test]
|
||||||
|
fn details_from_an_older_revision_are_refreshed_and_current_ones_are_not() {
|
||||||
|
let with = |revision| {
|
||||||
|
let mut record = matched("k", false);
|
||||||
|
if let Some(found) = record.metadata.as_mut() {
|
||||||
|
found.details = Some(TitleDetails {
|
||||||
|
revision,
|
||||||
|
..TitleDetails::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
record
|
||||||
|
};
|
||||||
|
let enriched = HashMap::from([("k".to_string(), (ProviderId::AniList, now()))]);
|
||||||
|
let run = |record: MetadataRecord| {
|
||||||
|
let stored = HashMap::from([("k".to_string(), record)]);
|
||||||
|
let work = plan(vec![title("k")], &stored, &enriched, ANILIST, false);
|
||||||
|
kinds(&work)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, kind)| kind.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(run(with(0)), vec!["refresh"]);
|
||||||
|
assert!(run(with(TitleDetails::REVISION)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A record from a provider outside the chain is never usable — switching
|
||||||
|
/// provider has to re-ask, including about titles the previous one found
|
||||||
|
/// nothing for.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() {
|
fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() {
|
||||||
let record = MetadataRecord {
|
let record = MetadataRecord {
|
||||||
@@ -632,8 +844,29 @@ mod tests {
|
|||||||
fetched_at: now(),
|
fetched_at: now(),
|
||||||
manual: false,
|
manual: false,
|
||||||
};
|
};
|
||||||
assert!(is_usable(Some(&record), ProviderId::AniList));
|
assert!(is_usable(Some(&record), &[ProviderId::AniList]));
|
||||||
assert!(!is_usable(Some(&record), ProviderId::Tmdb));
|
assert!(!is_usable(Some(&record), &[ProviderId::Tmdb]));
|
||||||
assert!(!is_usable(None, ProviderId::AniList));
|
assert!(!is_usable(None, &[ProviderId::AniList]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AniList is followed by TVmaze, which needs no key either; TMDB, which
|
||||||
|
/// has television of its own, is asked alone.
|
||||||
|
#[test]
|
||||||
|
fn anilist_is_followed_by_tvmaze_and_tmdb_stands_alone() {
|
||||||
|
let config = MetadataConfig {
|
||||||
|
enabled: true,
|
||||||
|
..MetadataConfig::default()
|
||||||
|
};
|
||||||
|
let service = MetadataService::new(&config, None).expect("service");
|
||||||
|
assert_eq!(service.chain(), ANILIST);
|
||||||
|
assert_eq!(service.provider(), ProviderId::AniList);
|
||||||
|
assert_eq!(service.label(), "AniList and TVmaze");
|
||||||
|
|
||||||
|
let tmdb = MetadataConfig {
|
||||||
|
provider: ProviderId::Tmdb,
|
||||||
|
..config
|
||||||
|
};
|
||||||
|
let service = MetadataService::new(&tmdb, Some("key".into())).expect("service");
|
||||||
|
assert_eq!(service.chain(), vec![ProviderId::Tmdb]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
//! not evidence enough to pick an endpoint. The `media_type` on each result is
|
//! not evidence enough to pick an endpoint. The `media_type` on each result is
|
||||||
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
|
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
|
||||||
//! anyway.
|
//! anyway.
|
||||||
|
//!
|
||||||
|
//! A stored key is also used with AniList chosen, by id alone: ani.zip names
|
||||||
|
//! the TMDB entry an AniList match is, and [`Tmdb::art`] fetches its backdrop
|
||||||
|
//! and logo — what the films TVDB has no art for are missing. No title is
|
||||||
|
//! searched for that way.
|
||||||
|
|
||||||
use pstr_core::library::{Title, TitleKind};
|
use pstr_core::library::{Title, TitleKind};
|
||||||
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
|
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
|
||||||
@@ -34,13 +39,16 @@ const STILL_SIZE: &str = "w300";
|
|||||||
|
|
||||||
/// Where TMDB serves artwork from, and the sizes worth asking for.
|
/// Where TMDB serves artwork from, and the sizes worth asking for.
|
||||||
///
|
///
|
||||||
/// `w500` for a poster and `w780` for a backdrop are what the images are drawn
|
/// `w500` for a poster and a logo and `w1280` for a backdrop are what the
|
||||||
/// at here, give or take; `original` is several megabytes of print-resolution
|
/// images are drawn at here, give or take — a backdrop fills the width of the
|
||||||
/// artwork per title, which is bandwidth spent on pixels no tile can show.
|
/// title page and the library's banner; `original` is several megabytes of
|
||||||
|
/// print-resolution artwork per title, which is bandwidth spent on pixels no
|
||||||
|
/// window shows.
|
||||||
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
|
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
|
||||||
|
|
||||||
const POSTER_SIZE: &str = "w500";
|
const POSTER_SIZE: &str = "w500";
|
||||||
const BACKDROP_SIZE: &str = "w780";
|
const BACKDROP_SIZE: &str = "w1280";
|
||||||
|
const LOGO_SIZE: &str = "w500";
|
||||||
|
|
||||||
/// TMDB's published ceiling is around fifty requests a second and it no longer
|
/// TMDB's published ceiling is around fifty requests a second and it no longer
|
||||||
/// enforces a daily one. This is well under it: the point of pacing here is a
|
/// enforces a daily one. This is well under it: the point of pacing here is a
|
||||||
@@ -68,6 +76,30 @@ impl Tmdb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A TMDB entry, by id. Films and television are numbered separately, so the
|
||||||
|
/// id alone does not name one.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TmdbId {
|
||||||
|
Movie(u32),
|
||||||
|
Tv(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbId {
|
||||||
|
fn path(self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Movie(id) => format!("movie/{id}"),
|
||||||
|
Self::Tv(id) => format!("tv/{id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An entry's wide picture and its logo.
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub struct Art {
|
||||||
|
pub backdrop: Option<String>,
|
||||||
|
pub logo: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Provider for Tmdb {
|
impl Provider for Tmdb {
|
||||||
fn id(&self) -> ProviderId {
|
fn id(&self) -> ProviderId {
|
||||||
ProviderId::Tmdb
|
ProviderId::Tmdb
|
||||||
@@ -95,13 +127,25 @@ impl Provider for Tmdb {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The episode list, and nothing else: the match already carried a proper
|
/// The episode list and the logo. The match already carried a proper
|
||||||
/// backdrop, and TMDB files every season under one show, so the library's
|
/// backdrop, and TMDB files every season under one show, so the library's
|
||||||
/// seasons need no walking.
|
/// seasons need no walking.
|
||||||
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
|
let logo_url = match found.remote_id.parse() {
|
||||||
|
Ok(id) => {
|
||||||
|
self.art_or_nothing(match found.kind {
|
||||||
|
TitleKind::Series => TmdbId::Tv(id),
|
||||||
|
TitleKind::Film => TmdbId::Movie(id),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.logo
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
Ok(Enrichment {
|
Ok(Enrichment {
|
||||||
episodes: self.episodes(found).await?,
|
episodes: self.episodes(found).await?,
|
||||||
backdrop_url: None,
|
backdrop_url: None,
|
||||||
|
logo_url,
|
||||||
chain: Vec::new(),
|
chain: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -168,14 +212,47 @@ impl Tmdb {
|
|||||||
still_url: episode
|
still_url: episode
|
||||||
.still_path
|
.still_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|path| image(STILL_SIZE, path)),
|
.map(|path| image_url(STILL_SIZE, path)),
|
||||||
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
|
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
|
||||||
|
runtime: episode.runtime.filter(|minutes| *minutes > 0),
|
||||||
|
mal_id: None,
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Ok(episodes)
|
Ok(episodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The best backdrop and logo TMDB has for `id`.
|
||||||
|
///
|
||||||
|
/// Artwork in the viewer's language, English or Japanese is asked for, and
|
||||||
|
/// artwork with no language at all, which for a backdrop is the one without
|
||||||
|
/// lettering on it. One request.
|
||||||
|
pub async fn art(&self, id: TmdbId) -> Result<Art> {
|
||||||
|
let languages = format!("{},en,ja,null", self.language_code());
|
||||||
|
let images: Images = self
|
||||||
|
.get(
|
||||||
|
&format!("{API_BASE}/{}/images", id.path()),
|
||||||
|
&[("include_image_language", languages.as_str())],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(images.into_art(self.language_code()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Tmdb::art`], with a failure logged and taken as no art: the art is
|
||||||
|
/// decoration on an answer that is already complete, and a bad key or a
|
||||||
|
/// slow minute must not stop the episodes beside it being stored.
|
||||||
|
pub async fn art_or_nothing(&self, id: TmdbId) -> Art {
|
||||||
|
self.art(id)
|
||||||
|
.await
|
||||||
|
.inspect_err(|error| tracing::debug!("TMDB art for {id:?}: {error}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `de` out of `de-DE`: images are tagged by language alone.
|
||||||
|
fn language_code(&self) -> &str {
|
||||||
|
self.language.split('-').next().unwrap_or("en")
|
||||||
|
}
|
||||||
|
|
||||||
/// One authenticated GET, paced, with TMDB's failures named.
|
/// One authenticated GET, paced, with TMDB's failures named.
|
||||||
///
|
///
|
||||||
/// TMDB is far more generous than AniList and a library-sized run rarely
|
/// TMDB is far more generous than AniList and a library-sized run rarely
|
||||||
@@ -272,6 +349,66 @@ struct EpisodeRow {
|
|||||||
overview: Option<String>,
|
overview: Option<String>,
|
||||||
still_path: Option<String>,
|
still_path: Option<String>,
|
||||||
air_date: Option<String>,
|
air_date: Option<String>,
|
||||||
|
/// Minutes.
|
||||||
|
runtime: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/{movie,tv}/{id}/images`.
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct Images {
|
||||||
|
#[serde(default)]
|
||||||
|
backdrops: Vec<Image>,
|
||||||
|
#[serde(default)]
|
||||||
|
logos: Vec<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Image {
|
||||||
|
file_path: String,
|
||||||
|
iso_639_1: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
vote_average: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Images {
|
||||||
|
fn into_art(self, language: &str) -> Art {
|
||||||
|
// Bare is best for a backdrop: a logo or a title is drawn over it,
|
||||||
|
// and lettering already in the picture fights that.
|
||||||
|
let backdrop = best(&self.backdrops, |image| match image.iso_639_1.as_deref() {
|
||||||
|
None => 2,
|
||||||
|
Some(tag) if tag == language || tag == "en" => 1,
|
||||||
|
Some(_) => 0,
|
||||||
|
});
|
||||||
|
// A logo is lettering, so it is wanted in a language the viewer reads.
|
||||||
|
// Only PNGs: an SVG is nothing the art loaders here can draw.
|
||||||
|
let logos: Vec<&Image> = self
|
||||||
|
.logos
|
||||||
|
.iter()
|
||||||
|
.filter(|image| image.file_path.ends_with(".png"))
|
||||||
|
.collect();
|
||||||
|
let logo = best(&logos, |image| match image.iso_639_1.as_deref() {
|
||||||
|
Some(tag) if tag == language => 3,
|
||||||
|
Some("en") => 2,
|
||||||
|
None => 1,
|
||||||
|
Some(_) => 0,
|
||||||
|
});
|
||||||
|
Art {
|
||||||
|
backdrop: backdrop.map(|image| image_url(BACKDROP_SIZE, &image.file_path)),
|
||||||
|
logo: logo.map(|image| image_url(LOGO_SIZE, &image.file_path)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The image `rank` likes most, the better-voted one among equals.
|
||||||
|
fn best<T: std::borrow::Borrow<Image>>(
|
||||||
|
images: &[T],
|
||||||
|
rank: impl Fn(&Image) -> u8,
|
||||||
|
) -> Option<&Image> {
|
||||||
|
images.iter().map(|image| image.borrow()).max_by(|a, b| {
|
||||||
|
rank(a)
|
||||||
|
.cmp(&rank(b))
|
||||||
|
.then(a.vote_average.total_cmp(&b.vote_average))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -335,8 +472,11 @@ impl Result_ {
|
|||||||
overview: self.overview.filter(|text| !text.is_empty()),
|
overview: self.overview.filter(|text| !text.is_empty()),
|
||||||
year: year_of(self.first_air_date.or(self.release_date).as_deref()),
|
year: year_of(self.first_air_date.or(self.release_date).as_deref()),
|
||||||
kind,
|
kind,
|
||||||
poster_url: self.poster_path.map(|path| image(POSTER_SIZE, &path)),
|
poster_url: self.poster_path.map(|path| image_url(POSTER_SIZE, &path)),
|
||||||
backdrop_url: self.backdrop_path.map(|path| image(BACKDROP_SIZE, &path)),
|
backdrop_url: self
|
||||||
|
.backdrop_path
|
||||||
|
.map(|path| image_url(BACKDROP_SIZE, &path)),
|
||||||
|
logo_url: None,
|
||||||
rating: self.vote_average.filter(|rating| *rating > 0.0),
|
rating: self.vote_average.filter(|rating| *rating > 0.0),
|
||||||
// `search/multi` carries neither, and a detail request per
|
// `search/multi` carries neither, and a detail request per
|
||||||
// candidate would be a round-trip per *result* rather than per
|
// candidate would be a round-trip per *result* rather than per
|
||||||
@@ -363,7 +503,7 @@ impl Result_ {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image(size: &str, path: &str) -> String {
|
fn image_url(size: &str, path: &str) -> String {
|
||||||
format!("{IMAGE_BASE}/{size}{path}")
|
format!("{IMAGE_BASE}/{size}{path}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +577,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
series.metadata.backdrop_url.as_deref(),
|
series.metadata.backdrop_url.as_deref(),
|
||||||
Some("https://image.tmdb.org/t/p/w780/b.jpg")
|
Some("https://image.tmdb.org/t/p/w1280/b.jpg")
|
||||||
);
|
);
|
||||||
|
|
||||||
let film: Result_ = serde_json::from_str(
|
let film: Result_ = serde_json::from_str(
|
||||||
@@ -451,6 +591,61 @@ mod tests {
|
|||||||
assert_eq!(film.metadata.year, Some(1988));
|
assert_eq!(film.metadata.year, Some(1988));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paprika's images as TMDB answers them, trimmed: a bare backdrop beside
|
||||||
|
/// better-voted lettered ones, and logos in three languages and two
|
||||||
|
/// formats.
|
||||||
|
#[test]
|
||||||
|
fn the_bare_backdrop_and_a_png_logo_in_the_viewers_language_are_chosen() {
|
||||||
|
let images: Images = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"backdrops": [
|
||||||
|
{"file_path": "/en.jpg", "iso_639_1": "en", "vote_average": 5.5},
|
||||||
|
{"file_path": "/bare.jpg", "iso_639_1": null, "vote_average": 5.2},
|
||||||
|
{"file_path": "/bare-worse.jpg", "iso_639_1": null, "vote_average": 4.0},
|
||||||
|
{"file_path": "/ja.jpg", "iso_639_1": "ja", "vote_average": 6.0}
|
||||||
|
],
|
||||||
|
"logos": [
|
||||||
|
{"file_path": "/ja.png", "iso_639_1": "ja", "vote_average": 6.0},
|
||||||
|
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 5.0},
|
||||||
|
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 3.0},
|
||||||
|
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("parse");
|
||||||
|
let art = images.into_art("en");
|
||||||
|
assert_eq!(
|
||||||
|
art.backdrop.as_deref(),
|
||||||
|
Some("https://image.tmdb.org/t/p/w1280/bare.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
art.logo.as_deref(),
|
||||||
|
Some("https://image.tmdb.org/t/p/w500/en.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_logo_in_the_viewers_own_language_beats_an_english_one() {
|
||||||
|
let images: Images = serde_json::from_str(
|
||||||
|
r#"{"logos": [
|
||||||
|
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 9.0},
|
||||||
|
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 9.0},
|
||||||
|
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.expect("parse");
|
||||||
|
assert_eq!(
|
||||||
|
images.into_art("de").logo.as_deref(),
|
||||||
|
Some("https://image.tmdb.org/t/p/w500/de.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_entry_with_no_images_has_no_art() {
|
||||||
|
let images: Images = serde_json::from_str("{}").expect("parse");
|
||||||
|
assert_eq!(images.into_art("en"), Art::default());
|
||||||
|
}
|
||||||
|
|
||||||
/// A rating of zero means "nobody has voted", not "everybody hated it", and
|
/// A rating of zero means "nobody has voted", not "everybody hated it", and
|
||||||
/// showing it as 0.0/10 would be a lie about the title.
|
/// showing it as 0.0/10 would be a lie about the title.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
//! TVmaze, over its public REST API.
|
||||||
|
//!
|
||||||
|
//! Television AniList does not have — which is everything that is not anime —
|
||||||
|
//! with no API key and no account. It is asked only about what AniList found
|
||||||
|
//! nothing for (see [`crate::service`]), so a library of anime sends it
|
||||||
|
//! nothing, and a library with one live-action show in it sends it that one
|
||||||
|
//! name.
|
||||||
|
//!
|
||||||
|
//! What it does not have is film: TVmaze indexes shows and nothing else, so a
|
||||||
|
//! title the library knows to be a film is not asked about at all. A show's
|
||||||
|
//! name is also a film's often enough that asking would mostly find the wrong
|
||||||
|
//! thing.
|
||||||
|
//!
|
||||||
|
//! Its art is good where it matters here: a 16:9 still for most episodes and a
|
||||||
|
//! full-HD background for most shows. Its "typography" image would be a logo
|
||||||
|
//! but is a JPEG on a solid ground, so there is no logo from here.
|
||||||
|
|
||||||
|
use pstr_core::library::{Title, TitleKind};
|
||||||
|
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::anilist::strip_markup;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::limiter::RateLimiter;
|
||||||
|
use crate::matching::{Candidate, Query};
|
||||||
|
use crate::provider::Provider;
|
||||||
|
|
||||||
|
const API_BASE: &str = "https://api.tvmaze.com";
|
||||||
|
|
||||||
|
/// TVmaze allows at least twenty calls every ten seconds per address. A little
|
||||||
|
/// under it, because the limit is counted on its side and a burst that lands
|
||||||
|
/// at the edge of its window gets a 429 for nothing.
|
||||||
|
const REQUESTS_PER_MINUTE: u32 = 100;
|
||||||
|
|
||||||
|
/// As [`crate::anilist`]'s.
|
||||||
|
const RETRIES: usize = 2;
|
||||||
|
|
||||||
|
/// The background width worth asking for. Shows often carry a 4K one beside
|
||||||
|
/// it, which is four times the bytes for a band no window draws that wide.
|
||||||
|
const BACKDROP_WIDTH: u32 = 1920;
|
||||||
|
|
||||||
|
pub struct TvMaze {
|
||||||
|
http: reqwest::Client,
|
||||||
|
limiter: RateLimiter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TvMaze {
|
||||||
|
pub fn new(http: reqwest::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
http,
|
||||||
|
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider for TvMaze {
|
||||||
|
fn id(&self) -> ProviderId {
|
||||||
|
ProviderId::TvMaze
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
|
||||||
|
if !asks_about(query) {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let hits: Vec<SearchHit> = self
|
||||||
|
.get("/search/shows", &[("q", query.name.as_str())])
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|hit| hit.show.into_candidate())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every episode and the show's background, in one request.
|
||||||
|
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
|
let show: Option<Show> = self
|
||||||
|
.get(
|
||||||
|
&format!("/shows/{}", found.remote_id),
|
||||||
|
&[("embed[]", "episodes"), ("embed[]", "images")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(show.map(Show::into_enrichment).unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TvMaze {
|
||||||
|
/// The show the TVmaze id `id` names, as search would have found it.
|
||||||
|
/// `None` for an id TVmaze no longer has.
|
||||||
|
pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
|
||||||
|
let show: Option<Show> = self.get(&format!("/shows/{id}"), &[]).await?;
|
||||||
|
Ok(show.map(|show| show.into_candidate().metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One GET, paced. `None` for a 404, which TVmaze answers for an id it
|
||||||
|
/// does not have.
|
||||||
|
async fn get<T: serde::de::DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, &str)],
|
||||||
|
) -> Result<Option<T>> {
|
||||||
|
let mut attempts = 0usize;
|
||||||
|
let response = loop {
|
||||||
|
self.limiter.acquire().await;
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(format!("{API_BASE}{path}"))
|
||||||
|
.query(query)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if status.as_u16() == 429 {
|
||||||
|
let waited = self.limiter.throttled(response.headers()).await;
|
||||||
|
attempts += 1;
|
||||||
|
if attempts > RETRIES {
|
||||||
|
return Err(Error::RateLimited);
|
||||||
|
}
|
||||||
|
tracing::debug!("TVmaze is throttling; waiting {waited:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if status.as_u16() == 404 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(Error::Http(format!("TVmaze answered {status}")));
|
||||||
|
}
|
||||||
|
break response;
|
||||||
|
};
|
||||||
|
Ok(Some(response.json().await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether TVmaze could have `query` at all: not when the library knows it is
|
||||||
|
/// a film.
|
||||||
|
fn asks_about(query: &Query) -> bool {
|
||||||
|
!(query.kind_known && query.kind == TitleKind::Film)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchHit {
|
||||||
|
show: Show,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Show {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
genres: Vec<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
runtime: Option<u32>,
|
||||||
|
average_runtime: Option<u32>,
|
||||||
|
premiered: Option<String>,
|
||||||
|
rating: Option<Rating>,
|
||||||
|
network: Option<Channel>,
|
||||||
|
web_channel: Option<Channel>,
|
||||||
|
image: Option<Image>,
|
||||||
|
summary: Option<String>,
|
||||||
|
#[serde(rename = "_embedded")]
|
||||||
|
embedded: Option<Embedded>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Rating {
|
||||||
|
average: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Channel {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Image {
|
||||||
|
medium: Option<String>,
|
||||||
|
original: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct Embedded {
|
||||||
|
#[serde(default)]
|
||||||
|
episodes: Vec<Episode>,
|
||||||
|
#[serde(default)]
|
||||||
|
images: Vec<Picture>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Episode {
|
||||||
|
name: Option<String>,
|
||||||
|
season: Option<u32>,
|
||||||
|
/// `null` on a special, which is numbered nowhere a file name would say.
|
||||||
|
number: Option<u32>,
|
||||||
|
airdate: Option<String>,
|
||||||
|
runtime: Option<u32>,
|
||||||
|
image: Option<Image>,
|
||||||
|
summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Picture {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
main: bool,
|
||||||
|
resolutions: Resolutions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Resolutions {
|
||||||
|
original: Option<Resolution>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Resolution {
|
||||||
|
url: String,
|
||||||
|
width: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Show {
|
||||||
|
fn into_candidate(self) -> Candidate {
|
||||||
|
let status = match self.status.as_deref() {
|
||||||
|
Some("Running") => Some("RELEASING"),
|
||||||
|
Some("Ended") => Some("FINISHED"),
|
||||||
|
Some("To Be Determined") => Some("HIATUS"),
|
||||||
|
Some("In Development") => Some("NOT_YET_RELEASED"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let channel = self.network.or(self.web_channel);
|
||||||
|
let details = TitleDetails {
|
||||||
|
start_date: self.premiered.clone().filter(|date| !date.is_empty()),
|
||||||
|
// Not a studio, but what the facts line names in its place: who
|
||||||
|
// made it, as a viewer would put it.
|
||||||
|
studios: channel.map(|channel| channel.name).into_iter().collect(),
|
||||||
|
status: status.map(str::to_string),
|
||||||
|
runtime: self
|
||||||
|
.runtime
|
||||||
|
.or(self.average_runtime)
|
||||||
|
.filter(|minutes| *minutes > 0),
|
||||||
|
revision: TitleDetails::REVISION,
|
||||||
|
..TitleDetails::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Candidate {
|
||||||
|
aliases: vec![self.name.clone()],
|
||||||
|
metadata: TitleMetadata {
|
||||||
|
provider: ProviderId::TvMaze,
|
||||||
|
remote_id: self.id.to_string(),
|
||||||
|
year: self
|
||||||
|
.premiered
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|date| date.get(..4)?.parse().ok()),
|
||||||
|
name: self.name,
|
||||||
|
original_name: None,
|
||||||
|
overview: self.summary.as_deref().and_then(overview),
|
||||||
|
kind: TitleKind::Series,
|
||||||
|
poster_url: self.image.and_then(|image| image.original.or(image.medium)),
|
||||||
|
backdrop_url: None,
|
||||||
|
logo_url: None,
|
||||||
|
rating: self
|
||||||
|
.rating
|
||||||
|
.and_then(|rating| rating.average)
|
||||||
|
.filter(|rating| *rating > 0.0),
|
||||||
|
genres: self.genres,
|
||||||
|
episodes: None,
|
||||||
|
url: self.url,
|
||||||
|
details: Some(details),
|
||||||
|
},
|
||||||
|
// Always: TVmaze has nothing but shows.
|
||||||
|
kind_known: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_enrichment(self) -> Enrichment {
|
||||||
|
let embedded = self.embedded.unwrap_or_default();
|
||||||
|
let episodes = embedded
|
||||||
|
.episodes
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|episode| {
|
||||||
|
Some(EpisodeMetadata {
|
||||||
|
season: episode.season,
|
||||||
|
number: episode.number?,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
|
name: episode.name.filter(|name| !name.is_empty()),
|
||||||
|
overview: episode.summary.as_deref().and_then(overview),
|
||||||
|
// The row draws a still about this wide; the original is
|
||||||
|
// a full-HD frame per episode.
|
||||||
|
still_url: episode
|
||||||
|
.image
|
||||||
|
.and_then(|image| image.medium.or(image.original)),
|
||||||
|
air_date: episode.airdate.filter(|date| !date.is_empty()),
|
||||||
|
runtime: episode.runtime.filter(|minutes| *minutes > 0),
|
||||||
|
mal_id: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Enrichment {
|
||||||
|
episodes,
|
||||||
|
backdrop_url: backdrop(&embedded.images),
|
||||||
|
logo_url: None,
|
||||||
|
chain: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The background to draw: the show's main one if it marked one, else the one
|
||||||
|
/// nearest [`BACKDROP_WIDTH`].
|
||||||
|
fn backdrop(images: &[Picture]) -> Option<String> {
|
||||||
|
images
|
||||||
|
.iter()
|
||||||
|
.filter(|picture| picture.kind.as_deref() == Some("background"))
|
||||||
|
.filter_map(|picture| Some((picture, picture.resolutions.original.as_ref()?)))
|
||||||
|
.min_by_key(|(picture, resolution)| {
|
||||||
|
(
|
||||||
|
!picture.main,
|
||||||
|
resolution.width.unwrap_or(0).abs_diff(BACKDROP_WIDTH),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|(_, resolution)| resolution.url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TVmaze's summaries are HTML paragraphs. Each one keeps its own line.
|
||||||
|
fn overview(html: &str) -> Option<String> {
|
||||||
|
let text = strip_markup(&html.replace("</p>", "</p>\n"));
|
||||||
|
(!text.is_empty()).then_some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SHOW: &str = r#"{
|
||||||
|
"id": 82, "url": "https://www.tvmaze.com/shows/82/game-of-thrones",
|
||||||
|
"name": "Game of Thrones", "type": "Scripted",
|
||||||
|
"genres": ["Drama", "Adventure", "Fantasy"], "status": "Ended",
|
||||||
|
"runtime": 60, "averageRuntime": 61, "premiered": "2011-04-17",
|
||||||
|
"rating": {"average": 8.9}, "network": {"id": 8, "name": "HBO"},
|
||||||
|
"webChannel": null,
|
||||||
|
"image": {"medium": "https://static.tvmaze.com/m/1.jpg",
|
||||||
|
"original": "https://static.tvmaze.com/o/1.jpg"},
|
||||||
|
"summary": "<p>Based on the novels.</p><p>Seven noble families fight.</p>",
|
||||||
|
"_embedded": {
|
||||||
|
"episodes": [
|
||||||
|
{"name": "Winter is Coming", "season": 1, "number": 1,
|
||||||
|
"type": "regular", "airdate": "2011-04-17", "runtime": 60,
|
||||||
|
"image": {"medium": "https://static.tvmaze.com/m/e1.jpg",
|
||||||
|
"original": "https://static.tvmaze.com/o/e1.jpg"},
|
||||||
|
"summary": "<p>Lord Stark is troubled.</p>"},
|
||||||
|
{"name": "The Kingsroad", "season": 1, "number": 2,
|
||||||
|
"type": "regular", "airdate": "2011-04-24", "runtime": null,
|
||||||
|
"image": null, "summary": null},
|
||||||
|
{"name": "Inside the Making", "season": 1, "number": null,
|
||||||
|
"type": "significant_special", "airdate": "2011-04-10"}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{"type": "poster", "main": true, "resolutions":
|
||||||
|
{"original": {"url": "poster", "width": 2030}}},
|
||||||
|
{"type": "background", "main": false, "resolutions":
|
||||||
|
{"original": {"url": "uhd", "width": 3840}}},
|
||||||
|
{"type": "background", "main": false, "resolutions":
|
||||||
|
{"original": {"url": "hd", "width": 1920}}},
|
||||||
|
{"type": "typography", "main": false, "resolutions":
|
||||||
|
{"original": {"url": "lettering", "width": 840}}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_show_becomes_a_series_with_its_details() {
|
||||||
|
let show: Show = serde_json::from_str(SHOW).expect("parse");
|
||||||
|
let found = show.into_candidate().metadata;
|
||||||
|
assert_eq!(found.provider, ProviderId::TvMaze);
|
||||||
|
assert_eq!(found.remote_id, "82");
|
||||||
|
assert_eq!(found.kind, TitleKind::Series);
|
||||||
|
assert_eq!(found.year, Some(2011));
|
||||||
|
assert_eq!(found.rating, Some(8.9));
|
||||||
|
assert_eq!(
|
||||||
|
found.poster_url.as_deref(),
|
||||||
|
Some("https://static.tvmaze.com/o/1.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found.overview.as_deref(),
|
||||||
|
Some("Based on the novels.\nSeven noble families fight.")
|
||||||
|
);
|
||||||
|
|
||||||
|
let details = found.details.expect("details");
|
||||||
|
assert!(details.is_current());
|
||||||
|
assert_eq!(details.studios, vec!["HBO"]);
|
||||||
|
assert_eq!(details.status.as_deref(), Some("FINISHED"));
|
||||||
|
assert_eq!(details.runtime, Some(60));
|
||||||
|
assert_eq!(details.start_date.as_deref(), Some("2011-04-17"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specials are numbered nowhere a file would be, so they are left out
|
||||||
|
/// rather than colliding with the episode they share a season with.
|
||||||
|
#[test]
|
||||||
|
fn enrichment_keeps_numbered_episodes_and_the_full_hd_background() {
|
||||||
|
let show: Show = serde_json::from_str(SHOW).expect("parse");
|
||||||
|
let enrichment = show.into_enrichment();
|
||||||
|
assert_eq!(enrichment.episodes.len(), 2);
|
||||||
|
|
||||||
|
let first = &enrichment.episodes[0];
|
||||||
|
assert_eq!((first.season, first.number), (Some(1), 1));
|
||||||
|
assert_eq!(first.name.as_deref(), Some("Winter is Coming"));
|
||||||
|
assert_eq!(first.overview.as_deref(), Some("Lord Stark is troubled."));
|
||||||
|
assert_eq!(
|
||||||
|
first.still_url.as_deref(),
|
||||||
|
Some("https://static.tvmaze.com/m/e1.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(first.runtime, Some(60));
|
||||||
|
assert_eq!(enrichment.episodes[1].runtime, None);
|
||||||
|
|
||||||
|
assert_eq!(enrichment.backdrop_url.as_deref(), Some("hd"));
|
||||||
|
assert_eq!(enrichment.logo_url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_background_the_show_marked_main_wins_over_a_better_sized_one() {
|
||||||
|
let images: Vec<Picture> = serde_json::from_str(
|
||||||
|
r#"[{"type": "background", "main": false, "resolutions":
|
||||||
|
{"original": {"url": "hd", "width": 1920}}},
|
||||||
|
{"type": "background", "main": true, "resolutions":
|
||||||
|
{"original": {"url": "chosen", "width": 1280}}}]"#,
|
||||||
|
)
|
||||||
|
.expect("parse");
|
||||||
|
assert_eq!(backdrop(&images).as_deref(), Some("chosen"));
|
||||||
|
assert_eq!(backdrop(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TVmaze has no films, and a show sharing a film's name is the wrong
|
||||||
|
/// answer far more often than it is an adaptation.
|
||||||
|
#[test]
|
||||||
|
fn a_known_film_is_not_asked_about_and_a_guessed_one_is() {
|
||||||
|
let film = Query::new("Paprika", None, TitleKind::Film);
|
||||||
|
assert!(!asks_about(&film));
|
||||||
|
assert!(asks_about(&film.with_guessed_kind()));
|
||||||
|
assert!(asks_about(&Query::new(
|
||||||
|
"Game of Thrones",
|
||||||
|
None,
|
||||||
|
TitleKind::Series
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub use chapters::{Chapter, ChapterRole, chapter_at, chapter_end, credits_start,
|
|||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use player::{
|
pub use player::{
|
||||||
EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput,
|
EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput,
|
||||||
|
subtitle_options,
|
||||||
};
|
};
|
||||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||||
pub use render::VideoRenderer;
|
pub use render::VideoRenderer;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use libmpv2::events::{Event, PropertyData};
|
use libmpv2::events::{Event, PropertyData};
|
||||||
use libmpv2::{Format, Mpv};
|
use libmpv2::{Format, Mpv};
|
||||||
|
use pstr_core::prefs::{SubtitleBackdrop, SubtitleStyle};
|
||||||
use pstr_stream::VideoStream;
|
use pstr_stream::VideoStream;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
@@ -181,6 +182,8 @@ pub struct PlayerConfig {
|
|||||||
/// Whether subtitles are shown at all. Off is a choice a viewer makes once
|
/// Whether subtitles are shown at all. Off is a choice a viewer makes once
|
||||||
/// and expects to keep, and no language preference can express it.
|
/// and expects to keep, and no language preference can express it.
|
||||||
pub subtitles: bool,
|
pub subtitles: bool,
|
||||||
|
/// How subtitles look. See [`subtitle_options`].
|
||||||
|
pub subtitle_style: SubtitleStyle,
|
||||||
/// Raw mpv options, applied last, so anything here wins.
|
/// Raw mpv options, applied last, so anything here wins.
|
||||||
pub options: Vec<(String, String)>,
|
pub options: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
@@ -206,6 +209,7 @@ impl Default for PlayerConfig {
|
|||||||
audio_language: None,
|
audio_language: None,
|
||||||
subtitle_language: None,
|
subtitle_language: None,
|
||||||
subtitles: true,
|
subtitles: true,
|
||||||
|
subtitle_style: SubtitleStyle::default(),
|
||||||
options: Vec::new(),
|
options: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +297,9 @@ impl Player {
|
|||||||
if !config.subtitles {
|
if !config.subtitles {
|
||||||
init.set_property("sid", "no")?;
|
init.set_property("sid", "no")?;
|
||||||
}
|
}
|
||||||
|
apply_subtitle_options(&config.subtitle_style, |name, value| {
|
||||||
|
init.set_property(name, value).map_err(Error::from)
|
||||||
|
});
|
||||||
for (name, value) in &config.options {
|
for (name, value) in &config.options {
|
||||||
init.set_property(name.as_str(), value.as_str())?;
|
init.set_property(name.as_str(), value.as_str())?;
|
||||||
}
|
}
|
||||||
@@ -537,6 +544,18 @@ impl Player {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restyle subtitles now, on the line already showing.
|
||||||
|
pub fn apply_subtitle_style(&self, style: &SubtitleStyle) {
|
||||||
|
apply_subtitle_options(style, |name, value| self.set_option(name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show subtitles `seconds` later (or, negative, earlier) than the file
|
||||||
|
/// says. For a release whose subtitles were timed against another cut.
|
||||||
|
pub fn set_subtitle_delay(&self, seconds: f64) -> Result<()> {
|
||||||
|
self.mpv.set_property("sub-delay", seconds)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's
|
/// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's
|
||||||
/// default and what keeps a voice a voice at 1.5×.
|
/// default and what keeps a voice a voice at 1.5×.
|
||||||
pub fn set_speed(&self, speed: f64) -> Result<()> {
|
pub fn set_speed(&self, speed: f64) -> Result<()> {
|
||||||
@@ -593,6 +612,78 @@ impl Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first option of the block that only makes sense on top of it. See
|
||||||
|
/// [`apply_subtitle_options`].
|
||||||
|
const BORDER_STYLE: &str = "sub-border-style";
|
||||||
|
|
||||||
|
/// The mpv options that draw `style`, every one of them every time, so that
|
||||||
|
/// changing a value back to its default while playing lands too.
|
||||||
|
///
|
||||||
|
/// `sub-ass-override` is `yes` unless the viewer asked otherwise, so a styled
|
||||||
|
/// track keeps its own size, fonts and colours. Not mpv's default, `scale`,
|
||||||
|
/// which hands it `sub-scale` as well. `force` repaints it whole. See
|
||||||
|
/// [`SubtitleStyle`] for why that is opt-in. `sub-pos` reaches a styled track
|
||||||
|
/// under `yes` too, so where a styled track sits is the caller's to decide:
|
||||||
|
/// see `pstr-app`'s `Playback::lift_subtitles`.
|
||||||
|
/// The border options come last, led by [`BORDER_STYLE`].
|
||||||
|
pub fn subtitle_options(style: &SubtitleStyle) -> Vec<(&'static str, String)> {
|
||||||
|
let flag = |on: bool| if on { "yes" } else { "no" }.to_owned();
|
||||||
|
let mut options = vec![
|
||||||
|
(
|
||||||
|
"sub-ass-override",
|
||||||
|
if style.restyle_styled { "force" } else { "yes" }.to_owned(),
|
||||||
|
),
|
||||||
|
("sub-scale", format!("{:.2}", style.scale)),
|
||||||
|
(
|
||||||
|
"sub-font",
|
||||||
|
style
|
||||||
|
.font
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| SubtitleStyle::DEFAULT_FONT.to_owned()),
|
||||||
|
),
|
||||||
|
("sub-bold", flag(style.bold)),
|
||||||
|
("sub-italic", flag(style.italic)),
|
||||||
|
("sub-color", style.colour.to_mpv()),
|
||||||
|
// mpv's name for the shadow's colour, older than the shadow alias.
|
||||||
|
("sub-back-color", style.shadow_colour.to_mpv()),
|
||||||
|
("sub-shadow-offset", format!("{:.2}", style.shadow_offset)),
|
||||||
|
("sub-blur", format!("{:.2}", style.blur)),
|
||||||
|
("sub-pos", format!("{:.0}", style.position)),
|
||||||
|
];
|
||||||
|
match style.backdrop {
|
||||||
|
SubtitleBackdrop::Outline => options.extend([
|
||||||
|
(BORDER_STYLE, "outline-and-shadow".to_owned()),
|
||||||
|
("sub-outline-color", style.outline_colour.to_mpv()),
|
||||||
|
("sub-outline-size", format!("{:.2}", style.outline_size)),
|
||||||
|
]),
|
||||||
|
// `opaque-box` draws the outline as a box hugging each line, so the
|
||||||
|
// outline's colour and size become the box's colour and margin.
|
||||||
|
SubtitleBackdrop::Box => options.extend([
|
||||||
|
(BORDER_STYLE, "opaque-box".to_owned()),
|
||||||
|
("sub-outline-color", style.box_colour.to_mpv()),
|
||||||
|
("sub-outline-size", "6".to_owned()),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand `style` to `set` option by option, best effort.
|
||||||
|
///
|
||||||
|
/// An option this libmpv refuses costs that option, not the player:
|
||||||
|
/// `sub-border-style` is mpv 0.38's, and Ubuntu 24.04 ships 0.37. A refused
|
||||||
|
/// border style ends the run, though, because what follows it only makes sense
|
||||||
|
/// on top of it: a box's colour and margin on a plain outline is a fat smear.
|
||||||
|
fn apply_subtitle_options(style: &SubtitleStyle, mut set: impl FnMut(&str, &str) -> Result<()>) {
|
||||||
|
for (name, value) in subtitle_options(style) {
|
||||||
|
if let Err(error) = set(name, &value) {
|
||||||
|
tracing::warn!("subtitle style {name}={value}: {error}");
|
||||||
|
if name == BORDER_STYLE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// mpv's borrowed event to ours.
|
/// mpv's borrowed event to ours.
|
||||||
fn translate(event: Event<'_>) -> PlayerEvent {
|
fn translate(event: Event<'_>) -> PlayerEvent {
|
||||||
match event {
|
match event {
|
||||||
@@ -620,6 +711,81 @@ fn translate(event: Event<'_>) -> PlayerEvent {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn option<'a>(options: &'a [(&'static str, String)], name: &str) -> Option<&'a str> {
|
||||||
|
options
|
||||||
|
.iter()
|
||||||
|
.find(|(option, _)| *option == name)
|
||||||
|
.map(|(_, value)| value.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_subtitle_style_is_what_mpv_would_do_anyway() {
|
||||||
|
let options = subtitle_options(&SubtitleStyle::default());
|
||||||
|
// mpv 0.41's own defaults, from `mpv --list-options` — all but
|
||||||
|
// `sub-ass-override`, which mpv has at `scale`.
|
||||||
|
for (name, value) in [
|
||||||
|
("sub-ass-override", "yes"),
|
||||||
|
("sub-scale", "1.00"),
|
||||||
|
("sub-font", "sans-serif"),
|
||||||
|
("sub-bold", "no"),
|
||||||
|
("sub-italic", "no"),
|
||||||
|
("sub-color", "#FFFFFFFF"),
|
||||||
|
("sub-back-color", "#AF000000"),
|
||||||
|
("sub-shadow-offset", "0.00"),
|
||||||
|
("sub-blur", "0.00"),
|
||||||
|
("sub-pos", "100"),
|
||||||
|
("sub-border-style", "outline-and-shadow"),
|
||||||
|
("sub-outline-color", "#FF000000"),
|
||||||
|
("sub-outline-size", "1.65"),
|
||||||
|
] {
|
||||||
|
assert_eq!(option(&options, name), Some(value), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_boxed_subtitle_style_turns_the_outline_into_a_box_of_its_colour() {
|
||||||
|
let style = SubtitleStyle {
|
||||||
|
backdrop: SubtitleBackdrop::Box,
|
||||||
|
box_colour: pstr_core::prefs::Colour::rgba(0x20, 0x10, 0x00, 0x80),
|
||||||
|
..SubtitleStyle::default()
|
||||||
|
};
|
||||||
|
let options = subtitle_options(&style);
|
||||||
|
assert_eq!(option(&options, "sub-border-style"), Some("opaque-box"));
|
||||||
|
assert_eq!(option(&options, "sub-outline-color"), Some("#80201000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn styled_subtitles_are_repainted_or_resized_only_when_asked() {
|
||||||
|
// `scale` would hand a styled track the size; `yes` hands it nothing
|
||||||
|
// but the `sub-ass-*` options, none of which this sets.
|
||||||
|
let kept = subtitle_options(&SubtitleStyle {
|
||||||
|
scale: 1.5,
|
||||||
|
..SubtitleStyle::default()
|
||||||
|
});
|
||||||
|
assert_eq!(option(&kept, "sub-ass-override"), Some("yes"));
|
||||||
|
let repainted = subtitle_options(&SubtitleStyle {
|
||||||
|
restyle_styled: true,
|
||||||
|
..SubtitleStyle::default()
|
||||||
|
});
|
||||||
|
assert_eq!(option(&repainted, "sub-ass-override"), Some("force"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_border_style_skips_what_depends_on_it_and_nothing_before() {
|
||||||
|
let mut set = Vec::new();
|
||||||
|
apply_subtitle_options(&SubtitleStyle::default(), |name, _| {
|
||||||
|
set.push(name.to_owned());
|
||||||
|
if name == BORDER_STYLE {
|
||||||
|
Err(Error::NulByte("test"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(set.last().map(String::as_str), Some(BORDER_STYLE));
|
||||||
|
assert!(set.iter().any(|name| name == "sub-color"));
|
||||||
|
assert!(!set.iter().any(|name| name == "sub-outline-color"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_end_reason_maps_to_something_the_ui_can_act_on() {
|
fn every_end_reason_maps_to_something_the_ui_can_act_on() {
|
||||||
use libmpv2::mpv_end_file_reason as raw;
|
use libmpv2::mpv_end_file_reason as raw;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 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, your settings and My List, 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
|
||||||
|
- My List: a title you add on one device is on the list of every other, and one you remove is
|
||||||
|
removed from all of them
|
||||||
|
|
||||||
|
**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, each setting and each title on My List, 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.
|
||||||
@@ -12,20 +12,27 @@ All of B14–B33 came out of a static review of the Android client
|
|||||||
device** — there was no Android acceptance harness to reproduce them with, which
|
device** — there was no Android acceptance harness to reproduce them with, which
|
||||||
is itself B42. Each states the reasoning that identifies it; treat the
|
is itself B42. Each states the reasoning that identifies it; treat the
|
||||||
reproduction step as part of the fix. The remediation order is `plan.md` at the
|
reproduction step as part of the fix. The remediation order is `plan.md` at the
|
||||||
repository root; Phases 1 to 5 of it have landed except for the tail of B41 and
|
repository root; Phases 1 to 5 of it have landed except for one row of Phase
|
||||||
one row of Phase 5's table — everything from B14 to B39 is below in **Fixed**,
|
5's table — everything from B14 to B39 is below in **Fixed**, and B41's items
|
||||||
and B41 states which of its items are done.
|
have all landed.
|
||||||
|
|
||||||
### B41 — assorted correctness and hygiene defects
|
### B41 — assorted correctness and hygiene defects
|
||||||
|
|
||||||
Individually small, grouped so they are not lost. The items marked **Landed**
|
Individually small, grouped so they are not lost. Every item has now landed;
|
||||||
came with Phases 4 and 5; the rest are still open, which is why this entry is.
|
the entry stays here, with B42, until a device run confirms them.
|
||||||
What remains is one item: the asynchronous track-list read-back.
|
|
||||||
|
|
||||||
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an
|
- **Landed.** `NativeMpvHost.selectTrack` read the track list back straight
|
||||||
asynchronous property set, so the selection tick lags by up to a second;
|
after setting `aid`/`sid`, before mpv had made the switch, so the tick in the
|
||||||
`alang`/`slang` are applied only at load (`pstr_mpv.cpp:225-227`), so a
|
track chooser stayed on the old track until the next poll, up to a second
|
||||||
language change takes effect one episode late.
|
later. The tick now moves when the track is chosen, and the list is read again
|
||||||
|
300 ms after. The second half of the report, a language change landing one
|
||||||
|
episode late, did not hold up: the choice is saved from the chosen track, and
|
||||||
|
the next episode loads with it. What was wrong nearby is that `load` set
|
||||||
|
`alang` and `slang` only when they were not empty, and mpv keeps both across
|
||||||
|
`loadfile`, so a file with no language preference took the previous file's.
|
||||||
|
Both are set on every load now. The iOS client had the same early read in
|
||||||
|
`MpvPlayer.select` and takes the same fix; it already set both languages on
|
||||||
|
every load.
|
||||||
- **Landed.** `NativeMpvHost.kt` polled on `Dispatchers.Main.immediate`, and
|
- **Landed.** `NativeMpvHost.kt` polled on `Dispatchers.Main.immediate`, and
|
||||||
`tracks_json` issues five `mpv_get_property` calls per track — 51 synchronous
|
`tracks_json` issues five `mpv_get_property` calls per track — 51 synchronous
|
||||||
property reads on the UI thread for a 10-track file, contending on the core
|
property reads on the UI thread for a 10-track file, contending on the core
|
||||||
|
|||||||
@@ -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. |
|
||||||
@@ -20,12 +20,13 @@ is the contract, and each side's copy is asserted against it by a test.
|
|||||||
|
|
||||||
## Type
|
## Type
|
||||||
|
|
||||||
Nine rungs. The desktop names them; Android restates them because Material's own
|
Ten rungs. The desktop names them; Android restates them because Material's own
|
||||||
scale is a different one (`displayLarge` is 57sp, which is a size for a lock
|
scale is a different one (`displayLarge` is 57sp, which is a size for a lock
|
||||||
screen and not for anything in a library grid).
|
screen and not for anything in a library grid).
|
||||||
|
|
||||||
| Role | Size | What it is |
|
| Role | Size | What it is |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| Hero | 34 | the banner's name and a title page's name, where no logo stands in |
|
||||||
| Display | 26 | the name of the thing you are looking at, once per page |
|
| Display | 26 | the name of the thing you are looking at, once per page |
|
||||||
| Title | 20 | an empty state, or a page heading with no display line |
|
| Title | 20 | an empty state, or a page heading with no display line |
|
||||||
| Heading | 18 | the brand in the nav bar; a dialog's heading |
|
| Heading | 18 | the brand in the nav bar; a dialog's heading |
|
||||||
@@ -39,8 +40,11 @@ screen and not for anything in a library grid).
|
|||||||
Weight and leading are each side's own. The desktop bundles Inter
|
Weight and leading are each side's own. The desktop bundles Inter
|
||||||
(`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in
|
(`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in
|
||||||
its semibold cut, which egui sees as a family of its own (`theme::semibold`);
|
its semibold cut, which egui sees as a family of its own (`theme::semibold`);
|
||||||
everything below Section is the regular cut. Compose derives line height as
|
everything below Section is the regular cut. Hero is set in Inter Display Bold,
|
||||||
1.35 × size rather than naming it nine more times.
|
the optical size Inter draws for large text (`theme::display`); Android and iOS
|
||||||
|
map it to `displayLarge`. Compose derives line height as 1.35 × size rather
|
||||||
|
than naming it ten more times, except Hero at 1.1, where the text leading
|
||||||
|
pushes a two-line name apart.
|
||||||
|
|
||||||
Icons on the desktop come from the Phosphor icon font (`egui-phosphor`,
|
Icons on the desktop come from the Phosphor icon font (`egui-phosphor`,
|
||||||
regular weight), installed right behind Inter so an icon can sit inside an
|
regular weight), installed right behind Inter so an icon can sit inside an
|
||||||
@@ -59,9 +63,23 @@ this module, and keep Material's sizes. Nothing in the app draws with them.
|
|||||||
buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
|
buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
|
||||||
also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
|
also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
|
||||||
small control inside something else, and **12** (`LG`) for what floats — row
|
small control inside something else, and **12** (`LG`) for what floats — row
|
||||||
cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has
|
cards, dialogs, toasts. The library's banner is square: it runs edge to edge. **16** for an Android bottom sheet, which has
|
||||||
no desktop counterpart.
|
no desktop counterpart.
|
||||||
|
|
||||||
|
## A picture's colour
|
||||||
|
|
||||||
|
The veils over a title's backdrop — the library's banner, the top of a title's
|
||||||
|
page — are the page colour tinted by the picture under them, so each title
|
||||||
|
carries its own light. All three clients get the same colour from the same
|
||||||
|
code: `pstr_core::appearance::mood` averages the picture, squeezed to a few
|
||||||
|
hundred pixels, counting a saturated pixel up to five times a grey one
|
||||||
|
(`MOOD_CHROMA_WEIGHT`), and `mood_veil` turns that into the veil. On a dark
|
||||||
|
palette the mood is darkened until its brightest channel is 60 (`MOOD_DEPTH`)
|
||||||
|
and the page moves 55% of the way to it (`MOOD_DARK`); a light palette moves
|
||||||
|
12% (`MOOD_LIGHT`). The phones reach both through the bridge, as
|
||||||
|
`pictureMood` and `moodVeil`. The fade at the bottom stays the page colour,
|
||||||
|
because that is what it ends in.
|
||||||
|
|
||||||
## Spacing
|
## Spacing
|
||||||
|
|
||||||
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
|
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
| `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) |
|
| `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) |
|
||||||
| `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context |
|
| `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context |
|
||||||
| `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management |
|
| `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management |
|
||||||
| Metadata providers (AniList / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default |
|
| Metadata providers (AniList, then TVmaze / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default |
|
||||||
| Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac |
|
| Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac |
|
||||||
|
|
||||||
## Build order from here
|
## Build order from here
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Subtitles are hard to read. Can I change them?
|
||||||
|
|
||||||
|
Yes. In **Settings → Subtitles** you can set the size, position, font, colours, outline, box,
|
||||||
|
shadow and blur. A **Box** background is easiest to read on bright scenes. Styled subtitles (ASS)
|
||||||
|
keep the look, size and place their makers gave them, unless you turn on **Restyle styled
|
||||||
|
subtitles**.
|
||||||
|
|
||||||
|
## The subtitles are early or late.
|
||||||
|
|
||||||
|
Press **Z** to show them 0.1 s earlier and **X** to show them later, or use **Delay** in the
|
||||||
|
player's subtitle menu. The delay holds for the file you are watching only.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -90,6 +90,12 @@ Each of these is the platform's doing, not a choice to diverge.
|
|||||||
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
|
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
|
||||||
stand in for the ripple, and a settings page goes back by an edge strip of its
|
stand in for the ripple, and a settings page goes back by an edge strip of its
|
||||||
own, as a title page does, where Android has the system back.
|
own, as a title page does, where Android has the system back.
|
||||||
|
- **No zoom into a title page.** iOS 18's `.navigationTransition(.zoom)` only
|
||||||
|
runs inside a `NavigationStack`, and the title page is shown over the library
|
||||||
|
by hand, so it keeps its slide in from the edge — Android's shared poster has
|
||||||
|
no counterpart here.
|
||||||
|
- **The volume swipe turns the player's own volume**, the one its slider sets.
|
||||||
|
iOS lets no app set the device's media volume, which Android's swipe turns.
|
||||||
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
|
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
|
||||||
secrets are this-device-only Keychain items, so a restored share list would
|
secrets are this-device-only Keychain items, so a restored share list would
|
||||||
arrive without them, and a restored `sync.json` would give two devices one sync
|
arrive without them, and a restored `sync.json` would give two devices one sync
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ good. What hung off it was not:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
|
||||||
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
|
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
|
||||||
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. |
|
| **AniSkip** `api.aniskip.com/v2/skip-times/` | none | Where an episode's opening and ending are, by MyAnimeList id and episode number, as viewers marked them. Asked at play time for files without such chapters; never stored. |
|
||||||
| **TVmaze** | none | Live-action TV episodes, keyless. |
|
| **TMDB** | free key | Film backdrops and logos; live-action TV. Chosen as the provider, or with AniList chosen, asked by id for art alone. |
|
||||||
|
| **TVmaze** | none | Live-action TV: episodes with 16:9 stills and runtimes, network, status, full-HD backgrounds. No films; its "typography" art is a JPEG, so no logos. In use after AniList. |
|
||||||
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
|
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
|
||||||
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
|
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
|
||||||
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
|
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
|
||||||
@@ -148,8 +149,30 @@ titles are one director's, and that is not a shelf — and a genre only under 40
|
|||||||
of the library. Four at most, over the unfiltered library. Here: *Directed by
|
of the library. Four at most, over the unfiltered library. Here: *Directed by
|
||||||
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
|
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
|
||||||
|
|
||||||
**The title page** adds the season, studios, director and next airing episode
|
**The title page** adds the season and next airing episode to its line of
|
||||||
to its line of facts, and the tags beside the genres.
|
facts, the tags beside the genres, and the studios and director as chips of
|
||||||
|
their own. Each genre, studio and director chip opens a page of every title
|
||||||
|
carrying it (`pstr_core::discover::Facet`).
|
||||||
|
|
||||||
|
**More like this.** `pstr_core::discover::more_like` scores every other title
|
||||||
|
by what it shares with this one — a director 4, a studio 3, a genre 2, one of
|
||||||
|
the first eight tags 1, the format 1 on top of anything else — and offers those
|
||||||
|
scoring 4 or more: a director, or two genres, but never one genre alone. The
|
||||||
|
title's own franchise is left out and each other franchise offers only its best
|
||||||
|
match. The same scoring, seeded by the title most recently watched to the end
|
||||||
|
and limited to titles not started, is the library's *Because you watched* row.
|
||||||
|
No request: it reads only what the match already stored.
|
||||||
|
|
||||||
|
**Cast and trailer.** The AniList query also asks for the first ten
|
||||||
|
characters by role, main ones first, each with their Japanese voice actor and
|
||||||
|
both portraits, and for the trailer's site and id. They are
|
||||||
|
`TitleDetails::cast` and `TitleDetails::trailer`, in the same JSON column — no
|
||||||
|
migration. `TitleDetails::REVISION` is 2, so a match stored before them is
|
||||||
|
fetched again by id on the next match run, as `runtime` was. A voice actor's
|
||||||
|
chip opens the same page as a director's (`Facet::Person`). The trailer is
|
||||||
|
only ever a link: `Trailer::url` builds the YouTube or Dailymotion address,
|
||||||
|
keeping only an id's own characters, and the browser opens it. Portraits are
|
||||||
|
fetched like posters, from AniList's image host.
|
||||||
|
|
||||||
**Android.** The same, through the bridge: `AndroidEngine::arrangement`
|
**Android.** The same, through the bridge: `AndroidEngine::arrangement`
|
||||||
returns the grid's tiles and the shelves as keys into `library`'s titles, and
|
returns the grid's tiles and the shelves as keys into `library`'s titles, and
|
||||||
@@ -160,15 +183,123 @@ same facts, tags and franchise row as the desktop's; *Title names* is in
|
|||||||
Settings → Appearance. Android's grid is always covers, so it has no
|
Settings → Appearance. Android's grid is always covers, so it has no
|
||||||
*Library tiles* choice.
|
*Library tiles* choice.
|
||||||
|
|
||||||
|
**Folders of films.** `pstr_core::catalog::build_rows` gives each file in a
|
||||||
|
folder of films its own title, so the four Rebuild films in one folder are
|
||||||
|
four entries, matched one by one, and the franchise folds them back into one
|
||||||
|
tile. Only when no file is numbered, none sits under `Movies`/`OVA`, and each
|
||||||
|
names itself differently from the others and from the folder; anything less
|
||||||
|
is a series with its specials. A film's version number (`1.11`) is not part of its name on
|
||||||
|
AniList, so a search that finds nothing is asked again without it.
|
||||||
|
|
||||||
Not done: TMDB fills no details.
|
Not done: TMDB fills no details.
|
||||||
|
|
||||||
|
**Logos and running times.** ani.zip sent both from the start and neither was
|
||||||
|
kept. Enrichment now keeps the series' TVDB *Clearlogo* — a transparent PNG —
|
||||||
|
in `title_metadata.logo_url`, kept across a refresh of the same entry like the
|
||||||
|
fanart, and each episode's running time (TVDB's, else AniDB's `length`) in
|
||||||
|
`episode_metadata.runtime`. AniList's `duration` lands in
|
||||||
|
`TitleDetails::runtime`: minutes per episode for a series, the whole running
|
||||||
|
time for a film. TMDB's episode runtimes are kept the same way.
|
||||||
|
|
||||||
|
A clear logo is padded to a square, so every client crops it to its visible
|
||||||
|
pixels before drawing it (`trim_alpha` on the desktop, `opaqueBounds` on
|
||||||
|
Android and iOS). It replaces the name over the library's banner and at the
|
||||||
|
top of the title page — except on a light theme's title page, where lettering
|
||||||
|
made for dark art could vanish. A film's running time joins its line of facts;
|
||||||
|
an episode row shows its own (on Android and iOS in place of the file size).
|
||||||
|
|
||||||
|
**Schema v13** adds the two columns and marks every AniList title un-enriched,
|
||||||
|
deleting nothing. `TitleDetails::revision` makes details written before
|
||||||
|
`runtime` existed refetch by id on the next run; they stay on screen until the
|
||||||
|
answer lands, rather than being blanked by the migration as schema v10 did.
|
||||||
|
|
||||||
|
**AniSkip.** A file whose chapters name no opening or ending — most files
|
||||||
|
that are not fansub releases — gets them from [AniSkip](https://aniskip.com),
|
||||||
|
where viewers mark where an episode's opening and ending are. ani.zip sends each
|
||||||
|
entry's MyAnimeList id with its mappings; enrichment now keeps it on every
|
||||||
|
episode row (`episode_metadata.mal_id`), next to the episode's number within
|
||||||
|
that entry. When such a file has loaded and its length is known, the player asks
|
||||||
|
`api.aniskip.com/v2/skip-times/{mal_id}/{number}?types=op&types=ed&episodeLength=`
|
||||||
|
once, with enrichment on only. The answer is kept in memory for as long as the
|
||||||
|
file is open and never stored.
|
||||||
|
|
||||||
|
`pstr_core::chapters::with_skip_ranges` cuts the answer into the file's
|
||||||
|
chapters as chapters named `Opening` and `Ending`, so the skip button, skipping
|
||||||
|
on its own, the up-next countdown and the marks on the seek bar all work through
|
||||||
|
the same code as for a file that came with them. A file whose own chapters
|
||||||
|
already name an opening or an ending keeps them: what a release says about its
|
||||||
|
own cut beats what a viewer of another release marked.
|
||||||
|
|
||||||
|
**Schema v14** adds the column and marks every AniList title un-enriched,
|
||||||
|
deleting nothing, as v13 did. Until the next match run fills it in, AniSkip is
|
||||||
|
not asked.
|
||||||
|
|
||||||
## Phase 3 — reach
|
## Phase 3 — reach
|
||||||
|
|
||||||
|
**Routing by title (done).** One provider for the whole library left Game of
|
||||||
|
Thrones unmatched in a share of anime, and switching the library to TMDB would
|
||||||
|
have thrown away every AniList match, chain and shelf. So the providers are now
|
||||||
|
asked in a chain (`pstr_meta::service::chain_of`): with AniList chosen, a title
|
||||||
|
AniList has no match for is searched on TVmaze, keyless, before it is given up
|
||||||
|
on. TMDB, which has television itself, is still asked alone.
|
||||||
|
|
||||||
|
- A match is stored under the provider that gave it, and everything after it
|
||||||
|
— enrichment, refreshing details by id — goes to that provider.
|
||||||
|
- A miss is stored under the *last* provider asked. `MetadataRecord::is_fresh`
|
||||||
|
takes the chain, and a miss only stands when the chain ends with the provider
|
||||||
|
that stored it. So an AniList miss from before TVmaze joined is not trusted,
|
||||||
|
and the next run asks TVmaze at once rather than after the miss expires.
|
||||||
|
- A provider that fails (a timeout, a 429) ends the lookup with an error rather
|
||||||
|
than falling through. The next provider's answer for a title AniList could
|
||||||
|
not be asked about is a guess about an anime, and a guess stored as a match
|
||||||
|
would stay.
|
||||||
|
- TVmaze is not asked about a title the library knows to be a film. It has
|
||||||
|
none, and a show sharing a film's name is far more often wrong than an
|
||||||
|
adaptation.
|
||||||
|
- A hand search asks every provider in the chain and labels each result with
|
||||||
|
the provider it came from; picking one pins the title to that provider.
|
||||||
|
|
||||||
|
TVmaze answers search with whole show records — genres, network, status,
|
||||||
|
premiere date, runtime, rating — so a match carries its details with no second
|
||||||
|
request. Enrichment is one more: `/shows/{id}` with the episodes and images
|
||||||
|
embedded. Specials, which TVmaze numbers nowhere a file would, are left out.
|
||||||
|
|
||||||
|
**Episode names from scene releases (done).** `Game.of.Thrones.S01E01.Winter.Is.Coming.2160p…`
|
||||||
|
names its episode in dotted words, and `naming::episode_title` now reads them,
|
||||||
|
stopping at the first release tag. A dotted run that no tag ends is not taken.
|
||||||
|
This is the fallback for a title no provider has.
|
||||||
|
|
||||||
|
**Film art from TMDB by id (done).** TVDB has no films, so ani.zip has no
|
||||||
|
fanart or logo for one, and seventeen of the library's titles — nearly all
|
||||||
|
films — had nothing wide to draw. ani.zip does give the TMDB id
|
||||||
|
(`mappings.themoviedb_id`, with `mappings.type` saying whether it is a film's
|
||||||
|
or a show's). With a TMDB key stored and AniList chosen, an entry left without a
|
||||||
|
backdrop or a logo is asked for `/{movie,tv}/{id}/images`: one request, by id,
|
||||||
|
so no title name goes to TMDB. A backdrop without lettering is preferred, and a
|
||||||
|
PNG logo in the viewer's language, then English. An OVA's or a special's id is
|
||||||
|
left alone, since TMDB files those either way. A failure there is logged and
|
||||||
|
the enrichment stored without the art, rather than lost over decoration.
|
||||||
|
|
||||||
|
With TMDB itself chosen, enrichment now makes the same request for the logo,
|
||||||
|
and backdrops are fetched at `w1280` rather than `w780`, since they fill the
|
||||||
|
width of the title page.
|
||||||
|
|
||||||
|
**A band that never looks broken (done).** A title with no wide art drew
|
||||||
|
its poster cropped to the band's shape: a blown-up slice of the middle of it.
|
||||||
|
Now the poster is squeezed to 64×36 and blurred once, when it is fetched
|
||||||
|
(`Fit::Ambient`; the cache keeps the wash, not the poster), and that is
|
||||||
|
stretched across the band — the poster's colours and light rather than a
|
||||||
|
crop. The library's banner then stands the poster itself at its right. Android
|
||||||
|
and iOS blur the poster the same way (Android 12 and later; older versions
|
||||||
|
decode it tiny, which softens it less).
|
||||||
|
|
||||||
|
**Quality from file names (done).** `naming::quality` reads `2160p`, `HDR`,
|
||||||
|
`10-bit` and `Dual-Audio` out of a release name, and a title's page shows what
|
||||||
|
most of its files say. No release group is read.
|
||||||
|
|
||||||
|
Still to do:
|
||||||
|
|
||||||
- **Local matching** against the offline database, so only ids leave the
|
- **Local matching** against the offline database, so only ids leave the
|
||||||
machine.
|
machine.
|
||||||
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
|
|
||||||
whatever is not anime — Game of Thrones in a mostly-anime share.
|
|
||||||
- **Folders of films** matched per file: four Rebuild films in one folder are
|
|
||||||
four entries, not one.
|
|
||||||
- **Skip intro** from AniSkip for files without chapters, and filler flags from
|
- **Skip intro** from AniSkip for files without chapters, and filler flags from
|
||||||
Jikan.
|
Jikan.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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.
|
||||||
|
- **Swipe** up and down on the left half for brightness and on the right half for volume. Swipe
|
||||||
|
sideways to seek: the bubble shows where you will land, and the seek happens when you let go.
|
||||||
|
Swipes that start at the very top or bottom of the screen are left to the system.
|
||||||
|
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks,
|
||||||
|
**Lock to landscape**, **Sleep timer** and **Lock touch**.
|
||||||
|
- **Sleep timer** pauses after 30 or 60 minutes, or stops at the end of this episode instead of
|
||||||
|
going on to the next. It keeps running with the screen off.
|
||||||
|
- **Lock touch** makes the picture ignore touches, for a phone in a pocket or a child's hands. Tap
|
||||||
|
to bring up **Unlock**.
|
||||||
|
- **Skip opening** and **Skip ending** work as on the desktop, AniSkip included: see
|
||||||
|
[The player](USER_GUIDE.md#the-player).
|
||||||
|
- 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,78 @@
|
|||||||
|
# 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 |
|
||||||
|
| TVmaze | With AniList chosen: the names of the titles AniList has no match for, and the ids of those TVmaze matched | Only with **Posters and descriptions** on |
|
||||||
|
| TMDB, with AniList chosen | The TMDB ids of matched titles that have no wide picture or logo yet. Never a title's name | Only with **Posters and descriptions** on and a TMDB key saved |
|
||||||
|
| AniSkip | The MyAnimeList id and episode number of what you play, and the file's length. Never a name | Only with **Posters and descriptions** on, when you play an episode whose file does not mark its opening and ending |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Trailer** on a title's page opens the trailer's YouTube or Dailymotion page in your web
|
||||||
|
browser. The app sends nothing there itself; your browser loads the page as it would any link.
|
||||||
|
|
||||||
|
## 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, the share list (without secrets), My List and your last eight searches (`recent-searches.json`) | `~/.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.
|
||||||
|
|
||||||
|
Recent searches are saved only when you open something a search found, are never synced, and
|
||||||
|
**Clear recent searches** under the empty search box deletes them.
|
||||||
|
|
||||||
|
## 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,294 @@
|
|||||||
|
# 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** across the top shows the title you watched last first, with **Resume**, how far
|
||||||
|
into it you are, **More info** and **My List**. It then turns through up to four more titles that have a wide
|
||||||
|
picture, a different set each day, moving on every eight seconds. It holds still while the
|
||||||
|
pointer is over it; on a phone, swipe it or tap a dot under it to move on yourself. Its shading
|
||||||
|
takes on the colours of the picture behind it, and so does the top of a title's page.
|
||||||
|
- **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**.
|
||||||
|
- **New episodes** has the shows you are watching that got episodes since you last watched them.
|
||||||
|
Their tiles say how many, for example **3 new**.
|
||||||
|
- **My List** has the titles you put aside to watch, the last one added first.
|
||||||
|
- **Recently added** has the titles that arrived in the last two weeks and that you have not
|
||||||
|
started. Their tiles say **New**. What was already there when you added a share is not new.
|
||||||
|
- **Because you watched Frieren** has titles like the last one you watched to the end, that you
|
||||||
|
have not started. It needs posters and descriptions.
|
||||||
|
- **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. On a shelf, a show that is still airing says when its next
|
||||||
|
episode is out, for example **Ep 8 · in 2 d**.
|
||||||
|
- **All titles** is the full grid. Choose a filter and a sort order above it.
|
||||||
|
|
||||||
|
Rows that run past the window fade out at the edge. Point at a row to show the arrows at its
|
||||||
|
ends, which move it along by a page.
|
||||||
|
|
||||||
|
| Filters | Sort orders |
|
||||||
|
|---|---|
|
||||||
|
| All, My List, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
|
||||||
|
|
||||||
|
**My List** is a list of titles to watch later. Add a title with **My List** on its page or on
|
||||||
|
the banner, or right-click its tile and choose **Add to My List**. Choose the button again, or
|
||||||
|
**Remove from My List**, to take it off. When you are signed in, the list syncs to your other
|
||||||
|
devices ([Account and sync](ACCOUNT_AND_SYNC.md)).
|
||||||
|
|
||||||
|
**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. With posters and descriptions on, it also finds:
|
||||||
|
|
||||||
|
- **People and studios**: directors and studios whose names match. Choose one to see all their
|
||||||
|
titles.
|
||||||
|
- **Genres** whose names match.
|
||||||
|
- **Episodes** whose names match. Choose one to play it.
|
||||||
|
|
||||||
|
The titles it finds come after these. Click the empty search box to see your last eight
|
||||||
|
searches. A search is kept only when you open something it found. **Clear recent searches**
|
||||||
|
removes the list. The list stays on this device and does not sync.
|
||||||
|
|
||||||
|
**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 |
|
||||||
|
| `Rebuild of Evangelion/Evangelion - 1.11 You Are (Not) Alone.mkv` and three more | Four films, one title each |
|
||||||
|
|
||||||
|
- 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. A folder of such files, each with a different name, is
|
||||||
|
one title per film.
|
||||||
|
|
||||||
|
The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video
|
||||||
|
containers.
|
||||||
|
|
||||||
|
## A title's page
|
||||||
|
|
||||||
|
<img src="../images/desktop-title.png" alt="A title's page" width="820">
|
||||||
|
|
||||||
|
Click a title to open its page. **Esc** goes back. On a phone, the picture at the top slides
|
||||||
|
away more slowly than the page, and once it has gone a bar with the title's name stays at the
|
||||||
|
top.
|
||||||
|
|
||||||
|
- **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, TVmaze or TMDB entry the title
|
||||||
|
is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
|
||||||
|
at all. A match you pick is never replaced.
|
||||||
|
- **More on AniList** (or TVmaze, or TMDB) opens the title's page there.
|
||||||
|
- Each season has **Mark season watched** and **Download season**. A show with more than five
|
||||||
|
seasons lists them in a drop-down instead of a row of buttons.
|
||||||
|
- With posters and descriptions on, a chip under the name says where the show stands: **Airing**
|
||||||
|
with the day the next episode comes out, **Finished**, **Upcoming**, **On hiatus** or
|
||||||
|
**Cancelled**.
|
||||||
|
- Each episode shows a tick when you have watched it, at the end of its row and in the corner of
|
||||||
|
its picture. 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.
|
||||||
|
- **More like this**, under the episodes, lists titles in your library that share the most with
|
||||||
|
this one: its director, studio, genres, tags and format. It leaves out the title's own
|
||||||
|
franchise, and shows one title from each other franchise. It needs posters and descriptions.
|
||||||
|
- The genre chips under the description open a page with every title of that genre in your
|
||||||
|
library. So do the **Studio** and **Director** chips. Back, or **Esc**, returns to the title.
|
||||||
|
- **Cast** lists the main characters and who voices them in Japanese. Click one to see every title
|
||||||
|
in your library with that voice actor. Search finds voice actors too.
|
||||||
|
- **Trailer** opens the title's trailer in your web browser. The app does not play it or load it
|
||||||
|
itself.
|
||||||
|
- Titles matched before this version get their cast and trailer by themselves the next time the
|
||||||
|
app looks up posters and descriptions.
|
||||||
|
- With posters and descriptions on, a show's logo takes the place of its name at the top of the
|
||||||
|
page and on the library's banner, and each episode says how long it runs. A film says how long
|
||||||
|
it runs in the line under its name.
|
||||||
|
- A title with no wide picture shows its poster blurred across the top of the page instead, and
|
||||||
|
on the library's banner the poster itself beside it.
|
||||||
|
- Small marks under the name say what most of the files are, when their names say it: **4K**,
|
||||||
|
**1080p**, **HDR**, **10-bit** or **Dual audio**.
|
||||||
|
|
||||||
|
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. For an anime whose file marks neither, the app asks AniSkip where they are, with
|
||||||
|
**Posters and descriptions** on; they then show as chapters too. 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**, **Speed**, **Chapters** and **Sleep timer** are buttons at the bottom
|
||||||
|
right. Each opens a panel down the right of the picture; click the button again, or press Esc,
|
||||||
|
to close it.
|
||||||
|
- **Subtitles and audio** remembers your choice for each show. The panel also has **Size** and
|
||||||
|
**Position**, which are saved like the ones in Settings and change styled subtitles only when
|
||||||
|
they are restyled, and **Delay**, which moves subtitles earlier or later for this file only.
|
||||||
|
- **Speed** goes from 0.5× to 3×, and stays for the next episode.
|
||||||
|
- **Sleep timer**, the moon, pauses after 30 or 60 minutes, or stops at the end of this episode
|
||||||
|
instead of going on to the next.
|
||||||
|
- **Seeking and volume** show what they did in the middle of the picture. Several presses of ← or
|
||||||
|
→ add up, so three presses of → say "+ 90 s".
|
||||||
|
- **Paused** for 5 seconds with the mouse still, the picture dims and shows what you are watching:
|
||||||
|
the show's logo or name, the episode, and what it is about. Move the mouse to get the controls
|
||||||
|
back.
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
| Z / X | Subtitles 0.1 s earlier and later |
|
||||||
|
| E | The list of episodes |
|
||||||
|
| F or double-click | Fullscreen |
|
||||||
|
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
|
||||||
|
| Esc | Close a panel, leave fullscreen, then leave 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.
|
||||||
|
|
||||||
|
**Subtitles**
|
||||||
|
|
||||||
|
How subtitles look. A sample line at the top shows the style on a bright and a dark picture, and
|
||||||
|
a video that is playing changes at once.
|
||||||
|
|
||||||
|
- **Size** and **Position**: how big the line is and how far up the picture it sits.
|
||||||
|
- **Font**, **Bold** and **Italic**: any font installed on this computer. On Linux the sample
|
||||||
|
line shows the font too; on Windows and macOS it is drawn in the app's own font, and only the
|
||||||
|
video uses the one you chose.
|
||||||
|
- **Text colour**, and a **Background** of **Outline** or **Box**, each with its own colour. A
|
||||||
|
colour's opacity sets how much of the picture shows through. Choose **Box** if text is hard to
|
||||||
|
read on bright scenes.
|
||||||
|
- **Shadow** and **Blur**.
|
||||||
|
- **Restyle styled subtitles** (off). Plain subtitles (SRT) always take the whole style. Styled
|
||||||
|
subtitles (ASS) keep the look, size and place their makers gave them unless this is on; they
|
||||||
|
only move up while the player's controls are showing. Turned on, signs and songs can come out
|
||||||
|
wrong.
|
||||||
|
- **Reset** goes back to the default look.
|
||||||
|
|
||||||
|
**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). With AniList, a title it does
|
||||||
|
not have, such as a live-action show, is looked up on **TVmaze** next, which needs no key
|
||||||
|
either. See [Privacy](PRIVACY.md).
|
||||||
|
- **TMDB API key:** needed for TMDB, optional with AniList. With a key, films and shows that have
|
||||||
|
no wide picture or logo get TMDB's, asked for by their TMDB id alone. After saving a key,
|
||||||
|
**Match everything again** fetches them for titles already matched.
|
||||||
|
- **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: 3.1 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 2.4 MiB |