mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 10:14:17 +01:00
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:
162
utils/atoms/downloadedSubtitles.ts
Normal file
162
utils/atoms/downloadedSubtitles.ts
Normal 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();
|
||||
}
|
||||
60
utils/atoms/selectedTVServer.ts
Normal file
60
utils/atoms/selectedTVServer.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}, {});
|
||||
|
||||
14
utils/atoms/tvAccountActionModal.ts
Normal file
14
utils/atoms/tvAccountActionModal.ts
Normal 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);
|
||||
14
utils/atoms/tvAccountSelectModal.ts
Normal file
14
utils/atoms/tvAccountSelectModal.ts
Normal 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);
|
||||
27
utils/atoms/tvOptionModal.ts
Normal file
27
utils/atoms/tvOptionModal.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
/**
|
||||
* Run onSelect AFTER the modal route is dismissed. Needed only when onSelect
|
||||
* navigates (the in-player audio switch replacing the player while
|
||||
* transcoding), which the still-active modal route would otherwise swallow.
|
||||
* Default (false) runs onSelect before closing, so state-only callers (detail
|
||||
* page, library filters, settings) don't re-render after focus returns and
|
||||
* lose TV focus.
|
||||
*/
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
} | null;
|
||||
|
||||
export const tvOptionModalAtom = atom<TVOptionModalState>(null);
|
||||
13
utils/atoms/tvRequestModal.ts
Normal file
13
utils/atoms/tvRequestModal.ts
Normal 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);
|
||||
18
utils/atoms/tvSeasonSelectModal.ts
Normal file
18
utils/atoms/tvSeasonSelectModal.ts
Normal 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);
|
||||
14
utils/atoms/tvSeriesSeasonModal.ts
Normal file
14
utils/atoms/tvSeriesSeasonModal.ts
Normal 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);
|
||||
16
utils/atoms/tvSubtitleModal.ts
Normal file
16
utils/atoms/tvSubtitleModal.ts
Normal 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);
|
||||
12
utils/atoms/tvUserSwitchModal.ts
Normal file
12
utils/atoms/tvUserSwitchModal.ts
Normal 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);
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Convert bits to megabits or gigabits
|
||||
*
|
||||
* Return nice looking string
|
||||
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||
*/
|
||||
|
||||
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||
if (!bits) return "0MB";
|
||||
|
||||
const megabits = bits / 1000000;
|
||||
|
||||
if (megabits < 1000) {
|
||||
return `${Math.round(megabits)}MB`;
|
||||
}
|
||||
const gigabits = megabits / 1000;
|
||||
return `${gigabits.toFixed(1)}GB`;
|
||||
}
|
||||
138
utils/chapters.test.ts
Normal file
138
utils/chapters.test.ts
Normal 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
97
utils/chapters.ts
Normal 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)}`;
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
BaseItemKind,
|
||||
CollectionType,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
/**
|
||||
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||
*
|
||||
* CollectionTypes
|
||||
* readonly Unknown: "unknown";
|
||||
readonly Movies: "movies";
|
||||
readonly Tvshows: "tvshows";
|
||||
readonly Trailers: "trailers";
|
||||
readonly Homevideos: "homevideos";
|
||||
readonly Boxsets: "boxsets";
|
||||
readonly Books: "books";
|
||||
readonly Photos: "photos";
|
||||
readonly Livetv: "livetv";
|
||||
readonly Playlists: "playlists";
|
||||
readonly Folders: "folders";
|
||||
*/
|
||||
export const colletionTypeToItemType = (
|
||||
collectionType?: CollectionType | null,
|
||||
): BaseItemKind | undefined => {
|
||||
if (!collectionType) return undefined;
|
||||
|
||||
switch (collectionType) {
|
||||
case CollectionType.Movies:
|
||||
return BaseItemKind.Movie;
|
||||
case CollectionType.Tvshows:
|
||||
return BaseItemKind.Series;
|
||||
case CollectionType.Homevideos:
|
||||
return BaseItemKind.Video;
|
||||
case CollectionType.Books:
|
||||
return BaseItemKind.Book;
|
||||
case CollectionType.Playlists:
|
||||
return BaseItemKind.Playlist;
|
||||
case CollectionType.Folders:
|
||||
return BaseItemKind.Folder;
|
||||
case CollectionType.Photos:
|
||||
return BaseItemKind.Photo;
|
||||
case CollectionType.Trailers:
|
||||
return BaseItemKind.Trailer;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SubtitleTrack {
|
||||
index: number;
|
||||
name: string;
|
||||
uri: string;
|
||||
language: string;
|
||||
default: boolean;
|
||||
forced: boolean;
|
||||
autoSelect: boolean;
|
||||
}
|
||||
|
||||
export async function parseM3U8ForSubtitles(
|
||||
url: string,
|
||||
): Promise<SubtitleTrack[]> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: "text" });
|
||||
const lines = response.data.split(/\r?\n/);
|
||||
const subtitleTracks: SubtitleTrack[] = [];
|
||||
let index = 0;
|
||||
|
||||
lines.forEach((line: string) => {
|
||||
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||
const attributes = parseAttributes(line);
|
||||
const track: SubtitleTrack = {
|
||||
index: index++,
|
||||
name: attributes.NAME || "",
|
||||
uri: attributes.URI || "",
|
||||
language: attributes.LANGUAGE || "",
|
||||
default: attributes.DEFAULT === "YES",
|
||||
forced: attributes.FORCED === "YES",
|
||||
autoSelect: attributes.AUTOSELECT === "YES",
|
||||
};
|
||||
subtitleTracks.push(track);
|
||||
}
|
||||
});
|
||||
|
||||
return subtitleTracks;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAttributes(line: string): { [key: string]: string } {
|
||||
const attributes: { [key: string]: string } = {};
|
||||
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
||||
|
||||
for (const match of line.matchAll(regex)) {
|
||||
const key = match[1];
|
||||
const value = match[2] ?? match[3]; // quoted or unquoted
|
||||
attributes[key] = value;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
utils/jellyfin/image/getUserImageUrl.ts
Normal file
32
utils/jellyfin/image/getUserImageUrl.ts
Normal 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()}`;
|
||||
};
|
||||
@@ -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 ({
|
||||
@@ -50,7 +50,7 @@ export const getDownloadUrl = async ({
|
||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||
console.log("Downloading item directly");
|
||||
return {
|
||||
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
|
||||
url: `${api.basePath}/Items/${mediaSource.Id}/Download?api_key=${api.accessToken}`,
|
||||
mediaSource: streamDetails?.mediaSource ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Settings } from "../../atoms/settings";
|
||||
import { generateDeviceProfile } from "../../profiles/native";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
interface PostCapabilitiesParams {
|
||||
api: Api | null | undefined;
|
||||
itemId: string | null | undefined;
|
||||
sessionId: string | null | undefined;
|
||||
deviceProfile: Settings["deviceProfile"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a media item as not played for a specific user.
|
||||
*
|
||||
* @param params - The parameters for marking an item as not played
|
||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||
*/
|
||||
export const postCapabilities = async ({
|
||||
api,
|
||||
itemId,
|
||||
sessionId,
|
||||
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||
if (!api || !itemId || !sessionId) {
|
||||
throw new Error("Missing parameters for marking item as not played");
|
||||
}
|
||||
|
||||
try {
|
||||
const d = api.axiosInstance.post(
|
||||
`${api.basePath}/Sessions/Capabilities/Full`,
|
||||
{
|
||||
playableMediaTypes: ["Audio", "Video"],
|
||||
supportedCommands: [
|
||||
"PlayState",
|
||||
"Play",
|
||||
"ToggleFullscreen",
|
||||
"DisplayMessage",
|
||||
"Mute",
|
||||
"Unmute",
|
||||
"SetVolume",
|
||||
"ToggleMute",
|
||||
],
|
||||
supportsMediaControl: true,
|
||||
id: sessionId,
|
||||
DeviceProfile: generateDeviceProfile(),
|
||||
},
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
return d;
|
||||
} catch (_error) {
|
||||
throw new Error("Failed to mark as not played");
|
||||
}
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
interface NextUpParams {
|
||||
itemId?: string | null;
|
||||
userId?: string | null;
|
||||
api?: Api | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the next up episodes for a series or all series for a user.
|
||||
*
|
||||
* @param params - The parameters for fetching next up episodes
|
||||
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
||||
*/
|
||||
export const nextUp = async ({
|
||||
itemId,
|
||||
userId,
|
||||
api,
|
||||
}: NextUpParams): Promise<BaseItemDto[]> => {
|
||||
if (!userId || !api) {
|
||||
console.error("Invalid parameters for nextUp: missing userId or api");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
||||
`${api.basePath}/Shows/NextUp`,
|
||||
{
|
||||
params: {
|
||||
SeriesId: itemId || undefined,
|
||||
UserId: userId,
|
||||
Fields: "MediaSourceCount",
|
||||
},
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
/**
|
||||
* Retrieves an item by its ID from the API.
|
||||
*
|
||||
* @param api - The Jellyfin API instance.
|
||||
* @param itemId - The ID of the item to retrieve.
|
||||
* @returns The item object or undefined if no item matches the ID.
|
||||
*/
|
||||
export const getItemById = async (
|
||||
api?: Api | null | undefined,
|
||||
itemId?: string | null | undefined,
|
||||
): Promise<BaseItemDto | undefined> => {
|
||||
if (!api || !itemId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
||||
|
||||
const item = itemData.data;
|
||||
if (!item) {
|
||||
console.error("No items found with the specified ID:", itemId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve the item:", error);
|
||||
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
||||
}
|
||||
};
|
||||
@@ -72,21 +72,6 @@ export const readFromLog = (): LogEntry[] => {
|
||||
return logs ? JSON.parse(logs) : [];
|
||||
};
|
||||
|
||||
export const clearLogs = () => {
|
||||
storage.remove("logs");
|
||||
};
|
||||
|
||||
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||
const diagnostics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
processes: extra?.processes || [],
|
||||
nativeTasks: extra?.nativeTasks || [],
|
||||
focusedProcess: extra?.focusedProcess || null,
|
||||
};
|
||||
writeDebugLog("Download diagnostics", diagnostics);
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
export function useLog() {
|
||||
const context = useContext(LogContext);
|
||||
if (context === null) {
|
||||
|
||||
289
utils/opensubtitles/api.ts
Normal file
289
utils/opensubtitles/api.ts
Normal 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" },
|
||||
];
|
||||
145
utils/pairingService.ts
Normal file
145
utils/pairingService.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 (!active) return;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,17 +3,17 @@
|
||||
* 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/.
|
||||
*/
|
||||
import { generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
||||
*/
|
||||
import type {
|
||||
DeviceProfile,
|
||||
SubtitleProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* Download-specific subtitle profiles.
|
||||
* These are more permissive than streaming profiles since we can embed subtitles.
|
||||
*/
|
||||
const downloadSubtitleProfiles = [
|
||||
const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
|
||||
/**
|
||||
* Generates a device profile optimized for downloads.
|
||||
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
||||
*
|
||||
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
|
||||
* @returns {Object} Jellyfin device profile for downloads
|
||||
*/
|
||||
export const generateDownloadProfile = (audioMode = "auto") => {
|
||||
export const generateDownloadProfile = (
|
||||
audioMode: AudioTranscodeModeType = "auto",
|
||||
): DeviceProfile => {
|
||||
// Get the base profile with proper audio codec configuration
|
||||
const baseProfile = generateDeviceProfile({ audioMode });
|
||||
|
||||
6
utils/profiles/index.ts
Normal file
6
utils/profiles/index.ts
Normal 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";
|
||||
23
utils/profiles/native.d.ts
vendored
23
utils/profiles/native.d.ts
vendored
@@ -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;
|
||||
@@ -3,26 +3,29 @@
|
||||
* 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/.
|
||||
*/
|
||||
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
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 +47,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 +69,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 +122,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(
|
||||
@@ -195,7 +194,7 @@ export const generateDeviceProfile = (options = {}) => {
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: getSubtitleProfiles(),
|
||||
};
|
||||
} satisfies DeviceProfile;
|
||||
|
||||
return profile;
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
* 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/.
|
||||
*/
|
||||
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||
// because MPV cannot load them externally over HTTP
|
||||
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
|
||||
"pgssub",
|
||||
"teletext",
|
||||
"vobsub",
|
||||
];
|
||||
] as const;
|
||||
|
||||
// Text-based formats - these can be loaded externally by MPV
|
||||
const TEXT_BASED_FORMATS = [
|
||||
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
|
||||
"text",
|
||||
"vplayer",
|
||||
"xsub",
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const getSubtitleProfiles = () => {
|
||||
const profiles = [];
|
||||
export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||
const profiles: SubtitleProfile[] = [];
|
||||
|
||||
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||
for (const format of IMAGE_BASED_FORMATS) {
|
||||
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
|
||||
};
|
||||
|
||||
// Export for use in player filtering
|
||||
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
||||
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
|
||||
19
utils/profiles/trackplayer.d.ts
vendored
19
utils/profiles/trackplayer.d.ts
vendored
@@ -1,19 +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 interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
export function generateTrackPlayerProfile(
|
||||
options?: TrackPlayerProfileOptions,
|
||||
): any;
|
||||
|
||||
declare const _default: any;
|
||||
export default _default;
|
||||
@@ -3,23 +3,25 @@
|
||||
* 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/.
|
||||
*/
|
||||
import type {
|
||||
CodecProfile,
|
||||
DeviceProfile,
|
||||
DirectPlayProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import type { PlatformType } from "./native";
|
||||
|
||||
/**
|
||||
* @typedef {"ios" | "android"} PlatformType
|
||||
*
|
||||
* @typedef {Object} TrackPlayerProfileOptions
|
||||
* @property {PlatformType} [platform] - Target platform
|
||||
*/
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio direct play profiles for react-native-track-player.
|
||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getDirectPlayProfile = (platform) => {
|
||||
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer supported formats
|
||||
return {
|
||||
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
|
||||
|
||||
/**
|
||||
* Audio codec profiles for react-native-track-player.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getCodecProfile = (platform) => {
|
||||
const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer codec constraints
|
||||
return {
|
||||
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
|
||||
* This profile is specifically for standalone audio playback using:
|
||||
* - AVPlayer on iOS
|
||||
* - ExoPlayer on Android
|
||||
*
|
||||
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
|
||||
* @returns {Object} Jellyfin device profile for track player
|
||||
*/
|
||||
export const generateTrackPlayerProfile = (options = {}) => {
|
||||
const platform = options.platform || Platform.OS;
|
||||
export const generateTrackPlayerProfile = (
|
||||
options: TrackPlayerProfileOptions = {},
|
||||
): DeviceProfile => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
|
||||
return {
|
||||
Name: "Track Player",
|
||||
9
utils/scaleSize.ts
Normal file
9
utils/scaleSize.ts
Normal 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);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
// seconds to ticks util
|
||||
|
||||
export function secondsToTicks(seconds: number): number {
|
||||
return seconds * 10000000;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,31 +203,10 @@ export async function hasAccountCredential(
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all credentials for all accounts on all servers.
|
||||
*/
|
||||
export async function clearAllCredentials(): Promise<void> {
|
||||
const previousServers = getPreviousServers();
|
||||
|
||||
for (const server of previousServers) {
|
||||
for (const account of server.accounts) {
|
||||
const key = credentialKey(server.address, account.userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all accounts from servers
|
||||
const clearedServers = previousServers.map((server) => ({
|
||||
...server,
|
||||
accounts: [],
|
||||
}));
|
||||
storage.set("previousServers", JSON.stringify(clearedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update an account in a server's accounts list.
|
||||
*/
|
||||
function addAccountToServer(
|
||||
export function addAccountToServer(
|
||||
serverUrl: string,
|
||||
serverName: string,
|
||||
account: SavedServerAccount,
|
||||
@@ -475,19 +457,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
57
utils/topshelf/cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
173
utils/tvDiscovery/payload.ts
Normal file
173
utils/tvDiscovery/payload.ts
Normal 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
88
utils/tvDiscovery/sync.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
94
utils/version.ts
Normal file
94
utils/version.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as Application from "expo-application";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin
|
||||
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
||||
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
||||
|
||||
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
|
||||
export interface BuildMeta {
|
||||
commit?: string | null;
|
||||
branch?: string | null;
|
||||
profile?: string | null;
|
||||
runNumber?: string | null;
|
||||
builtAt?: string | null;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
/** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */
|
||||
version: string | null;
|
||||
/** Build number (CFBundleVersion / versionCode), e.g. "42". */
|
||||
build: string | null;
|
||||
/** Short git commit the build was made from, e.g. "a1b2c3d". */
|
||||
commit: string | null;
|
||||
/** Git branch the build was made from, e.g. "develop". */
|
||||
branch: string | null;
|
||||
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
|
||||
profile: string | null;
|
||||
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
|
||||
runNumber: string | null;
|
||||
isDev: boolean;
|
||||
isProduction: boolean;
|
||||
/** Graduated label for the Settings "App version" row (see tiering below). */
|
||||
display: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a graduated version string for Settings.
|
||||
*
|
||||
* Tiering (most → least detailed):
|
||||
* - dev / local build → `version · branch · commit` (full context for debugging)
|
||||
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
|
||||
* Actions run number maps the build to its run — artifacts + logs — without
|
||||
* Expo access)
|
||||
* - production (store / TestFlight) → `version` (build number intentionally
|
||||
* not shown: TestFlight already displays it to testers, and the commit pins the
|
||||
* binary better)
|
||||
*/
|
||||
export function getVersionInfo(): VersionInfo {
|
||||
// Read native/config values defensively — a version string must never crash Settings
|
||||
// (e.g. a dev build whose native expo-constants is out of sync with the JS).
|
||||
const read = <T>(fn: () => T): T | null => {
|
||||
try {
|
||||
return fn() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const version = read(() => Application.nativeApplicationVersion);
|
||||
const build = read(() => Application.nativeBuildVersion);
|
||||
const meta = (read(() => Constants.expoConfig?.extra?.build) ??
|
||||
{}) as BuildMeta;
|
||||
const commit = meta.commit ?? null;
|
||||
const branch = meta.branch ?? null;
|
||||
const profile = meta.profile ?? null;
|
||||
const runNumber = meta.runNumber ?? null;
|
||||
const isDev = __DEV__ === true;
|
||||
const isProduction =
|
||||
typeof profile === "string" && profile.startsWith("production");
|
||||
|
||||
let display: string;
|
||||
if (isDev) {
|
||||
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
|
||||
} else if (isProduction) {
|
||||
display = version ?? build ?? "N/A";
|
||||
} else {
|
||||
display =
|
||||
[version, commit, runNumber && `#${runNumber}`]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "N/A";
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
build,
|
||||
commit,
|
||||
branch,
|
||||
profile,
|
||||
runNumber,
|
||||
isDev,
|
||||
isProduction,
|
||||
display,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user