diff --git a/src/config/src/lib.rs b/src/config/src/lib.rs index 180807ebb..5ad99c2c1 100644 --- a/src/config/src/lib.rs +++ b/src/config/src/lib.rs @@ -50,6 +50,7 @@ struct SettingsData { hwdec: String, audio_passthrough: String, audio_channels: String, + subtitle_scale: String, log_level: String, device_name: String, window: JfnWindowGeometry, @@ -68,6 +69,7 @@ impl Default for SettingsData { hwdec: String::new(), audio_passthrough: String::new(), audio_channels: String::new(), + subtitle_scale: String::new(), log_level: String::new(), device_name: String::new(), window: JfnWindowGeometry::default(), @@ -98,6 +100,9 @@ impl SettingsData { if let Some(s) = v.get("audioChannels").and_then(Value::as_str) { self.audio_channels = s.into(); } + if let Some(s) = v.get("subtitleScale").and_then(Value::as_str) { + self.subtitle_scale = s.into(); + } if let Some(s) = v.get("logLevel").and_then(Value::as_str) { self.log_level = s.into(); } @@ -199,6 +204,12 @@ impl SettingsData { Value::String(self.audio_channels.clone()), ); } + if !self.subtitle_scale.is_empty() { + o.insert( + "subtitleScale".into(), + Value::String(self.subtitle_scale.clone()), + ); + } if self.disable_gpu_compositing { o.insert("disableGpuCompositing".into(), Value::Bool(true)); } @@ -246,6 +257,12 @@ impl SettingsData { Value::String(self.audio_channels.clone()), ); } + if !self.subtitle_scale.is_empty() { + o.insert( + "subtitleScale".into(), + Value::String(self.subtitle_scale.clone()), + ); + } if self.disable_gpu_compositing { o.insert("disableGpuCompositing".into(), Value::Bool(true)); } @@ -476,8 +493,29 @@ string_accessors!(server_url, set_server_url, server_url); string_accessors!(hwdec, set_hwdec, hwdec); string_accessors!(audio_passthrough, set_audio_passthrough, audio_passthrough); string_accessors!(audio_channels, set_audio_channels, audio_channels); +string_accessors!(subtitle_scale, set_subtitle_scale, subtitle_scale); string_accessors!(log_level, set_log_level, log_level); +/// Parse a stored subtitle-scale string into an mpv `sub-scale` multiplier. +/// Empty or malformed values fall back to 0.5 — the app's default subtitle +/// size, deliberately smaller than mpv's own 1.0 default so plain-text subs +/// land close to the jellyfin-web client's "Normal" size. Valid values are +/// clamped so a bad stored number can neither shrink subtitles to nothing nor +/// blow them up off-screen. +fn parse_subtitle_scale(raw: &str) -> f64 { + raw.trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v > 0.0) + .map_or(0.5, |v| v.clamp(0.1, 10.0)) +} + +/// The saved subtitle size as an mpv `sub-scale` multiplier (see +/// [`parse_subtitle_scale`]). +pub fn subtitle_scale_value() -> f64 { + parse_subtitle_scale(&state().lock().data.subtitle_scale) +} + pub fn device_name() -> String { state().lock().data.device_name.clone() } @@ -590,10 +628,33 @@ fn normalize_device_name(raw: &str, platform_default: &str) -> String { #[cfg(test)] mod tests { - use super::normalize_device_name; + use super::{normalize_device_name, parse_subtitle_scale}; const PLATFORM: &str = "platform-host"; + #[test] + fn subtitle_scale_parses_clamps_and_defaults() { + // Compare with a tolerance so the assertions stay clippy::float_cmp-clean + // under `just strict-lint`. + let eq = |raw: &str, want: f64| { + let got = parse_subtitle_scale(raw); + assert!((got - want).abs() < 1e-9, "{raw:?} → {got}, want {want}"); + }; + // Unset / malformed → app default (0.5). + eq("", 0.5); + eq("garbage", 0.5); + // Non-positive and non-finite are rejected, not clamped, → default. + eq("0", 0.5); + eq("-3", 0.5); + eq("inf", 0.5); + // Valid values pass through; surrounding whitespace is tolerated. + eq("1.5", 1.5); + eq(" 2 ", 2.0); + // Out-of-range values clamp to the sane band. + eq("999", 10.0); + eq("0.001", 0.1); + } + #[test] fn trims_leading_and_trailing_whitespace() { assert_eq!(normalize_device_name(" foo ", PLATFORM), "foo"); diff --git a/src/jfn_cef/src/business_common.rs b/src/jfn_cef/src/business_common.rs index cda31bcf4..5124de8dc 100644 --- a/src/jfn_cef/src/business_common.rs +++ b/src/jfn_cef/src/business_common.rs @@ -69,6 +69,59 @@ pub(crate) fn apply_setting_value(_section: &str, key: &str, value: Option<&str> "audioPassthrough" => jfn_config::set_audio_passthrough(value), "audioExclusive" => jfn_config::set_audio_exclusive(value == "true"), "audioChannels" => jfn_config::set_audio_channels(value), + // Persist, then apply live so a change takes effect on the current + // video immediately (mpv re-renders) as well as future playback. + "subtitleScale" => { + jfn_config::set_subtitle_scale(value); + jfn_mpv::api::jfn_mpv_set_subtitle_scale(jfn_config::subtitle_scale_value()); + } + // --- Web "Subtitle Appearance" mirror -------------------------------- + // Reflect jellyfin-web's per-device Subtitle Appearance panel straight + // into mpv. The web client's localStorage is the source of truth, so + // these are applied live but NOT persisted here — each returns early to + // skip the config save below. + "subtitlePos" => { + if let Ok(v) = value.parse::() { + jfn_mpv::api::jfn_mpv_set_subtitle_pos(v); + } + return; + } + "subtitleColor" => { + jfn_mpv::api::jfn_mpv_set_subtitle_color(value); + return; + } + "subtitleBackColor" => { + jfn_mpv::api::jfn_mpv_set_subtitle_back_color(value); + return; + } + "subtitleBold" => { + jfn_mpv::api::jfn_mpv_set_subtitle_bold(value == "true"); + return; + } + "subtitleFont" => { + jfn_mpv::api::jfn_mpv_set_subtitle_font(value); + return; + } + "subtitleBorderSize" => { + if let Ok(v) = value.parse::() { + jfn_mpv::api::jfn_mpv_set_subtitle_border_size(v); + } + return; + } + "subtitleShadowOffset" => { + if let Ok(v) = value.parse::() { + jfn_mpv::api::jfn_mpv_set_subtitle_shadow_offset(v); + } + return; + } + // Panel-driven subtitle size (mpv `sub-scale`), non-persisted like the + // other appearance fields — the web panel's Text size is the source. + "subtitleSize" => { + if let Ok(v) = value.parse::() { + jfn_mpv::api::jfn_mpv_set_subtitle_scale(v); + } + return; + } "hideScrollbar" => jfn_config::set_hide_scrollbar(value == "true"), "logLevel" => jfn_config::set_log_level(value), "forceTranscoding" => jfn_config::set_force_transcoding(value == "true"), diff --git a/src/jfn_rust/src/app.rs b/src/jfn_rust/src/app.rs index 70cee8c39..4f060687d 100644 --- a/src/jfn_rust/src/app.rs +++ b/src/jfn_rust/src/app.rs @@ -616,6 +616,13 @@ pub fn jfn_app_main() -> c_int { let startup_bg = cs("#101010"); unsafe { jfn_mpv::api::jfn_mpv_set_background_color_hex(startup_bg.as_ptr()) }; + // Apply the "Subtitle size" (mpv `sub-scale`) at startup. The live-apply + // path (apply_setting_value) only runs on user changes, so a fresh launch + // has to push it once. Applied unconditionally — subtitle_scale_value() + // returns the app default (0.5) when unset, which is what establishes the + // smaller-than-mpv baseline on a clean install. + jfn_mpv::api::jfn_mpv_set_subtitle_scale(jfn_config::subtitle_scale_value()); + log_mpv_versions(); // input-default-bindings=no drops the builtin CLOSE_WIN -> quit binding; diff --git a/src/mpv/src/api.rs b/src/mpv/src/api.rs index 2228916f1..6ed50ad62 100644 --- a/src/mpv/src/api.rs +++ b/src/mpv/src/api.rs @@ -317,6 +317,51 @@ pub fn jfn_mpv_set_audio_delay(s: f64) { pub fn jfn_mpv_set_subtitle_delay(s: f64) { unsafe { set_double(c"sub-delay", s) }; } +/// Subtitle size, as a multiplier on the rendered font size (mpv `sub-scale`, +/// default 1.0). `sub-ass-override=scale` (set at boot) lets this reach ASS/SSA +/// subtitles too, not just plain text. +pub fn jfn_mpv_set_subtitle_scale(v: f64) { + unsafe { set_double(c"sub-scale", v) }; +} +/// Subtitle vertical position (mpv `sub-pos`, 0..=150; 100 = bottom, lower = +/// higher). Mirrors the jellyfin-web "Vertical position" appearance control. +pub fn jfn_mpv_set_subtitle_pos(v: f64) { + unsafe { set_double(c"sub-pos", v.clamp(0.0, 150.0)) }; +} +/// Subtitle bold weight (mpv `sub-bold`). Mirrors web "Text weight". +pub fn jfn_mpv_set_subtitle_bold(v: bool) { + unsafe { set_flag(c"sub-bold", v) }; +} +/// Subtitle text color as an mpv color string ("#RRGGBB" / "#AARRGGBB"). +/// Mirrors web "Text color". Ignored if the string has an interior NUL. +pub fn jfn_mpv_set_subtitle_color(color: &str) { + if let Ok(c) = CString::new(color) { + unsafe { set_str(c"sub-color", &c) }; + } +} +/// Subtitle background box color (mpv `sub-back-color`). Mirrors web "Text +/// background"; pass a fully-transparent color to disable the box. +pub fn jfn_mpv_set_subtitle_back_color(color: &str) { + if let Ok(c) = CString::new(color) { + unsafe { set_str(c"sub-back-color", &c) }; + } +} +/// Subtitle font family (mpv `sub-font`). Mirrors web "Font". mpv resolves it +/// via its font backend and falls back to the default if it can't. +pub fn jfn_mpv_set_subtitle_font(font: &str) { + if let Ok(c) = CString::new(font) { + unsafe { set_str(c"sub-font", &c) }; + } +} +/// Subtitle outline/border thickness (mpv `sub-border-size`). +pub fn jfn_mpv_set_subtitle_border_size(v: f64) { + unsafe { set_double(c"sub-border-size", v.max(0.0)) }; +} +/// Subtitle drop-shadow offset (mpv `sub-shadow-offset`). Together with border +/// size this reproduces the web "Drop shadow" presets. +pub fn jfn_mpv_set_subtitle_shadow_offset(v: f64) { + unsafe { set_double(c"sub-shadow-offset", v.max(0.0)) }; +} pub fn jfn_mpv_set_start_position(s: f64) { unsafe { set_double(c"start", s) }; } diff --git a/src/mpv/src/boot.rs b/src/mpv/src/boot.rs index 6d7223446..3d28dcee0 100644 --- a/src/mpv/src/boot.rs +++ b/src/mpv/src/boot.rs @@ -120,6 +120,16 @@ fn apply_defaults( set("osc", "no")?; set("display-tags", "")?; + // Make the user-facing "Subtitle size" setting (mpv `sub-scale`) also apply + // to ASS/SSA subtitles; `scale` preserves the rest of each script's styling + // (position, colors, fonts). If an mpv build lacks the option, set_option + // downgrades it to a skip rather than failing boot. + set("sub-ass-override", "scale")?; + + // Lift subtitles slightly off the bottom edge to match the jellyfin-web + // client's fullscreen placement (sub-pos 100 = bottom; lower = higher). + set("sub-pos", "95")?; + // Track selection is owned by Jellyfin. Disable mpv's heuristic // so unspecified tracks stay disabled instead of being auto-picked // by language / default-flag / codec scoring. diff --git a/src/web/native-shim.js b/src/web/native-shim.js index 9d13f1773..9fdd5de2c 100644 --- a/src/web/native-shim.js +++ b/src/web/native-shim.js @@ -112,6 +112,9 @@ settingsDescriptions: { playback: [ { key: 'hwdec', displayName: 'Hardware Decoding', help: 'Hardware video decoding mode. Use "auto" for automatic detection or "no" to disable.', options: _savedSettings.hwdecOptions } + // Subtitle size/appearance now comes from jellyfin-web's Subtitle + // Appearance panel (unlocked via the subtitleappearancesettings + // capability) and is mirrored to mpv in applyWebSubtitleAppearance. ], audio: [ { key: 'audioPassthrough', displayName: 'Audio Passthrough', help: 'Comma-separated list of codecs to pass through to the audio device (e.g. ac3,eac3,dts-hd,truehd). Leave empty to disable.', inputType: 'textarea' }, @@ -178,6 +181,94 @@ paused: false }; + // --- Web "Subtitle Appearance" mirror --------------------------------- + // jellyfin-web stores its per-device Subtitle Appearance under a localStorage + // key like "-localplayersubtitleappearance3" and renders it with its + // own HTML/CSS engine. Here mpv renders subtitles instead, bypassing that — + // so read the panel and push equivalent mpv `sub-*` properties. Applied on + // each playback start (the web value is the source of truth). + function readWebSubtitleAppearance() { + try { + // Entries are per-user ("-localplayersubtitleappearance3"), + // and a shared device can hold several. Match the logged-in user + // when the web client can tell us who that is; treat "no entry" as + // defaults ({}) so a user switch resets every field. Fall back to + // the first match only when the user id is unavailable. + const uid = window.ApiClient && typeof window.ApiClient.getCurrentUserId === 'function' + ? window.ApiClient.getCurrentUserId() + : null; + let fallback = null; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || !/localplayersubtitleappearance/i.test(k)) continue; + if (uid) { + if (k.indexOf(uid) === 0) return JSON.parse(localStorage.getItem(k) || '{}'); + } else if (fallback === null) { + fallback = k; + } + } + return fallback !== null ? JSON.parse(localStorage.getItem(fallback) || '{}') : {}; + } catch (e) { + console.error('[Media] subtitle appearance read failed:', e); + } + return null; + } + + function applyWebSubtitleAppearance() { + const a = readWebSubtitleAppearance(); + if (!a || !window.jmpNative || !window.jmpNative.setSettingValue) return; + const set = (key, val) => window.jmpNative.setSettingValue('playback', key, String(val)); + + // Text size: map jellyfin-web's token to an mpv sub-scale multiplier. + // Calibrated so web "Normal" ('') ~ 0.5 (≈ the web client's rendered size). + const SIZE = { extrasmall: 0.28, smaller: 0.35, small: 0.42, '': 0.5, large: 0.62, larger: 0.8, extralarge: 1.05 }; + set('subtitleSize', SIZE[a.textSize] != null ? SIZE[a.textSize] : 0.5); + + // Vertical position: web "line number" (negative = up from bottom) -> + // mpv sub-pos (100 = bottom, lower = higher). Calibrated so the web + // default (-3) lands near sub-pos 95. + const vp = parseInt(a.verticalPosition, 10); + set('subtitlePos', Math.max(0, Math.min(150, Math.round(100 + (isNaN(vp) ? -3 : vp) * (5 / 3))))); + + // Text color — hex passes straight through to mpv; unset falls back to + // mpv's default white so a cleared panel takes effect without restart. + set('subtitleColor', a.textColor || '#ffffff'); + + // Text weight. + set('subtitleBold', a.textWeight === 'bold'); + + // Font: jellyfin-web stores a token (e.g. 'typewriter'), not a usable + // family name — map each to a real Windows font mpv can resolve. + // '' (Default) and unmapped tokens (e.g. 'smallcaps') reset to mpv's + // default family so switching back takes effect without a restart. + const FONT = { + typewriter: 'Courier New', + print: 'Times New Roman', + console: 'Consolas', + casual: 'Comic Sans MS', + cursive: 'Segoe Script' + }; + set('subtitleFont', FONT[String(a.font || '').toLowerCase()] || 'sans-serif'); + + // Text background box ('transparent' -> disable the box). + set('subtitleBackColor', (a.textBackground && a.textBackground !== 'transparent') ? a.textBackground : '#00000000'); + + // Drop-shadow presets -> mpv shadow offset + outline size. + // NOTE: first-pass values — the default keeps mpv's legible outline + // (border 3, no shadow), i.e. today's look, so nothing regresses. + // True per-preset parity (real drop shadow etc.) is a calibration TODO. + const SHADOW = { + dropshadow: { shadow: 0, border: 3 }, + raised: { shadow: 2, border: 2 }, + depressed: { shadow: 2, border: 2 }, + uniform: { shadow: 0, border: 3 }, + none: { shadow: 0, border: 0 } + }; + const sh = SHADOW[(a.dropShadow || 'dropshadow').toLowerCase()] || SHADOW.dropshadow; + set('subtitleShadowOffset', sh.shadow); + set('subtitleBorderSize', sh.border); + } + // window.api.player - MPV control API window.api = { player: { @@ -216,6 +307,9 @@ this.playing.connect(onPlaying); this.error.connect(onError); } + // Mirror the web client's Subtitle Appearance into mpv for this + // playback (color, weight, position, shadow, font, background). + applyWebSubtitleAppearance(); if (window.jmpNative && window.jmpNative.playerLoad) { const metadataJson = streamdata?.metadata ? JSON.stringify(streamdata.metadata) : '{}'; window.jmpNative.playerLoad(url, options.startMilliseconds, videoStream, audioStream, subtitleStream, metadataJson, externalAudioUrl || '', externalSubUrl || '', !!options.isInfiniteStream); @@ -416,7 +510,12 @@ 'fileinput', 'filedownload', 'displaylanguage', 'htmlaudioautoplay', 'htmlvideoautoplay', 'externallinks', 'multiserver', 'fullscreenchange', 'remotevideo', 'displaymode', - 'exitmenu', 'clientsettings' + 'exitmenu', 'clientsettings', + // Unlock jellyfin-web's Subtitle Appearance panel in the desktop + // app. mpv (not HTML/CSS) renders subs, so the shim reads the + // panel's localStorage values and applies them as mpv sub-* + // properties (see applyWebSubtitleAppearance). + 'subtitleappearancesettings' ]; return features.includes(command.toLowerCase()); },