Merge remote-tracking branch 'origin/develop' into fix/maxEpisodes-count

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-05-31 11:47:54 +02:00
395 changed files with 41605 additions and 5472 deletions

View File

@@ -0,0 +1,162 @@
/**
* Downloaded Subtitles Storage
*
* Persists metadata about client-side downloaded subtitles (from OpenSubtitles).
* Subtitle files are stored in Paths.cache/streamyfin-subtitles/ directory.
* Filenames are prefixed with itemId for organization: {itemId}_{filename}
*
* While files are in cache, metadata is persisted in MMKV so subtitles survive
* app restarts (unless cache is manually cleared by the user).
*
* TV platform only.
*/
import { storage } from "../mmkv";
// MMKV storage key
const DOWNLOADED_SUBTITLES_KEY = "downloadedSubtitles.json";
/**
* Metadata for a downloaded subtitle file
*/
export interface DownloadedSubtitle {
/** Unique identifier (uuid) */
id: string;
/** Jellyfin item ID */
itemId: string;
/** Local file path in documents directory */
filePath: string;
/** Display name */
name: string;
/** 3-letter language code */
language: string;
/** File format (srt, ass, etc.) */
format: string;
/** Source provider */
source: "opensubtitles";
/** Unix timestamp when downloaded */
downloadedAt: number;
}
/**
* Storage structure for downloaded subtitles
*/
interface DownloadedSubtitlesStorage {
/** Map of itemId to array of downloaded subtitles */
byItemId: Record<string, DownloadedSubtitle[]>;
}
/**
* Load the storage from MMKV
*/
function loadStorage(): DownloadedSubtitlesStorage {
try {
const data = storage.getString(DOWNLOADED_SUBTITLES_KEY);
if (data) {
return JSON.parse(data) as DownloadedSubtitlesStorage;
}
} catch {
// Ignore parse errors, return empty storage
}
return { byItemId: {} };
}
/**
* Save the storage to MMKV
*/
function saveStorage(data: DownloadedSubtitlesStorage): void {
try {
storage.set(DOWNLOADED_SUBTITLES_KEY, JSON.stringify(data));
} catch (error) {
console.error("Failed to save downloaded subtitles:", error);
}
}
/**
* Get all downloaded subtitles for a specific Jellyfin item
*/
export function getSubtitlesForItem(itemId: string): DownloadedSubtitle[] {
const data = loadStorage();
return data.byItemId[itemId] ?? [];
}
/**
* Add a downloaded subtitle to storage
*/
export function addDownloadedSubtitle(subtitle: DownloadedSubtitle): void {
const data = loadStorage();
// Initialize array for item if it doesn't exist
if (!data.byItemId[subtitle.itemId]) {
data.byItemId[subtitle.itemId] = [];
}
// Check if subtitle with same id already exists and update it
const existingIndex = data.byItemId[subtitle.itemId].findIndex(
(s) => s.id === subtitle.id,
);
if (existingIndex !== -1) {
// Update existing entry
data.byItemId[subtitle.itemId][existingIndex] = subtitle;
} else {
// Add new entry
data.byItemId[subtitle.itemId].push(subtitle);
}
saveStorage(data);
}
/**
* Remove a downloaded subtitle from storage
*/
export function removeDownloadedSubtitle(
itemId: string,
subtitleId: string,
): void {
const data = loadStorage();
if (data.byItemId[itemId]) {
data.byItemId[itemId] = data.byItemId[itemId].filter(
(s) => s.id !== subtitleId,
);
// Clean up empty arrays
if (data.byItemId[itemId].length === 0) {
delete data.byItemId[itemId];
}
saveStorage(data);
}
}
/**
* Remove all downloaded subtitles for a specific item
*/
export function removeAllSubtitlesForItem(itemId: string): void {
const data = loadStorage();
if (data.byItemId[itemId]) {
delete data.byItemId[itemId];
saveStorage(data);
}
}
/**
* Check if a subtitle file already exists for an item by language
*/
export function hasSubtitleForLanguage(
itemId: string,
language: string,
): boolean {
const subtitles = getSubtitlesForItem(itemId);
return subtitles.some((s) => s.language === language);
}
/**
* Get all downloaded subtitles across all items
*/
export function getAllDownloadedSubtitles(): DownloadedSubtitle[] {
const data = loadStorage();
return Object.values(data.byItemId).flat();
}

View File

@@ -0,0 +1,60 @@
import { atom } from "jotai";
import { storage } from "../mmkv";
const STORAGE_KEY = "selectedTVServer";
export interface SelectedTVServerState {
address: string;
name?: string;
}
/**
* Load the selected TV server from MMKV storage.
*/
function loadSelectedTVServer(): SelectedTVServerState | null {
const stored = storage.getString(STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored) as SelectedTVServerState;
} catch {
return null;
}
}
return null;
}
/**
* Save the selected TV server to MMKV storage.
*/
function saveSelectedTVServer(server: SelectedTVServerState | null): void {
if (server) {
storage.set(STORAGE_KEY, JSON.stringify(server));
} else {
storage.remove(STORAGE_KEY);
}
}
/**
* Base atom holding the selected TV server state.
*/
const baseSelectedTVServerAtom = atom<SelectedTVServerState | null>(
loadSelectedTVServer(),
);
/**
* Derived atom that persists changes to MMKV storage.
*/
export const selectedTVServerAtom = atom(
(get) => get(baseSelectedTVServerAtom),
(_get, set, newValue: SelectedTVServerState | null) => {
saveSelectedTVServer(newValue);
set(baseSelectedTVServerAtom, newValue);
},
);
/**
* Clear the selected TV server (used when changing servers).
*/
export function clearSelectedTVServer(): void {
storage.remove(STORAGE_KEY);
}

View File

