Merge remote-tracking branch 'origin/develop' into feat/local-intros

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-07-17 14:25:05 +02:00
545 changed files with 53870 additions and 13458 deletions

View File

@@ -6,8 +6,10 @@ import {
type SortOrder,
SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client";
import { t } from "i18next";
import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
@@ -121,6 +123,46 @@ export interface MaxAutoPlayEpisodeCount {
value: number;
}
/**
* The plugin may send object-typed settings as plain primitives.
* Resolve to the proper option object from the available choices.
*/
const normalizePluginValue = (
settingsKey: keyof Settings,
value: unknown,
): unknown => {
if (typeof value !== "object" || value === null) {
const defaultVal = defaultValues[settingsKey];
if (
typeof defaultVal === "object" &&
defaultVal !== null &&
"key" in defaultVal &&
"value" in defaultVal
) {
// defaultBitrate needs a lookup because its keys are human-readable
// (e.g. "8 Mb/s") that can't be derived from the raw value (e.g. 8000000).
// Other { key, value } settings like maxAutoPlayEpisodeCount work with
// the fallback because their keys are just String(value) (e.g. "5").
if (settingsKey === "defaultBitrate") {
const match = BITRATES.find(
(b) => b.key === value || b.value === value,
);
if (match) return match;
}
// maxAutoPlayEpisodeCount: 0 is invalid (breaks autoplay), clamp to -1
// -1 key must match the translated dropdown label so the UI shows "Disabled"
if (
settingsKey === "maxAutoPlayEpisodeCount" &&
(value === 0 || value === -1)
) {
return { key: t("home.settings.other.disabled"), value: -1 };
}
return { key: String(value), value };
}
}
return value;
};
export type HomeSectionLatestResolver = {
parentId?: string;
limit?: number;
@@ -137,6 +179,14 @@ export enum VideoPlayer {
// Segment skip behavior options
export type SegmentSkipMode = "none" | "ask" | "auto";
// TV Typography scale presets
export enum TVTypographyScale {
Small = "small",
Default = "default",
Large = "large",
ExtraLarge = "extraLarge",
}
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -149,6 +199,22 @@ export enum AudioTranscodeMode {
AllowAll = "passthrough", // Direct play all audio formats
}
// Inactivity timeout for TV - auto logout after period of no activity
export enum InactivityTimeout {
Disabled = 0,
OneMinute = 60000,
FiveMinutes = 300000,
FifteenMinutes = 900000,
ThirtyMinutes = 1800000,
OneHour = 3600000,
FourHours = 14400000,
TwentyFourHours = 86400000,
}
// MPV cache mode - controls how caching is enabled
export type MpvCacheMode = "auto" | "yes" | "no";
export type MpvVoDriver = "gpu-next" | "gpu";
export type Settings = {
home?: Home | null;
deviceProfile?: "Expo" | "Native" | "Old";
@@ -200,6 +266,15 @@ export type Settings = {
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
mpvSubtitleBackgroundEnabled?: boolean;
mpvSubtitleBackgroundOpacity?: number; // 0-100
// MPV buffer/cache settings
mpvCacheEnabled?: MpvCacheMode;
mpvCacheSeconds?: number;
mpvDemuxerMaxBytes?: number; // MB
mpvDemuxerMaxBackBytes?: number; // MB
// MPV video output driver (Android only)
mpvVoDriver?: MpvVoDriver;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
@@ -207,8 +282,13 @@ export type Settings = {
hideVolumeSlider: boolean;
hideBrightnessSlider: boolean;
usePopularPlugin: boolean;
showLargeHomeCarousel: boolean;
mergeNextUpAndContinueWatching: boolean;
// TV-specific settings
showHomeBackdrop: boolean;
showTVHeroCarousel: boolean;
tvTypographyScale: TVTypographyScale;
showSeriesPosterOnEpisode: boolean;
tvThemeMusicEnabled: boolean;
// Appearance
hideRemoteSessionButton: boolean;
hideWatchlistsTab: boolean;
@@ -220,6 +300,10 @@ export type Settings = {
preferLocalAudio: boolean;
// Audio transcoding mode
audioTranscodeMode: AudioTranscodeMode;
// OpenSubtitles API key for client-side subtitle fetching
openSubtitlesApiKey?: string;
// TV-only: Inactivity timeout for auto-logout
inactivityTimeout: InactivityTimeout;
};
export interface Lockable<T> {
@@ -291,6 +375,20 @@ export const defaultValues: Settings = {
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
mpvSubtitleBackgroundEnabled: false,
mpvSubtitleBackgroundOpacity: 75,
// MPV buffer/cache defaults.
// Android TV gets tighter caps — combined with libmpv 1.0's larger
// baseline (fontconfig + libxml2 + libplacebo HDR path + scudo
// retention) the larger mobile budget pushes 2 GB Android TV boxes
// into swap death during 4K HDR playback. Apple TV has more RAM and
// keeps the full budget. Users can override via the settings screen.
mpvCacheEnabled: "auto",
mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB
mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB
// MPV video output driver defaults (Android only)
mpvVoDriver: "gpu-next",
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,
@@ -298,8 +396,13 @@ export const defaultValues: Settings = {
hideVolumeSlider: false,
hideBrightnessSlider: false,
usePopularPlugin: true,
showLargeHomeCarousel: false,
mergeNextUpAndContinueWatching: false,
// TV-specific settings
showHomeBackdrop: true,
showTVHeroCarousel: true,
tvTypographyScale: TVTypographyScale.Default,
showSeriesPosterOnEpisode: false,
tvThemeMusicEnabled: true,
// Appearance
hideRemoteSessionButton: false,
hideWatchlistsTab: false,
@@ -311,6 +414,8 @@ export const defaultValues: Settings = {
preferLocalAudio: true,
// Audio transcoding mode
audioTranscodeMode: AudioTranscodeMode.Auto,
// TV-only: Inactivity timeout (disabled by default)
inactivityTimeout: InactivityTimeout.Disabled,
};
const loadSettings = (): Partial<Settings> => {
@@ -356,6 +461,14 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
loadPluginSettings(),
);
const hasMeaningfulSettingValue = (value: unknown) =>
value !== undefined && value !== null && value !== "";
const getEffectiveSettingValue = <K extends keyof Settings>(
settings: Partial<Settings> | null | undefined,
settingsKey: K,
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
export const useSettings = () => {
const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
@@ -376,60 +489,37 @@ export const useSettings = () => {
[_setPluginSettings],
);
const refreshStreamyfinPluginSettings = useCallback(
async (forceOverride = false) => {
if (!api) {
return;
const refreshStreamyfinPluginSettings = useCallback(async () => {
if (!api) {
return;
}
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
({ data }) => {
writeInfoLog("Got plugin settings", data?.settings);
return data?.settings;
},
(_err) => undefined,
);
setPluginSettings(newPluginSettings);
// Locked/unlocked values are handled by the settings memo, which
// applies locked values at runtime without overwriting user storage.
// We only handle auto-enabling Streamystats here.
if (newPluginSettings && _settings) {
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
if (streamyStatsUrl?.value && _settings.searchEngine !== "Streamystats") {
const newSettings = {
...defaultValues,
..._settings,
searchEngine: "Streamystats",
} as Settings;
setSettings(newSettings);
saveSettings(newSettings);
}
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
({ data }) => {
writeInfoLog("Got plugin settings", data?.settings);
return data?.settings;
},
(_err) => undefined,
);
setPluginSettings(newPluginSettings);
}
// Apply plugin values to settings
if (newPluginSettings && _settings) {
const updates: Partial<Settings> = {};
for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting && !setting.locked && setting.value !== undefined) {
const settingsKey = key as keyof Settings;
// Apply if forceOverride is true, or if user hasn't explicitly set this value
if (
forceOverride ||
_settings[settingsKey] === undefined ||
_settings[settingsKey] === ""
) {
(updates as any)[settingsKey] = setting.value;
}
}
}
// Auto-enable Streamystats if server URL is provided
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
if (
streamyStatsUrl?.value &&
_settings.searchEngine !== "Streamystats"
) {
updates.searchEngine = "Streamystats";
}
if (Object.keys(updates).length > 0) {
const newSettings = {
...defaultValues,
..._settings,
...updates,
} as Settings;
setSettings(newSettings);
saveSettings(newSettings);
}
}
return newPluginSettings;
},
[api, _settings],
);
return newPluginSettings;
}, [api, _settings]);
const updateSettings = (update: Partial<Settings>) => {
if (!_settings) {
@@ -453,28 +543,27 @@ export const useSettings = () => {
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
// If admin sets locked to false but provides a value,
// use user settings first and fallback on admin setting if required.
// use persisted settings first, then app defaults, and only fallback on the
// plugin value when neither provides a meaningful value.
const settings: Settings = useMemo(() => {
const unlockedPluginDefaults: Partial<Settings> = {};
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
Partial<Settings>
>((acc, [key, setting]) => {
if (setting) {
const { value, locked } = setting;
let { value } = setting;
const { locked } = setting;
const settingsKey = key as keyof Settings;
// Make sure we override default settings with plugin settings when they are not locked.
if (
!locked &&
value !== undefined &&
_settings?.[settingsKey] !== value
) {
(unlockedPluginDefaults as any)[settingsKey] = value;
}
// Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
(acc as any)[settingsKey] = locked
? value
: (_settings?.[settingsKey] ?? value);
: hasMeaningfulSettingValue(effectiveValue)
? effectiveValue
: value;
}
return acc;
}, {});