mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
init
This commit is contained in:
commit
a1b345a029
80 files changed
+28033
No files matched your search
@@ -0,0 +1,161 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user