Files
proton-stream/crates/pstr-core/src/prefs.rs
T
2026-08-02 19:37:03 +02:00

162 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Playback preferences that outlive one file.
//!
//! Volume, mute, and which language of audio and subtitles to prefer. All four
//! are things a viewer sets once and expects to still hold for the next
//! episode, the next film and the next launch — a player that starts every file
//! at full volume in the container's default language is one that has to be
//! corrected every time.
//!
//! Stored beside the share list and written the same way: atomically, and never
//! overwritten when it will not parse. Losing this file costs nothing more than
//! a re-set, so unlike `shares.json` an unreadable one is reported and *stepped
//! over* rather than being allowed to stop anything.
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// The loudest this app plays. Matches `pstr_player::MAX_VOLUME`, which is not
/// referenced here because `pstr-core` does not depend on the player.
const MAX_VOLUME: f64 = 100.0;
/// How playback should sound, and in which language.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
// A file written by an older version, or hand-edited, is missing fields rather
// than invalid: every one of them has a sensible default.
#[serde(default)]
pub struct PlaybackPrefs {
/// 0–100.
pub volume: f64,
pub muted: bool,
/// The language tag of the audio track to prefer — "jpn", "eng". `None`
/// leaves the choice to the container's default.
pub audio_language: Option<String>,
pub subtitle_language: Option<String>,
/// Whether subtitles are wanted at all.
///
/// Separate from the language: "off" is a decision, and an absent language
/// cannot express it — that means "no preference", which is what a viewer
/// who has never touched the menu has.
pub subtitles: bool,
/// Whether reaching the end of an episode starts the next one.
///
/// On by default, because that is what every service this is modelled on
/// does and what a season of anything is watched like. Off is one checkbox
/// away for someone who falls asleep to it.
pub autoplay_next: bool,
}
impl Default for PlaybackPrefs {
fn default() -> Self {
Self {
volume: MAX_VOLUME,
muted: false,
audio_language: None,
subtitle_language: None,
subtitles: true,
autoplay_next: true,
}
}
}
impl PlaybackPrefs {
/// The same preferences with anything out of range brought back into it.
///
/// A hand-edited or older file can hold a volume of 900 or of NaN, and both
/// would otherwise reach mpv.
pub fn sanitized(mut self) -> Self {
if !self.volume.is_finite() {
self.volume = MAX_VOLUME;
}
self.volume = self.volume.clamp(0.0, MAX_VOLUME);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self
}
}
fn prefs_file(dirs: &AppDirs) -> std::path::PathBuf {
dirs.config.join("playback.json")
}
/// The stored preferences, or the defaults on a first run.
pub fn load(dirs: &AppDirs) -> Result<PlaybackPrefs> {
Ok(read_json::<PlaybackPrefs>(&prefs_file(dirs))?
.unwrap_or_default()
.sanitized())
}
/// Write the preferences.
pub fn save(dirs: &AppDirs, prefs: &PlaybackPrefs) -> Result<()> {
write_json(&prefs_file(dirs), prefs)
}
#[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_full_volume_and_no_language_preference() {
let prefs = PlaybackPrefs::default();
assert_eq!(prefs.volume, MAX_VOLUME);
assert!(!prefs.muted);
assert!(prefs.subtitles);
assert_eq!(prefs.audio_language, None);
}
#[test]
fn what_was_saved_is_what_loads() {
let temp = std::env::temp_dir().join(format!("pstr-prefs-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
let dirs = dirs(&temp);
let prefs = PlaybackPrefs {
volume: 42.0,
muted: true,
audio_language: Some("jpn".into()),
subtitle_language: Some("eng".into()),
subtitles: true,
autoplay_next: false,
};
save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs);
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
let prefs = PlaybackPrefs {
volume: 900.0,
..PlaybackPrefs::default()
}
.sanitized();
assert_eq!(prefs.volume, MAX_VOLUME);
let prefs = PlaybackPrefs {
volume: f64::NAN,
..PlaybackPrefs::default()
}
.sanitized();
// NaN clamps to NaN, which reaches mpv as a volume of nothing at all.
assert_eq!(prefs.volume, MAX_VOLUME);
}
#[test]
fn a_language_set_to_blank_is_no_preference_rather_than_a_language_named_nothing() {
let prefs = PlaybackPrefs {
audio_language: Some(" ".into()),
..PlaybackPrefs::default()
}
.sanitized();
assert_eq!(prefs.audio_language, None);
}
}