@@ -175,6 +175,14 @@ export enum VideoPlayer {
MPV = 0,
}
// 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
@@ -187,6 +195,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";
@@ -232,6 +256,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;
@@ -239,8 +272,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;
@@ -252,6 +290,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> {
@@ -317,6 +359,15 @@ export const defaultValues: Settings = {
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
mpvSubtitleBackgroundEnabled: false,
mpvSubtitleBackgroundOpacity: 75,
// MPV buffer/cache defaults
mpvCacheEnabled: "auto",
mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: 150, // MB
mpvDemuxerMaxBackBytes: 50, // MB
// MPV video output driver defaults (Android only)
mpvVoDriver: "gpu-next",
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,
@@ -324,8 +375,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,
@@ -337,6 +393,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> => {
@@ -382,6 +440,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);
@@ -423,6 +489,7 @@ export const useSettings = () => {
for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting?.locked) {
const settingsKey = key as keyof Settings;
// Normalize and apply locked values unconditionally
(updates as any)[settingsKey] = normalizePluginValue(
settingsKey,
setting.value,
@@ -476,9 +543,9 @@ 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]) => {
@@ -490,18 +557,12 @@ export const useSettings = () => {
// Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value);
// For unlocked settings: use plugin value unless user explicitly
// customized (their saved value differs from the default)
const userVal = _settings?.[settingsKey];
const defaultVal = defaultValues[settingsKey];
const userCustomized =
userVal !== undefined &&
JSON.stringify(userVal) !== JSON.stringify(defaultVal);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
(acc as any)[settingsKey] = locked
? value
: userCustomized
? userVal
: hasMeaningfulSettingValue(effectiveValue)
? effectiveValue
: value;
}
return acc;

View File

@@ -0,0 +1,14 @@
import { atom } from "jotai";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
export type TVAccountActionModalState = {
server: SavedServer;
account: SavedServerAccount;
onLogin: () => void;
onDelete: () => void;
} | null;
export const tvAccountActionModalAtom = atom<TVAccountActionModalState>(null);

View File

@@ -0,0 +1,14 @@
import { atom } from "jotai";
import type {
SavedServer,
SavedServerAccount,
} from "@/utils/secureCredentials";
export type TVAccountSelectModalState = {
server: SavedServer;
onAccountAction: (account: SavedServerAccount) => void;
onAddAccount: () => void;
onDeleteServer: () => void;
} | null;
export const tvAccountSelectModalAtom = atom<TVAccountSelectModalState>(null);

View File

@@ -0,0 +1,18 @@
import { atom } from "jotai";
export type TVOptionItem<T = any> = {
label: string;
sublabel?: string;
value: T;
selected: boolean;
};
export type TVOptionModalState = {
title: string;
options: TVOptionItem[];
onSelect: (value: any) => void;
cardWidth?: number;
cardHeight?: number;
} | null;
export const tvOptionModalAtom = atom<TVOptionModalState>(null);

View File

@@ -0,0 +1,13 @@
import { atom } from "jotai";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
export type TVRequestModalState = {
requestBody: MediaRequestBody;
title: string;
id: number;
mediaType: MediaType;
onRequested: () => void;
} | null;
export const tvRequestModalAtom = atom<TVRequestModalState>(null);

View File

@@ -0,0 +1,18 @@
import { atom } from "jotai";
import type { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
export type TVSeasonSelectModalState = {
seasons: Array<{
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
}>;
title: string;
mediaId: number;
tvdbId?: number;
hasAdvancedRequestPermission: boolean;
onRequested: () => void;
} | null;
export const tvSeasonSelectModalAtom = atom<TVSeasonSelectModalState>(null);

View File

@@ -0,0 +1,14 @@
import { atom } from "jotai";
export type TVSeriesSeasonModalState = {
seasons: Array<{
label: string;
value: number;
selected: boolean;
}>;
selectedSeasonIndex: number | string;
itemId: string;
onSeasonSelect: (seasonIndex: number) => void;
} | null;
export const tvSeriesSeasonModalAtom = atom<TVSeriesSeasonModalState>(null);

View File

@@ -0,0 +1,16 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { atom } from "jotai";
import type { Track } from "@/components/video-player/controls/types";
export type TVSubtitleModalState = {
item: BaseItemDto;
mediaSourceId?: string | null;
subtitleTracks: Track[];
currentSubtitleIndex: number;
onDisableSubtitles?: () => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<Track[]>;
} | null;
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);

View File

@@ -0,0 +1,12 @@
import { atom } from "jotai";
import type { SavedServerAccount } from "@/utils/secureCredentials";
export type TVUserSwitchModalState = {
serverUrl: string;
serverName: string;
accounts: SavedServerAccount[];
currentUserId: string;
onAccountSelect: (account: SavedServerAccount) => void;
} | null;
export const tvUserSwitchModalAtom = atom<TVUserSwitchModalState>(null);

138
utils/chapters.test.ts Normal file
View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import {
chapterMarkers,
chapterNameAt,
chapterStartsMs,
currentChapterIndex,
formatChapterTime,
sortedChapters,
} from "./chapters";
// Helper: a ChapterInfo with a start in milliseconds.
const ch = (ms: number, name?: string) => ({
StartPositionTicks: ms * 10000,
Name: name,
});
describe("chapterMarkers", () => {
test("maps chapters to position + percent", () => {
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
{ positionMs: 30_000, percent: 25 },
{ positionMs: 60_000, percent: 50 },
]);
});
test("drops chapters past the duration", () => {
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
]);
});
test("returns [] when duration is 0 or chapters missing", () => {
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
expect(chapterMarkers(null, 120_000)).toEqual([]);
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
});
test("excludes a chapter exactly at the duration", () => {
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
]);
});
test("skips chapters with no StartPositionTicks", () => {
expect(
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
).toEqual([{ positionMs: 30_000, percent: 25 }]);
});
});
describe("currentChapterIndex", () => {
const chapters = [ch(0), ch(30_000), ch(60_000)];
test("returns the chapter containing the position", () => {
expect(currentChapterIndex(0, chapters)).toBe(0);
expect(currentChapterIndex(15_000, chapters)).toBe(0);
expect(currentChapterIndex(30_000, chapters)).toBe(1);
expect(currentChapterIndex(90_000, chapters)).toBe(2);
});
test("returns -1 before the first chapter and for no chapters", () => {
expect(currentChapterIndex(-5, chapters)).toBe(-1);
expect(currentChapterIndex(10_000, [])).toBe(-1);
expect(currentChapterIndex(10_000, null)).toBe(-1);
});
});
describe("sortedChapters", () => {
test("pairs each chapter with its ms start, sorted ascending", () => {
const a = ch(60_000, "C");
const b = ch(0, "A");
const c = ch(30_000, "B");
expect(sortedChapters([a, b, c])).toEqual([
{ chapter: b, positionMs: 0 },
{ chapter: c, positionMs: 30_000 },
{ chapter: a, positionMs: 60_000 },
]);
});
test("returns [] for null/undefined", () => {
expect(sortedChapters(null)).toEqual([]);
expect(sortedChapters(undefined)).toEqual([]);
});
});
describe("chapterStartsMs", () => {
test("returns sorted ms positions", () => {
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
0, 30_000, 60_000,
]);
});
test("skips entries without StartPositionTicks", () => {
expect(
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
).toEqual([0, 30_000]);
});
test("returns [] for null/undefined/empty", () => {
expect(chapterStartsMs(null)).toEqual([]);
expect(chapterStartsMs(undefined)).toEqual([]);
expect(chapterStartsMs([])).toEqual([]);
});
});
describe("chapterNameAt", () => {
const named = [
{ StartPositionTicks: 0, Name: "Intro" },
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
];
test("returns the chapter name for the active position", () => {
expect(chapterNameAt(0, named)).toBe("Intro");
expect(chapterNameAt(15_000, named)).toBe("Intro");
expect(chapterNameAt(45_000, named)).toBe("Action");
expect(chapterNameAt(90_000, named)).toBe("Outro");
});
test("returns null before the first chapter", () => {
expect(chapterNameAt(-1, named)).toBeNull();
});
test("returns null for null/undefined/empty chapters", () => {
expect(chapterNameAt(10_000, null)).toBeNull();
expect(chapterNameAt(10_000, undefined)).toBeNull();
expect(chapterNameAt(10_000, [])).toBeNull();
});
test("returns null when the active chapter has no Name", () => {
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
});
});
describe("formatChapterTime", () => {
test("formats m:ss and h:mm:ss", () => {
expect(formatChapterTime(65_000)).toBe("1:05");
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
expect(formatChapterTime(-100)).toBe("0:00");
});
});

