mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
bug fixes, windows build
This commit is contained in:
1 parent
a1b345a029
commit
14c48428a3
29 files changed
+2500
-156
No files matched your search
Generated
+97
-6
@@ -231,7 +231,7 @@ dependencies = [
|
|||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.60.2",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2841,9 +2841,13 @@ version = "3.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
"dbus-secret-service",
|
"dbus-secret-service",
|
||||||
"log",
|
"log",
|
||||||
"secret-service",
|
"secret-service",
|
||||||
|
"security-framework 2.11.1",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4032,7 +4036,7 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.3.3"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -4057,7 +4061,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.3.3"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4631,7 +4635,7 @@ dependencies = [
|
|||||||
"openssl-probe",
|
"openssl-probe",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"schannel",
|
"schannel",
|
||||||
"security-framework",
|
"security-framework 3.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4659,7 +4663,7 @@ dependencies = [
|
|||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-platform-verifier-android",
|
"rustls-platform-verifier-android",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"security-framework",
|
"security-framework 3.7.0",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4759,6 +4763,19 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -6209,6 +6226,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.53.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -6257,13 +6283,30 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu 0.52.6",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"windows_i686_msvc 0.52.6",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc 0.52.6",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
|
"windows_aarch64_msvc 0.53.1",
|
||||||
|
"windows_i686_gnu 0.53.1",
|
||||||
|
"windows_i686_gnullvm 0.53.1",
|
||||||
|
"windows_i686_msvc 0.53.1",
|
||||||
|
"windows_x86_64_gnu 0.53.1",
|
||||||
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
|
"windows_x86_64_msvc 0.53.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6282,6 +6325,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6300,6 +6349,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6318,12 +6373,24 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6342,6 +6409,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6360,6 +6433,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6378,6 +6457,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6396,6 +6481,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winit"
|
name = "winit"
|
||||||
version = "0.30.13"
|
version = "0.30.13"
|
||||||
|
|||||||
+11
-3
@@ -27,8 +27,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.3.3" }
|
proton-sdk = { version = "0.4.0" }
|
||||||
proton-drive-rs = { version = "0.3.3" }
|
proton-drive-rs = { version = "0.4.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
@@ -56,7 +56,15 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
|||||||
# The share URL fragment and the custom password are both secrets; they go in
|
# The share URL fragment and the custom password are both secrets; they go in
|
||||||
# the OS credential store (Secret Service on Linux, Credential Manager on
|
# the OS credential store (Secret Service on Linux, Credential Manager on
|
||||||
# Windows), never in the config file next to them.
|
# Windows), never in the config file next to them.
|
||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
#
|
||||||
|
# No platform features here on purpose. `keyring` selects its keystore by
|
||||||
|
# `cfg(all(target_os = ..., feature = ...))`, and with the matching feature off
|
||||||
|
# it falls back to `mock` — an in-memory store built fresh per `Entry`, so
|
||||||
|
# `set_password` succeeds, the next `Entry::new(..).get_password()` returns
|
||||||
|
# `NoEntry`, and nothing warns. Every consuming crate therefore enables the
|
||||||
|
# store for its target in a `[target.'cfg(...)'.dependencies]` block; see
|
||||||
|
# `crates/pstr-core/Cargo.toml`.
|
||||||
|
keyring = { version = "3" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
||||||
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ against the SDK's own range download.
|
|||||||
| Player | Embedded libmpv: 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. |
|
| Player | Embedded libmpv: 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. |
|
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||||
|
| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares 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. |
|
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. |
|
||||||
| Platforms | Linux and Windows. |
|
| Platforms | Linux and Windows. |
|
||||||
|
|
||||||
|
|||||||
+181
-22
@@ -9,6 +9,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::mpsc::Receiver;
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
use pstr_core::Share;
|
use pstr_core::Share;
|
||||||
|
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};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
|
||||||
@@ -128,10 +129,21 @@ pub enum Action {
|
|||||||
MatchTitles {
|
MatchTitles {
|
||||||
force: bool,
|
force: bool,
|
||||||
},
|
},
|
||||||
|
/// Open the hand-matching search for one title, seeded with its own name.
|
||||||
|
OpenMatcher(String),
|
||||||
|
CloseMatcher,
|
||||||
|
/// Ask the provider what the text in the box might be.
|
||||||
|
SearchMatches,
|
||||||
|
/// Pin the open title to this entry.
|
||||||
|
ChooseMatch(Box<pstr_core::metadata::TitleMetadata>),
|
||||||
|
/// Forget what is stored for a title, so it is matched from scratch again.
|
||||||
|
ForgetMatch(String),
|
||||||
/// Play the file before or after the one playing, within its title.
|
/// Play the file before or after the one playing, within its title.
|
||||||
PlayAdjacent(Adjacent),
|
PlayAdjacent(Adjacent),
|
||||||
/// Start or stop the next episode playing on its own at the end of one.
|
/// Start or stop the next episode playing on its own at the end of one.
|
||||||
SetAutoplay(bool),
|
SetAutoplay(bool),
|
||||||
|
/// Repaint the window in a different palette.
|
||||||
|
SetAppearance(Appearance),
|
||||||
/// In or out of fullscreen, from the player page.
|
/// In or out of fullscreen, from the player page.
|
||||||
ToggleFullscreen,
|
ToggleFullscreen,
|
||||||
/// Stop watching, keep playing: back to the page this film came from, with
|
/// Stop watching, keep playing: back to the page this film came from, with
|
||||||
@@ -178,6 +190,52 @@ pub struct Status {
|
|||||||
pub at: f64,
|
pub at: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picking a title's entry by hand.
|
||||||
|
///
|
||||||
|
/// The escape hatch from the matcher, which is deliberately unwilling to guess:
|
||||||
|
/// [`pstr_meta::matching::MATCH_FLOOR`] is set where a wrong poster is worse
|
||||||
|
/// than none, and the cost of that is a handful of titles that match nothing.
|
||||||
|
/// The `Fate/stay night [Heaven's Feel]` films are the standing example — three
|
||||||
|
/// films in one folder, filed by AniList as three separate entries, so there is
|
||||||
|
/// no answer for the scorer to find and only the viewer knows which one the
|
||||||
|
/// folder means.
|
||||||
|
pub struct Matcher {
|
||||||
|
/// The title being matched, by [`pstr_core::library::Title::key`].
|
||||||
|
pub title_key: String,
|
||||||
|
/// Its name as the library has it, for the dialog's own heading.
|
||||||
|
pub title_name: String,
|
||||||
|
pub kind: pstr_core::library::TitleKind,
|
||||||
|
/// What is in the search box. Seeded with the library's name for the title,
|
||||||
|
/// which is the thing that failed — so it is the right starting point to
|
||||||
|
/// edit rather than to re-send.
|
||||||
|
pub query: String,
|
||||||
|
pub results: Vec<pstr_core::metadata::TitleMetadata>,
|
||||||
|
/// A request is out. The box stays usable; only the button is held.
|
||||||
|
pub searching: bool,
|
||||||
|
/// Whether a search has come back yet, so an empty list can read as "nothing
|
||||||
|
/// found" rather than "nothing asked".
|
||||||
|
pub asked: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Set for the frame the dialog opens, to put the caret in the box.
|
||||||
|
pub focus: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Matcher {
|
||||||
|
pub fn new(title: &pstr_core::library::Title) -> Self {
|
||||||
|
Self {
|
||||||
|
title_key: title.key.clone(),
|
||||||
|
title_name: title.name.clone(),
|
||||||
|
kind: title.kind,
|
||||||
|
query: title.name.clone(),
|
||||||
|
results: Vec::new(),
|
||||||
|
searching: false,
|
||||||
|
asked: false,
|
||||||
|
error: None,
|
||||||
|
focus: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The share the viewer is typing in.
|
/// The share the viewer is typing in.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ShareForm {
|
pub struct ShareForm {
|
||||||
@@ -206,6 +264,8 @@ pub struct App {
|
|||||||
/// it goes straight to the credential store on save.
|
/// it goes straight to the credential store on save.
|
||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
pub matching: bool,
|
pub matching: bool,
|
||||||
|
/// The hand-matching dialog, while it is open.
|
||||||
|
pub matcher: Option<Matcher>,
|
||||||
pub search: String,
|
pub search: String,
|
||||||
pub form: ShareForm,
|
pub form: ShareForm,
|
||||||
pub playback: Option<Playback>,
|
pub playback: Option<Playback>,
|
||||||
@@ -229,9 +289,11 @@ impl App {
|
|||||||
runtime: std::sync::Arc<tokio::runtime::Runtime>,
|
runtime: std::sync::Arc<tokio::runtime::Runtime>,
|
||||||
dirs: AppDirs,
|
dirs: AppDirs,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
theme::apply(&cc.egui_ctx);
|
// After the engine, not before: the engine is what reads the stored
|
||||||
|
// theme, and a window that paints one frame in the default palette
|
||||||
|
// before switching is a window that flashes on every launch.
|
||||||
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
||||||
|
theme::apply(&cc.egui_ctx, engine.appearance());
|
||||||
|
|
||||||
// Paint from the catalog immediately; the network catches up. A library
|
// Paint from the catalog immediately; the network catches up. A library
|
||||||
// that was crawled yesterday is on screen before the shares open.
|
// that was crawled yesterday is on screen before the shares open.
|
||||||
@@ -254,6 +316,7 @@ impl App {
|
|||||||
settings,
|
settings,
|
||||||
api_key: String::new(),
|
api_key: String::new(),
|
||||||
matching: false,
|
matching: false,
|
||||||
|
matcher: None,
|
||||||
search: String::new(),
|
search: String::new(),
|
||||||
form: ShareForm::default(),
|
form: ShareForm::default(),
|
||||||
playback: None,
|
playback: None,
|
||||||
@@ -345,6 +408,28 @@ impl App {
|
|||||||
self.note(ctx, text, failed > 0);
|
self.note(ctx, text, failed > 0);
|
||||||
}
|
}
|
||||||
Event::MatchFinished => self.matching = false,
|
Event::MatchFinished => self.matching = false,
|
||||||
|
// Both of these are addressed to a dialog, and the viewer may
|
||||||
|
// have closed it or opened another one while the request was
|
||||||
|
// out — so an answer for a title that is not the open one is
|
||||||
|
// dropped rather than shown under the wrong heading.
|
||||||
|
Event::MatchOptions { title_key, options } => {
|
||||||
|
if let Some(matcher) = &mut self.matcher
|
||||||
|
&& matcher.title_key == title_key
|
||||||
|
{
|
||||||
|
matcher.results = options;
|
||||||
|
matcher.searching = false;
|
||||||
|
matcher.asked = true;
|
||||||
|
matcher.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::MatchSearchFailed { title_key, error } => {
|
||||||
|
if let Some(matcher) = &mut self.matcher
|
||||||
|
&& matcher.title_key == title_key
|
||||||
|
{
|
||||||
|
matcher.searching = false;
|
||||||
|
matcher.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Event::PlaybackReady { target, stream } => {
|
Event::PlaybackReady { target, stream } => {
|
||||||
self.opening = None;
|
self.opening = None;
|
||||||
// Dropped before the new one is built: two mpv cores would
|
// Dropped before the new one is built: two mpv cores would
|
||||||
@@ -605,6 +690,43 @@ impl App {
|
|||||||
self.engine.match_titles(self.library.titles.clone(), force);
|
self.engine.match_titles(self.library.titles.clone(), force);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::OpenMatcher(key) => {
|
||||||
|
self.matcher = self.library.get(&key).map(Matcher::new);
|
||||||
|
// The library's own name is what the automatic search already
|
||||||
|
// failed on, so the first search is the viewer's to press —
|
||||||
|
// except that pressing it unchanged is exactly what someone
|
||||||
|
// wants when the *scorer* was the problem rather than the name.
|
||||||
|
// So it is sent, and the box stays theirs to edit.
|
||||||
|
if self.matcher.is_some() {
|
||||||
|
self.apply(ctx, Action::SearchMatches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::CloseMatcher => self.matcher = None,
|
||||||
|
Action::SearchMatches => {
|
||||||
|
if let Some(matcher) = &mut self.matcher {
|
||||||
|
matcher.searching = true;
|
||||||
|
matcher.error = None;
|
||||||
|
self.engine.search_matches(
|
||||||
|
matcher.title_key.clone(),
|
||||||
|
matcher.query.clone(),
|
||||||
|
matcher.kind,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::ChooseMatch(found) => {
|
||||||
|
if let Some(title) = self
|
||||||
|
.matcher
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|matcher| self.library.get(&matcher.title_key))
|
||||||
|
{
|
||||||
|
self.engine.choose_match(title.clone(), *found);
|
||||||
|
}
|
||||||
|
self.matcher = None;
|
||||||
|
}
|
||||||
|
Action::ForgetMatch(key) => {
|
||||||
|
self.engine.forget_match(key);
|
||||||
|
self.matcher = None;
|
||||||
|
}
|
||||||
Action::ToggleFullscreen => {
|
Action::ToggleFullscreen => {
|
||||||
self.fullscreen = !self.fullscreen;
|
self.fullscreen = !self.fullscreen;
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
|
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
|
||||||
@@ -679,6 +801,10 @@ impl App {
|
|||||||
prefs.autoplay_next = autoplay;
|
prefs.autoplay_next = autoplay;
|
||||||
self.engine.set_playback_prefs(prefs, true);
|
self.engine.set_playback_prefs(prefs, true);
|
||||||
}
|
}
|
||||||
|
Action::SetAppearance(appearance) => {
|
||||||
|
self.engine.set_appearance(appearance);
|
||||||
|
theme::apply(ctx, appearance);
|
||||||
|
}
|
||||||
Action::SetVolume { volume, commit } => self.set_volume(volume, commit),
|
Action::SetVolume { volume, commit } => self.set_volume(volume, commit),
|
||||||
Action::ToggleMute => {
|
Action::ToggleMute => {
|
||||||
let mut prefs = self.engine.playback_prefs();
|
let mut prefs = self.engine.playback_prefs();
|
||||||
@@ -734,18 +860,15 @@ impl App {
|
|||||||
egui::RichText::new("proton-stream")
|
egui::RichText::new("proton-stream")
|
||||||
.size(18.0)
|
.size(18.0)
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::ACCENT),
|
.color(theme::accent()),
|
||||||
);
|
);
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
let on_library = matches!(self.page, Page::Library | Page::Title(_));
|
let on_library = matches!(self.page, Page::Library | Page::Title(_));
|
||||||
if ui.selectable_label(on_library, "Library").clicked() {
|
if ui::tab(ui, on_library, "Library").clicked() {
|
||||||
actions.push(Action::Goto(Page::Library));
|
actions.push(Action::Goto(Page::Library));
|
||||||
}
|
}
|
||||||
if ui
|
if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() {
|
||||||
.selectable_label(self.page == Page::Shares, "Shares")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::Goto(Page::Shares));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,17 +968,35 @@ impl eframe::App for App {
|
|||||||
{
|
{
|
||||||
let mut search = std::mem::take(&mut self.search);
|
let mut search = std::mem::take(&mut self.search);
|
||||||
let neighbours = self.neighbours();
|
let neighbours = self.neighbours();
|
||||||
let autoplay = self.engine.playback_prefs().autoplay_next;
|
let prefs = ui::shares::Prefs {
|
||||||
|
autoplay: self.engine.playback_prefs().autoplay_next,
|
||||||
|
appearance: self.engine.appearance(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_MARGIN: egui::Vec2 = egui::vec2(14.0, 10.0);
|
||||||
egui::Panel::top("nav")
|
egui::Panel::top("nav")
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.frame(
|
// No fill: the bar is a gradient, and a `Frame` only takes a
|
||||||
egui::Frame::new()
|
// colour. It is painted below, into a shape reserved before the
|
||||||
.fill(theme::SURFACE)
|
// contents are laid out — which is the only point at which the
|
||||||
.inner_margin(egui::Margin::symmetric(14, 10)),
|
// height they came to is known.
|
||||||
)
|
.frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(
|
||||||
|
NAV_MARGIN.x as i8,
|
||||||
|
NAV_MARGIN.y as i8,
|
||||||
|
)))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
let background = ui.painter().add(egui::Shape::Noop);
|
||||||
self.navigation(ui, &mut actions, &mut search);
|
self.navigation(ui, &mut actions, &mut search);
|
||||||
|
// Full width from the space the panel was given, height from
|
||||||
|
// the space its contents took.
|
||||||
|
let content = ui.min_rect();
|
||||||
|
let bar = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(ui.max_rect().left(), content.top()),
|
||||||
|
egui::pos2(ui.max_rect().right(), content.bottom()),
|
||||||
|
)
|
||||||
|
.expand2(NAV_MARGIN);
|
||||||
|
ui.painter()
|
||||||
|
.set(background, theme::bar_shape(ui.ctx(), bar));
|
||||||
});
|
});
|
||||||
self.search = search;
|
self.search = search;
|
||||||
|
|
||||||
@@ -872,6 +1013,7 @@ impl eframe::App for App {
|
|||||||
api_key,
|
api_key,
|
||||||
search,
|
search,
|
||||||
form,
|
form,
|
||||||
|
matcher,
|
||||||
playback,
|
playback,
|
||||||
opening,
|
opening,
|
||||||
status,
|
status,
|
||||||
@@ -883,7 +1025,7 @@ impl eframe::App for App {
|
|||||||
.resizable(false)
|
.resizable(false)
|
||||||
.frame(
|
.frame(
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::SURFACE)
|
.fill(theme::surface())
|
||||||
.inner_margin(egui::Margin::symmetric(16, 10)),
|
.inner_margin(egui::Margin::symmetric(16, 10)),
|
||||||
)
|
)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
@@ -904,14 +1046,14 @@ impl eframe::App for App {
|
|||||||
.resizable(false)
|
.resizable(false)
|
||||||
.frame(
|
.frame(
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::BACKGROUND)
|
.fill(theme::background())
|
||||||
.inner_margin(egui::Margin::symmetric(16, 6)),
|
.inner_margin(egui::Margin::symmetric(16, 6)),
|
||||||
)
|
)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
let colour = if line.error {
|
let colour = if line.error {
|
||||||
theme::DANGER
|
theme::danger()
|
||||||
} else {
|
} else {
|
||||||
theme::MUTED
|
theme::muted()
|
||||||
};
|
};
|
||||||
ui.label(egui::RichText::new(&line.text).size(12.0).color(colour));
|
ui.label(egui::RichText::new(&line.text).size(12.0).color(colour));
|
||||||
});
|
});
|
||||||
@@ -926,14 +1068,17 @@ impl eframe::App for App {
|
|||||||
egui::CentralPanel::default()
|
egui::CentralPanel::default()
|
||||||
.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::symmetric(18, 12)),
|
||||||
)
|
)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
// Reborrowed rather than moved: the matching dialog below is
|
||||||
|
// drawn from the same caches, and a moved `&mut` would leave
|
||||||
|
// nothing to draw it with.
|
||||||
let mut art = ui::Art {
|
let mut art = ui::Art {
|
||||||
engine,
|
engine,
|
||||||
thumbs,
|
thumbs: &mut *thumbs,
|
||||||
posters,
|
posters: &mut *posters,
|
||||||
metadata,
|
metadata,
|
||||||
episodes,
|
episodes,
|
||||||
};
|
};
|
||||||
@@ -949,7 +1094,7 @@ impl eframe::App for App {
|
|||||||
shares,
|
shares,
|
||||||
form,
|
form,
|
||||||
settings,
|
settings,
|
||||||
autoplay,
|
prefs,
|
||||||
api_key,
|
api_key,
|
||||||
&mut actions,
|
&mut actions,
|
||||||
),
|
),
|
||||||
@@ -957,6 +1102,20 @@ impl eframe::App for App {
|
|||||||
Page::Player => {}
|
Page::Player => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Over everything, and after it: a modal takes the input the page
|
||||||
|
// under it would otherwise get, and it can only do that for widgets
|
||||||
|
// laid out after it claims the layer.
|
||||||
|
if let Some(open) = matcher {
|
||||||
|
let mut art = ui::Art {
|
||||||
|
engine,
|
||||||
|
thumbs,
|
||||||
|
posters,
|
||||||
|
metadata,
|
||||||
|
episodes,
|
||||||
|
};
|
||||||
|
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for action in actions {
|
for action in actions {
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ use std::sync::Arc;
|
|||||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use pstr_core::appearance::Appearance;
|
||||||
use pstr_core::catalog::{Catalog, CatalogNode, WatchState, build_rows};
|
use pstr_core::catalog::{Catalog, CatalogNode, WatchState, build_rows};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::{Library, Title};
|
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::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
use pstr_core::proton_drive_rs::ThumbnailType;
|
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||||
@@ -85,6 +86,16 @@ pub enum Event {
|
|||||||
},
|
},
|
||||||
/// Every requested match is done.
|
/// Every requested match is done.
|
||||||
MatchFinished,
|
MatchFinished,
|
||||||
|
/// What the provider answered a hand search with, unscored and in its own
|
||||||
|
/// order. `title_key` says which title asked, because the viewer can close
|
||||||
|
/// the search and open another one while a request is still out.
|
||||||
|
MatchOptions {
|
||||||
|
title_key: String,
|
||||||
|
options: Vec<pstr_core::metadata::TitleMetadata>,
|
||||||
|
},
|
||||||
|
/// A hand search did not get an answer. Distinct from an empty one: nothing
|
||||||
|
/// found is a result, and a rate limit is not.
|
||||||
|
MatchSearchFailed { title_key: String, error: String },
|
||||||
/// A decoded piece of provider artwork, keyed by title key.
|
/// A decoded piece of provider artwork, keyed by title key.
|
||||||
Poster {
|
Poster {
|
||||||
key: String,
|
key: String,
|
||||||
@@ -158,6 +169,9 @@ pub struct Engine {
|
|||||||
/// because a new player is built from them, and the player is started from
|
/// because a new player is built from them, and the player is started from
|
||||||
/// a background event.
|
/// a background event.
|
||||||
prefs: Arc<Mutex<PlaybackPrefs>>,
|
prefs: Arc<Mutex<PlaybackPrefs>>,
|
||||||
|
/// 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.
|
||||||
|
appearance: Arc<Mutex<Appearance>>,
|
||||||
events: Sender<Event>,
|
events: Sender<Event>,
|
||||||
ctx: egui::Context,
|
ctx: egui::Context,
|
||||||
}
|
}
|
||||||
@@ -221,6 +235,14 @@ impl Engine {
|
|||||||
PlaybackPrefs::default()
|
PlaybackPrefs::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// And the same again for the theme, which is the least load-bearing
|
||||||
|
// file of the three: an unreadable one costs the viewer their colours
|
||||||
|
// until they pick them again.
|
||||||
|
let appearance = pstr_core::appearance::load(&dirs).unwrap_or_else(|error| {
|
||||||
|
tracing::warn!("read the appearance: {error}");
|
||||||
|
Appearance::default()
|
||||||
|
});
|
||||||
|
|
||||||
let engine = Self {
|
let engine = Self {
|
||||||
runtime,
|
runtime,
|
||||||
store: Arc::new(ShareStore::new(dirs.clone())),
|
store: Arc::new(ShareStore::new(dirs.clone())),
|
||||||
@@ -232,6 +254,7 @@ impl Engine {
|
|||||||
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
|
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
|
||||||
metadata: Arc::new(Mutex::new(Enrichment::build(config))),
|
metadata: Arc::new(Mutex::new(Enrichment::build(config))),
|
||||||
prefs: Arc::new(Mutex::new(prefs)),
|
prefs: Arc::new(Mutex::new(prefs)),
|
||||||
|
appearance: Arc::new(Mutex::new(appearance)),
|
||||||
events,
|
events,
|
||||||
ctx,
|
ctx,
|
||||||
};
|
};
|
||||||
@@ -271,6 +294,27 @@ impl Engine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which theme the window wears.
|
||||||
|
pub fn appearance(&self) -> Appearance {
|
||||||
|
*self.appearance.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the theme and write it.
|
||||||
|
///
|
||||||
|
/// Always committed, unlike the volume: a theme is picked with a click and
|
||||||
|
/// there is no drag to coalesce.
|
||||||
|
pub fn set_appearance(&self, appearance: Appearance) {
|
||||||
|
*self.appearance.lock() = appearance;
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn_blocking(move || {
|
||||||
|
if let Err(error) = pstr_core::appearance::save(&engine.dirs, &appearance) {
|
||||||
|
// Not `fail`, for the same reason the playback preferences are
|
||||||
|
// not: the change happened, and only the remembering did not.
|
||||||
|
tracing::warn!("save the appearance: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Send an event and wake the UI. A closed channel means the window is
|
/// Send an event and wake the UI. A closed channel means the window is
|
||||||
/// gone, which is not an error worth propagating anywhere.
|
/// gone, which is not an error worth propagating anywhere.
|
||||||
pub(crate) fn emit(&self, event: Event) {
|
pub(crate) fn emit(&self, event: Event) {
|
||||||
@@ -745,7 +789,13 @@ impl Engine {
|
|||||||
let mut pending: Vec<Work> = Vec::new();
|
let mut pending: Vec<Work> = Vec::new();
|
||||||
for title in titles {
|
for title in titles {
|
||||||
let record = stored.get(&title.key);
|
let record = stored.get(&title.key);
|
||||||
if force || !pstr_meta::service::is_usable(record, provider) {
|
// A hand-picked entry is never searched for again, not even by
|
||||||
|
// "match again" — that button 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. Its episode list is
|
||||||
|
// still fetched below if it is missing.
|
||||||
|
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
|
||||||
|
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
|
||||||
pending.push(Work::Match(title));
|
pending.push(Work::Match(title));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -848,6 +898,98 @@ impl Engine {
|
|||||||
self.load_metadata();
|
self.load_metadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the provider what `term` might be, for a viewer choosing by hand.
|
||||||
|
///
|
||||||
|
/// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is
|
||||||
|
/// stored: this is a look, and only [`Engine::choose_match`] writes.
|
||||||
|
pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) {
|
||||||
|
let Some(service) = self.metadata.lock().service.clone() else {
|
||||||
|
let config = self.metadata_config();
|
||||||
|
return self.emit(Event::MatchSearchFailed {
|
||||||
|
title_key,
|
||||||
|
error: if !config.enabled {
|
||||||
|
"turn on metadata enrichment first".into()
|
||||||
|
} else {
|
||||||
|
format!("{} needs an API key", config.provider.label())
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = self.clone();
|
||||||
|
let permits = Arc::clone(&self.lookups);
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
// The same permit the batch run takes: a viewer typing in the search
|
||||||
|
// box while a match run is going must not be what earns the 429.
|
||||||
|
let Ok(_permit) = permits.acquire().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match service.search(&term, kind).await {
|
||||||
|
Ok(options) => engine.emit(Event::MatchOptions { title_key, options }),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("search {term:?}: {error}");
|
||||||
|
engine.emit(Event::MatchSearchFailed {
|
||||||
|
title_key,
|
||||||
|
error: error.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin one title to the entry the viewer picked, and take its episodes.
|
||||||
|
///
|
||||||
|
/// Stored as [`MetadataRecord::manual`], so it outlives every TTL and
|
||||||
|
/// survives "match again". The episode list is fetched here rather than left
|
||||||
|
/// to the next match run, because the point of choosing an entry by hand is
|
||||||
|
/// usually that its episode names were wrong too.
|
||||||
|
pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) {
|
||||||
|
let Some(service) = self.metadata.lock().service.clone() else {
|
||||||
|
return self.emit(Event::Error("turn on metadata enrichment first".into()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let name = found.name.clone();
|
||||||
|
let record = service.chosen(title.key.clone(), found.clone());
|
||||||
|
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
|
||||||
|
return engine.fail("store the match", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let episodes = episodes_of(&service, &title, &found).await;
|
||||||
|
if !episodes.is_empty() {
|
||||||
|
let stored = engine.catalog.lock().set_episode_metadata(
|
||||||
|
&title.key,
|
||||||
|
service.provider(),
|
||||||
|
now(),
|
||||||
|
&episodes,
|
||||||
|
);
|
||||||
|
if let Err(error) = stored {
|
||||||
|
tracing::warn!("store episodes for {}: {error}", title.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
|
||||||
|
engine.load_metadata();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop what is stored for one title, so it is matched from scratch again.
|
||||||
|
///
|
||||||
|
/// The undo for [`Engine::choose_match`], and the repair for a bad automatic
|
||||||
|
/// match: the row goes entirely rather than becoming a stored miss, which is
|
||||||
|
/// what makes the next match run treat the title as one it has never asked
|
||||||
|
/// about.
|
||||||
|
pub fn forget_match(&self, title_key: String) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn_blocking(move || {
|
||||||
|
if let Err(error) = engine.catalog.lock().forget_metadata(&title_key) {
|
||||||
|
return engine.fail("forget the match", error);
|
||||||
|
}
|
||||||
|
engine.emit(Event::Status("match cleared".into()));
|
||||||
|
engine.load_metadata();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
//! logging, the tokio runtime, the window, and the app inside it. See
|
//! logging, the tokio runtime, the window, and the app inside it. See
|
||||||
//! [`pstr_app`] for how the three threads divide the work.
|
//! [`pstr_app`] for how the three threads divide the work.
|
||||||
|
|
||||||
|
// A GUI binary linked against the Windows *console* subsystem gets a console
|
||||||
|
// allocated for it, so launching from the Start menu flashes up a cmd window
|
||||||
|
// that lives as long as the app. Only release builds are switched: with the
|
||||||
|
// windows subsystem there is no stdout for `tracing_subscriber` to write to, and
|
||||||
|
// a developer running `cargo run` on Windows wants the log more than the tidy
|
||||||
|
// window. `pstr-cli` stays on the console subsystem, which is correct for it.
|
||||||
|
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
|
|
||||||
|
|||||||
+703
-34
@@ -1,21 +1,33 @@
|
|||||||
//! The look: one dark palette, applied once at startup.
|
//! The look: one palette, chosen once and swapped at runtime.
|
||||||
//!
|
//!
|
||||||
//! Deliberately dark and low-contrast around the edges — this is a window full
|
//! Deliberately dark and low-contrast around the edges — this is a window full
|
||||||
//! of video stills, and every bit of chrome brightness competes with them. The
|
//! of video stills, and every bit of chrome brightness competes with them. The
|
||||||
//! accent is Proton's purple, which is also the only strong colour in the app,
|
//! accent is the only strong colour in the app, so anything wearing it is
|
||||||
//! so anything wearing it is something to click.
|
//! something to click.
|
||||||
|
//!
|
||||||
|
//! ## Why the palette is a function call and not a constant
|
||||||
|
//!
|
||||||
|
//! It used to be a wall of `const`s, which is what a theme wants to be right up
|
||||||
|
//! until there is more than one of them. The colours now come from
|
||||||
|
//! [`palette()`], which reads a process-wide palette that [`apply`] writes.
|
||||||
|
//! Every alternative was worse: threading a `&Palette` through the fifty call
|
||||||
|
//! sites in `ui/` would have put a lifetime on every drawing signature in the
|
||||||
|
//! app, and stashing it in `egui::Context` would have made the colour of a
|
||||||
|
//! label depend on which `Ui` happened to be in scope. There is one window and
|
||||||
|
//! one palette in it; a global is what that is.
|
||||||
|
//!
|
||||||
|
//! The palette is only ever written by [`apply`], from the UI thread, between
|
||||||
|
//! frames.
|
||||||
|
|
||||||
use egui::{Color32, CornerRadius, Stroke, Visuals};
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub const BACKGROUND: Color32 = Color32::from_rgb(0x0e, 0x0e, 0x12);
|
use egui::{
|
||||||
pub const SURFACE: Color32 = Color32::from_rgb(0x17, 0x17, 0x1d);
|
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
||||||
pub const CARD: Color32 = Color32::from_rgb(0x1e, 0x1e, 0x26);
|
ThemePreference, ViewportCommand, Visuals,
|
||||||
pub const CARD_HOVER: Color32 = Color32::from_rgb(0x2a, 0x2a, 0x35);
|
};
|
||||||
pub const TEXT: Color32 = Color32::from_rgb(0xea, 0xea, 0xf0);
|
use parking_lot::RwLock;
|
||||||
pub const MUTED: Color32 = Color32::from_rgb(0x8e, 0x8e, 0x9c);
|
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
||||||
pub const ACCENT: Color32 = Color32::from_rgb(0x7d, 0x4d, 0xff);
|
|
||||||
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x53, 0x33, 0xad);
|
|
||||||
pub const DANGER: Color32 = Color32::from_rgb(0xe0, 0x55, 0x61);
|
|
||||||
|
|
||||||
/// Card geometry. The grid is built from these, so a change here moves
|
/// Card geometry. The grid is built from these, so a change here moves
|
||||||
/// everything together.
|
/// everything together.
|
||||||
@@ -24,39 +36,259 @@ pub const CARD_WIDTH: f32 = 232.0;
|
|||||||
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
||||||
pub const CARD_GAP: f32 = 14.0;
|
pub const CARD_GAP: f32 = 14.0;
|
||||||
|
|
||||||
pub fn apply(ctx: &egui::Context) {
|
/// Every colour the app draws with, resolved from a [`Flavor`] and an
|
||||||
let mut visuals = Visuals::dark();
|
/// [`Accent`].
|
||||||
|
///
|
||||||
|
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
|
||||||
|
/// a drawing loop has to think about.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Palette {
|
||||||
|
/// Behind everything: the page.
|
||||||
|
pub background: Color32,
|
||||||
|
/// The bars above and below it.
|
||||||
|
pub surface: Color32,
|
||||||
|
/// The deepest colour in the flavour — text fields, wells.
|
||||||
|
pub sunken: Color32,
|
||||||
|
/// Tiles, rows, forms.
|
||||||
|
pub card: Color32,
|
||||||
|
pub card_hover: Color32,
|
||||||
|
pub border: Color32,
|
||||||
|
pub text: Color32,
|
||||||
|
pub muted: Color32,
|
||||||
|
/// The one strong colour.
|
||||||
|
pub accent: Color32,
|
||||||
|
/// What a gradient in the accent runs into. Equal to [`Self::accent`] when
|
||||||
|
/// the accent is a single hue and gradients would have nothing to do.
|
||||||
|
pub accent_alt: Color32,
|
||||||
|
/// The accent taken down into the background, for a pressed control and for
|
||||||
|
/// selection behind text.
|
||||||
|
pub accent_dim: Color32,
|
||||||
|
/// Ink that stays readable on top of the accent. Chosen by the accent's
|
||||||
|
/// luminance, because Catppuccin's dark flavours have *pale* accents and
|
||||||
|
/// white-on-pastel is not readable at 13 px.
|
||||||
|
pub on_accent: Color32,
|
||||||
|
pub danger: Color32,
|
||||||
|
/// Whether this is a light theme.
|
||||||
|
pub light: bool,
|
||||||
|
}
|
||||||
|
|
||||||
visuals.panel_fill = BACKGROUND;
|
/// Which way a gradient runs.
|
||||||
visuals.window_fill = SURFACE;
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
visuals.extreme_bg_color = Color32::from_rgb(0x0a, 0x0a, 0x0d);
|
pub enum Direction {
|
||||||
visuals.faint_bg_color = SURFACE;
|
Horizontal,
|
||||||
visuals.override_text_color = Some(TEXT);
|
Vertical,
|
||||||
visuals.selection.bg_fill = ACCENT_DIM;
|
}
|
||||||
visuals.selection.stroke = Stroke::new(1.0, TEXT);
|
|
||||||
visuals.hyperlink_color = ACCENT;
|
/// The palette as it stands.
|
||||||
|
pub fn palette() -> Palette {
|
||||||
|
ACTIVE.read().palette
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn background() -> Color32 {
|
||||||
|
palette().background
|
||||||
|
}
|
||||||
|
pub fn surface() -> Color32 {
|
||||||
|
palette().surface
|
||||||
|
}
|
||||||
|
pub fn card() -> Color32 {
|
||||||
|
palette().card
|
||||||
|
}
|
||||||
|
pub fn card_hover() -> Color32 {
|
||||||
|
palette().card_hover
|
||||||
|
}
|
||||||
|
pub fn text() -> Color32 {
|
||||||
|
palette().text
|
||||||
|
}
|
||||||
|
pub fn muted() -> Color32 {
|
||||||
|
palette().muted
|
||||||
|
}
|
||||||
|
pub fn accent() -> Color32 {
|
||||||
|
palette().accent
|
||||||
|
}
|
||||||
|
pub fn accent_dim() -> Color32 {
|
||||||
|
palette().accent_dim
|
||||||
|
}
|
||||||
|
pub fn on_accent() -> Color32 {
|
||||||
|
palette().on_accent
|
||||||
|
}
|
||||||
|
pub fn danger() -> Color32 {
|
||||||
|
palette().danger
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the accent is currently drawn as a gradient.
|
||||||
|
///
|
||||||
|
/// True only when the viewer has gradients on *and* the accent is a pair of
|
||||||
|
/// hues rather than one: a "gradient" from a colour to itself is a flat fill
|
||||||
|
/// that costs a texture lookup.
|
||||||
|
pub fn gradients() -> bool {
|
||||||
|
let active = ACTIVE.read();
|
||||||
|
active.gradients && active.palette.accent != active.palette.accent_alt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill `rect` with the accent, as a gradient when there is one to draw.
|
||||||
|
///
|
||||||
|
/// `lift` brightens the result for a hovered control and darkens it for a
|
||||||
|
/// pressed one — `0.0` is the resting state. It is a veil over the top rather
|
||||||
|
/// than a change to the colours, because the gradient is a texture and tinting
|
||||||
|
/// a texture can only ever darken it.
|
||||||
|
pub fn accent_fill(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: Rect,
|
||||||
|
corner_radius: impl Into<CornerRadius>,
|
||||||
|
lift: f32,
|
||||||
|
) {
|
||||||
|
let corner_radius = corner_radius.into();
|
||||||
|
fill(painter, rect, corner_radius, &palette());
|
||||||
|
veil(painter, rect, corner_radius, lift);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, for a palette that is not the one on screen: the accent swatches
|
||||||
|
/// on the settings page, each of which previews the theme clicking it would
|
||||||
|
/// give.
|
||||||
|
pub fn swatch_fill(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: Rect,
|
||||||
|
corner_radius: impl Into<CornerRadius>,
|
||||||
|
palette: &Palette,
|
||||||
|
) {
|
||||||
|
fill(painter, rect, corner_radius.into(), palette);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint `palette`'s accent — gradient if it has two hues and gradients are on,
|
||||||
|
/// flat otherwise.
|
||||||
|
fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palette: &Palette) {
|
||||||
|
if !ACTIVE.read().gradients || palette.accent == palette.accent_alt {
|
||||||
|
painter.rect_filled(rect, corner_radius, palette.accent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let texture = ramp(
|
||||||
|
painter.ctx(),
|
||||||
|
palette.accent,
|
||||||
|
palette.accent_alt,
|
||||||
|
Direction::Horizontal,
|
||||||
|
);
|
||||||
|
painter.add(textured(rect, corner_radius, &texture));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one gradient that is not the accent: the top bar, which runs from the
|
||||||
|
/// surface colour down into the page so that the join between them is a fade
|
||||||
|
/// rather than an edge.
|
||||||
|
///
|
||||||
|
/// Handed back as a shape rather than painted, because a panel's background has
|
||||||
|
/// to be drawn *behind* content whose size is only known once it is laid out —
|
||||||
|
/// the caller reserves an index before drawing and fills it in after.
|
||||||
|
pub fn bar_shape(ctx: &egui::Context, rect: Rect) -> egui::Shape {
|
||||||
|
let palette = palette();
|
||||||
|
if !ACTIVE.read().gradients {
|
||||||
|
return egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, palette.surface).into();
|
||||||
|
}
|
||||||
|
let texture = ramp(
|
||||||
|
ctx,
|
||||||
|
palette.surface,
|
||||||
|
// Not all the way to the page colour: a bar that ends in exactly the
|
||||||
|
// background has no bottom edge, and the shadow under a top panel then
|
||||||
|
// reads as the only thing separating the chrome from the content.
|
||||||
|
mix(palette.surface, palette.background, 0.7),
|
||||||
|
Direction::Vertical,
|
||||||
|
);
|
||||||
|
textured(rect, CornerRadius::ZERO, &texture)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A white or black film over `rect`, for hover and press states. A no-op at
|
||||||
|
/// zero, which is the case that matters — most controls are at rest.
|
||||||
|
fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) {
|
||||||
|
if lift == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alpha = (lift.abs() * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
let film = if lift > 0.0 {
|
||||||
|
Color32::from_white_alpha(alpha)
|
||||||
|
} else {
|
||||||
|
Color32::from_black_alpha(alpha)
|
||||||
|
};
|
||||||
|
painter.rect_filled(rect, corner_radius, film);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rounded rectangle painted with a gradient texture.
|
||||||
|
///
|
||||||
|
/// The fill is white because `Brush` *multiplies* the texture by it, so
|
||||||
|
/// anything else would tint the ramp.
|
||||||
|
fn textured(rect: Rect, corner_radius: CornerRadius, texture: &TextureHandle) -> egui::Shape {
|
||||||
|
let mut shape = egui::epaint::RectShape::filled(rect, corner_radius, Color32::WHITE);
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a palette, and tell egui and the window manager which of the two
|
||||||
|
/// kinds of theme it is.
|
||||||
|
pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
||||||
|
let palette = Palette::resolve(appearance);
|
||||||
|
|
||||||
|
// egui keeps a separate style per theme and follows the OS by default, and
|
||||||
|
// `set_visuals` only writes the one that is active *right now*. On the
|
||||||
|
// first call this runs before the platform has reported its theme, so on a
|
||||||
|
// Windows desktop set to light the palette below would land on the unused
|
||||||
|
// dark style and then be swapped out — panels stayed dark because they are
|
||||||
|
// painted by hand, but every stock widget (buttons, the search field, the
|
||||||
|
// volume slider) came back white. Pin the preference and write both styles.
|
||||||
|
let theme = if palette.light {
|
||||||
|
Theme::Light
|
||||||
|
} else {
|
||||||
|
Theme::Dark
|
||||||
|
};
|
||||||
|
ctx.set_theme(match theme {
|
||||||
|
Theme::Light => ThemePreference::Light,
|
||||||
|
Theme::Dark => ThemePreference::Dark,
|
||||||
|
});
|
||||||
|
// The native title bar follows the *window's* theme, not egui's. Without
|
||||||
|
// this, Windows draws a light caption above a dark window.
|
||||||
|
ctx.send_viewport_cmd(ViewportCommand::SetTheme(match theme {
|
||||||
|
Theme::Light => SystemTheme::Light,
|
||||||
|
Theme::Dark => SystemTheme::Dark,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mut visuals = if palette.light {
|
||||||
|
Visuals::light()
|
||||||
|
} else {
|
||||||
|
Visuals::dark()
|
||||||
|
};
|
||||||
|
|
||||||
|
visuals.panel_fill = palette.background;
|
||||||
|
visuals.window_fill = palette.surface;
|
||||||
|
visuals.extreme_bg_color = palette.sunken;
|
||||||
|
visuals.faint_bg_color = palette.surface;
|
||||||
|
visuals.override_text_color = Some(palette.text);
|
||||||
|
visuals.selection.bg_fill = palette.accent_dim;
|
||||||
|
visuals.selection.stroke = Stroke::new(1.0, palette.text);
|
||||||
|
visuals.hyperlink_color = palette.accent;
|
||||||
|
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(8);
|
||||||
visuals.widgets.noninteractive.bg_fill = SURFACE;
|
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
||||||
visuals.widgets.noninteractive.corner_radius = radius;
|
visuals.widgets.noninteractive.corner_radius = radius;
|
||||||
visuals.widgets.noninteractive.bg_stroke =
|
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
||||||
Stroke::new(1.0, Color32::from_rgb(0x26, 0x26, 0x30));
|
|
||||||
|
|
||||||
visuals.widgets.inactive.bg_fill = CARD;
|
visuals.widgets.inactive.bg_fill = palette.card;
|
||||||
visuals.widgets.inactive.weak_bg_fill = CARD;
|
visuals.widgets.inactive.weak_bg_fill = palette.card;
|
||||||
visuals.widgets.inactive.corner_radius = radius;
|
visuals.widgets.inactive.corner_radius = radius;
|
||||||
visuals.widgets.inactive.bg_stroke = Stroke::NONE;
|
visuals.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||||
|
|
||||||
visuals.widgets.hovered.bg_fill = CARD_HOVER;
|
visuals.widgets.hovered.bg_fill = palette.card_hover;
|
||||||
visuals.widgets.hovered.weak_bg_fill = CARD_HOVER;
|
visuals.widgets.hovered.weak_bg_fill = palette.card_hover;
|
||||||
visuals.widgets.hovered.corner_radius = radius;
|
visuals.widgets.hovered.corner_radius = radius;
|
||||||
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, ACCENT);
|
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, palette.accent);
|
||||||
|
|
||||||
visuals.widgets.active.bg_fill = ACCENT_DIM;
|
visuals.widgets.active.bg_fill = palette.accent_dim;
|
||||||
visuals.widgets.active.weak_bg_fill = ACCENT_DIM;
|
visuals.widgets.active.weak_bg_fill = palette.accent_dim;
|
||||||
visuals.widgets.active.corner_radius = radius;
|
visuals.widgets.active.corner_radius = radius;
|
||||||
|
|
||||||
ctx.set_visuals(visuals);
|
// Both, not just the active one: a menu or a tooltip that opens while the
|
||||||
|
// platform is mid-way through telling us its theme should not flash the
|
||||||
|
// stock palette.
|
||||||
|
ctx.set_visuals_of(Theme::Dark, visuals.clone());
|
||||||
|
ctx.set_visuals_of(Theme::Light, visuals);
|
||||||
|
|
||||||
ctx.all_styles_mut(|style| {
|
ctx.all_styles_mut(|style| {
|
||||||
style.spacing.item_spacing = egui::vec2(10.0, 10.0);
|
style.spacing.item_spacing = egui::vec2(10.0, 10.0);
|
||||||
@@ -78,4 +310,441 @@ pub fn apply(ctx: &egui::Context) {
|
|||||||
]
|
]
|
||||||
.into();
|
.into();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dropped, not rebuilt: the textures are only wanted if something draws a
|
||||||
|
// gradient this frame, and building them here would load two textures on
|
||||||
|
// every theme change including the ones nothing gradient-filled follows.
|
||||||
|
let mut active = ACTIVE.write();
|
||||||
|
active.palette = palette;
|
||||||
|
active.gradients = appearance.gradients;
|
||||||
|
active.ramps.clear();
|
||||||
|
drop(active);
|
||||||
|
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The palette, and the gradient textures drawn from it.
|
||||||
|
struct Active {
|
||||||
|
palette: Palette,
|
||||||
|
gradients: bool,
|
||||||
|
/// Gradient ramps by the pair of colours and the direction they run,
|
||||||
|
/// emptied whenever the palette changes.
|
||||||
|
///
|
||||||
|
/// Keyed on the colours rather than on what they are *for* because the
|
||||||
|
/// settings page previews every accent at once: eight swatches in a
|
||||||
|
/// flavour the window is not wearing are eight ramps that belong to no
|
||||||
|
/// palette in particular. Bounded by the number of flavours times the
|
||||||
|
/// number of accents, and only the ones actually drawn.
|
||||||
|
ramps: BTreeMap<(u32, u32, bool), TextureHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static ACTIVE: RwLock<Active> = RwLock::new(Active {
|
||||||
|
palette: Palette::PROTON,
|
||||||
|
gradients: true,
|
||||||
|
ramps: BTreeMap::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A ramp between two colours, built once and kept.
|
||||||
|
fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle {
|
||||||
|
let key = (
|
||||||
|
u32::from_le_bytes(from.to_array()),
|
||||||
|
u32::from_le_bytes(to.to_array()),
|
||||||
|
direction == Direction::Vertical,
|
||||||
|
);
|
||||||
|
if let Some(texture) = ACTIVE.read().ramps.get(&key) {
|
||||||
|
return texture.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pixels: Vec<Color32> = (0..RAMP_STOPS)
|
||||||
|
.map(|stop| mix(from, to, stop as f32 / (RAMP_STOPS - 1) as f32))
|
||||||
|
.collect();
|
||||||
|
let size = match direction {
|
||||||
|
Direction::Horizontal => [RAMP_STOPS, 1],
|
||||||
|
Direction::Vertical => [1, RAMP_STOPS],
|
||||||
|
};
|
||||||
|
let texture = ctx.load_texture(
|
||||||
|
"pstr-ramp",
|
||||||
|
egui::ColorImage::new(size, pixels),
|
||||||
|
// Clamped and filtered: the ramp is stretched across a whole seek bar,
|
||||||
|
// and nearest-neighbour would show every one of its stops.
|
||||||
|
TextureOptions::LINEAR,
|
||||||
|
);
|
||||||
|
ACTIVE.write().ramps.insert(key, texture.clone());
|
||||||
|
texture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many texels a ramp is drawn from.
|
||||||
|
///
|
||||||
|
/// Sampled linearly, so this is not the number of visible steps — it is how
|
||||||
|
/// closely the ramp follows sRGB. Interpolation happens in the texture's own
|
||||||
|
/// gamma space, and a two-texel ramp between distant hues takes a visibly
|
||||||
|
/// different path through colour space than one built stop by stop in linear
|
||||||
|
/// light, which is what this does.
|
||||||
|
const RAMP_STOPS: usize = 64;
|
||||||
|
|
||||||
|
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
|
||||||
|
/// ramp between saturated hues, which is exactly where a gradient is looked at.
|
||||||
|
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
|
||||||
|
let from = Rgba::from(from);
|
||||||
|
let to = Rgba::from(to);
|
||||||
|
Color32::from(from * (1.0 - t) + to * t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative luminance, in linear light, as WCAG defines it.
|
||||||
|
fn luminance(color: Color32) -> f32 {
|
||||||
|
let rgba = Rgba::from(color);
|
||||||
|
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
|
||||||
|
/// 21.0 for black against white.
|
||||||
|
fn contrast(one: Color32, other: Color32) -> f32 {
|
||||||
|
let (one, other) = (luminance(one), luminance(other));
|
||||||
|
(one.max(other) + 0.05) / (one.min(other) + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The least contrast a label gets anywhere along a gradient between two
|
||||||
|
/// colours. Sampled rather than solved: the worst point is not always an end,
|
||||||
|
/// because the ink can be brighter than one end and darker than the other.
|
||||||
|
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> f32 {
|
||||||
|
(0..=4)
|
||||||
|
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The least contrast the app will accept between a label and the fill under
|
||||||
|
/// it: WCAG AA for large text, which is what wears the accent — button labels,
|
||||||
|
/// a tab, a play glyph, never body copy.
|
||||||
|
const MIN_CONTRAST: f32 = 3.0;
|
||||||
|
|
||||||
|
/// One flavour's raw colours, named as Catppuccin names them.
|
||||||
|
///
|
||||||
|
/// Stored rather than computed: these are somebody else's palette, and the
|
||||||
|
/// point of using it is to use it exactly.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Ramp {
|
||||||
|
base: Color32,
|
||||||
|
mantle: Color32,
|
||||||
|
crust: Color32,
|
||||||
|
surface0: Color32,
|
||||||
|
surface1: Color32,
|
||||||
|
subtext0: Color32,
|
||||||
|
text: Color32,
|
||||||
|
pink: Color32,
|
||||||
|
mauve: Color32,
|
||||||
|
sky: Color32,
|
||||||
|
sapphire: Color32,
|
||||||
|
blue: Color32,
|
||||||
|
lavender: Color32,
|
||||||
|
teal: Color32,
|
||||||
|
green: Color32,
|
||||||
|
peach: Color32,
|
||||||
|
yellow: Color32,
|
||||||
|
red: Color32,
|
||||||
|
light: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn rgb(hex: u32) -> Color32 {
|
||||||
|
Color32::from_rgb(
|
||||||
|
((hex >> 16) & 0xff) as u8,
|
||||||
|
((hex >> 8) & 0xff) as u8,
|
||||||
|
(hex & 0xff) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The palette this app shipped with, extended to the hues an accent can be.
|
||||||
|
///
|
||||||
|
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
|
||||||
|
/// chosen to sit on a near-black base at roughly the saturation that one does,
|
||||||
|
/// which is a good deal hotter than any Catppuccin flavour.
|
||||||
|
const PROTON: Ramp = Ramp {
|
||||||
|
base: rgb(0x0e0e12),
|
||||||
|
mantle: rgb(0x17171d),
|
||||||
|
crust: rgb(0x0a0a0d),
|
||||||
|
surface0: rgb(0x1e1e26),
|
||||||
|
surface1: rgb(0x2a2a35),
|
||||||
|
subtext0: rgb(0x8e8e9c),
|
||||||
|
text: rgb(0xeaeaf0),
|
||||||
|
pink: rgb(0xff5fbe),
|
||||||
|
mauve: rgb(0x7d4dff),
|
||||||
|
sky: rgb(0x4dd2ff),
|
||||||
|
sapphire: rgb(0x3a8fc4),
|
||||||
|
blue: rgb(0x3f6fe0),
|
||||||
|
lavender: rgb(0xa68cff),
|
||||||
|
teal: rgb(0x3fd6b8),
|
||||||
|
green: rgb(0x56d364),
|
||||||
|
peach: rgb(0xff9a4d),
|
||||||
|
yellow: rgb(0xffd166),
|
||||||
|
red: rgb(0xe05561),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LATTE: Ramp = Ramp {
|
||||||
|
base: rgb(0xeff1f5),
|
||||||
|
mantle: rgb(0xe6e9ef),
|
||||||
|
crust: rgb(0xdce0e8),
|
||||||
|
surface0: rgb(0xccd0da),
|
||||||
|
surface1: rgb(0xbcc0cc),
|
||||||
|
subtext0: rgb(0x6c6f85),
|
||||||
|
text: rgb(0x4c4f69),
|
||||||
|
pink: rgb(0xea76cb),
|
||||||
|
mauve: rgb(0x8839ef),
|
||||||
|
sky: rgb(0x04a5e5),
|
||||||
|
sapphire: rgb(0x209fb5),
|
||||||
|
blue: rgb(0x1e66f5),
|
||||||
|
lavender: rgb(0x7287fd),
|
||||||
|
teal: rgb(0x179299),
|
||||||
|
green: rgb(0x40a02b),
|
||||||
|
peach: rgb(0xfe640b),
|
||||||
|
yellow: rgb(0xdf8e1d),
|
||||||
|
red: rgb(0xd20f39),
|
||||||
|
light: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRAPPE: Ramp = Ramp {
|
||||||
|
base: rgb(0x303446),
|
||||||
|
mantle: rgb(0x292c3c),
|
||||||
|
crust: rgb(0x232634),
|
||||||
|
surface0: rgb(0x414559),
|
||||||
|
surface1: rgb(0x51576d),
|
||||||
|
subtext0: rgb(0xa5adce),
|
||||||
|
text: rgb(0xc6d0f5),
|
||||||
|
pink: rgb(0xf4b8e4),
|
||||||
|
mauve: rgb(0xca9ee6),
|
||||||
|
sky: rgb(0x99d1db),
|
||||||
|
sapphire: rgb(0x85c1dc),
|
||||||
|
blue: rgb(0x8caaee),
|
||||||
|
lavender: rgb(0xbabbf1),
|
||||||
|
teal: rgb(0x81c8be),
|
||||||
|
green: rgb(0xa6d189),
|
||||||
|
peach: rgb(0xef9f76),
|
||||||
|
yellow: rgb(0xe5c890),
|
||||||
|
red: rgb(0xe78284),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MACCHIATO: Ramp = Ramp {
|
||||||
|
base: rgb(0x24273a),
|
||||||
|
mantle: rgb(0x1e2030),
|
||||||
|
crust: rgb(0x181926),
|
||||||
|
surface0: rgb(0x363a4f),
|
||||||
|
surface1: rgb(0x494d64),
|
||||||
|
subtext0: rgb(0xa5adcb),
|
||||||
|
text: rgb(0xcad3f5),
|
||||||
|
pink: rgb(0xf5bde6),
|
||||||
|
mauve: rgb(0xc6a0f6),
|
||||||
|
sky: rgb(0x91d7e3),
|
||||||
|
sapphire: rgb(0x7dc4e4),
|
||||||
|
blue: rgb(0x8aadf4),
|
||||||
|
lavender: rgb(0xb7bdf8),
|
||||||
|
teal: rgb(0x8bd5ca),
|
||||||
|
green: rgb(0xa6da95),
|
||||||
|
peach: rgb(0xf5a97f),
|
||||||
|
yellow: rgb(0xeed49f),
|
||||||
|
red: rgb(0xed8796),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCHA: Ramp = Ramp {
|
||||||
|
base: rgb(0x1e1e2e),
|
||||||
|
mantle: rgb(0x181825),
|
||||||
|
crust: rgb(0x11111b),
|
||||||
|
surface0: rgb(0x313244),
|
||||||
|
surface1: rgb(0x45475a),
|
||||||
|
subtext0: rgb(0xa6adc8),
|
||||||
|
text: rgb(0xcdd6f4),
|
||||||
|
pink: rgb(0xf5c2e7),
|
||||||
|
mauve: rgb(0xcba6f7),
|
||||||
|
sky: rgb(0x89dceb),
|
||||||
|
sapphire: rgb(0x74c7ec),
|
||||||
|
blue: rgb(0x89b4fa),
|
||||||
|
lavender: rgb(0xb4befe),
|
||||||
|
teal: rgb(0x94e2d5),
|
||||||
|
green: rgb(0xa6e3a1),
|
||||||
|
peach: rgb(0xfab387),
|
||||||
|
yellow: rgb(0xf9e2af),
|
||||||
|
red: rgb(0xf38ba8),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Ramp {
|
||||||
|
const fn of(flavor: Flavor) -> Self {
|
||||||
|
match flavor {
|
||||||
|
Flavor::Proton => PROTON,
|
||||||
|
Flavor::Latte => LATTE,
|
||||||
|
Flavor::Frappe => FRAPPE,
|
||||||
|
Flavor::Macchiato => MACCHIATO,
|
||||||
|
Flavor::Mocha => MOCHA,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accent, and the hue a gradient in it runs into.
|
||||||
|
///
|
||||||
|
/// The partners are neighbours on the wheel rather than complements: a
|
||||||
|
/// gradient across half the spectrum passes through a colour that belongs
|
||||||
|
/// to neither end, and on a seek bar that reads as a bug. The exception is
|
||||||
|
/// [`Accent::PinkSky`], which is the whole point of that entry.
|
||||||
|
const fn accents(&self, accent: Accent) -> (Color32, Color32) {
|
||||||
|
match accent {
|
||||||
|
Accent::Mauve => (self.mauve, self.blue),
|
||||||
|
Accent::Pink => (self.pink, self.mauve),
|
||||||
|
Accent::Sky => (self.sky, self.sapphire),
|
||||||
|
Accent::PinkSky => (self.pink, self.sky),
|
||||||
|
Accent::Lavender => (self.lavender, self.blue),
|
||||||
|
Accent::Blue => (self.blue, self.sapphire),
|
||||||
|
Accent::Teal => (self.teal, self.green),
|
||||||
|
Accent::Peach => (self.peach, self.yellow),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
/// The default, spelled out as a constant so the global has something to
|
||||||
|
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
|
||||||
|
/// panic message painted before the engine exists, still gets colours.
|
||||||
|
const PROTON: Self = Self {
|
||||||
|
background: PROTON.base,
|
||||||
|
surface: PROTON.mantle,
|
||||||
|
sunken: PROTON.crust,
|
||||||
|
card: PROTON.surface0,
|
||||||
|
card_hover: PROTON.surface1,
|
||||||
|
border: rgb(0x262630),
|
||||||
|
text: PROTON.text,
|
||||||
|
muted: PROTON.subtext0,
|
||||||
|
accent: PROTON.mauve,
|
||||||
|
accent_alt: PROTON.blue,
|
||||||
|
accent_dim: rgb(0x5333ad),
|
||||||
|
on_accent: PROTON.text,
|
||||||
|
danger: PROTON.red,
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolve a choice into colours.
|
||||||
|
pub fn resolve(appearance: Appearance) -> Self {
|
||||||
|
let ramp = Ramp::of(appearance.flavor);
|
||||||
|
let (accent, partner) = ramp.accents(appearance.accent);
|
||||||
|
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
|
||||||
|
// at body-text weight — which makes them mid-luminance, and a
|
||||||
|
// mid-luminance fill is one that neither black nor white sits on. They
|
||||||
|
// are taken down in value here, and only here: the hue and the
|
||||||
|
// saturation are Latte's, so it still looks like Latte, and a label on
|
||||||
|
// a button is legible.
|
||||||
|
let deepen = |color: Color32| {
|
||||||
|
if ramp.light {
|
||||||
|
mix(color, Color32::BLACK, 0.30)
|
||||||
|
} else {
|
||||||
|
color
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let accent = deepen(accent);
|
||||||
|
let accent_alt = if appearance.gradients {
|
||||||
|
deepen(partner)
|
||||||
|
} else {
|
||||||
|
accent
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ink that survives on the accent. The flavour's own reading first —
|
||||||
|
// pale text on a dark theme — and the opposite when that does not clear
|
||||||
|
// the bar, which on Catppuccin's dark flavours is most of the time:
|
||||||
|
// their accents are *pastel*, and white on #f5c2e7 is not a label.
|
||||||
|
//
|
||||||
|
// Latte's darkest colour is its body text, and even that is only #4c4f69,
|
||||||
|
// so the dark side is taken a third of the way to black.
|
||||||
|
let ink_dark = if ramp.light {
|
||||||
|
mix(ramp.text, Color32::BLACK, 0.35)
|
||||||
|
} else {
|
||||||
|
ramp.crust
|
||||||
|
};
|
||||||
|
let ink_light = if ramp.light { ramp.base } else { ramp.text };
|
||||||
|
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
|
||||||
|
ink_light
|
||||||
|
} else {
|
||||||
|
ink_dark
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
background: ramp.base,
|
||||||
|
surface: ramp.mantle,
|
||||||
|
sunken: ramp.crust,
|
||||||
|
card: ramp.surface0,
|
||||||
|
card_hover: ramp.surface1,
|
||||||
|
border: ramp.surface1,
|
||||||
|
text: ramp.text,
|
||||||
|
muted: ramp.subtext0,
|
||||||
|
accent,
|
||||||
|
accent_alt,
|
||||||
|
// Behind text, so it is the accent taken most of the way back to
|
||||||
|
// the page: a selection in a saturated hue is a selection nobody
|
||||||
|
// can read through.
|
||||||
|
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
|
||||||
|
on_accent,
|
||||||
|
danger: ramp.red,
|
||||||
|
light: ramp.light,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_flavour_and_accent_resolves_to_readable_ink() {
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
for accent in Accent::ALL {
|
||||||
|
let palette = Palette::resolve(Appearance {
|
||||||
|
flavor,
|
||||||
|
accent,
|
||||||
|
gradients: true,
|
||||||
|
});
|
||||||
|
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
|
||||||
|
assert!(
|
||||||
|
ratio >= MIN_CONTRAST,
|
||||||
|
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_light_flavour_reports_itself_light() {
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
let palette = Palette::resolve(Appearance {
|
||||||
|
flavor,
|
||||||
|
..Appearance::default()
|
||||||
|
});
|
||||||
|
assert_eq!(palette.light, flavor.is_light());
|
||||||
|
// And the page is on the right side of the middle either way.
|
||||||
|
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turning_gradients_off_leaves_one_colour_to_draw() {
|
||||||
|
let flat = Palette::resolve(Appearance {
|
||||||
|
accent: Accent::PinkSky,
|
||||||
|
gradients: false,
|
||||||
|
..Appearance::default()
|
||||||
|
});
|
||||||
|
assert_eq!(flat.accent, flat.accent_alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
||||||
|
let resolved = Palette::resolve(Appearance::default());
|
||||||
|
assert_eq!(resolved.background, Palette::PROTON.background);
|
||||||
|
assert_eq!(resolved.accent, Palette::PROTON.accent);
|
||||||
|
assert_eq!(resolved.text, Palette::PROTON.text);
|
||||||
|
assert_eq!(resolved.muted, Palette::PROTON.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ramp_runs_from_one_colour_to_the_other() {
|
||||||
|
let from = Color32::from_rgb(0, 0, 0);
|
||||||
|
let to = Color32::from_rgb(255, 255, 255);
|
||||||
|
assert_eq!(mix(from, to, 0.0), from);
|
||||||
|
assert_eq!(mix(from, to, 1.0), to);
|
||||||
|
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
|
||||||
|
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,9 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
name: &title.name,
|
name: &title.name,
|
||||||
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: (title.kind == TitleKind::Film).then(|| "Film".to_string()),
|
// What a film is says itself in the subtitle below the
|
||||||
|
// card; a second `Film` over the poster is noise.
|
||||||
|
badge: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.clicked();
|
.clicked();
|
||||||
@@ -132,13 +134,19 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grey line under a card: how much there is, and how much is left.
|
/// The grey line under a card: what it is, how much there is, and how much is
|
||||||
|
/// left.
|
||||||
|
///
|
||||||
|
/// A film says so, and says its year after it. The year alone was ambiguous in
|
||||||
|
/// the one place it mattered: `Ghost in the Shell` over `1995` reads as a
|
||||||
|
/// season, an episode count, anything — where every series card on the same
|
||||||
|
/// shelf is counting episodes.
|
||||||
fn subtitle(title: &Title) -> String {
|
fn subtitle(title: &Title) -> String {
|
||||||
if title.kind == TitleKind::Film {
|
if title.kind == TitleKind::Film {
|
||||||
return title
|
return match title.year {
|
||||||
.year
|
Some(year) => format!("Film · {year}"),
|
||||||
.map(|year| year.to_string())
|
None => "Film".into(),
|
||||||
.unwrap_or_else(|| "Film".into());
|
};
|
||||||
}
|
}
|
||||||
let total = title.episode_count();
|
let total = title.episode_count();
|
||||||
let watched = title.watched_count();
|
let watched = title.watched_count();
|
||||||
@@ -182,6 +190,32 @@ pub fn plural(count: usize, noun: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn title(kind: TitleKind, year: Option<u32>) -> Title {
|
||||||
|
Title {
|
||||||
|
key: "k".into(),
|
||||||
|
name: "Ghost in the Shell".into(),
|
||||||
|
year,
|
||||||
|
kind,
|
||||||
|
seasons: Vec::new(),
|
||||||
|
share_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A film says it is one. The year on its own was read as anything but —
|
||||||
|
/// the cards beside it are all counting episodes.
|
||||||
|
#[test]
|
||||||
|
fn a_film_is_labelled_a_film_and_dated_after_it() {
|
||||||
|
assert_eq!(
|
||||||
|
subtitle(&title(TitleKind::Film, Some(1995))),
|
||||||
|
"Film · 1995"
|
||||||
|
);
|
||||||
|
assert_eq!(subtitle(&title(TitleKind::Film, None)), "Film");
|
||||||
|
assert_eq!(
|
||||||
|
subtitle(&title(TitleKind::Series, Some(1995))),
|
||||||
|
"0 episodes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plurals_read_naturally() {
|
fn plurals_read_naturally() {
|
||||||
assert_eq!(plural(1, "title"), "1 title");
|
assert_eq!(plural(1, "title"), "1 title");
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
//! Picking a title's provider entry by hand.
|
||||||
|
//!
|
||||||
|
//! The matcher is deliberately unwilling to guess — see
|
||||||
|
//! [`pstr_meta::matching::MATCH_FLOOR`], and the reasoning that a wrong poster
|
||||||
|
//! does not look like a bug but like the library being wrong. The cost of that
|
||||||
|
//! is a handful of titles it will not decide about, and this dialog is where
|
||||||
|
//! they get decided: the provider's own answers, unscored and in its own order,
|
||||||
|
//! and a click that pins one of them.
|
||||||
|
//!
|
||||||
|
//! Nothing here scores anything. A person reading a list of eight entries with
|
||||||
|
//! their posters next to them is not guessing, which is exactly why the floor
|
||||||
|
//! that stops the scorer should not also stop them.
|
||||||
|
|
||||||
|
use pstr_core::metadata::{ProviderId, TitleMetadata};
|
||||||
|
|
||||||
|
use crate::app::{Action, Matcher};
|
||||||
|
use crate::theme;
|
||||||
|
use crate::ui::{self, Art};
|
||||||
|
|
||||||
|
/// How tall the result list may get before it scrolls. Enough for four rows,
|
||||||
|
/// which is what a page of provider answers usually needs.
|
||||||
|
const RESULTS_HEIGHT: f32 = 340.0;
|
||||||
|
|
||||||
|
/// A result row's poster, at the shape a cover image actually is.
|
||||||
|
const THUMB: egui::Vec2 = egui::vec2(46.0, 69.0);
|
||||||
|
|
||||||
|
pub fn show(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
matcher: &mut Matcher,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
provider: ProviderId,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
// What the title is matched to now, cloned out before `art` is borrowed for
|
||||||
|
// the rows below.
|
||||||
|
let current = art
|
||||||
|
.metadata
|
||||||
|
.get(&matcher.title_key)
|
||||||
|
.map(|record| (record.metadata.clone(), record.manual));
|
||||||
|
|
||||||
|
let modal = egui::Modal::new(egui::Id::new("matcher"))
|
||||||
|
.frame(
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface())
|
||||||
|
.inner_margin(egui::Margin::same(18))
|
||||||
|
.corner_radius(egui::CornerRadius::same(10)),
|
||||||
|
)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(620.0);
|
||||||
|
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("Match “{}”", matcher.title_name))
|
||||||
|
.size(18.0)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
);
|
||||||
|
ui.label(ui::muted(format!(
|
||||||
|
"Everything {} answers with, in its own order — nothing is scored here.",
|
||||||
|
provider.label()
|
||||||
|
)));
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
search_row(ui, matcher, actions);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
current_row(ui, matcher, current.as_ref(), actions);
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
if let Some(error) = &matcher.error {
|
||||||
|
ui.label(egui::RichText::new(error).size(12.0).color(theme::danger()));
|
||||||
|
ui.add_space(6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
results(ui, matcher, art, actions);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
actions.push(Action::CloseMatcher);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape, a click on the backdrop, or the close button above: all of them
|
||||||
|
// mean the same thing, and the dialog changes nothing until a row is
|
||||||
|
// clicked, so leaving it needs no confirmation.
|
||||||
|
if modal.should_close() {
|
||||||
|
actions.push(Action::CloseMatcher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The box and the button. Enter searches, because a search box that ignores it
|
||||||
|
/// is a search box people think is broken.
|
||||||
|
fn search_row(ui: &mut egui::Ui, matcher: &mut Matcher, actions: &mut Vec<Action>) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let box_width = ui.available_width() - 90.0;
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut matcher.query)
|
||||||
|
.hint_text("Title to search for")
|
||||||
|
.desired_width(box_width.max(120.0)),
|
||||||
|
);
|
||||||
|
if std::mem::take(&mut matcher.focus) {
|
||||||
|
field.request_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
let entered = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||||
|
if matcher.searching {
|
||||||
|
ui.add(egui::Spinner::new().size(16.0));
|
||||||
|
} else if (ui.button("Search").clicked() || entered) && !matcher.query.trim().is_empty() {
|
||||||
|
actions.push(Action::SearchMatches);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the title is matched to now, and the way back to no match at all.
|
||||||
|
fn current_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
matcher: &Matcher,
|
||||||
|
current: Option<&(Option<TitleMetadata>, bool)>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
match current {
|
||||||
|
Some((Some(found), manual)) => {
|
||||||
|
ui.label(ui::muted(format!(
|
||||||
|
"Currently {}{}",
|
||||||
|
found.name,
|
||||||
|
if *manual { ", chosen by hand" } else { "" }
|
||||||
|
)));
|
||||||
|
// Only worth offering when there is something to undo: with no
|
||||||
|
// row stored, "clear" would delete nothing.
|
||||||
|
if ui
|
||||||
|
.button("Clear")
|
||||||
|
.on_hover_text("Forget this match and let the next match run decide again")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((None, _)) => {
|
||||||
|
ui.label(ui::muted("Currently unmatched — the provider had nothing."));
|
||||||
|
if ui
|
||||||
|
.button("Clear")
|
||||||
|
.on_hover_text("Forget the stored miss so this title is asked about again")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui.label(ui::muted("Not matched yet."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider's answers, one clickable row each.
|
||||||
|
fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mut Vec<Action>) {
|
||||||
|
if matcher.results.is_empty() {
|
||||||
|
ui.label(ui::muted(if matcher.searching {
|
||||||
|
"Searching…"
|
||||||
|
} else if matcher.asked {
|
||||||
|
// Both providers match on whole words, so a name the index has never
|
||||||
|
// seen empties the page rather than returning something near it.
|
||||||
|
"Nothing came back. Try a shorter name, or the title in another language."
|
||||||
|
} else {
|
||||||
|
"Search to see what the provider has."
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(RESULTS_HEIGHT)
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for found in &matcher.results {
|
||||||
|
if row(ui, art, found).clicked() {
|
||||||
|
actions.push(Action::ChooseMatch(Box::new(found.clone())));
|
||||||
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One answer: its cover, its names, and enough of its synopsis to tell two
|
||||||
|
/// entries in the same franchise apart — which is the whole job of this list.
|
||||||
|
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
|
||||||
|
let response = egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(egui::CornerRadius::same(6))
|
||||||
|
.inner_margin(egui::Margin::same(8))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
let (rect, _) = ui.allocate_exact_size(THUMB, egui::Sense::hover());
|
||||||
|
if let Some(texture) = poster(art, found) {
|
||||||
|
ui.painter().image(
|
||||||
|
texture.id(),
|
||||||
|
rect,
|
||||||
|
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(4),
|
||||||
|
theme::card_hover(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(&found.name)
|
||||||
|
.size(14.0)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
);
|
||||||
|
if let Some(original) = &found.original_name {
|
||||||
|
ui.label(ui::muted(original.clone()));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(facts(found)));
|
||||||
|
if let Some(overview) = &found.overview {
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
overview.replace('\n', " "),
|
||||||
|
egui::FontId::proportional(11.5),
|
||||||
|
theme::muted(),
|
||||||
|
ui.available_width(),
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 2;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
ui.label(job);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
|
||||||
|
let response = response.interact(egui::Sense::click());
|
||||||
|
if response.hovered() {
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
response.rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Stroke::new(1.5, theme::accent()),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cover for one answer, cached under the provider's id for it rather than
|
||||||
|
/// under a title key — these are not any title's artwork until one is picked.
|
||||||
|
fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandle> {
|
||||||
|
let url = found
|
||||||
|
.poster_url
|
||||||
|
.clone()
|
||||||
|
.or_else(|| found.backdrop_url.clone())?;
|
||||||
|
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
|
||||||
|
let engine = art.engine;
|
||||||
|
let requested = key.clone();
|
||||||
|
art.posters
|
||||||
|
.texture(key, || engine.request_poster(requested, url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
|
||||||
|
/// simply absent.
|
||||||
|
fn facts(found: &TitleMetadata) -> String {
|
||||||
|
let mut parts: Vec<String> = vec![
|
||||||
|
match found.kind {
|
||||||
|
pstr_core::library::TitleKind::Series => "Series",
|
||||||
|
pstr_core::library::TitleKind::Film => "Film",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
];
|
||||||
|
if let Some(year) = found.year {
|
||||||
|
parts.push(year.to_string());
|
||||||
|
}
|
||||||
|
if let Some(episodes) = found.episodes {
|
||||||
|
parts.push(ui::library::plural(episodes as usize, "episode"));
|
||||||
|
}
|
||||||
|
if let Some(rating) = found.rating {
|
||||||
|
parts.push(format!("★ {rating:.1}"));
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod matcher;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod title;
|
pub mod title;
|
||||||
@@ -115,23 +116,74 @@ pub fn section(ui: &mut egui::Ui, text: &str) {
|
|||||||
egui::RichText::new(text)
|
egui::RichText::new(text)
|
||||||
.size(17.0)
|
.size(17.0)
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::TEXT),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
ui.add_space(2.0);
|
ui.add_space(2.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Small grey text, for everything that is context rather than content.
|
/// Small grey text, for everything that is context rather than content.
|
||||||
pub fn muted(text: impl Into<String>) -> egui::RichText {
|
pub fn muted(text: impl Into<String>) -> egui::RichText {
|
||||||
egui::RichText::new(text).size(12.0).color(theme::MUTED)
|
egui::RichText::new(text).size(12.0).color(theme::muted())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one button style that means "this is the action".
|
/// The one button style that means "this is the action".
|
||||||
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
ui.add(
|
filled(ui, text, true, Vec2::new(0.0, 0.0))
|
||||||
egui::Button::new(egui::RichText::new(text).strong().color(Color32::WHITE))
|
}
|
||||||
.fill(theme::ACCENT)
|
|
||||||
.corner_radius(CornerRadius::same(8)),
|
/// A tab in the top bar. The selected one wears the accent; the others are text
|
||||||
)
|
/// until the pointer is over them.
|
||||||
|
pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
|
||||||
|
filled(ui, text, selected, Vec2::new(4.0, 0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A button painted by hand, so that "filled with the accent" can mean a
|
||||||
|
/// gradient.
|
||||||
|
///
|
||||||
|
/// `egui::Button` takes a single `Color32` and there is no way in to give it
|
||||||
|
/// anything else, so the fill, the label and the hover states are all drawn
|
||||||
|
/// here. Everything else — sizing, padding, the click — is still egui's.
|
||||||
|
fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::Response {
|
||||||
|
let padding = ui.spacing().button_padding + extra;
|
||||||
|
let galley = ui.painter().layout_no_wrap(
|
||||||
|
text.to_owned(),
|
||||||
|
egui::TextStyle::Button.resolve(ui.style()),
|
||||||
|
Color32::PLACEHOLDER,
|
||||||
|
);
|
||||||
|
let size = Vec2::new(
|
||||||
|
galley.size().x + padding.x * 2.0,
|
||||||
|
(galley.size().y + padding.y * 2.0).max(ui.spacing().interact_size.y),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
||||||
|
if !ui.is_rect_visible(rect) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = CornerRadius::same(8);
|
||||||
|
let lift = if response.is_pointer_button_down_on() {
|
||||||
|
-0.14
|
||||||
|
} else if response.hovered() {
|
||||||
|
0.10
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let ink = if accented {
|
||||||
|
theme::accent_fill(ui.painter(), rect, radius, lift);
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
if response.hovered() {
|
||||||
|
ui.painter().rect_filled(rect, radius, theme::card_hover());
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let position = rect.center() - galley.size() / 2.0;
|
||||||
|
ui.painter().galley(position, galley, ink);
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a tile shows.
|
/// What a tile shows.
|
||||||
@@ -142,7 +194,7 @@ pub struct Card<'a> {
|
|||||||
pub subtitle: String,
|
pub subtitle: String,
|
||||||
/// Fraction watched, drawn as a bar across the bottom of the still.
|
/// Fraction watched, drawn as a bar across the bottom of the still.
|
||||||
pub progress: Option<f32>,
|
pub progress: Option<f32>,
|
||||||
/// A short label in the corner — `S01E04`, `Film`.
|
/// A short label in the corner — `S01E04`.
|
||||||
pub badge: Option<String>,
|
pub badge: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +225,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
let hovered = response.hovered();
|
let hovered = response.hovered();
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
painter.rect_filled(image_rect, radius, theme::CARD);
|
painter.rect_filled(image_rect, radius, theme::card());
|
||||||
|
|
||||||
match &card.art {
|
match &card.art {
|
||||||
Some((texture, ArtShape::Landscape)) => {
|
Some((texture, ArtShape::Landscape)) => {
|
||||||
@@ -199,7 +251,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
initials(card.name),
|
initials(card.name),
|
||||||
egui::FontId::proportional(28.0),
|
egui::FontId::proportional(28.0),
|
||||||
theme::MUTED,
|
theme::muted(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,14 +283,14 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
painter.rect_filled(track, CornerRadius::ZERO, Color32::from_black_alpha(150));
|
painter.rect_filled(track, CornerRadius::ZERO, Color32::from_black_alpha(150));
|
||||||
let mut played = track;
|
let mut played = track;
|
||||||
played.set_width(track.width() * progress.clamp(0.0, 1.0));
|
played.set_width(track.width() * progress.clamp(0.0, 1.0));
|
||||||
painter.rect_filled(played, CornerRadius::ZERO, theme::ACCENT);
|
theme::accent_fill(painter, played, CornerRadius::ZERO, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if hovered {
|
if hovered {
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
image_rect,
|
image_rect,
|
||||||
radius,
|
radius,
|
||||||
Stroke::new(2.0, theme::ACCENT),
|
Stroke::new(2.0, theme::accent()),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -254,20 +306,24 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
let mut job = egui::text::LayoutJob::simple(
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
card.name.to_string(),
|
card.name.to_string(),
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
if hovered { Color32::WHITE } else { theme::TEXT },
|
if hovered {
|
||||||
|
Color32::WHITE
|
||||||
|
} else {
|
||||||
|
theme::text()
|
||||||
|
},
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
job.wrap.max_rows = 2;
|
job.wrap.max_rows = 2;
|
||||||
job.wrap.overflow_character = Some('…');
|
job.wrap.overflow_character = Some('…');
|
||||||
let name = painter.layout_job(job);
|
let name = painter.layout_job(job);
|
||||||
let name_height = name.size().y;
|
let name_height = name.size().y;
|
||||||
painter.galley(text_rect.min, name, theme::TEXT);
|
painter.galley(text_rect.min, name, theme::text());
|
||||||
painter.text(
|
painter.text(
|
||||||
text_rect.min + Vec2::new(0.0, name_height + 2.0),
|
text_rect.min + Vec2::new(0.0, name_height + 2.0),
|
||||||
Align2::LEFT_TOP,
|
Align2::LEFT_TOP,
|
||||||
card.subtitle,
|
card.subtitle,
|
||||||
egui::FontId::proportional(11.5),
|
egui::FontId::proportional(11.5),
|
||||||
theme::MUTED,
|
theme::muted(),
|
||||||
);
|
);
|
||||||
|
|
||||||
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
|
|||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
&playback.target.title_name,
|
&playback.target.title_name,
|
||||||
egui::FontId::proportional(20.0),
|
egui::FontId::proportional(20.0),
|
||||||
theme::TEXT,
|
theme::text(),
|
||||||
);
|
);
|
||||||
painter.text(
|
painter.text(
|
||||||
rect.center() + Vec2::new(0.0, 10.0),
|
rect.center() + Vec2::new(0.0, 10.0),
|
||||||
@@ -299,7 +299,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
|
|||||||
"playing in mpv's own window"
|
"playing in mpv's own window"
|
||||||
},
|
},
|
||||||
egui::FontId::proportional(13.0),
|
egui::FontId::proportional(13.0),
|
||||||
theme::MUTED,
|
theme::muted(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,14 +314,14 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
|||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
"Up next",
|
"Up next",
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
theme::MUTED,
|
theme::muted(),
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
rect.center() + Vec2::new(0.0, 12.0),
|
rect.center() + Vec2::new(0.0, 12.0),
|
||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
opening.unwrap_or("opening…"),
|
opening.unwrap_or("opening…"),
|
||||||
egui::FontId::proportional(20.0),
|
egui::FontId::proportional(20.0),
|
||||||
theme::TEXT,
|
theme::text(),
|
||||||
);
|
);
|
||||||
|
|
||||||
ui.scope_builder(
|
ui.scope_builder(
|
||||||
@@ -451,7 +451,7 @@ fn up_next_card(
|
|||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
|
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
|
||||||
.size(12.0)
|
.size(12.0)
|
||||||
.color(theme::ACCENT),
|
.color(theme::accent()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,18 +7,30 @@
|
|||||||
//! typed.
|
//! typed.
|
||||||
|
|
||||||
use pstr_core::Share;
|
use pstr_core::Share;
|
||||||
|
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
||||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||||
|
|
||||||
use crate::app::{Action, ShareForm};
|
use crate::app::{Action, ShareForm};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
|
/// The settings on this page that are a single value rather than a form.
|
||||||
|
///
|
||||||
|
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
|
||||||
|
/// they are read-only, and passing them one by one is what pushed this
|
||||||
|
/// function past the argument count anybody can read.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Prefs {
|
||||||
|
pub autoplay: bool,
|
||||||
|
pub appearance: Appearance,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
shares: &[Share],
|
shares: &[Share],
|
||||||
form: &mut ShareForm,
|
form: &mut ShareForm,
|
||||||
settings: &mut MetadataConfig,
|
settings: &mut MetadataConfig,
|
||||||
autoplay: bool,
|
prefs: Prefs,
|
||||||
api_key: &mut String,
|
api_key: &mut String,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -37,13 +49,107 @@ pub fn show(
|
|||||||
add_form(ui, form, actions);
|
add_form(ui, form, actions);
|
||||||
|
|
||||||
ui.add_space(28.0);
|
ui.add_space(28.0);
|
||||||
playback_form(ui, autoplay, actions);
|
playback_form(ui, prefs.autoplay, actions);
|
||||||
|
|
||||||
|
ui.add_space(28.0);
|
||||||
|
appearance_form(ui, prefs.appearance, actions);
|
||||||
|
|
||||||
ui.add_space(28.0);
|
ui.add_space(28.0);
|
||||||
metadata_form(ui, settings, api_key, actions);
|
metadata_form(ui, settings, api_key, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The theme: a flavour, an accent, and whether the accent is a gradient.
|
||||||
|
///
|
||||||
|
/// Every control here repaints the whole window the moment it is clicked —
|
||||||
|
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||||
|
/// only changed a small square would be asking someone to imagine the result.
|
||||||
|
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||||
|
ui::section(ui, "Appearance");
|
||||||
|
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(egui::CornerRadius::same(8))
|
||||||
|
.inner_margin(egui::Margin::same(14))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
|
|
||||||
|
ui.label(ui::muted("Palette"));
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
if ui
|
||||||
|
.radio(appearance.flavor == flavor, flavor.label())
|
||||||
|
.on_hover_text(flavor.description())
|
||||||
|
.clicked()
|
||||||
|
&& appearance.flavor != flavor
|
||||||
|
{
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
flavor,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.label(ui::muted("Accent"));
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
for accent in Accent::ALL {
|
||||||
|
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
accent,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
let mut gradients = appearance.gradients;
|
||||||
|
if ui
|
||||||
|
.checkbox(&mut gradients, "Fade the accent into a second colour")
|
||||||
|
.on_hover_text(
|
||||||
|
"Off draws it flat — better on a panel that bands a gradient into stripes",
|
||||||
|
)
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
gradients,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One accent, drawn in the colours it would actually produce.
|
||||||
|
///
|
||||||
|
/// Resolved against the *selected* flavour rather than the active palette, so
|
||||||
|
/// the row previews what clicking it would give rather than what is on screen.
|
||||||
|
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
||||||
|
let selected = appearance.accent == accent;
|
||||||
|
let size = egui::Vec2::new(52.0, 26.0);
|
||||||
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let palette = theme::Palette::resolve(Appearance {
|
||||||
|
accent,
|
||||||
|
..appearance
|
||||||
|
});
|
||||||
|
let radius = egui::CornerRadius::same(6);
|
||||||
|
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
||||||
|
if selected {
|
||||||
|
// A ring rather than a tick: the tick would have to be drawn in an
|
||||||
|
// ink that reads on eight different fills.
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
rect.expand(2.0),
|
||||||
|
egui::CornerRadius::same(8),
|
||||||
|
egui::Stroke::new(2.0, theme::text()),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.on_hover_text(accent.label())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
||||||
///
|
///
|
||||||
/// The privacy note is not decoration and it is not in a tooltip. Turning this
|
/// The privacy note is not decoration and it is not in a tooltip. Turning this
|
||||||
@@ -59,7 +165,7 @@ fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
|
|||||||
ui::section(ui, "Playback");
|
ui::section(ui, "Playback");
|
||||||
|
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::CARD)
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(8))
|
||||||
.inner_margin(egui::Margin::same(14))
|
.inner_margin(egui::Margin::same(14))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
@@ -89,7 +195,7 @@ fn metadata_form(
|
|||||||
ui::section(ui, "Posters and descriptions");
|
ui::section(ui, "Posters and descriptions");
|
||||||
|
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::CARD)
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(8))
|
||||||
.inner_margin(egui::Margin::same(14))
|
.inner_margin(egui::Margin::same(14))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
@@ -180,7 +286,7 @@ fn metadata_form(
|
|||||||
|
|
||||||
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::CARD)
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(8))
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
.inner_margin(egui::Margin::symmetric(12, 10))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
@@ -198,7 +304,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
|||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if ui
|
if ui
|
||||||
.add(egui::Button::new("Remove").fill(theme::CARD_HOVER))
|
.add(egui::Button::new("Remove").fill(theme::card_hover()))
|
||||||
.on_hover_text("Forget this share, its catalog rows and its stored secrets")
|
.on_hover_text("Forget this share, its catalog rows and its stored secrets")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -222,7 +328,7 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
ui.add_space(10.0);
|
ui.add_space(10.0);
|
||||||
|
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::CARD)
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(8))
|
||||||
.inner_margin(egui::Margin::same(14))
|
.inner_margin(egui::Margin::same(14))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
|||||||
@@ -61,10 +61,9 @@ pub fn show(
|
|||||||
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
|
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
|
||||||
// Cloned out of the borrow: `art` is held mutably for the card below, and
|
// Cloned out of the borrow: `art` is held mutably for the card below, and
|
||||||
// the description is drawn beside it.
|
// the description is drawn beside it.
|
||||||
let found = art
|
let record = art.metadata.get(&title.key);
|
||||||
.metadata
|
let found = record.and_then(|record| record.metadata.clone());
|
||||||
.get(&title.key)
|
let by_hand = record.is_some_and(|record| record.manual);
|
||||||
.and_then(|record| record.metadata.clone());
|
|
||||||
|
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
ui::card(
|
ui::card(
|
||||||
@@ -87,7 +86,11 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
|
|||||||
if let Some(found) = &found
|
if let Some(found) = &found
|
||||||
&& found.name != title.name
|
&& found.name != title.name
|
||||||
{
|
{
|
||||||
ui.label(ui::muted(format!("also known as {}", found.name)));
|
ui.label(ui::muted(format!(
|
||||||
|
"also known as {}{}",
|
||||||
|
found.name,
|
||||||
|
if by_hand { " (chosen by hand)" } else { "" }
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
||||||
@@ -122,6 +125,21 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The way out of a match the scorer would not make, or made
|
||||||
|
// wrongly. Offered whether or not anything matched: the two
|
||||||
|
// cases it exists for are a title with no poster at all and a
|
||||||
|
// title wearing someone else's.
|
||||||
|
if ui
|
||||||
|
.button(match &found {
|
||||||
|
Some(_) => "Change match",
|
||||||
|
None => "Match…",
|
||||||
|
})
|
||||||
|
.on_hover_text("Search the metadata provider and pick the entry yourself")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::OpenMatcher(title.key.clone()));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(found) = &found {
|
if let Some(found) = &found {
|
||||||
@@ -140,7 +158,12 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
|||||||
}
|
}
|
||||||
if let Some(overview) = &found.overview {
|
if let Some(overview) = &found.overview {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(egui::RichText::new(overview).size(13.0).color(theme::TEXT)).wrap(),
|
egui::Label::new(
|
||||||
|
egui::RichText::new(overview)
|
||||||
|
.size(13.0)
|
||||||
|
.color(theme::text()),
|
||||||
|
)
|
||||||
|
.wrap(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(url) = &found.url {
|
if let Some(url) = &found.url {
|
||||||
@@ -215,9 +238,9 @@ fn episode_row(
|
|||||||
let watched = episode.is_watched();
|
let watched = episode.is_watched();
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(if watched {
|
.fill(if watched {
|
||||||
theme::BACKGROUND
|
theme::background()
|
||||||
} else {
|
} else {
|
||||||
theme::CARD
|
theme::card()
|
||||||
})
|
})
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
.corner_radius(egui::CornerRadius::same(6))
|
||||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||||
@@ -230,7 +253,7 @@ fn episode_row(
|
|||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(egui::RichText::new("▶").size(13.0))
|
egui::Button::new(egui::RichText::new("▶").size(13.0))
|
||||||
.fill(theme::CARD_HOVER),
|
.fill(theme::card_hover()),
|
||||||
)
|
)
|
||||||
.on_hover_text("Play")
|
.on_hover_text("Play")
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -247,7 +270,7 @@ fn episode_row(
|
|||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
egui::RichText::new(numbering)
|
egui::RichText::new(numbering)
|
||||||
.monospace()
|
.monospace()
|
||||||
.color(theme::MUTED),
|
.color(theme::muted()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -259,9 +282,9 @@ fn episode_row(
|
|||||||
.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 label = egui::RichText::new(name).color(if watched {
|
let label = egui::RichText::new(name).color(if watched {
|
||||||
theme::MUTED
|
theme::muted()
|
||||||
} else {
|
} else {
|
||||||
theme::TEXT
|
theme::text()
|
||||||
});
|
});
|
||||||
let hover = match &found {
|
let hover = match &found {
|
||||||
// The synopsis, where the provider has one — AniList does
|
// The synopsis, where the provider has one — AniList does
|
||||||
@@ -306,7 +329,7 @@ fn episode_row(
|
|||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("empty")
|
egui::RichText::new("empty")
|
||||||
.size(12.0)
|
.size(12.0)
|
||||||
.color(theme::DANGER),
|
.color(theme::danger()),
|
||||||
)
|
)
|
||||||
.on_hover_text(
|
.on_hover_text(
|
||||||
"The share reports no content for this file; it cannot be played.",
|
"The share reports no content for this file; it cannot be played.",
|
||||||
@@ -321,7 +344,7 @@ fn episode_row(
|
|||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!("resume {}", ui::format_time(at)))
|
egui::RichText::new(format!("resume {}", ui::format_time(at)))
|
||||||
.size(12.0)
|
.size(12.0)
|
||||||
.color(theme::ACCENT),
|
.color(theme::accent()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//! Both push the same [`Action`]s, so there is one behaviour and two layouts,
|
//! Both push the same [`Action`]s, so there is one behaviour and two layouts,
|
||||||
//! and neither mutates: this module draws.
|
//! and neither mutates: this module draws.
|
||||||
|
|
||||||
use egui::{Color32, CornerRadius, Rect, Sense, Vec2};
|
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
|
||||||
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};
|
||||||
@@ -26,6 +26,20 @@ const SEEK_HIT_HEIGHT: f32 = 18.0;
|
|||||||
/// enough that it does not read as a second seek bar.
|
/// enough that it does not read as a second seek bar.
|
||||||
const VOLUME_WIDTH: f32 = 90.0;
|
const VOLUME_WIDTH: f32 = 90.0;
|
||||||
|
|
||||||
|
/// Height of the transport row, and the reason it is a constant.
|
||||||
|
///
|
||||||
|
/// `Align::Center` in a horizontal layout centres each widget against the row
|
||||||
|
/// height *known when that widget is added*, and the row only grows as it goes.
|
||||||
|
/// The play button is the tallest thing in the cluster and sits in the middle,
|
||||||
|
/// so everything left of it — previous, −10s — is centred against a shorter row
|
||||||
|
/// and lands a few pixels high. Calling `set_min_height` with the tallest
|
||||||
|
/// widget's height before adding anything gives every one of them the same
|
||||||
|
/// height to centre against.
|
||||||
|
const ROW_HEIGHT_FULL: f32 = 44.0;
|
||||||
|
|
||||||
|
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
|
||||||
|
const ROW_HEIGHT_MINI: f32 = 34.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 {
|
||||||
@@ -45,6 +59,8 @@ pub fn full(
|
|||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
ui.set_min_height(ROW_HEIGHT_FULL);
|
||||||
|
|
||||||
// Left cluster: everything that moves the playhead, in the order a
|
// Left cluster: everything that moves the playhead, in the order a
|
||||||
// hand reaches for it.
|
// hand reaches for it.
|
||||||
step_button(
|
step_button(
|
||||||
@@ -93,13 +109,15 @@ pub fn mini(
|
|||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
ui.set_min_height(ROW_HEIGHT_MINI);
|
||||||
|
|
||||||
// The way back to the picture. First, and a real button rather than a
|
// The way back to the picture. First, and a real button rather than a
|
||||||
// label, because leaving the player page with Esc is otherwise a
|
// label, because leaving the player page with Esc is otherwise a
|
||||||
// one-way trip: playback carries on with nowhere to watch it.
|
// one-way trip: playback carries on with nowhere to watch it.
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(egui::RichText::new("Back to video").size(13.0))
|
egui::Button::new(egui::RichText::new("Back to video").size(13.0))
|
||||||
.fill(theme::CARD_HOVER)
|
.fill(theme::card_hover())
|
||||||
.corner_radius(CornerRadius::same(8)),
|
.corner_radius(CornerRadius::same(8)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Show the picture again")
|
.on_hover_text("Show the picture again")
|
||||||
@@ -167,15 +185,63 @@ pub fn mini(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The one button whose position should never move.
|
/// The one button whose position should never move.
|
||||||
|
///
|
||||||
|
/// Painted rather than set as `▶`/`⏸` text. A glyph is centred by its advance
|
||||||
|
/// width, and `U+25B6`'s advance carries side bearings that are not symmetric —
|
||||||
|
/// in a round button that reads, correctly, as a triangle sitting off to one
|
||||||
|
/// side. It also comes from whichever fallback font has it, so the shape and its
|
||||||
|
/// weight vary by platform. Two polygons cost less than either problem.
|
||||||
fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, size: f32) {
|
fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, size: f32) {
|
||||||
let symbol = if playback.paused { "▶" } else { "⏸" };
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||||
if ui
|
|
||||||
.add(
|
let lift = if response.is_pointer_button_down_on() {
|
||||||
egui::Button::new(egui::RichText::new(symbol).size(size * 0.42))
|
-0.18
|
||||||
.fill(theme::ACCENT)
|
} else if response.hovered() {
|
||||||
.corner_radius(CornerRadius::same((size / 2.0) as u8))
|
0.12
|
||||||
.min_size(Vec2::splat(size)),
|
} else {
|
||||||
)
|
0.0
|
||||||
|
};
|
||||||
|
let painter = ui.painter();
|
||||||
|
// A rounded rectangle whose corner radius is its half-width *is* a circle,
|
||||||
|
// and unlike `circle_filled` it can be painted with a gradient.
|
||||||
|
theme::accent_fill(
|
||||||
|
painter,
|
||||||
|
rect,
|
||||||
|
CornerRadius::same((size / 2.0).round() as u8),
|
||||||
|
lift,
|
||||||
|
);
|
||||||
|
|
||||||
|
let centre = rect.center();
|
||||||
|
if playback.paused {
|
||||||
|
// A triangle's centroid is a third of the way from base to apex, not
|
||||||
|
// half — so centring its bounding box puts the visual mass left of the
|
||||||
|
// circle's centre, which is the usual reason a play button looks wrong.
|
||||||
|
// Placed by centroid instead: base at `centre.x - width / 3`.
|
||||||
|
let (width, height) = (size * 0.30, size * 0.34);
|
||||||
|
let base = centre.x - width / 3.0;
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
vec![
|
||||||
|
pos2(base, centre.y - height / 2.0),
|
||||||
|
pos2(base, centre.y + height / 2.0),
|
||||||
|
pos2(base + width, centre.y),
|
||||||
|
],
|
||||||
|
theme::on_accent(),
|
||||||
|
Stroke::NONE,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Pause is symmetric, so it is centred the ordinary way.
|
||||||
|
let (bar, height, gap) = (size * 0.09, size * 0.32, size * 0.10);
|
||||||
|
for side in [-1.0f32, 1.0] {
|
||||||
|
let x = centre.x + side * (gap / 2.0 + bar / 2.0);
|
||||||
|
painter.rect_filled(
|
||||||
|
Rect::from_center_size(pos2(x, centre.y), Vec2::new(bar, height)),
|
||||||
|
CornerRadius::same((bar / 2.0).round() as u8),
|
||||||
|
theme::on_accent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if response
|
||||||
.on_hover_text(if playback.paused {
|
.on_hover_text(if playback.paused {
|
||||||
"Play (Space)"
|
"Play (Space)"
|
||||||
} else {
|
} else {
|
||||||
@@ -232,7 +298,7 @@ fn times(ui: &mut egui::Ui, playback: &Playback, over_picture: bool) {
|
|||||||
let (size, ink, dim) = if over_picture {
|
let (size, ink, dim) = if over_picture {
|
||||||
(13.0, Color32::WHITE, Color32::from_white_alpha(215))
|
(13.0, Color32::WHITE, Color32::from_white_alpha(215))
|
||||||
} else {
|
} else {
|
||||||
(12.0, theme::MUTED, theme::MUTED)
|
(12.0, theme::muted(), theme::muted())
|
||||||
};
|
};
|
||||||
let time = |text: String| egui::RichText::new(text).size(size).color(ink);
|
let time = |text: String| egui::RichText::new(text).size(size).color(ink);
|
||||||
|
|
||||||
@@ -413,7 +479,11 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
|||||||
if let Some(progress) = playback.progress() {
|
if let Some(progress) = playback.progress() {
|
||||||
let mut played = track;
|
let mut played = track;
|
||||||
played.set_width(track.width() * progress);
|
played.set_width(track.width() * progress);
|
||||||
painter.rect_filled(played, CornerRadius::same(3), theme::ACCENT);
|
// The gradient is sized to the *played* part, not to the whole bar, so
|
||||||
|
// the far end of it arrives at the playhead rather than at the end of
|
||||||
|
// the file. A bar that only ever shows the first fifth of its own ramp
|
||||||
|
// does not read as a gradient at all.
|
||||||
|
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0);
|
||||||
painter.circle_filled(
|
painter.circle_filled(
|
||||||
egui::pos2(played.right(), track.center().y),
|
egui::pos2(played.right(), track.center().y),
|
||||||
if hovered { 8.0 } else { 6.0 },
|
if hovered { 8.0 } else { 6.0 },
|
||||||
|
|||||||
@@ -21,3 +21,17 @@ directories.workspace = true
|
|||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
|
||||||
|
# The credential store, per platform. Without the target's feature `keyring`
|
||||||
|
# compiles fine and silently resolves to its in-memory `mock` store, which
|
||||||
|
# accepts every write and has forgotten it by the next `Entry` — a share that
|
||||||
|
# adds cleanly and then reports "has no stored credentials" on open. These
|
||||||
|
# blocks only add features to the dependency above.
|
||||||
|
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))'.dependencies]
|
||||||
|
keyring = { workspace = true, features = ["sync-secret-service", "crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
keyring = { workspace = true, features = ["windows-native"] }
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||||
|
keyring = { workspace = true, features = ["apple-native"] }
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//! What the window wears.
|
||||||
|
//!
|
||||||
|
//! Only the *choice* lives here — the colours themselves are `pstr-app`'s, and
|
||||||
|
//! nothing outside the window has an opinion about them. This is stored beside
|
||||||
|
//! the playback preferences and read the same way: a file that will not parse is
|
||||||
|
//! a line in the log and a fall back to the defaults, because a broken theme
|
||||||
|
//! file must never be a reason the library does not open.
|
||||||
|
|
||||||
|
use crate::config::{AppDirs, read_json, write_json};
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// A palette family: Catppuccin's four, plus the one this app shipped with.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Flavor {
|
||||||
|
/// Near-black and Proton purple. The default, and the only one that is not
|
||||||
|
/// somebody else's palette.
|
||||||
|
#[default]
|
||||||
|
Proton,
|
||||||
|
/// Catppuccin Latte — the light one.
|
||||||
|
Latte,
|
||||||
|
Frappe,
|
||||||
|
Macchiato,
|
||||||
|
Mocha,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flavor {
|
||||||
|
pub const ALL: [Self; 5] = [
|
||||||
|
Self::Proton,
|
||||||
|
Self::Mocha,
|
||||||
|
Self::Macchiato,
|
||||||
|
Self::Frappe,
|
||||||
|
Self::Latte,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Proton => "Proton",
|
||||||
|
Self::Latte => "Catppuccin Latte",
|
||||||
|
Self::Frappe => "Catppuccin Frappé",
|
||||||
|
Self::Macchiato => "Catppuccin Macchiato",
|
||||||
|
Self::Mocha => "Catppuccin Mocha",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Proton => "Near-black, so the artwork is the only bright thing on screen",
|
||||||
|
Self::Latte => "Light. The only one that is",
|
||||||
|
Self::Frappe => "Dark, warm, the lowest contrast of the three",
|
||||||
|
Self::Macchiato => "Dark, between Frappé and Mocha",
|
||||||
|
Self::Mocha => "Dark, the deepest of Catppuccin's three",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this flavour is a light theme. Latte, and only Latte.
|
||||||
|
///
|
||||||
|
/// It decides more than the colours: egui keeps a style per theme and the
|
||||||
|
/// platform draws the title bar from the window's own, so both have to be
|
||||||
|
/// told which of the two this is.
|
||||||
|
pub fn is_light(self) -> bool {
|
||||||
|
matches!(self, Self::Latte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one strong colour in the window — and, where a gradient is drawn, the
|
||||||
|
/// hue it runs into.
|
||||||
|
///
|
||||||
|
/// Named for the first hue rather than for the pair, except where the pair *is*
|
||||||
|
/// the point: [`Accent::PinkSky`] is a gradient before it is a colour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Accent {
|
||||||
|
/// Proton purple, and the equivalent hue in every Catppuccin flavour.
|
||||||
|
#[default]
|
||||||
|
Mauve,
|
||||||
|
Pink,
|
||||||
|
Sky,
|
||||||
|
/// Pink running into light blue.
|
||||||
|
PinkSky,
|
||||||
|
Lavender,
|
||||||
|
Blue,
|
||||||
|
Teal,
|
||||||
|
Peach,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Accent {
|
||||||
|
pub const ALL: [Self; 8] = [
|
||||||
|
Self::Mauve,
|
||||||
|
Self::Pink,
|
||||||
|
Self::Sky,
|
||||||
|
Self::PinkSky,
|
||||||
|
Self::Lavender,
|
||||||
|
Self::Blue,
|
||||||
|
Self::Teal,
|
||||||
|
Self::Peach,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Mauve => "Mauve",
|
||||||
|
Self::Pink => "Pink",
|
||||||
|
Self::Sky => "Sky",
|
||||||
|
Self::PinkSky => "Pink → Sky",
|
||||||
|
Self::Lavender => "Lavender",
|
||||||
|
Self::Blue => "Blue",
|
||||||
|
Self::Teal => "Teal",
|
||||||
|
Self::Peach => "Peach",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole of the choice.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
// An older or hand-edited file is missing fields rather than invalid.
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Appearance {
|
||||||
|
pub flavor: Flavor,
|
||||||
|
pub accent: Accent,
|
||||||
|
/// Whether the accent is painted as a gradient — the seek bar, the play
|
||||||
|
/// button, the top bar — or flat.
|
||||||
|
///
|
||||||
|
/// A switch rather than a fixed decision because a gradient is the first
|
||||||
|
/// thing to go wrong on a screen that cannot show it: on 6-bit panels a
|
||||||
|
/// slow ramp across a wide bar bands visibly, and flat is better than
|
||||||
|
/// striped.
|
||||||
|
pub gradients: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Appearance {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
flavor: Flavor::default(),
|
||||||
|
accent: Accent::default(),
|
||||||
|
gradients: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appearance_file(dirs: &AppDirs) -> std::path::PathBuf {
|
||||||
|
dirs.config.join("appearance.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored choice, or the defaults on a first run.
|
||||||
|
pub fn load(dirs: &AppDirs) -> Result<Appearance> {
|
||||||
|
Ok(read_json::<Appearance>(&appearance_file(dirs))?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the choice.
|
||||||
|
pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
|
||||||
|
write_json(&appearance_file(dirs), appearance)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn dirs(root: &std::path::Path) -> AppDirs {
|
||||||
|
AppDirs {
|
||||||
|
config: root.to_path_buf(),
|
||||||
|
data: root.to_path_buf(),
|
||||||
|
cache: root.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_first_run_gets_the_palette_the_app_shipped_with() {
|
||||||
|
let appearance = Appearance::default();
|
||||||
|
assert_eq!(appearance.flavor, Flavor::Proton);
|
||||||
|
assert_eq!(appearance.accent, Accent::Mauve);
|
||||||
|
assert!(appearance.gradients);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_was_saved_is_what_loads() {
|
||||||
|
let temp = std::env::temp_dir().join(format!("pstr-appearance-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&temp).unwrap();
|
||||||
|
let dirs = dirs(&temp);
|
||||||
|
|
||||||
|
let appearance = Appearance {
|
||||||
|
flavor: Flavor::Mocha,
|
||||||
|
accent: Accent::PinkSky,
|
||||||
|
gradients: false,
|
||||||
|
};
|
||||||
|
save(&dirs, &appearance).unwrap();
|
||||||
|
assert_eq!(load(&dirs).unwrap(), appearance);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&temp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_written_before_a_field_existed_keeps_the_rest() {
|
||||||
|
let temp = std::env::temp_dir().join(format!("pstr-appearance-old-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&temp).unwrap();
|
||||||
|
let dirs = dirs(&temp);
|
||||||
|
|
||||||
|
std::fs::write(appearance_file(&dirs), r#"{"flavor":"mocha"}"#).unwrap();
|
||||||
|
let appearance = load(&dirs).unwrap();
|
||||||
|
assert_eq!(appearance.flavor, Flavor::Mocha);
|
||||||
|
assert_eq!(appearance.accent, Accent::default());
|
||||||
|
assert!(appearance.gradients);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&temp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_latte_is_a_light_theme() {
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
|||||||
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 = 4;
|
pub const SCHEMA_VERSION: i64 = 5;
|
||||||
|
|
||||||
const MIGRATION_V1: &str = r#"
|
const MIGRATION_V1: &str = r#"
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
@@ -118,6 +118,16 @@ CREATE TABLE episode_metadata (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Which rows the viewer chose themselves.
|
||||||
|
///
|
||||||
|
/// A column rather than a separate table of overrides, because it changes
|
||||||
|
/// nothing about what the row *holds* — only about who put it there, and
|
||||||
|
/// therefore whether a match run may replace it. See
|
||||||
|
/// [`MetadataRecord::manual`].
|
||||||
|
const MIGRATION_V5: &str = r#"
|
||||||
|
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
|
||||||
|
"#;
|
||||||
|
|
||||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||||
const ABSOLUTE_SEASON: i64 = -1;
|
const ABSOLUTE_SEASON: i64 = -1;
|
||||||
|
|
||||||
@@ -219,6 +229,13 @@ impl Catalog {
|
|||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if version < 5 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V5)?;
|
||||||
|
tx.pragma_update(None, "user_version", 5)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +401,8 @@ impl Catalog {
|
|||||||
pub fn all_metadata(&self) -> Result<HashMap<String, MetadataRecord>> {
|
pub fn all_metadata(&self) -> Result<HashMap<String, MetadataRecord>> {
|
||||||
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
|
||||||
FROM title_metadata",
|
FROM title_metadata",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], row_to_metadata)?;
|
let rows = statement.query_map([], row_to_metadata)?;
|
||||||
@@ -404,7 +422,8 @@ impl Catalog {
|
|||||||
.conn
|
.conn
|
||||||
.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, url
|
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
|
||||||
|
url, manual
|
||||||
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,
|
||||||
@@ -422,8 +441,9 @@ impl Catalog {
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"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,
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
|
manual
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||||
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,
|
||||||
@@ -439,7 +459,8 @@ impl Catalog {
|
|||||||
rating = excluded.rating,
|
rating = excluded.rating,
|
||||||
genres = excluded.genres,
|
genres = excluded.genres,
|
||||||
episodes = excluded.episodes,
|
episodes = excluded.episodes,
|
||||||
url = excluded.url",
|
url = excluded.url,
|
||||||
|
manual = excluded.manual",
|
||||||
params![
|
params![
|
||||||
record.title_key,
|
record.title_key,
|
||||||
record.provider.as_str(),
|
record.provider.as_str(),
|
||||||
@@ -457,11 +478,31 @@ impl Catalog {
|
|||||||
data.map(|data| data.genres.join("\n")),
|
data.map(|data| data.genres.join("\n")),
|
||||||
data.and_then(|data| data.episodes),
|
data.and_then(|data| data.episodes),
|
||||||
data.and_then(|data| data.url.as_deref()),
|
data.and_then(|data| data.url.as_deref()),
|
||||||
|
record.manual as i64,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget one title's answer entirely, episodes included.
|
||||||
|
///
|
||||||
|
/// Different from storing a miss: a miss says "asked, nothing there" and is
|
||||||
|
/// trusted for a while, where this leaves no row at all and the next match
|
||||||
|
/// run treats the title as one it has never seen. That is what "match this
|
||||||
|
/// one automatically again" has to mean after a hand-picked match, since a
|
||||||
|
/// hand-picked one never expires.
|
||||||
|
pub fn forget_metadata(&self, title_key: &str) -> Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM title_metadata WHERE title_key = ?1",
|
||||||
|
params![title_key],
|
||||||
|
)?;
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM episode_metadata WHERE title_key = ?1",
|
||||||
|
params![title_key],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Forget every stored answer.
|
/// Forget every stored answer.
|
||||||
///
|
///
|
||||||
/// What "match again" and "turn enrichment off" both do. Deliberately not
|
/// What "match again" and "turn enrichment off" both do. Deliberately not
|
||||||
@@ -618,6 +659,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
|
|||||||
provider,
|
provider,
|
||||||
metadata,
|
metadata,
|
||||||
fetched_at,
|
fetched_at,
|
||||||
|
manual: row.get::<_, i64>(16)? != 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,6 +935,7 @@ mod tests {
|
|||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
metadata: Some(found()),
|
metadata: Some(found()),
|
||||||
fetched_at: 1_700_000_000,
|
fetched_at: 1_700_000_000,
|
||||||
|
manual: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
catalog.set_metadata(&record).expect("store");
|
catalog.set_metadata(&record).expect("store");
|
||||||
@@ -907,6 +950,76 @@ mod tests {
|
|||||||
assert_eq!(catalog.all_metadata().expect("read all").len(), 1);
|
assert_eq!(catalog.all_metadata().expect("read all").len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A hand-picked match has to read back as hand-picked, or the next match
|
||||||
|
/// run overwrites the one thing the viewer corrected themselves.
|
||||||
|
#[test]
|
||||||
|
fn a_hand_picked_match_says_so_when_it_is_read_back() {
|
||||||
|
let catalog = Catalog::in_memory().expect("open");
|
||||||
|
let record = MetadataRecord {
|
||||||
|
title_key: "fate stay night heavens feel".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(found()),
|
||||||
|
fetched_at: 1_700_000_000,
|
||||||
|
manual: true,
|
||||||
|
};
|
||||||
|
catalog.set_metadata(&record).expect("store");
|
||||||
|
|
||||||
|
let read = catalog.metadata(&record.title_key).expect("read");
|
||||||
|
assert_eq!(read.as_ref().map(|read| read.manual), Some(true));
|
||||||
|
assert_eq!(read, Some(record.clone()));
|
||||||
|
|
||||||
|
// And an automatic write over the top says so in turn: the flag is the
|
||||||
|
// row's, not the title's.
|
||||||
|
catalog
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
manual: false,
|
||||||
|
..record.clone()
|
||||||
|
})
|
||||||
|
.expect("store again");
|
||||||
|
assert_eq!(
|
||||||
|
catalog
|
||||||
|
.metadata(&record.title_key)
|
||||||
|
.expect("read")
|
||||||
|
.map(|read| read.manual),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgetting is not the same as storing a miss: it must leave no row at
|
||||||
|
/// all, so the next match run treats the title as one it never asked about.
|
||||||
|
#[test]
|
||||||
|
fn forgetting_a_title_leaves_neither_its_match_nor_its_episodes() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
catalog
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
title_key: "attack on titan".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(found()),
|
||||||
|
fetched_at: 1,
|
||||||
|
manual: true,
|
||||||
|
})
|
||||||
|
.expect("store");
|
||||||
|
catalog
|
||||||
|
.set_episode_metadata(
|
||||||
|
"attack on titan",
|
||||||
|
ProviderId::AniList,
|
||||||
|
1,
|
||||||
|
&[EpisodeMetadata {
|
||||||
|
season: None,
|
||||||
|
number: 1,
|
||||||
|
name: Some("To You, in 2000 Years".into()),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("store episodes");
|
||||||
|
|
||||||
|
catalog.forget_metadata("attack on titan").expect("forget");
|
||||||
|
assert!(catalog.metadata("attack on titan").expect("read").is_none());
|
||||||
|
assert!(catalog.all_episode_metadata().expect("read").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// The negative answer is the one that has to survive a round trip: without
|
/// The negative answer is the one that has to survive a round trip: without
|
||||||
/// it every render re-asks about every unmatched title.
|
/// it every render re-asks about every unmatched title.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -917,6 +1030,7 @@ mod tests {
|
|||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
metadata: None,
|
metadata: None,
|
||||||
fetched_at: 1_700_000_000,
|
fetched_at: 1_700_000_000,
|
||||||
|
manual: false,
|
||||||
};
|
};
|
||||||
catalog.set_metadata(&record).expect("store");
|
catalog.set_metadata(&record).expect("store");
|
||||||
|
|
||||||
@@ -959,6 +1073,7 @@ mod tests {
|
|||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
metadata: Some(found()),
|
metadata: Some(found()),
|
||||||
fetched_at: 1,
|
fetched_at: 1,
|
||||||
|
manual: false,
|
||||||
})
|
})
|
||||||
.expect("store");
|
.expect("store");
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! how a share is opened and how its contents are modelled.
|
//! how a share is opened and how its contents are modelled.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod appearance;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -162,6 +162,19 @@ impl Title {
|
|||||||
self.episodes().filter(|e| e.is_watched()).count()
|
self.episodes().filter(|e| e.is_watched()).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the files themselves say this is episodic, rather than
|
||||||
|
/// [`Title::kind`] having been inferred from how many of them there are.
|
||||||
|
///
|
||||||
|
/// The difference matters to whoever is matching this against a provider: a
|
||||||
|
/// folder of three unnumbered films is a `Series` here purely because it
|
||||||
|
/// holds three files, and it must not be scored down against the film
|
||||||
|
/// entries it is actually made of. Numbering is a statement; a file count
|
||||||
|
/// is a guess.
|
||||||
|
pub fn states_its_numbering(&self) -> bool {
|
||||||
|
self.episodes()
|
||||||
|
.any(|episode| episode.node.parsed.is_episode() || episode.node.parsed.season.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
/// The file to pull a poster thumbnail from: the first episode there is.
|
/// The file to pull a poster thumbnail from: the first episode there is.
|
||||||
/// Proton renders a thumbnail per file, so any of them is a frame of the
|
/// Proton renders a thumbnail per file, so any of them is a frame of the
|
||||||
/// right show; the first is the one most likely already cached.
|
/// right show; the first is the one most likely already cached.
|
||||||
@@ -288,9 +301,7 @@ impl Library {
|
|||||||
for title in &mut titles {
|
for title in &mut titles {
|
||||||
// A season number, an episode number or more than one file all mean
|
// A season number, an episode number or more than one file all mean
|
||||||
// series. A lone unnumbered file is a film.
|
// series. A lone unnumbered file is a film.
|
||||||
let numbered = title.episodes().any(|episode| {
|
let numbered = title.states_its_numbering();
|
||||||
episode.node.parsed.is_episode() || episode.node.parsed.season.is_some()
|
|
||||||
});
|
|
||||||
title.kind = if numbered || title.episode_count() > 1 {
|
title.kind = if numbered || title.episode_count() > 1 {
|
||||||
TitleKind::Series
|
TitleKind::Series
|
||||||
} else {
|
} else {
|
||||||
@@ -357,10 +368,19 @@ impl Library {
|
|||||||
|
|
||||||
/// The grouping key: case- and punctuation-insensitive, so `Fullmetal
|
/// The grouping key: case- and punctuation-insensitive, so `Fullmetal
|
||||||
/// Alchemist: Brotherhood` and `Fullmetal Alchemist Brotherhood` are one title.
|
/// Alchemist: Brotherhood` and `Fullmetal Alchemist Brotherhood` are one title.
|
||||||
|
///
|
||||||
|
/// An apostrophe is the one punctuation mark that closes up rather than
|
||||||
|
/// separating: `Heaven's Feel` keys as `heavens feel`, which is what a folder
|
||||||
|
/// that had to give its apostrophe up to the filesystem is called. Splitting it
|
||||||
|
/// into `heaven s feel` instead files the two apart and, worse, costs the
|
||||||
|
/// matcher a real match on the difference.
|
||||||
pub fn title_key(title: &str) -> String {
|
pub fn title_key(title: &str) -> String {
|
||||||
let mut key = String::with_capacity(title.len());
|
let mut key = String::with_capacity(title.len());
|
||||||
let mut pending_space = false;
|
let mut pending_space = false;
|
||||||
for character in title.chars() {
|
for character in title.chars() {
|
||||||
|
if matches!(character, '\'' | '\u{2019}' | '\u{02bc}') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if character.is_alphanumeric() {
|
if character.is_alphanumeric() {
|
||||||
if pending_space && !key.is_empty() {
|
if pending_space && !key.is_empty() {
|
||||||
key.push(' ');
|
key.push(' ');
|
||||||
@@ -635,6 +655,16 @@ mod tests {
|
|||||||
assert_eq!(title_key("K-On!!"), "k on");
|
assert_eq!(title_key("K-On!!"), "k on");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An apostrophe closes up rather than separating, and a name written to
|
||||||
|
/// disk has usually lost it — the two have to key alike, or a folder called
|
||||||
|
/// `Heavens Feel` is a different title to the one the provider has.
|
||||||
|
#[test]
|
||||||
|
fn an_apostrophe_keys_the_same_as_its_absence() {
|
||||||
|
assert_eq!(title_key("Heaven's Feel"), "heavens feel");
|
||||||
|
assert_eq!(title_key("Heaven\u{2019}s Feel"), title_key("Heavens Feel"));
|
||||||
|
assert_eq!(title_key("Devils' Line"), title_key("Devils Line"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn folders_are_not_episodes() {
|
fn folders_are_not_episodes() {
|
||||||
let mut folder = node("s", "1", "Show", None, None);
|
let mut folder = node("s", "1", "Show", None, None);
|
||||||
|
|||||||
@@ -250,6 +250,13 @@ pub struct MetadataRecord {
|
|||||||
pub metadata: Option<TitleMetadata>,
|
pub metadata: Option<TitleMetadata>,
|
||||||
/// Unix seconds. What makes a miss expire rather than being forever.
|
/// Unix seconds. What makes a miss expire rather than being forever.
|
||||||
pub fetched_at: i64,
|
pub fetched_at: i64,
|
||||||
|
/// The viewer picked this entry themselves.
|
||||||
|
///
|
||||||
|
/// A hand-picked match outranks anything the matcher would decide: it never
|
||||||
|
/// expires, and "match again" leaves it alone. Otherwise the one title the
|
||||||
|
/// scorer gets wrong — the case this flag exists for — would be corrected by
|
||||||
|
/// hand and then quietly un-corrected by the next run.
|
||||||
|
pub manual: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a stored answer is trusted.
|
/// How long a stored answer is trusted.
|
||||||
@@ -267,6 +274,11 @@ impl MetadataRecord {
|
|||||||
if self.provider != provider {
|
if self.provider != provider {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// A hand-picked match does not go stale: nothing a re-ask could learn
|
||||||
|
// would be better than what the viewer already said it was.
|
||||||
|
if self.manual {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let ttl = if self.metadata.is_some() {
|
let ttl = if self.metadata.is_some() {
|
||||||
MATCH_TTL_SECS
|
MATCH_TTL_SECS
|
||||||
} else {
|
} else {
|
||||||
@@ -425,6 +437,7 @@ mod tests {
|
|||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
metadata: None,
|
metadata: None,
|
||||||
fetched_at: now - MISS_TTL_SECS - 1,
|
fetched_at: now - MISS_TTL_SECS - 1,
|
||||||
|
manual: false,
|
||||||
};
|
};
|
||||||
assert!(!miss.is_fresh(now, ProviderId::AniList));
|
assert!(!miss.is_fresh(now, ProviderId::AniList));
|
||||||
|
|
||||||
@@ -435,6 +448,22 @@ mod tests {
|
|||||||
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
|
||||||
|
/// that could go stale, it is an instruction.
|
||||||
|
#[test]
|
||||||
|
fn a_hand_picked_match_never_expires() {
|
||||||
|
let record = MetadataRecord {
|
||||||
|
title_key: "k".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(metadata()),
|
||||||
|
fetched_at: 0,
|
||||||
|
manual: true,
|
||||||
|
};
|
||||||
|
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.
|
||||||
|
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 —
|
||||||
/// especially its silence.
|
/// especially its silence.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -444,6 +473,7 @@ mod tests {
|
|||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
metadata: Some(metadata()),
|
metadata: Some(metadata()),
|
||||||
fetched_at: 1_000_000_000,
|
fetched_at: 1_000_000_000,
|
||||||
|
manual: false,
|
||||||
};
|
};
|
||||||
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
|
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,9 +345,7 @@ fn ordinal_before(lower: &str, at: usize) -> Option<(u32, usize)> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find_map(|suffix| head.strip_suffix(suffix))?;
|
.find_map(|suffix| head.strip_suffix(suffix))?;
|
||||||
|
|
||||||
let digits_start = head
|
let digits_start = trailing_digits_start(head).unwrap_or(0);
|
||||||
.rfind(|c: char| !c.is_ascii_digit())
|
|
||||||
.map_or(0, |index| index + 1);
|
|
||||||
let digits = &head[digits_start..];
|
let digits = &head[digits_start..];
|
||||||
if digits.is_empty() || digits.len() > 2 {
|
if digits.is_empty() || digits.len() > 2 {
|
||||||
return None;
|
return None;
|
||||||
@@ -596,6 +594,26 @@ fn read_number(text: &str, from: usize) -> Option<(u32, usize)> {
|
|||||||
text[from..end].parse().ok().map(|n| (n, end))
|
text[from..end].parse().ok().map(|n| (n, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the trailing run of ASCII digits starts, or `None` when the text is
|
||||||
|
/// nothing but digits (or empty).
|
||||||
|
///
|
||||||
|
/// Walked back by `char_indices`, not by `rfind(…) + 1`: one past the *start*
|
||||||
|
/// of the character before the digits is inside it whenever that character is
|
||||||
|
/// multi-byte. `Death (True)² - 007` is the case that proves it — the `+ 1`
|
||||||
|
/// form cuts the `²` in half and panics.
|
||||||
|
fn trailing_digits_start(text: &str) -> Option<usize> {
|
||||||
|
let mut start = None;
|
||||||
|
|
||||||
|
for (index, ch) in text.char_indices().rev() {
|
||||||
|
if ch.is_ascii_digit() {
|
||||||
|
start = Some(index);
|
||||||
|
} else {
|
||||||
|
return Some(start.unwrap_or(text.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn is_token_char(byte: Option<u8>) -> bool {
|
fn is_token_char(byte: Option<u8>) -> bool {
|
||||||
byte.is_some_and(|b| b.is_ascii_alphanumeric())
|
byte.is_some_and(|b| b.is_ascii_alphanumeric())
|
||||||
}
|
}
|
||||||
@@ -715,7 +733,7 @@ pub fn classify_folder(name: &str) -> FolderRole {
|
|||||||
/// `Evangelion 3`, `Kaijuu 8-gou` — would lose it to a season it never claimed.
|
/// `Evangelion 3`, `Kaijuu 8-gou` — would lose it to a season it never claimed.
|
||||||
fn trailing_season(name: &str) -> Option<(&str, u32)> {
|
fn trailing_season(name: &str) -> Option<(&str, u32)> {
|
||||||
let trimmed = name.trim_end();
|
let trimmed = name.trim_end();
|
||||||
let digits_start = trimmed.rfind(|c: char| !c.is_ascii_digit())? + 1;
|
let digits_start = trailing_digits_start(trimmed)?;
|
||||||
let (head, digits) = trimmed.split_at(digits_start);
|
let (head, digits) = trimmed.split_at(digits_start);
|
||||||
let season: u32 = digits.parse().ok()?;
|
let season: u32 = digits.parse().ok()?;
|
||||||
|
|
||||||
@@ -910,6 +928,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **A panic, not a mis-parse.** A folder name whose trailing digits sit
|
||||||
|
/// directly behind a multi-byte character — or that simply *ends* in one —
|
||||||
|
/// used to be split one byte past that character's start, i.e. inside it.
|
||||||
|
#[test]
|
||||||
|
fn a_multi_byte_character_does_not_break_folder_classification() {
|
||||||
|
for name in [
|
||||||
|
"Neon Genesis Evangelion Death (True)\u{b2}",
|
||||||
|
"Show\u{b2}3",
|
||||||
|
"Show\u{b2}3rd Season",
|
||||||
|
"Caf\u{e9} Season 2",
|
||||||
|
"\u{b2}",
|
||||||
|
] {
|
||||||
|
// The classification itself is the assertion: any of these used to
|
||||||
|
// panic before it could return one.
|
||||||
|
let _ = classify_folder(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let FolderRole::Title(parsed) = classify_folder("Caf\u{e9} Season 2") else {
|
||||||
|
panic!("expected a title");
|
||||||
|
};
|
||||||
|
assert_eq!((parsed.title.as_str(), parsed.season), ("Café", Some(2)));
|
||||||
|
}
|
||||||
|
|
||||||
/// A resolution is four digits in brackets too, and must not read as a year.
|
/// A resolution is four digits in brackets too, and must not read as a year.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bracketed_resolution_is_not_mistaken_for_a_year() {
|
fn a_bracketed_resolution_is_not_mistaken_for_a_year() {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
# The per-platform keystore feature comes from `pstr-core` above — cargo unifies
|
||||||
|
# it across the graph, and `pstr-core` is a hard dependency here. Do not enable
|
||||||
|
# `keyring` without it: the feature-less build resolves to the `mock` store.
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
|
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
|
||||||
# binary means two TLS stacks linked into it.
|
# binary means two TLS stacks linked into it.
|
||||||
|
|||||||
@@ -85,19 +85,28 @@ impl Provider for AniList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
|
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
|
||||||
|
// Each term is asked only because the one before it came back with
|
||||||
|
// nothing at all — see `search_terms`. A page of candidates, however
|
||||||
|
// weak, is the matcher's business rather than this one's.
|
||||||
|
for term in search_terms(&query.name) {
|
||||||
let data: Option<SearchData> = self
|
let data: Option<SearchData> = self
|
||||||
.query(serde_json::json!({
|
.query(serde_json::json!({
|
||||||
"query": SEARCH,
|
"query": SEARCH,
|
||||||
"variables": { "search": query.name, "perPage": PAGE_SIZE },
|
"variables": { "search": term, "perPage": PAGE_SIZE },
|
||||||
}))
|
}))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(data
|
let candidates: Vec<Candidate> = data
|
||||||
.map(|data| data.page.media)
|
.map(|data| data.page.media)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(Media::into_candidate)
|
.map(Media::into_candidate)
|
||||||
.collect())
|
.collect();
|
||||||
|
if !candidates.is_empty() {
|
||||||
|
return Ok(candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
|
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
|
||||||
@@ -157,6 +166,61 @@ impl AniList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The search strings to try, in order, until one of them returns anything.
|
||||||
|
///
|
||||||
|
/// AniList does not do fuzzy search. Every word of the query has to be a word
|
||||||
|
/// its index actually holds — only the *last* one is matched as a prefix — and
|
||||||
|
/// all of them have to match, so a single word AniList has never seen empties
|
||||||
|
/// the whole result set. `ghost in the shel` finds the film; `cowbo bebop`
|
||||||
|
/// finds nothing.
|
||||||
|
///
|
||||||
|
/// That turns one filesystem habit into a total miss: a name written to disk
|
||||||
|
/// has had its apostrophes taken out, because plenty of tools and shares still
|
||||||
|
/// dislike them. `Fate/stay night [Heaven's Feel]` indexes the words `heaven`
|
||||||
|
/// and `s`, and the folder is called `Fate stay night Heavens Feel` — whose
|
||||||
|
/// `heavens` is not a word in the index and not a prefix of one either. AniList
|
||||||
|
/// answers with an empty page, the title is stored as a miss, and it stays
|
||||||
|
/// unmatched for as long as the record is fresh.
|
||||||
|
///
|
||||||
|
/// So the fallback puts the apostrophe back the only way that survives
|
||||||
|
/// 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
|
||||||
|
/// broadening a search that was already working.
|
||||||
|
fn search_terms(name: &str) -> Vec<String> {
|
||||||
|
let mut terms = vec![name.to_string()];
|
||||||
|
|
||||||
|
let depossessed = without_possessive_s(name);
|
||||||
|
if depossessed != name {
|
||||||
|
terms.push(depossessed);
|
||||||
|
}
|
||||||
|
terms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Heavens Feel` → `Heaven Feel`.
|
||||||
|
///
|
||||||
|
/// Only words long enough to still mean something without it, and only where
|
||||||
|
/// the `s` follows a letter — a word that kept its apostrophe (`Heaven's`) is
|
||||||
|
/// already what the index holds, and cutting the `s` off it would leave the
|
||||||
|
/// apostrophe behind as its own token.
|
||||||
|
fn without_possessive_s(name: &str) -> String {
|
||||||
|
name.split(' ')
|
||||||
|
.map(|word| {
|
||||||
|
let mut characters = word.chars().rev();
|
||||||
|
let last = characters.next();
|
||||||
|
let previous = characters.next();
|
||||||
|
let long_enough = word.chars().count() >= 4;
|
||||||
|
|
||||||
|
match (last, previous) {
|
||||||
|
(Some('s' | 'S'), Some(previous)) if long_enough && previous.is_alphabetic() => {
|
||||||
|
&word[..word.len() - 1]
|
||||||
|
}
|
||||||
|
_ => word,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Response<T> {
|
struct Response<T> {
|
||||||
data: Option<T>,
|
data: Option<T>,
|
||||||
@@ -402,6 +466,45 @@ mod tests {
|
|||||||
assert_eq!(kind_of(None), TitleKind::Film);
|
assert_eq!(kind_of(None), TitleKind::Film);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The miss this exists for: a folder named after a title whose apostrophe
|
||||||
|
/// the filesystem never got, searched against an index that tokenised the
|
||||||
|
/// apostrophe into a word boundary.
|
||||||
|
#[test]
|
||||||
|
fn a_name_that_lost_its_apostrophe_is_searched_for_again_without_the_s() {
|
||||||
|
assert_eq!(
|
||||||
|
search_terms("Fate stay night Heavens Feel"),
|
||||||
|
vec![
|
||||||
|
"Fate stay night Heavens Feel".to_string(),
|
||||||
|
"Fate stay night Heaven Feel".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One request, not two, for the names that need no repair — the fallback
|
||||||
|
/// is a second round trip and a title that already matched must not pay it.
|
||||||
|
#[test]
|
||||||
|
fn a_name_with_nothing_to_repair_is_searched_for_once() {
|
||||||
|
assert_eq!(search_terms("Cowboy Bebop"), vec!["Cowboy Bebop"]);
|
||||||
|
// Short words keep their `s`: `Kids on the Slope` is not `Kid on the
|
||||||
|
// Slope`, and a name that still has its apostrophe is already what the
|
||||||
|
// index holds.
|
||||||
|
assert_eq!(search_terms("Heaven's Feel"), vec!["Heaven's Feel"]);
|
||||||
|
assert_eq!(
|
||||||
|
search_terms("Kids on the Bus"),
|
||||||
|
vec!["Kids on the Bus".to_string(), "Kid on the Bus".to_string()],
|
||||||
|
"a four-letter word is long enough; a three-letter one is not"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte slicing, on names that are not ASCII.
|
||||||
|
#[test]
|
||||||
|
fn a_multi_byte_name_is_not_cut_inside_a_character() {
|
||||||
|
for name in ["進撃の巨人", "Kaguya-sama: Love is War?", "café"] {
|
||||||
|
let terms = search_terms(name);
|
||||||
|
assert_eq!(terms[0], name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn markup_becomes_text_and_breaks_become_newlines() {
|
fn markup_becomes_text_and_breaks_become_newlines() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ pub struct Query {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub year: Option<u32>,
|
pub year: Option<u32>,
|
||||||
pub kind: TitleKind,
|
pub kind: TitleKind,
|
||||||
|
/// Whether the library *knows* what kind of thing this is.
|
||||||
|
///
|
||||||
|
/// The mirror of [`Candidate::kind_known`], and it exists for the same
|
||||||
|
/// reason. A folder holding the three `Heaven's Feel` films is a `Series`
|
||||||
|
/// to [`pstr_core::library`] only because it holds three files — the films
|
||||||
|
/// themselves are numbered nothing — and scoring that guess against the
|
||||||
|
/// provider's (correct) `Film` costs the right answer the 0.05 that puts it
|
||||||
|
/// under the floor.
|
||||||
|
pub kind_known: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Query {
|
impl Query {
|
||||||
@@ -46,8 +55,16 @@ impl Query {
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
year,
|
year,
|
||||||
kind,
|
kind,
|
||||||
|
kind_known: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Say that [`Query::kind`] was inferred rather than stated — see
|
||||||
|
/// [`Query::kind_known`].
|
||||||
|
pub fn with_guessed_kind(mut self) -> Self {
|
||||||
|
self.kind_known = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One of a provider's answers, with every name it is known by.
|
/// One of a provider's answers, with every name it is known by.
|
||||||
@@ -93,10 +110,11 @@ pub fn score(query: &Query, candidate: &Candidate) -> f32 {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if candidate.kind_known && query.kind != candidate.metadata.kind {
|
if query.kind_known && candidate.kind_known && query.kind != candidate.metadata.kind {
|
||||||
// A film and a series of the same name are usually genuinely related —
|
// A film and a series of the same name are usually genuinely related —
|
||||||
// an adaptation, a compilation — so this is a nudge, not a veto. And
|
// an adaptation, a compilation — so this is a nudge, not a veto. And
|
||||||
// only when the provider said; see `Candidate::kind_known`.
|
// only when both sides actually said; see `Candidate::kind_known` and
|
||||||
|
// `Query::kind_known`.
|
||||||
score -= 0.05;
|
score -= 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +371,47 @@ mod tests {
|
|||||||
|
|
||||||
assert!(score(&query, &unstated) > score(&query, &stated));
|
assert!(score(&query, &unstated) > score(&query, &stated));
|
||||||
assert_eq!(score(&query, &unstated), 1.0);
|
assert_eq!(score(&query, &unstated), 1.0);
|
||||||
|
|
||||||
|
// And the same in reverse: a library that guessed its own kind has
|
||||||
|
// nothing to disagree with the provider about.
|
||||||
|
let guessed = query.clone().with_guessed_kind();
|
||||||
|
assert_eq!(score(&guessed, &stated), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The apostrophe case, both shapes of it.** A folder cannot carry the
|
||||||
|
/// apostrophe of `Fate/stay night [Heaven's Feel]`, so it is called
|
||||||
|
/// `Heavens Feel` — and until [`title_key`] closed the apostrophe up rather
|
||||||
|
/// than splitting on it, the provider's own entry scored under the floor.
|
||||||
|
///
|
||||||
|
/// One film per folder matches outright. The trilogy in a single folder is
|
||||||
|
/// the harder half: the library calls three files a series where the
|
||||||
|
/// provider calls each of them a film, so it clears the floor by a hair
|
||||||
|
/// with that mismatch subtracted.
|
||||||
|
#[test]
|
||||||
|
fn a_title_that_lost_its_apostrophe_matches_the_entry_that_kept_one() {
|
||||||
|
let film = candidate(
|
||||||
|
"Fate/stay night [Heaven's Feel] I. presage flower",
|
||||||
|
&[],
|
||||||
|
Some(2017),
|
||||||
|
TitleKind::Film,
|
||||||
|
);
|
||||||
|
|
||||||
|
let alone = Query::new(
|
||||||
|
"Fate stay night Heavens Feel I Presage Flower",
|
||||||
|
Some(2017),
|
||||||
|
TitleKind::Film,
|
||||||
|
);
|
||||||
|
assert!(score(&alone, &film) >= 1.0);
|
||||||
|
|
||||||
|
// Three films in one folder: a `Series` only because there are three of
|
||||||
|
// them, so the kind is a guess and costs the film entry nothing.
|
||||||
|
let trilogy =
|
||||||
|
Query::new("Fate stay night Heavens Feel", None, TitleKind::Series).with_guessed_kind();
|
||||||
|
assert!(
|
||||||
|
score(&trilogy, &film) >= MATCH_FLOOR,
|
||||||
|
"scored {}",
|
||||||
|
score(&trilogy, &film)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tie-break must not override the score: a later candidate that
|
/// The tie-break must not override the score: a later candidate that
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use pstr_core::library::{Title, title_key};
|
use pstr_core::library::{Title, TitleKind, title_key};
|
||||||
use pstr_core::metadata::{
|
use pstr_core::metadata::{
|
||||||
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
||||||
};
|
};
|
||||||
@@ -124,7 +124,12 @@ impl MetadataService {
|
|||||||
/// `Ok(None)` is a real answer — nothing matched well enough — and the
|
/// `Ok(None)` is a real answer — nothing matched well enough — and the
|
||||||
/// caller should store it. An `Err` is not, and the caller should not.
|
/// caller should store it. An `Err` is not, and the caller should not.
|
||||||
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
|
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
|
||||||
let 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
|
||||||
|
// them — see `Query::kind_known`.
|
||||||
|
if !title.states_its_numbering() {
|
||||||
|
query = query.with_guessed_kind();
|
||||||
|
}
|
||||||
let candidates = self.source.search(&query).await?;
|
let candidates = self.source.search(&query).await?;
|
||||||
let found = candidates.len();
|
let found = candidates.len();
|
||||||
let best = matching::best(&query, candidates);
|
let best = matching::best(&query, candidates);
|
||||||
@@ -149,9 +154,56 @@ impl MetadataService {
|
|||||||
provider: self.provider(),
|
provider: self.provider(),
|
||||||
metadata,
|
metadata,
|
||||||
fetched_at: now(),
|
fetched_at: now(),
|
||||||
|
manual: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything the provider thinks `name` might be, unscored and in its own
|
||||||
|
/// order.
|
||||||
|
///
|
||||||
|
/// The escape hatch from [`matching::best`], for a viewer picking an entry
|
||||||
|
/// by hand. Nothing is filtered here and nothing is ranked: the floor exists
|
||||||
|
/// to stop the *matcher* guessing, and a person reading the list is not
|
||||||
|
/// guessing. The `Fate/stay night [Heaven's Feel]` trilogy is the case that
|
||||||
|
/// wants it — three films in one folder, matched against a provider that
|
||||||
|
/// files each of them separately, where no single entry is the right answer
|
||||||
|
/// and only the viewer knows which one they meant.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// went wrong.
|
||||||
|
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();
|
||||||
|
if query.name.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let candidates = self.source.search(&query).await?;
|
||||||
|
tracing::debug!(
|
||||||
|
"hand search {:?}: {} candidates from {}",
|
||||||
|
query.name,
|
||||||
|
candidates.len(),
|
||||||
|
self.provider().label()
|
||||||
|
);
|
||||||
|
Ok(candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| candidate.metadata)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A record for an entry the viewer picked themselves.
|
||||||
|
///
|
||||||
|
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run
|
||||||
|
/// — including a forced one — from undoing it.
|
||||||
|
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
|
||||||
|
MetadataRecord {
|
||||||
|
title_key,
|
||||||
|
provider: self.provider(),
|
||||||
|
metadata: Some(found),
|
||||||
|
fetched_at: now(),
|
||||||
|
manual: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the provider lists as the episodes of a title it matched.
|
/// What the provider lists as the episodes of a title it matched.
|
||||||
///
|
///
|
||||||
/// 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
|
||||||
@@ -339,6 +391,7 @@ mod tests {
|
|||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
metadata: None,
|
metadata: None,
|
||||||
fetched_at: now(),
|
fetched_at: now(),
|
||||||
|
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));
|
||||||
|
|||||||
@@ -111,6 +111,24 @@ tagged with that season. The absolute fallback in `EpisodeGuide::get` stops at
|
|||||||
season one for the same reason: without that it answered `S02E01` with season
|
season one for the same reason: without that it answered `S02E01` with season
|
||||||
one's episode one, which is not a missing caption but a wrong one.
|
one's episode one, which is not a missing caption but a wrong one.
|
||||||
|
|
||||||
|
Done: **matching a title by hand.** `MATCH_FLOOR` is set where a wrong poster
|
||||||
|
costs more than none, and the price of that is a few titles it refuses to decide
|
||||||
|
about — `Fate/stay night [Heaven's Feel]` is the standing example, three films in
|
||||||
|
one folder against a provider that files each of them as its own entry, so there
|
||||||
|
is no single answer for the scorer to find. *Change match* on the title page opens
|
||||||
|
`ui::matcher`: the provider's own answers for whatever is typed, unscored and in
|
||||||
|
its own order (`MetadataService::search`), and a click pins one. Nothing is
|
||||||
|
ranked there on purpose — the floor exists to stop the *scorer* guessing, and a
|
||||||
|
person reading eight entries with their posters is not guessing.
|
||||||
|
|
||||||
|
A chosen row is stored `manual` (schema **v5**), which means two things:
|
||||||
|
`MetadataRecord::is_fresh` never expires it, and `run_match` skips it even when
|
||||||
|
forced — "match again" must not undo the one title someone fixed by hand. The
|
||||||
|
way back is *Clear*, which drops the row entirely (`Catalog::forget_metadata`)
|
||||||
|
rather than storing a miss, so the next run treats the title as one it has never
|
||||||
|
asked about. Switching provider still clears everything, hand-picked rows
|
||||||
|
included: a `remote_id` means nothing to the other provider.
|
||||||
|
|
||||||
Worth knowing before chasing a missing name: AniList's episode titles come from
|
Worth knowing before chasing a missing name: AniList's episode titles come from
|
||||||
`streamingEpisodes`, which is what streaming sites published, and for some shows
|
`streamingEpisodes`, which is what streaming sites published, and for some shows
|
||||||
it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal
|
it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ almost every field in it is computed.
|
|||||||
- **The `[patch.crates-io]` block in `Cargo.toml` is development only.** It points
|
- **The `[patch.crates-io]` block in `Cargo.toml` is development only.** It points
|
||||||
at `../proton-sdk-rs`, so both build scripts refuse to start when it is present
|
at `../proton-sdk-rs`, so both build scripts refuse to start when it is present
|
||||||
and that checkout is missing. A release must build against the published
|
and that checkout is missing. A release must build against the published
|
||||||
crates — drop the block once proton-sdk 0.3.3 is on crates.io.
|
crates — drop the block once the pinned proton-sdk is on crates.io.
|
||||||
- **There is no `LICENSE` file in the repository root** even though `Cargo.toml`
|
- **There is no `LICENSE` file in the repository root** even though `Cargo.toml`
|
||||||
declares MIT. Every packager warns and ships without the license text; the MSI
|
declares MIT. Every packager warns and ships without the license text; the MSI
|
||||||
falls back to `windows/license.rtf`. Adding the file fixes all of them at once.
|
falls back to `windows/license.rtf`. Adding the file fixes all of them at once.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
-D Version=0.1.0 -D BinDir=... -D MpvDll=... -D IconFile=...
|
-D Version=0.1.0 -D BinDir=... -D MpvDll=... -D IconFile=...
|
||||||
|
|
||||||
Why a second file at all, next to proton-stream.wxs
|
Why a second file at all, next to proton-stream.wxs
|
||||||
---------------------------------------------------
|
===================================================
|
||||||
The WiX toolset cannot build an .msi on Linux. Not "is unsupported" — it
|
The WiX toolset cannot build an .msi on Linux. Not "is unsupported" — it
|
||||||
fails outright, in WixToolset.Core's BundleValidator.GetCanonicalRelativePath,
|
fails outright, in WixToolset.Core's BundleValidator.GetCanonicalRelativePath,
|
||||||
which validates every Directory/@Name by prepending a drive letter and asking
|
which validates every Directory/@Name by prepending a drive letter and asking
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
of it — hence this file rather than a preprocessor branch inside the v4 one.
|
of it — hence this file rather than a preprocessor branch inside the v4 one.
|
||||||
|
|
||||||
What this loses against the WiX build
|
What this loses against the WiX build
|
||||||
-------------------------------------
|
=====================================
|
||||||
The dialog UI. WixUI_InstallDir, and with it the license page and the
|
The dialog UI. WixUI_InstallDir, and with it the license page and the
|
||||||
"choose a folder" page, is a WiX extension with no wixl equivalent, so this
|
"choose a folder" page, is a WiX extension with no wixl equivalent, so this
|
||||||
.msi installs with the plain Windows Installer progress UI and always lands in
|
.msi installs with the plain Windows Installer progress UI and always lands in
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
entry, clean uninstall — is authored here and identical.
|
entry, clean uninstall — is authored here and identical.
|
||||||
|
|
||||||
Keep in sync with proton-stream.wxs
|
Keep in sync with proton-stream.wxs
|
||||||
-----------------------------------
|
===================================
|
||||||
UpgradeCode and every Component Guid are shared with that file, deliberately.
|
UpgradeCode and every Component Guid are shared with that file, deliberately.
|
||||||
They are what lets an .msi built here upgrade one built on Windows (and the
|
They are what lets an .msi built here upgrade one built on Windows (and the
|
||||||
reverse) instead of the two stacking up or corrupting each other's component
|
reverse) instead of the two stacking up or corrupting each other's component
|
||||||
@@ -89,17 +89,21 @@
|
|||||||
|
|
||||||
<Component Id="AppExe" Guid="623c8cb8-b826-4d57-a054-e5be56f24c19" Win64="yes">
|
<Component Id="AppExe" Guid="623c8cb8-b826-4d57-a054-e5be56f24c19" Win64="yes">
|
||||||
<File Id="AppExe" Source="$(var.BinDir)/proton-stream.exe"
|
<File Id="AppExe" Source="$(var.BinDir)/proton-stream.exe"
|
||||||
Name="proton-stream.exe" KeyPath="yes">
|
Name="proton-stream.exe" KeyPath="yes" />
|
||||||
<!-- Not advertised: an advertised shortcut needs the feature
|
<!-- Not advertised: an advertised shortcut needs the feature state
|
||||||
state machine that the dialog-less install does not drive. -->
|
machine that the dialog-less install does not drive. wixl has
|
||||||
|
no Advertise attribute — it logs "builder.vala: unimplemented"
|
||||||
|
and writes the advertised form anyway, with the feature name in
|
||||||
|
Shortcut/Target. What selects the non-advertised form is being
|
||||||
|
a child of the Component rather than of the File, plus an
|
||||||
|
explicit formatted Target. Keep it out of the <File>. -->
|
||||||
<Shortcut Id="StartMenuShortcut"
|
<Shortcut Id="StartMenuShortcut"
|
||||||
Directory="ProgramMenuFolder"
|
Directory="ProgramMenuFolder"
|
||||||
Name="Proton Stream"
|
Name="Proton Stream"
|
||||||
Description="Browse and stream a Proton Drive public link"
|
Description="Browse and stream a Proton Drive public link"
|
||||||
|
Target="[INSTALLDIR]proton-stream.exe"
|
||||||
WorkingDirectory="INSTALLDIR"
|
WorkingDirectory="INSTALLDIR"
|
||||||
Icon="proton-stream.ico"
|
Icon="proton-stream.ico" />
|
||||||
Advertise="no" />
|
|
||||||
</File>
|
|
||||||
</Component>
|
</Component>
|
||||||
|
|
||||||
<!-- The CLI, and the PATH entry that makes it worth installing. Tied
|
<!-- The CLI, and the PATH entry that makes it worth installing. Tied
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ fetch_mpv() {
|
|||||||
resolve_mpv() {
|
resolve_mpv() {
|
||||||
[[ $FETCH_MPV -eq 1 ]] && fetch_mpv
|
[[ $FETCH_MPV -eq 1 ]] && fetch_mpv
|
||||||
|
|
||||||
|
# A previous --fetch-mpv left its unpacked copy in dist/mpv-dev. Adopt it
|
||||||
|
# rather than making every later run repeat the flag; it is the same
|
||||||
|
# directory fetch_mpv would have reused.
|
||||||
|
if [[ -z "$MPV_DEV" && -f "$DIST/mpv-dev/libmpv-2.dll" ]]; then
|
||||||
|
log "reusing $DIST/mpv-dev"
|
||||||
|
MPV_DEV="$DIST/mpv-dev"
|
||||||
|
fi
|
||||||
|
|
||||||
[[ -n "$MPV_DEV" ]] || die "$(cat <<EOF
|
[[ -n "$MPV_DEV" ]] || die "$(cat <<EOF
|
||||||
no libmpv development files. Either pass --fetch-mpv, or download the mpv-dev
|
no libmpv development files. Either pass --fetch-mpv, or download the mpv-dev
|
||||||
archive from
|
archive from
|
||||||
|
|||||||
Reference in new issue
Block a user