Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion src/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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::<f64>()
.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()
}
Expand Down Expand Up @@ -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");
Expand Down
53 changes: 53 additions & 0 deletions src/jfn_cef/src/business_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f64>() {
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::<f64>() {
jfn_mpv::api::jfn_mpv_set_subtitle_border_size(v);
}
return;
}
"subtitleShadowOffset" => {
if let Ok(v) = value.parse::<f64>() {
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::<f64>() {
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"),
Expand Down
7 changes: 7 additions & 0 deletions src/jfn_rust/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/mpv/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
Expand Down
10 changes: 10 additions & 0 deletions src/mpv/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 100 additions & 1 deletion src/web/native-shim.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -178,6 +181,94 @@
paused: false
};

// --- Web "Subtitle Appearance" mirror ---------------------------------
// jellyfin-web stores its per-device Subtitle Appearance under a localStorage
// key like "<userId>-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 ("<userId>-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: {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
},
Expand Down