97
utils/chapters.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
* unit-testable under `bun test`.
*/
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { ticksToMs } from "@/utils/time";
export interface ChapterMarker {
/** Chapter start, in milliseconds. */
positionMs: number;
/** Chapter start as a percentage (0-100) of the media duration. */
percent: number;
}
export interface ChapterEntry {
chapter: ChapterInfo;
/** Chapter start, in milliseconds. */
positionMs: number;
}
/** Chapters paired with their millisecond start, sorted ascending by start. */
export const sortedChapters = (
chapters: ChapterInfo[] | null | undefined,
): ChapterEntry[] =>
(chapters ?? [])
.filter((c) => c.StartPositionTicks != null)
.map((chapter) => ({
chapter,
positionMs: ticksToMs(chapter.StartPositionTicks),
}))
.sort((a, b) => a.positionMs - b.positionMs);
/** Chapter start positions in milliseconds, ascending. */
export const chapterStartsMs = (
chapters: ChapterInfo[] | null | undefined,
): number[] =>
(chapters ?? [])
.filter((c) => c.StartPositionTicks != null)
.map((c) => ticksToMs(c.StartPositionTicks))
.sort((a, b) => a - b);
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
export const chapterMarkers = (
chapters: ChapterInfo[] | null | undefined,
durationMs: number,
): ChapterMarker[] => {
if (durationMs <= 0) return [];
return chapterStartsMs(chapters)
.filter((ms) => ms >= 0 && ms < durationMs)
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
};
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
export const currentChapterIndex = (
positionMs: number,
chapters: ChapterInfo[] | null | undefined,
): number => {
const starts = chapterStartsMs(chapters);
let index = -1;
for (let i = 0; i < starts.length; i++) {
if (positionMs >= starts[i]) index = i;
else break;
}
return index;
};
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
export const chapterNameAt = (
positionMs: number,
chapters: ChapterInfo[] | null | undefined,
): string | null => {
// Sort once, derive both the active index and the entry from the same array
// — `chapterNameAt` runs on every playback tick, so paying for one `sort()`
// instead of two is worth the duplication of the index loop here.
const sorted = sortedChapters(chapters);
let idx = -1;
for (let i = 0; i < sorted.length; i++) {
if (positionMs >= sorted[i].positionMs) idx = i;
else break;
}
if (idx < 0) return null;
const name = sorted[idx]?.chapter.Name;
return name && name.length > 0 ? name : null;
};
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
export const formatChapterTime = (positionMs: number): string => {
const total = Math.max(0, Math.floor(positionMs / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours > 0
? `${hours}:${pad(minutes)}:${pad(seconds)}`
: `${minutes}:${pad(seconds)}`;
};

View File

@@ -1,7 +1,7 @@
import type { Api } from "@jellyfin/sdk";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import trackPlayerProfile from "@/utils/profiles/trackplayer";
import trackPlayerProfile from "../../profiles/trackplayer";
export interface AudioStreamResult {
url: string;

View File

@@ -12,7 +12,9 @@
import type {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { BITRATES } from "@/components/BitrateSelector";
import { type Settings } from "../atoms/settings";
import {
@@ -34,20 +36,152 @@ export interface PreviousIndexes {
subtitleIndex?: number;
}
export interface PlaySettingsOptions {
/** Apply language preferences from settings (used on TV) */
applyLanguagePreferences?: boolean;
}
/**
* Find a track by language code.
*
* @param streams - Available media streams
* @param languageCode - ISO 639-2 three-letter language code (e.g., "eng", "swe")
* @param streamType - Type of stream to search ("Audio" or "Subtitle")
* @param forcedOnly - If true, only match forced subtitles
* @returns The stream index if found, undefined otherwise
*/
function findTrackByLanguage(
streams: MediaStream[],
languageCode: string | undefined,
streamType: "Audio" | "Subtitle",
forcedOnly = false,
): number | undefined {
if (!languageCode) return undefined;
const candidates = streams.filter((s) => {
if (s.Type !== streamType) return false;
if (forcedOnly && !s.IsForced) return false;
// Match on ThreeLetterISOLanguageName (ISO 639-2)
return (
s.Language?.toLowerCase() === languageCode.toLowerCase() ||
// Fallback: some Jellyfin servers use two-letter codes in Language field
s.Language?.toLowerCase() === languageCode.substring(0, 2).toLowerCase()
);
});
// Prefer default track if multiple match
const defaultTrack = candidates.find((s) => s.IsDefault);
return defaultTrack?.Index ?? candidates[0]?.Index;
}
/**
* Apply subtitle mode logic to determine the final subtitle index.
*
* @param streams - Available media streams
* @param settings - User settings containing subtitleMode
* @param defaultIndex - The current default subtitle index
* @param audioLanguage - The selected audio track's language (for Smart mode)
* @param subtitleLanguageCode - The user's preferred subtitle language
* @returns The final subtitle index (-1 for disabled)
*/
function applySubtitleMode(
streams: MediaStream[],
settings: Settings,
defaultIndex: number,
audioLanguage: string | undefined,
subtitleLanguageCode: string | undefined,
): number {
const subtitleStreams = streams.filter((s) => s.Type === "Subtitle");
const mode = settings.subtitleMode ?? SubtitlePlaybackMode.Default;
switch (mode) {
case SubtitlePlaybackMode.None:
// Always disable subtitles
return -1;
case SubtitlePlaybackMode.OnlyForced: {
// Only show forced subtitles, prefer matching language
const forcedMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
true,
);
if (forcedMatch !== undefined) return forcedMatch;
// Fallback to any forced subtitle
const anyForced = subtitleStreams.find((s) => s.IsForced);
return anyForced?.Index ?? -1;
}
case SubtitlePlaybackMode.Always: {
// Always enable subtitles, prefer language match
const alwaysMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
);
if (alwaysMatch !== undefined) return alwaysMatch;
// Fallback to first available or current default
return subtitleStreams[0]?.Index ?? defaultIndex;
}
case SubtitlePlaybackMode.Smart: {
// Enable subtitles only when audio language differs from subtitle preference
if (audioLanguage && subtitleLanguageCode) {
const audioLang = audioLanguage.toLowerCase();
const subLang = subtitleLanguageCode.toLowerCase();
// If audio matches subtitle preference, disable subtitles
if (
audioLang === subLang ||
audioLang.startsWith(subLang.substring(0, 2)) ||
subLang.startsWith(audioLang.substring(0, 2))
) {
return -1;
}
}
// Audio doesn't match preference, enable subtitles
const smartMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
);
return smartMatch ?? subtitleStreams[0]?.Index ?? -1;
}
default:
// Use language preference if set, else keep Jellyfin default
if (subtitleLanguageCode) {
const langMatch = findTrackByLanguage(
streams,
subtitleLanguageCode,
"Subtitle",
);
if (langMatch !== undefined) return langMatch;
}
return defaultIndex;
}
}
/**
* Get default play settings for an item.
*
* @param item - The media item to play
* @param settings - User settings (language preferences, bitrate, etc.)
* @param previous - Optional previous track selections to carry over (for sequential play)
* @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV)
*/
export function getDefaultPlaySettings(
item: BaseItemDto,
item: BaseItemDto | null | undefined,
settings: Settings | null,
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
options?: PlaySettingsOptions,
): PlaySettings {
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
// Handle undefined/null item
if (!item) {
return { item: {} as BaseItemDto, bitrate };
}
// Live TV programs don't have media sources
if (item.Type === "Program") {
return { item, bitrate };
@@ -60,6 +194,10 @@ export function getDefaultPlaySettings(
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
// Track whether we matched previous selections (for language preference fallback)
let matchedPreviousAudio = false;
let matchedPreviousSubtitle = false;
// Try to match previous selections (sequential play)
if (previous?.indexes && previous?.source && settings) {
if (
@@ -67,14 +205,22 @@ export function getDefaultPlaySettings(
previous.indexes.subtitleIndex !== undefined
) {
const ranker = new StreamRanker(new SubtitleStreamRanker());
const result = { DefaultSubtitleStreamIndex: subtitleIndex };
const result = {
DefaultSubtitleStreamIndex: subtitleIndex,
matched: false,
};
ranker.rankStream(
previous.indexes.subtitleIndex,
previous.source,
streams,
result,
);
subtitleIndex = result.DefaultSubtitleStreamIndex;
// Use the ranker's explicit match signal — this also covers a deliberate
// "subtitles off" (-1) and the case where the match equals the default.
if (result.matched) {
subtitleIndex = result.DefaultSubtitleStreamIndex;
matchedPreviousSubtitle = true;
}
}
if (
@@ -82,14 +228,58 @@ export function getDefaultPlaySettings(
previous.indexes.audioIndex !== undefined
) {
const ranker = new StreamRanker(new AudioStreamRanker());
const result = { DefaultAudioStreamIndex: audioIndex };
const result = { DefaultAudioStreamIndex: audioIndex, matched: false };
ranker.rankStream(
previous.indexes.audioIndex,
previous.source,
streams,
result,
);
audioIndex = result.DefaultAudioStreamIndex;
// Use the ranker's explicit match signal
if (result.matched) {
audioIndex = result.DefaultAudioStreamIndex;
matchedPreviousAudio = true;
}
}
}
// Apply language preferences when enabled (TV) and no previous selection matched
if (options?.applyLanguagePreferences && settings) {
const audioLanguageCode =
settings.defaultAudioLanguage?.ThreeLetterISOLanguageName ?? undefined;
const subtitleLanguageCode =
settings.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ?? undefined;
// Apply audio language preference if no previous selection matched
if (!matchedPreviousAudio && audioLanguageCode) {
const langMatch = findTrackByLanguage(
streams,
audioLanguageCode,
"Audio",
);
if (langMatch !== undefined) {
audioIndex = langMatch;
}
}
// Get the selected audio track's language for Smart mode
const selectedAudioTrack = streams.find(
(s) => s.Type === "Audio" && s.Index === audioIndex,
);
const selectedAudioLanguage =
selectedAudioTrack?.Language ??
selectedAudioTrack?.DisplayTitle ??
undefined;
// Apply subtitle mode logic if no previous selection matched
if (!matchedPreviousSubtitle) {
subtitleIndex = applySubtitleMode(
streams,
settings,
subtitleIndex,
selectedAudioLanguage,
subtitleLanguageCode,
);
}
}

View File

@@ -0,0 +1,32 @@
/**
* Retrieves the profile image URL for a Jellyfin user.
*
* @param serverAddress - The Jellyfin server base URL.
* @param userId - The user's ID.
* @param primaryImageTag - The user's primary image tag (required for the image to exist).
* @param width - The desired image width (default: 280).
* @returns The image URL or null if no image tag is provided.
*/
export const getUserImageUrl = ({
serverAddress,
userId,
primaryImageTag,
width = 280,
}: {
serverAddress: string;
userId: string;
primaryImageTag?: string | null;
width?: number;
}): string | null => {
if (!primaryImageTag) {
return null;
}
const params = new URLSearchParams({
tag: primaryImageTag,
quality: "90",
width: String(width),
});
return `${serverAddress}/Users/${userId}/Images/Primary?${params.toString()}`;
};

View File

@@ -7,7 +7,7 @@ import { Bitrate } from "@/components/BitrateSelector";
import {
type AudioTranscodeModeType,
generateDeviceProfile,
} from "@/utils/profiles/native";
} from "../../profiles/native";
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
export const getDownloadUrl = async ({

View File

@@ -5,13 +5,14 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { generateDownloadProfile } from "@/utils/profiles/download";
import type { AudioTranscodeModeType } from "@/utils/profiles/native";
import { generateDownloadProfile } from "../../profiles/download";
import type { AudioTranscodeModeType } from "../../profiles/native";
interface StreamResult {
url: string;
sessionId: string | null;
mediaSource: MediaSourceInfo | undefined;
requiredHttpHeaders?: Record<string, string>;
}
/**
@@ -50,10 +51,24 @@ const getPlaybackUrl = (
return `${api.basePath}${transcodeUrl}`;
}
// Handle remote/external streams (like live TV with external URLs)
// These have Protocol "Http" and IsRemote true, with the actual URL in Path
if (
mediaSource?.IsRemote &&
mediaSource?.Protocol === "Http" &&
mediaSource?.Path
) {
console.log("Video is remote stream, using direct Path:", mediaSource.Path);
return mediaSource.Path;
}
// Fall back to direct play
// Use the mediaSource's actual container when available (important for live TV
// where the container may be ts/hls, not mp4)
const container = params.container || mediaSource?.Container || "mp4";
const streamParams = new URLSearchParams({
static: params.static || "true",
container: params.container || "mp4",
container,
mediaSourceId: mediaSource?.Id || "",
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
audioStreamIndex: params.audioStreamIndex?.toString() || "",
@@ -163,6 +178,7 @@ export const getStreamUrl = async ({
url: string | null;
sessionId: string | null;
mediaSource: MediaSourceInfo | undefined;
requiredHttpHeaders?: Record<string, string>;
} | null> => {
if (!api || !userId || !item?.Id) {
console.warn("Missing required parameters for getStreamUrl");
@@ -210,6 +226,9 @@ export const getStreamUrl = async ({
url,
sessionId: sessionId || null,
mediaSource,
requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
| Record<string, string>
| undefined,
};
}
@@ -254,6 +273,9 @@ export const getStreamUrl = async ({
url,
sessionId: sessionId || null,
mediaSource,
requiredHttpHeaders: mediaSource?.RequiredHttpHeaders as
| Record<string, string>
| undefined,
};
};

289
utils/opensubtitles/api.ts Normal file
View File

@@ -0,0 +1,289 @@
/**
* OpenSubtitles REST API Client
* Docs: https://opensubtitles.stoplight.io/docs/opensubtitles-api
*
* This is a fallback for when the Jellyfin server doesn't have a subtitle provider configured.
*/
const OPENSUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1";
export interface OpenSubtitlesSearchParams {
/** IMDB ID (without "tt" prefix) */
imdbId?: string;
/** Title for text search */
query?: string;
/** Year of release */
year?: number;
/** ISO 639-2B language code (e.g., "eng", "spa") */
languages?: string;
/** Season number for TV shows */
seasonNumber?: number;
/** Episode number for TV shows */
episodeNumber?: number;
}
export interface OpenSubtitlesFile {
file_id: number;
file_name: string;
}
export interface OpenSubtitlesFeatureDetails {
imdb_id: number;
title: string;
year: number;
feature_type: string;
season_number?: number;
episode_number?: number;
}
export interface OpenSubtitlesAttributes {
subtitle_id: string;
language: string;
download_count: number;
hearing_impaired: boolean;
ai_translated: boolean;
machine_translated: boolean;
fps: number;
format: string;
from_trusted: boolean;
foreign_parts_only: boolean;
release: string;
files: OpenSubtitlesFile[];
feature_details: OpenSubtitlesFeatureDetails;
ratings: number;
}
export interface OpenSubtitlesResult {
id: string;
type: string;
attributes: OpenSubtitlesAttributes;
}
export interface OpenSubtitlesSearchResponse {
total_count: number;
total_pages: number;
page: number;
data: OpenSubtitlesResult[];
}
export interface OpenSubtitlesDownloadResponse {
link: string;
file_name: string;
requests: number;
remaining: number;
message: string;
reset_time: string;
reset_time_utc: string;
}
export class OpenSubtitlesApiError extends Error {
constructor(
message: string,
public statusCode?: number,
public response?: unknown,
) {
super(message);
this.name = "OpenSubtitlesApiError";
}
}
/**
* Mapping between ISO 639-1 (2-letter) and ISO 639-2B (3-letter) language codes
*/
const ISO_639_MAPPING: Record<string, string> = {
en: "eng",
es: "spa",
fr: "fre",
de: "ger",
it: "ita",
pt: "por",
ru: "rus",
ja: "jpn",
ko: "kor",
zh: "chi",
ar: "ara",
pl: "pol",
nl: "dut",
sv: "swe",
no: "nor",
da: "dan",
fi: "fin",
tr: "tur",
cs: "cze",
el: "gre",
he: "heb",
hu: "hun",
ro: "rum",
th: "tha",
vi: "vie",
id: "ind",
ms: "may",
bg: "bul",
hr: "hrv",
sk: "slo",
sl: "slv",
uk: "ukr",
};
// Reverse mapping: 3-letter to 2-letter
const ISO_639_REVERSE: Record<string, string> = Object.fromEntries(
Object.entries(ISO_639_MAPPING).map(([k, v]) => [v, k]),
);
/**
* Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
* OpenSubtitles REST API uses 2-letter codes
*/
function toIso6391(code: string): string {
if (code.length === 2) return code;
return ISO_639_REVERSE[code.toLowerCase()] || code;
}
/**
* OpenSubtitles API client for direct subtitle fetching
*/
export class OpenSubtitlesApi {
private apiKey: string;
private userAgent: string;
constructor(apiKey: string, userAgent = "streamyfin v1.0") {
this.apiKey = apiKey;
this.userAgent = userAgent;
}
private async request<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const url = `${OPENSUBTITLES_API_URL}${endpoint}`;
const headers: HeadersInit = {
"Api-Key": this.apiKey,
"Content-Type": "application/json",
"User-Agent": this.userAgent,
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorBody = await response.text();
throw new OpenSubtitlesApiError(
`OpenSubtitles API error: ${response.status} ${response.statusText}`,
response.status,
errorBody,
);
}
return response.json();
}
/**
* Search for subtitles
* Rate limit: 40 requests / 10 seconds
*/
async search(
params: OpenSubtitlesSearchParams,
): Promise<OpenSubtitlesSearchResponse> {
const queryParams = new URLSearchParams();
if (params.imdbId) {
// Ensure IMDB ID has "tt" prefix
const imdbId = params.imdbId.startsWith("tt")
? params.imdbId
: `tt${params.imdbId}`;
queryParams.set("imdb_id", imdbId);
}
if (params.query) {
queryParams.set("query", params.query);
}
if (params.year) {
queryParams.set("year", params.year.toString());
}
if (params.languages) {
// Convert 3-letter codes to 2-letter codes (API uses ISO 639-1)
const lang =
params.languages.length === 3
? toIso6391(params.languages)
: params.languages;
queryParams.set("languages", lang);
}
if (params.seasonNumber !== undefined) {
queryParams.set("season_number", params.seasonNumber.toString());
}
if (params.episodeNumber !== undefined) {
queryParams.set("episode_number", params.episodeNumber.toString());
}
return this.request<OpenSubtitlesSearchResponse>(
`/subtitles?${queryParams.toString()}`,
);
}
/**
* Get download link for a subtitle file
* Rate limits:
* - Anonymous: 5 downloads/day
* - Authenticated: 10 downloads/day (can be increased)
*/
async download(fileId: number): Promise<OpenSubtitlesDownloadResponse> {
return this.request<OpenSubtitlesDownloadResponse>("/download", {
method: "POST",
body: JSON.stringify({ file_id: fileId }),
});
}
}
/**
* Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
* Exported for external use
*/
export { toIso6391 };
/**
* Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code
*/
export function toIso6392B(code: string): string {
if (code.length === 3) return code;
return ISO_639_MAPPING[code.toLowerCase()] || code;
}
/**
* Common subtitle languages for display
*/
export const COMMON_SUBTITLE_LANGUAGES = [
{ code: "eng", name: "English" },
{ code: "spa", name: "Spanish" },
{ code: "fre", name: "French" },
{ code: "ger", name: "German" },
{ code: "ita", name: "Italian" },
{ code: "por", name: "Portuguese" },
{ code: "rus", name: "Russian" },
{ code: "jpn", name: "Japanese" },
{ code: "kor", name: "Korean" },
{ code: "chi", name: "Chinese" },
{ code: "ara", name: "Arabic" },
{ code: "pol", name: "Polish" },
{ code: "dut", name: "Dutch" },
{ code: "swe", name: "Swedish" },
{ code: "nor", name: "Norwegian" },
{ code: "dan", name: "Danish" },
{ code: "fin", name: "Finnish" },
{ code: "tur", name: "Turkish" },
{ code: "cze", name: "Czech" },
{ code: "gre", name: "Greek" },
{ code: "heb", name: "Hebrew" },
{ code: "hun", name: "Hungarian" },
{ code: "rom", name: "Romanian" },
{ code: "tha", name: "Thai" },
{ code: "vie", name: "Vietnamese" },
{ code: "ind", name: "Indonesian" },
{ code: "may", name: "Malay" },
{ code: "bul", name: "Bulgarian" },
{ code: "hrv", name: "Croatian" },
{ code: "slo", name: "Slovak" },
{ code: "slv", name: "Slovenian" },
{ code: "ukr", name: "Ukrainian" },
];

144
utils/pairingService.ts Normal file
View File

@@ -0,0 +1,144 @@
import dgram from "react-native-udp";
const PAIRING_PORT = 54322;
const PAIRING_MESSAGE_TYPE = "streamyfin-pair-response";
export interface PairingCredentials {
serverUrl: string;
username: string;
password: string;
}
export function generatePairingCode(): string {
return String(Math.floor(100000 + Math.random() * 900000));
}
export function startPairingListener(
code: string,
onCredentialsReceived: (credentials: PairingCredentials) => void,
onError?: (error: string) => void,
): () => void {
let active = true;
const socket = dgram.createSocket({
type: "udp4",
reusePort: true,
debug: __DEV__,
});
socket.on("error", (err) => {
if (__DEV__) console.error("[PairingService] Socket error:", err);
onError?.(err.message);
cleanup();
});
socket.bind(PAIRING_PORT, () => {
if (__DEV__)
console.log("[PairingService] Listening on port", PAIRING_PORT);
});
socket.on("message", (msg) => {
if (!active) return;
try {
const data = JSON.parse(new TextDecoder().decode(msg));
if (data.type !== PAIRING_MESSAGE_TYPE) return;
if (data.code !== code) return;
if (!data.server_url || !data.username || !data.password) {
if (__DEV__)
console.error("[PairingService] Missing fields in pairing response");
return;
}
active = false;
onCredentialsReceived({
serverUrl: data.server_url,
username: data.username,
password: data.password,
});
cleanup();
} catch (error) {
if (__DEV__)
console.error("[PairingService] Error parsing message:", error);
}
});
function cleanup() {
active = false;
try {
socket.close();
} catch {
// Socket may already be closed
}
}
return cleanup;
}
export function sendCredentialsToTV(
code: string,
serverUrl: string,
username: string,
password: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({
type: "udp4",
reusePort: true,
debug: __DEV__,
});
const message = JSON.stringify({
type: PAIRING_MESSAGE_TYPE,
code,
server_url: serverUrl,
username,
password,
});
const messageBuffer = new TextEncoder().encode(message);
socket.on("error", (err) => {
reject(err);
try {
socket.close();
} catch {
// Ignore
}
});
socket.bind(0, () => {
try {
socket.setBroadcast(true);
socket.send(
messageBuffer,
0,
messageBuffer.length,
PAIRING_PORT,
"255.255.255.255",
(err) => {
try {
socket.close();
} catch {
// Ignore
}
if (err) {
reject(err);
} else {
resolve();
}
},
);
} catch (error) {
try {
socket.close();
} catch {
// Ignore
}
reject(error);
}
});
});
}

6
utils/profiles/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export { chromecast } from "./chromecast";
export { chromecasth265 } from "./chromecasth265";
export { generateDownloadProfile } from "./download";
export * from "./native";
export { default } from "./native";
export { default as trackPlayerProfile } from "./trackplayer";

View File

@@ -1,23 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export type PlayerType = "mpv";
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
export interface ProfileOptions {
/** Target platform */
platform?: PlatformType;
/** Video player being used */
player?: PlayerType;
/** Audio transcoding mode */
audioMode?: AudioTranscodeModeType;
}
export function generateDeviceProfile(options?: ProfileOptions): any;
declare const _default: any;
export default _default;

View File

@@ -7,22 +7,24 @@ import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
/**
* @typedef {"ios" | "android"} PlatformType
* @typedef {"mpv"} PlayerType
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
*
* @typedef {Object} ProfileOptions
* @property {PlatformType} [platform] - Target platform
* @property {PlayerType} [player] - Video player being used (MPV only)
* @property {AudioTranscodeModeType} [audioMode] - Audio transcoding mode
*/
export type PlatformType = "ios" | "android";
export type PlayerType = "mpv";
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
export interface ProfileOptions {
/** Target platform */
platform?: PlatformType;
/** Video player being used */
player?: PlayerType;
/** Audio transcoding mode */
audioMode?: AudioTranscodeModeType;
}
/**
* Audio direct play profiles for standalone audio items in MPV player.
* These define which audio file formats can be played directly without transcoding.
*/
const getAudioDirectPlayProfile = (platform) => {
const getAudioDirectPlayProfile = (platform: PlatformType) => {
if (platform === "ios") {
// iOS audio formats supported by MPV
return {
@@ -44,7 +46,7 @@ const getAudioDirectPlayProfile = (platform) => {
* Audio codec profiles for standalone audio items in MPV player.
* These define codec constraints for audio file playback.
*/
const getAudioCodecProfile = (platform) => {
const getAudioCodecProfile = (platform: PlatformType) => {
if (platform === "ios") {
// iOS audio codec constraints for MPV
return {
@@ -66,12 +68,11 @@ const getAudioCodecProfile = (platform) => {
* MPV (via FFmpeg) can decode all audio codecs including TrueHD and DTS-HD MA.
* The audioMode setting only controls the maximum channel count - MPV will
* decode and downmix as needed.
*
* @param {PlatformType} platform
* @param {AudioTranscodeModeType} audioMode
* @returns {{ directPlayCodec: string, maxAudioChannels: string }}
*/
const getVideoAudioCodecs = (platform, audioMode) => {
const getVideoAudioCodecs = (
platform: PlatformType,
audioMode: AudioTranscodeModeType,
): { directPlayCodec: string; maxAudioChannels: string } => {
// Base codecs
const baseCodecs = "aac,mp3,flac,opus,vorbis";
@@ -120,12 +121,9 @@ const getVideoAudioCodecs = (platform, audioMode) => {
/**
* Generates a device profile for Jellyfin playback.
*
* @param {ProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile
*/
export const generateDeviceProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
const platform = (options.platform || Platform.OS) as PlatformType;
const audioMode = options.audioMode || "auto";
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(

9
utils/scaleSize.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Dimensions } from "react-native";
const { width: W, height: H } = Dimensions.get("window");
export const scaleSize = (size: number): number => {
const widthRatio = W / 1920;
const heightRatio = H / 1080;
return size * Math.min(widthRatio, heightRatio);
};

View File

@@ -22,6 +22,7 @@ export interface ServerCredential {
savedAt: number;
securityType: AccountSecurityType;
pinHash?: string;
primaryImageTag?: string;
}
/**
@@ -32,6 +33,7 @@ export interface SavedServerAccount {
username: string;
securityType: AccountSecurityType;
savedAt: number;
primaryImageTag?: string;
}
/**
@@ -131,6 +133,7 @@ export async function saveAccountCredential(
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
primaryImageTag: credential.primaryImageTag,
});
}
@@ -224,7 +227,7 @@ export async function clearAllCredentials(): Promise<void> {
/**
* Add or update an account in a server's accounts list.
*/
function addAccountToServer(
export function addAccountToServer(
serverUrl: string,
serverName: string,
account: SavedServerAccount,
@@ -475,19 +478,32 @@ export async function migrateToMultiAccount(): Promise<void> {
}
/**
* Update account's token after successful login.
* Update account's token and optionally other fields after successful login.
*/
export async function updateAccountToken(
serverUrl: string,
userId: string,
newToken: string,
primaryImageTag?: string,
): Promise<void> {
const credential = await getAccountCredential(serverUrl, userId);
if (credential) {
credential.token = newToken;
credential.savedAt = Date.now();
if (primaryImageTag !== undefined) {
credential.primaryImageTag = primaryImageTag;
}
const key = credentialKey(serverUrl, userId);
await SecureStore.setItemAsync(key, JSON.stringify(credential));
// Also update the account info in the server list
addAccountToServer(serverUrl, credential.serverName, {
userId: credential.userId,
username: credential.username,
securityType: credential.securityType,
savedAt: credential.savedAt,
primaryImageTag: credential.primaryImageTag,
});
}
}

View File

@@ -13,6 +13,42 @@ abstract class StreamRankerStrategy {
trackOptions: any,
): void;
/**
* Score how well a candidate stream matches the previously selected stream.
* Overridable so subtitle ranking can add mode (forced / hearing-impaired)
* awareness without changing audio behavior.
*/
protected computeScore(
prevStream: MediaStream,
stream: MediaStream,
prevRelIndex: number,
newRelIndex: number,
): number {
let score = 0;
if (prevStream.Codec === stream.Codec) {
score += 1;
}
if (prevRelIndex === newRelIndex) {
score += 1;
}
if (
prevStream.DisplayTitle &&
prevStream.DisplayTitle === stream.DisplayTitle
) {
score += 2;
}
if (
prevStream.Language &&
prevStream.Language !== "und" &&
prevStream.Language === stream.Language
) {
score += 2;
}
return score;
}
protected rank(
prevIndex: number,
prevSource: MediaSourceInfo,
@@ -22,6 +58,9 @@ abstract class StreamRankerStrategy {
if (prevIndex === -1) {
console.debug("AutoSet Subtitle - No Stream Set");
trackOptions[`Default${this.streamType}StreamIndex`] = -1;
// A deliberate "off" selection is a valid match to retain — flag it so
// callers don't fall back to language preferences / subtitle mode.
trackOptions.matched = true;
return;
}
@@ -63,27 +102,12 @@ abstract class StreamRankerStrategy {
continue;
}
let score = 0;
if (prevStream.Codec === stream.Codec) {
score += 1;
}
if (prevRelIndex === newRelIndex) {
score += 1;
}
if (
prevStream.DisplayTitle &&
prevStream.DisplayTitle === stream.DisplayTitle
) {
score += 2;
}
if (
prevStream.Language &&
prevStream.Language !== "und" &&
prevStream.Language === stream.Language
) {
score += 2;
}
const score = this.computeScore(
prevStream,
stream,
prevRelIndex,
newRelIndex,
);
console.debug(
`AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`,
@@ -101,6 +125,7 @@ abstract class StreamRankerStrategy {
`AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`,
);
trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex;
trackOptions.matched = true;
} else {
console.debug(
`AutoSet ${this.streamType} - Threshold not met. Using default.`,
@@ -112,6 +137,67 @@ abstract class StreamRankerStrategy {
class SubtitleStreamRanker extends StreamRankerStrategy {
streamType = "Subtitle";
/**
* Subtitle scoring that retains both language and mode across episodes.
*
* - When the previous track has a language: a language match is weighted high
* (+3) so it clears the threshold even when codec / title / position differ,
* and mode (forced / hearing-impaired) acts as a tiebreaker among
* same-language tracks. Different-language candidates get no language or mode
* points, so they can never be selected on mode alone (no cross-language
* hijack).
* - When the previous track has NO usable language (common for SRT/SUBRIP):
* language can't help, so mode (forced / hearing-impaired) + codec + relative
* position become the identity signal. Without this, unlabeled subtitles
* score only codec+relIndex (≤2) and the selection is silently lost.
*/
protected computeScore(
prevStream: MediaStream,
stream: MediaStream,
prevRelIndex: number,
newRelIndex: number,
): number {
let score = 0;
if (prevStream.Codec === stream.Codec) {
score += 1;
}
if (prevRelIndex === newRelIndex) {
score += 1;
}
if (
prevStream.DisplayTitle &&
prevStream.DisplayTitle === stream.DisplayTitle
) {
score += 2;
}
const prevHasLanguage =
!!prevStream.Language && prevStream.Language !== "und";
const languageMatches =
prevHasLanguage && prevStream.Language === stream.Language;
if (languageMatches) {
score += 3;
} else if (prevHasLanguage) {
// Previous track had a language but this candidate's differs — do not award
// mode points, so a different language is never matched on mode alone.
return score;
}
// Either the language matched, or the previous track had no language (so mode
// is the primary identity). Normalize the flags to booleans since
// IsForced / IsHearingImpaired may be undefined.
if (!!prevStream.IsForced === !!stream.IsForced) {
score += 2;
}
if (!!prevStream.IsHearingImpaired === !!stream.IsHearingImpaired) {
score += 1;
}
return score;
}
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
@@ -156,4 +242,4 @@ class StreamRanker {
}
}
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };
export { AudioStreamRanker, StreamRanker, SubtitleStreamRanker };

57
utils/topshelf/cache.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import { clearTopShelfCache, writeTopShelfCache } from "@/modules";
import {
buildTVDiscoveryPayload,
type TVDiscoveryPayload,
} from "@/utils/tvDiscovery/payload";
export function updateTopShelfCache({
api,
sections,
}: {
api: Api | null | undefined;
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
}): void {
if (Platform.OS !== "ios" || !Platform.isTV) return;
const payload = buildTVDiscoveryPayload({ api, sections });
if (!payload) {
clearTopShelfCacheSafely();
return;
}
writeTopShelfPayload(payload, api?.accessToken || undefined);
}
export function writeTopShelfPayload(
payload: TVDiscoveryPayload,
apiKey?: string,
): void {
if (Platform.OS !== "ios" || !Platform.isTV) return;
try {
const didWrite = writeTopShelfCache(JSON.stringify(payload), apiKey);
if (!didWrite) {
console.warn("[TopShelf] Native cache writer is unavailable");
}
} catch (error) {
console.warn("[TopShelf] Failed to write cache", error);
}
}
export function clearTopShelfCacheSafely(): void {
if (Platform.OS !== "ios" || !Platform.isTV) return;
try {
const didClear = clearTopShelfCache();
if (!didClear) {
console.warn("[TopShelf] Native cache clearer is unavailable");
}
} catch (error) {
console.warn("[TopShelf] Failed to clear cache", error);
}
}

View File

@@ -0,0 +1,173 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
const TV_DISCOVERY_ITEM_LIMIT = 12;
const TV_DISCOVERY_SECTION_LIMIT = 3;
export interface TVDiscoveryItem {
id: string;
itemType?: string;
title: string;
subtitle?: string;
imageUrl?: string;
route: string;
playRoute?: string;
}
export interface TVDiscoverySection {
title: string;
items: TVDiscoveryItem[];
}
export interface TVDiscoveryPayload {
version: 1;
updatedAt: string;
sections: TVDiscoverySection[];
}
function getTVDiscoveryImage(
item: BaseItemDto,
api: Api,
): { url: string } | undefined {
const baseUrl = api.basePath;
// 1. Episode backdrop
const episodeBackdrop = item.BackdropImageTags?.[0];
if (item.Id && episodeBackdrop) {
return {
url:
`${baseUrl}/Items/${item.Id}/Images/Backdrop/0` +
`?fillWidth=1920` +
`&fillHeight=1080` +
`&quality=90` +
`&tag=${encodeURIComponent(episodeBackdrop)}`,
};
}
// 2. Series backdrop
if (item.SeriesId) {
return {
url:
`${baseUrl}/Items/${item.SeriesId}/Images/Backdrop` +
`?fillWidth=1920` +
`&fillHeight=1080` +
`&quality=90`,
};
}
// 3. Generic item backdrop
const backdrop = item.BackdropImageTags?.[0];
if (item.Id && backdrop) {
return {
url:
`${baseUrl}/Items/${item.Id}/Images/Backdrop/0` +
`?fillWidth=1920` +
`&fillHeight=1080` +
`&quality=90` +
`&tag=${encodeURIComponent(backdrop)}`,
};
}
// 4. Last resort: crop poster into landscape
const primaryTag = item.ImageTags?.Primary;
if (item.Id && primaryTag) {
return {
url:
`${baseUrl}/Items/${item.Id}/Images/Primary` +
`?fillWidth=1920` +
`&fillHeight=1080` +
`&quality=90` +
`&tag=${encodeURIComponent(primaryTag)}`,
};
}
return undefined;
}
function formatEpisodeNumber(item: BaseItemDto): string | undefined {
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season != null && episode != null) {
return `S${season} • E${episode}`;
}
if (season != null) return `Season ${season}`;
if (episode != null) return `Episode ${episode}`;
return undefined;
}
function getTVDiscoveryTitle(item: BaseItemDto): string {
if (item.Type === "Episode") {
const episodeNumber = formatEpisodeNumber(item);
if (item.SeriesName && episodeNumber) {
return `${item.SeriesName} - ${episodeNumber}`;
}
if (item.SeriesName) return item.SeriesName;
if (episodeNumber) return episodeNumber;
return item.Name || "";
}
return item.Name || "";
}
function getTVDiscoverySubtitle(item: BaseItemDto): string | undefined {
if (item.Type === "Episode") return undefined;
return item.ProductionYear ? String(item.ProductionYear) : item.Type;
}
function sectionFromItems(
title: string,
items: BaseItemDto[] | undefined,
api: Api,
): TVDiscoverySection | null {
const payloadItems = (items || [])
.filter((item) => item.Id && item.Name)
.slice(0, TV_DISCOVERY_ITEM_LIMIT)
.map((item) => {
const image = getTVDiscoveryImage(item, api);
return {
id: item.Id!,
itemType: item.Type || undefined,
title: getTVDiscoveryTitle(item),
subtitle: getTVDiscoverySubtitle(item),
imageUrl: image?.url,
route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`,
playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`,
};
});
if (payloadItems.length === 0) return null;
return {
title,
items: payloadItems,
};
}
export function buildTVDiscoveryPayload({
api,
sections,
}: {
api: Api | null | undefined;
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
}): TVDiscoveryPayload | null {
if (!api) return null;
const payloadSections = sections
.map((section) => sectionFromItems(section.title, section.items, api))
.filter((section): section is TVDiscoverySection => section !== null)
.slice(0, TV_DISCOVERY_SECTION_LIMIT);
if (payloadSections.length === 0) return null;
return {
version: 1,
updatedAt: new Date().toISOString(),
sections: payloadSections,
};
}

88
utils/tvDiscovery/sync.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import { clearTvRecommendations, syncTvRecommendations } from "@/modules";
import {
clearTopShelfCacheSafely,
writeTopShelfPayload,
} from "@/utils/topshelf/cache";
import { buildTVDiscoveryPayload } from "./payload";
export function updateTVDiscovery({
api,
sections,
}: {
api: Api | null | undefined;
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
}): void {
if (!Platform.isTV) return;
const payload = buildTVDiscoveryPayload({ api, sections });
if (!payload) {
console.log("[TVDiscovery] No payload generated; clearing TV discovery");
clearTVDiscoverySafely();
return;
}
const sectionSummary = payload.sections
.map((section) => `${section.title}:${section.items.length}`)
.join(", ");
console.log(
`[TVDiscovery] Sync payload prepared for ${Platform.OS} TV with ${payload.sections.length} section(s): ${sectionSummary}`,
);
if (Platform.OS === "ios") {
writeTopShelfPayload(payload, api?.accessToken || undefined);
return;
}
if (Platform.OS === "android") {
try {
const didSync = syncTvRecommendations(JSON.stringify(payload));
console.log(`[TVDiscovery] Android sync result: ${didSync}`);
if (!didSync) {
console.warn(
"[TVDiscovery] Android recommendations sync is unavailable",
);
}
} catch (error) {
console.warn(
"[TVDiscovery] Failed to sync Android recommendations",
error,
);
}
}
}
export function clearTVDiscoverySafely(): void {
if (!Platform.isTV) return;
console.log(`[TVDiscovery] Clearing TV discovery for ${Platform.OS} TV`);
if (Platform.OS === "ios") {
clearTopShelfCacheSafely();
return;
}
if (Platform.OS === "android") {
try {
const didClear = clearTvRecommendations();
console.log(`[TVDiscovery] Android clear result: ${didClear}`);
if (!didClear) {
console.warn(
"[TVDiscovery] Android recommendations clearer is unavailable",
);
}
} catch (error) {
console.warn(
"[TVDiscovery] Failed to clear Android recommendations",
error,
);
}
}
}

View File

@@ -1,10 +1,10 @@
import { useFocusEffect } from "@react-navigation/core";
import {
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
useQuery,
} from "@tanstack/react-query";
import { useFocusEffect } from "expo-router/react-navigation";
import { useCallback } from "react";
export function useReactNavigationQuery<