mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 01:22:56 +01:00
Compare commits
11 Commits
I10n_crowd
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec | ||
|
|
97b6a912e0 |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
|
||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
/iosmobile
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js)
|
||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
|
||||
@@ -56,8 +56,8 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
@@ -639,12 +639,9 @@ export default function DirectPlayerPage() {
|
||||
).map((s) => s.DeliveryUrl!);
|
||||
}
|
||||
|
||||
// Calculate track IDs for initial selection
|
||||
const initialSubtitleId = getMpvSubtitleId(
|
||||
mediaSource,
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||
// list is known — see applySubtitleSelection / onTracksReady.
|
||||
const initialAudioId = getMpvAudioId(
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
@@ -662,7 +659,6 @@ export default function DirectPlayerPage() {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
cacheConfig: {
|
||||
@@ -710,7 +706,6 @@ export default function DirectPlayerPage() {
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
api?.accessToken,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
settings.mpvCacheEnabled,
|
||||
@@ -908,30 +903,41 @@ export default function DirectPlayerPage() {
|
||||
);
|
||||
|
||||
// TV subtitle track change handler
|
||||
/**
|
||||
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
||||
* it. Identity-based (external by filename, embedded by language/title) so it
|
||||
* stays correct across external/embedded reordering and server-hidden embedded
|
||||
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
||||
* fired again after each external sub-add) and runtime changes.
|
||||
*/
|
||||
const applySubtitleSelection = useCallback(
|
||||
async (jellyfinSubtitleIndex: number) => {
|
||||
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle",
|
||||
);
|
||||
await applyMpvSubtitleSelection(videoRef.current, {
|
||||
subtitleStreams,
|
||||
jellyfinSubtitleIndex,
|
||||
// The exact URL each external sub was loaded into MPV with — mirrors the
|
||||
// externalSubtitles array built in videoSource (online: basePath +
|
||||
// DeliveryUrl, offline: local DeliveryUrl).
|
||||
getExpectedExternalUrl: (s) => {
|
||||
if (!s.DeliveryUrl) return undefined;
|
||||
if (offline) return s.DeliveryUrl;
|
||||
return api?.basePath ? `${api.basePath}${s.DeliveryUrl}` : undefined;
|
||||
},
|
||||
});
|
||||
},
|
||||
[stream?.mediaSource, offline, api?.basePath],
|
||||
);
|
||||
|
||||
// TV/mobile subtitle track change handler
|
||||
const handleSubtitleIndexChange = useCallback(
|
||||
async (index: number) => {
|
||||
setCurrentSubtitleIndex(index);
|
||||
|
||||
// Check if we're transcoding
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
|
||||
if (index === -1) {
|
||||
// Disable subtitles
|
||||
await videoRef.current?.disableSubtitles?.();
|
||||
} else {
|
||||
// Convert Jellyfin index to MPV track ID
|
||||
const mpvTrackId = getMpvSubtitleId(
|
||||
stream?.mediaSource,
|
||||
index,
|
||||
isTranscoding,
|
||||
);
|
||||
|
||||
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
||||
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
||||
}
|
||||
}
|
||||
await applySubtitleSelection(index);
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
[applySubtitleSelection],
|
||||
);
|
||||
|
||||
// Technical info toggle handler
|
||||
@@ -1296,6 +1302,10 @@ export default function DirectPlayerPage() {
|
||||
}}
|
||||
onTracksReady={() => {
|
||||
setTracksReady(true);
|
||||
// Fired after embedded tracks enumerate and again after each
|
||||
// external sub-add; re-resolve so the final fire (full track
|
||||
// list) selects the right track by identity.
|
||||
void applySubtitleSelection(currentSubtitleIndex);
|
||||
}}
|
||||
/>
|
||||
{!hasPlaybackStarted && (
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
@@ -232,12 +233,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
return streams ?? [];
|
||||
}, [selectedOptions?.mediaSource]);
|
||||
|
||||
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
||||
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
||||
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
const subtitleStreams = useMemo(() => {
|
||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle",
|
||||
);
|
||||
return streams ?? [];
|
||||
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||
}, [selectedOptions?.mediaSource]);
|
||||
|
||||
// Store handleSubtitleChange in a ref for stable callback reference
|
||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
)
|
||||
: freshItem.MediaSources?.[0];
|
||||
|
||||
// Get subtitle streams from the fresh data
|
||||
const streams =
|
||||
mediaSource?.MediaStreams?.filter(
|
||||
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||
// (embedded first, externals last) — same as the initial list.
|
||||
const streams = [
|
||||
...(mediaSource?.MediaStreams?.filter(
|
||||
(s: MediaStream) => s.Type === "Subtitle",
|
||||
) ?? [];
|
||||
) ?? []),
|
||||
].sort(compareTracksForMenu);
|
||||
|
||||
// Convert to Track[] with setTrack callbacks
|
||||
const tracks: Track[] = streams.map((stream) => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || [],
|
||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
[
|
||||
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || []),
|
||||
].sort(compareTracksForMenu),
|
||||
[selectedOptions.mediaSource],
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
return subs ? [...subs].sort(compareTracksForMenu) : subs;
|
||||
}, [source]);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { useVideoContext } from "./contexts/VideoContext";
|
||||
@@ -317,8 +318,10 @@ export const Controls: FC<Props> = ({
|
||||
try {
|
||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||
// Skip streams without a real index: `?? -1` would alias them to the
|
||||
// "disable subtitles" sentinel and mis-route selection.
|
||||
return streams
|
||||
// "disable subtitles" sentinel and mis-route selection. Order like
|
||||
// jellyfin-web (embedded first, externals last, forced/default up).
|
||||
return [...streams]
|
||||
.sort(compareTracksForMenu)
|
||||
.filter((stream) => typeof stream.Index === "number")
|
||||
.map((stream) => {
|
||||
const index = stream.Index as number;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type SubtitleSearchResult,
|
||||
useRemoteSubtitles,
|
||||
} from "@/hooks/useRemoteSubtitles";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||
|
||||
interface TVSubtitleSheetProps {
|
||||
@@ -96,13 +97,19 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
const sortedTracks = useMemo(
|
||||
() => [...subtitleTracks].sort(compareTracksForMenu),
|
||||
[subtitleTracks],
|
||||
);
|
||||
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0;
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
const trackIdx = sortedTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
}, [sortedTracks, currentSubtitleIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -215,7 +222,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const options = subtitleTracks.map((track) => ({
|
||||
const options = sortedTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
sublabel: track.Codec?.toUpperCase(),
|
||||
@@ -223,7 +230,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...options];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
}, [sortedTracks, currentSubtitleIndex, t]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
|
||||
@@ -23,32 +23,29 @@
|
||||
* - Used to report playback state to Jellyfin server
|
||||
* - Value of -1 means disabled/none
|
||||
*
|
||||
* 2. MPV INDEX (track.mpvIndex)
|
||||
* - MPV's internal track ID
|
||||
* - MPV orders tracks as: [all embedded, then all external]
|
||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
||||
* 2. PLAYER TRACK (selected by IDENTITY, not position)
|
||||
* - Selection resolves the server Index against MPV's REAL track list via
|
||||
* applyMpvSubtitleSelection: externals matched by external-filename,
|
||||
* embedded by language/title. `track.mpvIndex` is no longer used to select
|
||||
* (kept -1) — positional mapping mis-selected when externals/embedded were
|
||||
* reordered or the server hid embedded subs (#954 et al.).
|
||||
*
|
||||
* ============================================================================
|
||||
* SUBTITLE HANDLING
|
||||
* ============================================================================
|
||||
*
|
||||
* Embedded (DeliveryMethod.Embed):
|
||||
* - Already in MPV's track list
|
||||
* - Select via setSubtitleTrack(mpvId)
|
||||
*
|
||||
* External (DeliveryMethod.External):
|
||||
* - Loaded into MPV on video start
|
||||
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
||||
* Embedded & External:
|
||||
* - Selected via applyMpvSubtitleSelection (identity match against the live
|
||||
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
|
||||
* embedded first, externals last, forced/default float up).
|
||||
*
|
||||
* Image-based during transcoding:
|
||||
* - Burned into video by Jellyfin, not in MPV
|
||||
* - Requires replacePlayer() to change
|
||||
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { File } from "expo-file-system";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
@@ -61,9 +58,14 @@ import {
|
||||
import { Platform } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import type { MpvAudioTrack } from "@/modules";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
compareTracksForMenu,
|
||||
isImageBasedSubtitle,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
@@ -87,6 +89,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
const offline = useOfflineMode();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
@@ -141,6 +144,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
useEffect(() => {
|
||||
if (!tracksReady) return;
|
||||
|
||||
// Guard every state commit against stale runs: api?.basePath /
|
||||
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
|
||||
// earlier async run (which captured an old `api`) must not finish later and
|
||||
// overwrite the fresh track list with callbacks bound to stale closures.
|
||||
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
|
||||
let cancelled = false;
|
||||
const commitSubtitleTracks = (next: Track[]) => {
|
||||
if (!cancelled) setSubtitleTracks(next);
|
||||
};
|
||||
const commitAudioTracks = (next: Track[]) => {
|
||||
if (!cancelled) setAudioTracks(next);
|
||||
};
|
||||
|
||||
const fetchTracks = async () => {
|
||||
// Check if this is offline transcoded content
|
||||
// For transcoded offline content, only ONE audio track exists in the file
|
||||
@@ -166,10 +182,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
setAudioTracks(audio);
|
||||
commitAudioTracks(audio);
|
||||
} else {
|
||||
// Fallback: show no audio tracks if the stored track wasn't found
|
||||
setAudioTracks([]);
|
||||
commitAudioTracks([]);
|
||||
}
|
||||
|
||||
// For subtitles in transcoded offline content:
|
||||
@@ -179,6 +195,24 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
downloadedItem.userData.subtitleStreamIndex;
|
||||
const subs: Track[] = [];
|
||||
|
||||
// If an IMAGE subtitle was burned into the transcoded download it's in the
|
||||
// video pixels — it can't be turned off or swapped. Show only that entry
|
||||
// instead of advertising "Disable"/text controls that can't affect it.
|
||||
const burnedInSub = allSubs.find(
|
||||
(s) => s.Index === downloadedSubtitleIndex,
|
||||
);
|
||||
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
|
||||
commitSubtitleTracks([
|
||||
{
|
||||
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
|
||||
index: burnedInSub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add "Disable" option
|
||||
subs.push({
|
||||
name: "Disable",
|
||||
@@ -190,123 +224,82 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
});
|
||||
|
||||
// For text-based subs, they should still be available in the file
|
||||
let subIdx = 1;
|
||||
for (const sub of allSubs) {
|
||||
if (sub.IsTextSubtitleStream) {
|
||||
// Text subs are muxed into the transcoded file and switchable; resolve by
|
||||
// identity against MPV's real track list (same as online). Order matches web.
|
||||
// Image subs aren't in the transcoded file (only the burned one was, handled
|
||||
// above), so skip them here.
|
||||
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||
if (!isImageBasedSubtitle(sub)) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: subIdx,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
playerControls.setSubtitleTrack(subIdx);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
subIdx++;
|
||||
} else if (sub.Index === downloadedSubtitleIndex) {
|
||||
// This image-based sub was burned in - show it but indicate it's active
|
||||
subs.push({
|
||||
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1, // Can't be changed
|
||||
setTrack: () => {
|
||||
// Already burned in, just update params
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
void applyMpvSubtitleSelection(playerControls, {
|
||||
subtitleStreams: allSubs,
|
||||
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||
getExpectedExternalUrl: (s) => {
|
||||
if (!s.DeliveryUrl) return undefined;
|
||||
if (offline) return s.DeliveryUrl;
|
||||
return api?.basePath
|
||||
? `${api.basePath}${s.DeliveryUrl}`
|
||||
: undefined;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
commitSubtitleTracks(subs);
|
||||
return;
|
||||
}
|
||||
|
||||
// MPV track handling
|
||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||
if (cancelled) return;
|
||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||
|
||||
// Separate embedded vs external subtitles from Jellyfin's list
|
||||
// MPV orders tracks as: [all embedded, then all external]
|
||||
const embeddedSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
|
||||
);
|
||||
const externalSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
|
||||
);
|
||||
|
||||
// Count embedded subs that will be in MPV
|
||||
// (excludes image-based subs during transcoding as they're burned in)
|
||||
const embeddedInPlayer = embeddedSubs.filter(
|
||||
(s) => !isTranscoding || !isImageBasedSubtitle(s),
|
||||
);
|
||||
|
||||
const subs: Track[] = [];
|
||||
|
||||
// Process all Jellyfin subtitles
|
||||
for (const sub of allSubs) {
|
||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
||||
const isExternal =
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
|
||||
// For image-based subs during transcoding, need to refresh player
|
||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate MPV track ID based on type
|
||||
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
||||
let mpvId = -1;
|
||||
|
||||
if (isEmbedded) {
|
||||
// Find position among embedded subs that are in player
|
||||
const embeddedPosition = embeddedInPlayer.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (embeddedPosition !== -1) {
|
||||
mpvId = embeddedPosition + 1; // 1-based ID
|
||||
}
|
||||
} else if (isExternal) {
|
||||
// Find position among external subs, offset by embedded count
|
||||
const externalPosition = externalSubs.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (externalPosition !== -1) {
|
||||
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
||||
}
|
||||
}
|
||||
// Process all Jellyfin subtitles. Selection resolves against MPV's real
|
||||
// track list by identity (applyMpvSubtitleSelection) — never positional
|
||||
// index math, which mis-selects across external/embedded reordering and
|
||||
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
|
||||
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
|
||||
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||
// Image-based subs during transcoding are burned into the video by the
|
||||
// server; both switching TO one and switching AWAY from a currently
|
||||
// active one require a player refresh (re-transcode), not a track change.
|
||||
const needsReplace =
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
|
||||
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: mpvId,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
// Transcoding + switching to/from image-based sub
|
||||
if (
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||
) {
|
||||
if (needsReplace) {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct switch in player
|
||||
if (mpvId !== -1) {
|
||||
playerControls.setSubtitleTrack(mpvId);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback - refresh player
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
void applyMpvSubtitleSelection(playerControls, {
|
||||
subtitleStreams: allSubs,
|
||||
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||
// Mirror how external subs are loaded into MPV (online: basePath +
|
||||
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
|
||||
// external-filename lines up.
|
||||
getExpectedExternalUrl: (s) => {
|
||||
if (!s.DeliveryUrl) return undefined;
|
||||
if (offline) return s.DeliveryUrl;
|
||||
return api?.basePath
|
||||
? `${api.basePath}${s.DeliveryUrl}`
|
||||
: undefined;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -374,12 +367,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
}
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
||||
// at the front (unshifted), local downloaded subs at the end.
|
||||
commitSubtitleTracks(subs);
|
||||
commitAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
|
||||
// API is ready so online externals don't resolve with undefined.
|
||||
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
|
||||
// decision — rebuild when it flips so we refresh the stream when we should.
|
||||
}, [
|
||||
tracksReady,
|
||||
mediaSource,
|
||||
offline,
|
||||
downloadedItem,
|
||||
itemId,
|
||||
api?.basePath,
|
||||
isCurrentSubImageBased,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
* 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 default {
|
||||
const MediaTypes = {
|
||||
Audio: "Audio",
|
||||
Video: "Video",
|
||||
Photo: "Photo",
|
||||
Book: "Book",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||
|
||||
export default MediaTypes;
|
||||
@@ -532,16 +532,27 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||
|
||||
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
|
||||
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||
|
||||
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||
// (instead of fragile positional counting). `external` + `external-filename`
|
||||
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
||||
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
|
||||
track["external"] = external
|
||||
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
|
||||
track["externalFilename"] = it
|
||||
}
|
||||
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
|
||||
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
|
||||
tracks.add(track)
|
||||
}
|
||||
|
||||
|
||||
return tracks
|
||||
}
|
||||
|
||||
|
||||
@@ -770,12 +770,32 @@ final class MPVLayerRenderer {
|
||||
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
||||
track["lang"] = lang
|
||||
}
|
||||
|
||||
|
||||
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
|
||||
track["codec"] = codec
|
||||
}
|
||||
|
||||
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||
// (instead of fragile positional counting). `external` + `external-filename`
|
||||
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
||||
var external: Int32 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
|
||||
track["external"] = external != 0
|
||||
|
||||
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
|
||||
track["externalFilename"] = extFilename
|
||||
}
|
||||
|
||||
var ffIndex: Int64 = 0
|
||||
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
|
||||
track["ffIndex"] = Int(ffIndex)
|
||||
}
|
||||
|
||||
var selected: Int32 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||
track["selected"] = selected != 0
|
||||
|
||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
||||
|
||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
|
||||
tracks.append(track)
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,14 @@ export type SubtitleTrack = {
|
||||
id: number;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
|
||||
codec?: string;
|
||||
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
|
||||
external?: boolean;
|
||||
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
||||
externalFilename?: string;
|
||||
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
|
||||
ffIndex?: number;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"typecheck": "bun scripts/typecheck.ts",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -34,9 +34,20 @@ import {
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||
|
||||
interface I18nConfig {
|
||||
localesDir: string;
|
||||
sourceLocale: string;
|
||||
srcDirs: string[];
|
||||
srcExtensions: string[];
|
||||
excludeDirs: string[];
|
||||
ignoreUnused: string[];
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, def) => {
|
||||
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG = {
|
||||
const DEFAULT_CONFIG: I18nConfig = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||
? {
|
||||
...DEFAULT_CONFIG,
|
||||
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||
}
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- helpers ----
|
||||
const flatten = (obj, prefix = "", out = {}) => {
|
||||
const flatten = (
|
||||
obj: LocaleTree,
|
||||
prefix = "",
|
||||
out: Record<string, string> = {},
|
||||
): Record<string, string> => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out[key] = v;
|
||||
else out[key] = v as string;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key, pattern) => {
|
||||
const globMatch = (key: string, pattern: string): boolean => {
|
||||
if (pattern.endsWith(".*"))
|
||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||
return key === pattern;
|
||||
};
|
||||
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
const walk = (dir: string, files: string[] = []): string[] => {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st;
|
||||
let st: ReturnType<typeof statSync>;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||
|
||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set();
|
||||
const fullyDynamic = []; // { file, line }
|
||||
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||
|
||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||
// `://` inside string URLs intact.
|
||||
const stripComments = (src) =>
|
||||
const stripComments = (src: string): string =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||
const literalUsed = (key) =>
|
||||
const literalUsed = (key: string): boolean =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key) =>
|
||||
const isUsed = (key: string): boolean =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
|
||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||
|
||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||
const removeKey = (obj, parts) => {
|
||||
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
removeKey(obj[head], rest);
|
||||
if (
|
||||
obj[head] &&
|
||||
typeof obj[head] === "object" &&
|
||||
Object.keys(obj[head]).length === 0
|
||||
)
|
||||
delete obj[head];
|
||||
const child = obj[head];
|
||||
if (!child || typeof child !== "object") return;
|
||||
removeKey(child, rest);
|
||||
if (Object.keys(child).length === 0) delete obj[head];
|
||||
};
|
||||
if (FIX_UNUSED && unused.length) {
|
||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||
// the keys from them automatically on the next sync once they disappear from the source.
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||
for (const key of unused) removeKey(data, key.split("."));
|
||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(
|
||||
@@ -259,7 +274,7 @@ if (JSON_OUT) {
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||
);
|
||||
console.log(
|
||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||
@@ -21,8 +21,14 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||
const numEnv = (name, def) => {
|
||||
const numEnv = (name: string, def: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return def;
|
||||
const n = Number(raw);
|
||||
@@ -51,9 +57,9 @@ const STOP = new Set(
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
||||
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||
|
||||
const tokens = (s) =>
|
||||
const tokens = (s: string | null): string[] =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||
@@ -65,7 +71,7 @@ const tokens = (s) =>
|
||||
.map(stem)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
const jaccard = (a, b) => {
|
||||
const jaccard = (a: string[], b: string[]): number => {
|
||||
const A = new Set(a);
|
||||
const B = new Set(b);
|
||||
if (!A.size || !B.size) return 0;
|
||||
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
|
||||
|
||||
const newTitle = tokens(TITLE);
|
||||
const newBody = tokens(BODY);
|
||||
const score = (o) =>
|
||||
const score = (o: Issue): number =>
|
||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||
0.4 * jaccard(newBody, tokens(o.body));
|
||||
|
||||
// fetch open issues (excluding PRs and the new issue itself)
|
||||
let issues;
|
||||
let issues: Issue[];
|
||||
if (process.env.DUP_FIXTURE) {
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||
} else {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
|
||||
issues = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l));
|
||||
.map((l) => JSON.parse(l) as Issue);
|
||||
}
|
||||
|
||||
const matches = issues
|
||||
@@ -123,7 +129,7 @@ if (!matches.length) {
|
||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||
const safeTitle = (t) =>
|
||||
const safeTitle = (t: string): string =>
|
||||
(t || "")
|
||||
.replace(/@/g, "@")
|
||||
.replace(/[`<>|*_~[\]]/g, " ")
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const _fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const process = require("node:process");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const root = process.cwd();
|
||||
// const tvosPath = path.join(root, 'iostv');
|
||||
// const iosPath = path.join(root, 'iosmobile');
|
||||
// const androidPath = path.join(root, 'androidmobile');
|
||||
// const androidTVPath = path.join(root, 'androidtv');
|
||||
// const device = process.argv[2];
|
||||
// const platform = process.argv[2];
|
||||
const isTV = process.env.EXPO_TV || false;
|
||||
|
||||
const paths = new Map([
|
||||
["tvos", path.join(root, "iostv")],
|
||||
["ios", path.join(root, "iosmobile")],
|
||||
["android", path.join(root, "androidmobile")],
|
||||
["androidtv", path.join(root, "androidtv")],
|
||||
]);
|
||||
|
||||
// const platformPath = paths.get(platform);
|
||||
|
||||
if (isTV) {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
||||
"androidtv",
|
||||
)} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
} else {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
|
||||
// target = "";
|
||||
// switch (platform) {
|
||||
// case "tvos":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "ios":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "android":
|
||||
// target = "android";
|
||||
// break;
|
||||
// case "androidtv":
|
||||
// target = "android";
|
||||
// break;
|
||||
// }
|
||||
@@ -1,5 +1,8 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const process = require("node:process");
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Enhanced ANSI color codes and styles
|
||||
const colors = {
|
||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
||||
|
||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
|
||||
function log(message, color = "") {
|
||||
function log(message: string, color = "") {
|
||||
if (useColor && color) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
} else {
|
||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(errorLine) {
|
||||
function formatError(errorLine: string): string {
|
||||
if (!useColor) return errorLine;
|
||||
|
||||
// Color file paths in cyan
|
||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function parseErrorsAndCreateSummary(errorOutput) {
|
||||
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
formattedErrors: string[];
|
||||
errorsByFile: Map<string, number>;
|
||||
} {
|
||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||
const errorsByFile = new Map();
|
||||
const formattedErrors = [];
|
||||
const errorsByFile = new Map<string, number>();
|
||||
const formattedErrors: string[] = [];
|
||||
|
||||
let currentError = [];
|
||||
let currentError: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
if (!errorsByFile.has(filePath)) {
|
||||
errorsByFile.set(filePath, 0);
|
||||
}
|
||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
||||
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||
|
||||
// Start new error
|
||||
currentError.push(formatError(line));
|
||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
return { formattedErrors, errorsByFile };
|
||||
}
|
||||
|
||||
function createErrorSummaryTable(errorsByFile) {
|
||||
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
if (errorsByFile.size === 0) return "";
|
||||
|
||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
|
||||
return table;
|
||||
}
|
||||
|
||||
function runTypeCheck() {
|
||||
function runTypeCheck(): { ok: boolean } {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
// Prefer local TypeScript binary when available
|
||||
@@ -150,16 +156,13 @@ function runTypeCheck() {
|
||||
"false",
|
||||
...extraArgs,
|
||||
];
|
||||
let execArgs = null;
|
||||
let execArgs: { cmd: string; args: string[] };
|
||||
try {
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||
} catch {
|
||||
// fallback to PATH tsc
|
||||
execArgs = {
|
||||
cmd: "tsc",
|
||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
||||
};
|
||||
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
|
||||
execArgs = { cmd: "tsc", args: runnerArgs };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -183,7 +186,21 @@ function runTypeCheck() {
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
const execError = error as { stderr?: string; stdout?: string };
|
||||
const errorOutput = [execError.stdout, execError.stderr]
|
||||
.filter((chunk): chunk is string => Boolean(chunk))
|
||||
.join("\n");
|
||||
|
||||
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
|
||||
// launch failure fall through to the "passed" branch and green-light CI.
|
||||
if (!errorOutput) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
`❌ ${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
|
||||
colors.red,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
221
utils/jellyfin/subtitleUtils.test.ts
Normal file
221
utils/jellyfin/subtitleUtils.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
MediaStream,
|
||||
SubtitleDeliveryMethod,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
compareTracksForMenu,
|
||||
isExternalSubtitle,
|
||||
type PlayerSubtitleTrack,
|
||||
resolveSubtitleTrack,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
|
||||
// String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts).
|
||||
const External = "External" as SubtitleDeliveryMethod;
|
||||
const Embed = "Embed" as SubtitleDeliveryMethod;
|
||||
|
||||
// --- fixtures --------------------------------------------------------------
|
||||
|
||||
const sub = (o: Partial<MediaStream> & { Index: number }): MediaStream =>
|
||||
({ Type: "Subtitle", ...o }) as MediaStream;
|
||||
|
||||
const ext = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
||||
sub({
|
||||
Index,
|
||||
DeliveryMethod: External,
|
||||
IsExternal: true,
|
||||
DeliveryUrl: `/sub/${Index}.srt`,
|
||||
...o,
|
||||
});
|
||||
|
||||
const emb = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
||||
sub({ Index, DeliveryMethod: Embed, ...o });
|
||||
|
||||
const track = (o: PlayerSubtitleTrack): PlayerSubtitleTrack => o;
|
||||
|
||||
// Mirror direct-player.tsx online URL builder.
|
||||
const urlBuilder =
|
||||
(base: string) =>
|
||||
(s: MediaStream): string | undefined =>
|
||||
s.DeliveryUrl ? `${base}${s.DeliveryUrl}` : undefined;
|
||||
|
||||
const resolve = (
|
||||
streams: MediaStream[],
|
||||
index: number | undefined,
|
||||
player: PlayerSubtitleTrack[],
|
||||
getExpectedExternalUrl = urlBuilder("http://srv"),
|
||||
) =>
|
||||
resolveSubtitleTrack({
|
||||
subtitleStreams: streams,
|
||||
jellyfinSubtitleIndex: index,
|
||||
playerTracks: player,
|
||||
getExpectedExternalUrl,
|
||||
});
|
||||
|
||||
// --- tests -----------------------------------------------------------------
|
||||
|
||||
describe("isExternalSubtitle", () => {
|
||||
test("true for External delivery or the IsExternal flag, not a bare DeliveryUrl", () => {
|
||||
expect(isExternalSubtitle(ext(0))).toBe(true);
|
||||
expect(isExternalSubtitle(sub({ Index: 1, IsExternal: true }))).toBe(true);
|
||||
expect(isExternalSubtitle(emb(2))).toBe(false);
|
||||
// A DeliveryUrl alone (e.g. an Hls-delivered sub) is NOT a sub-added sidecar.
|
||||
expect(isExternalSubtitle(sub({ Index: 3, DeliveryUrl: "/x.srt" }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — disable / notFound", () => {
|
||||
test("index -1 or undefined disables", () => {
|
||||
expect(resolve([], -1, [])).toEqual({ kind: "disable" });
|
||||
expect(resolve([], undefined, [])).toEqual({ kind: "disable" });
|
||||
});
|
||||
|
||||
test("index not present returns notFound", () => {
|
||||
expect(resolve([emb(0)], 99, [track({ id: 1 })])).toEqual({
|
||||
kind: "notFound",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — hidden embedded (#954)", () => {
|
||||
// Server hides embedded subs: MediaStreams lists only the 3 externals,
|
||||
// but mpv still demuxes the 3 embedded from the file → externals get ids 4,5,6.
|
||||
const streams = [
|
||||
ext(0, { Language: "por" }),
|
||||
ext(1, { Language: "eng" }),
|
||||
ext(2, { Language: "eng", Title: "SDH" }),
|
||||
];
|
||||
const player = [
|
||||
track({ id: 1, external: false, language: "eng", title: "CC" }),
|
||||
track({ id: 2, external: false, language: "spa" }),
|
||||
track({ id: 3, external: false, language: "fre" }),
|
||||
track({ id: 4, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
||||
track({ id: 5, external: true, externalFilename: "http://srv/sub/1.srt" }),
|
||||
track({ id: 6, external: true, externalFilename: "http://srv/sub/2.srt" }),
|
||||
];
|
||||
|
||||
test("each external maps to the right player id by filename (not 1,2,3)", () => {
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 5 });
|
||||
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 6 });
|
||||
});
|
||||
|
||||
test("falls back to external ordinal when filenames are unavailable", () => {
|
||||
const noNames = player.map((t) =>
|
||||
t.external ? { ...t, externalFilename: undefined } : t,
|
||||
);
|
||||
expect(resolve(streams, 1, noNames)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — external/embed reversal (non-hidden)", () => {
|
||||
// Jellyfin lists externals first; mpv lists embedded first then externals.
|
||||
const streams = [
|
||||
ext(0, { Language: "eng" }),
|
||||
emb(1, { Language: "spa" }),
|
||||
emb(2, { Language: "fre" }),
|
||||
];
|
||||
const player = [
|
||||
track({ id: 1, external: false, language: "spa" }),
|
||||
track({ id: 2, external: false, language: "fre" }),
|
||||
track({ id: 3, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
||||
];
|
||||
|
||||
test("external resolves by filename, embedded by language", () => {
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 3 });
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 });
|
||||
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — external without DeliveryUrl (#1763 CodeRabbit)", () => {
|
||||
// Middle external has no DeliveryUrl → never loaded into the player.
|
||||
const streams = [
|
||||
ext(0, { Language: "eng", DeliveryUrl: "/sub/a.srt" }),
|
||||
sub({ Index: 1, DeliveryMethod: External, IsExternal: true }),
|
||||
ext(2, { Language: "fre", DeliveryUrl: "/sub/c.srt" }),
|
||||
];
|
||||
const player = [
|
||||
track({ id: 4, external: true, externalFilename: "http://srv/sub/a.srt" }),
|
||||
track({ id: 5, external: true, externalFilename: "http://srv/sub/c.srt" }),
|
||||
];
|
||||
|
||||
test("loaded externals still map correctly despite the gap", () => {
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
||||
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 5 });
|
||||
});
|
||||
|
||||
test("selecting the unloaded external returns notFound", () => {
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "notFound" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — embedded matching", () => {
|
||||
test("unique language match wins even when player order differs (not positional)", () => {
|
||||
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "jpn" })];
|
||||
// Player lists them in the OPPOSITE order — a positional map would mis-pick.
|
||||
const player = [
|
||||
track({ id: 1, external: false, language: "jpn" }),
|
||||
track({ id: 2, external: false, language: "eng" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 }); // eng
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 }); // jpn
|
||||
});
|
||||
|
||||
test("same-language tracks with no distinguishing title fall back to ordinal among matches", () => {
|
||||
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "eng" })];
|
||||
// Both eng, no title → identity can't disambiguate → ordinal among matches.
|
||||
const player = [
|
||||
track({ id: 5, external: false, language: "eng" }),
|
||||
track({ id: 6, external: false, language: "eng" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 5 });
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 6 });
|
||||
});
|
||||
|
||||
test("falls back to embedded ordinal when no language/title info", () => {
|
||||
const streams = [emb(0), emb(1)];
|
||||
const player = [
|
||||
track({ id: 1, external: false }),
|
||||
track({ id: 2, external: false }),
|
||||
];
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareTracksForMenu — jellyfin-web order", () => {
|
||||
test("externals sort after embedded despite lower Index", () => {
|
||||
const sorted = [
|
||||
ext(0, { Language: "eng" }),
|
||||
emb(7, { Language: "fra" }),
|
||||
].sort(compareTracksForMenu);
|
||||
expect(sorted.map((s) => s.Index)).toEqual([7, 0]);
|
||||
});
|
||||
|
||||
test("forced then default float to the top within a group", () => {
|
||||
const sorted = [
|
||||
emb(2, { Language: "eng" }),
|
||||
emb(1, { Language: "eng", IsDefault: true }),
|
||||
emb(0, { Language: "eng", IsForced: true }),
|
||||
].sort(compareTracksForMenu);
|
||||
expect(sorted.map((s) => s.Index)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("full Okiku order: embedded first, externals last by Index", () => {
|
||||
const streams = [
|
||||
ext(0, { Language: "eng" }),
|
||||
ext(1, { Language: "eng" }),
|
||||
ext(2, { Language: "fra" }),
|
||||
ext(3, { Language: "fra" }),
|
||||
emb(7, { Language: "fra", Title: "French" }),
|
||||
];
|
||||
expect([...streams].sort(compareTracksForMenu).map((s) => s.Index)).toEqual(
|
||||
[7, 0, 1, 2, 3],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +1,287 @@
|
||||
/**
|
||||
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
||||
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
|
||||
* the *player's real track list* by identity — never by positional counting.
|
||||
*
|
||||
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
|
||||
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
|
||||
* Why: Jellyfin renumbers MediaStreams (externals first); the player enumerates
|
||||
* embedded-from-container first and externals (`sub-add`) last; and a library that
|
||||
* hides embedded subs drops them from MediaStreams while the player still demuxes
|
||||
* them from the file. Positional Index→id mapping therefore mis-selects (e.g.
|
||||
* picking Spanish shows English). See {@link resolveSubtitleTrack}.
|
||||
*
|
||||
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
||||
* and NOT available in MPV's track list.
|
||||
* and absent from the player's track list.
|
||||
*/
|
||||
|
||||
import {
|
||||
type MediaSourceInfo,
|
||||
type MediaStream,
|
||||
import type {
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
SubtitleDeliveryMethod,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
// "External" is the value of SubtitleDeliveryMethod.External. Compared as a typed
|
||||
// literal so this util needs no *runtime* import of the SDK barrel — which pulls in
|
||||
// the axios-dependent `/api` modules and breaks unit tests under `bun test`.
|
||||
const EXTERNAL_DELIVERY = "External" as SubtitleDeliveryMethod;
|
||||
|
||||
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.IsTextSubtitleStream === false;
|
||||
|
||||
/**
|
||||
* Determine if a subtitle will be available in MPV's track list.
|
||||
* A Jellyfin subtitle stream is "external" when the server delivers it as a
|
||||
* sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal`
|
||||
* flag before a device-specific delivery method is assigned).
|
||||
*
|
||||
* A subtitle is in MPV if:
|
||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
||||
* Deliberately NOT keyed on `DeliveryUrl`: an Hls-delivered sub also carries a
|
||||
* `DeliveryUrl` but lives inside the player's track list (not `sub-add`-ed), so
|
||||
* it must resolve through the embedded path. Keeping this in lockstep with the
|
||||
* load sites (which only `sub-add` `DeliveryMethod === External`) and with the
|
||||
* menu comparator below avoids a sub being sorted as embedded yet resolved as
|
||||
* external (→ `notFound`).
|
||||
*/
|
||||
export const isSubtitleInMpv = (
|
||||
sub: MediaStream,
|
||||
isTranscoding: boolean,
|
||||
): boolean => {
|
||||
// During transcoding, image-based subs are burned in, not in MPV
|
||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||
return false;
|
||||
}
|
||||
export const isExternalSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.DeliveryMethod === EXTERNAL_DELIVERY || sub.IsExternal === true;
|
||||
|
||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
||||
return (
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
||||
);
|
||||
/**
|
||||
* Order subtitle MediaStreams for the selection menu exactly like jellyfin-web's
|
||||
* `itemHelper.sortTracks`: in-container tracks first then external, and within
|
||||
* each group forced first, then default, then `Index` ascending. Callers prepend
|
||||
* their own "None/Off" entry separately.
|
||||
*
|
||||
* The Jellyfin server inserts external (sidecar) streams at the FRONT of
|
||||
* `MediaStreams` (low indices), so raw Index order shows externals first — this
|
||||
* comparator flips that to match web (externals last). Uses {@link isExternalSubtitle}
|
||||
* (not the raw `IsExternal` flag) so ordering and resolution agree.
|
||||
*/
|
||||
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
|
||||
Number(isExternalSubtitle(a)) - Number(isExternalSubtitle(b)) ||
|
||||
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
|
||||
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
|
||||
(a.Index ?? 0) - (b.Index ?? 0);
|
||||
|
||||
/**
|
||||
* Identity of a subtitle track as reported by the *player's real track list*
|
||||
* (mpv `track-list`, or a Cast media-track list). Player-agnostic on purpose so
|
||||
* the same resolver can drive the mpv player today and the Chromecast backend later.
|
||||
*/
|
||||
export type PlayerSubtitleTrack = {
|
||||
/** Player-side id used to actually select the track (mpv `sid`, cast trackId). */
|
||||
id: number;
|
||||
/** True if loaded from a separate file (mpv `external`). */
|
||||
external?: boolean;
|
||||
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
||||
externalFilename?: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
codec?: string;
|
||||
};
|
||||
|
||||
export type SubtitleSelection =
|
||||
| { kind: "select"; trackId: number }
|
||||
| { kind: "disable" }
|
||||
| { kind: "notFound" };
|
||||
|
||||
/** Decode percent-encoding and strip a leading `file://` scheme for tolerant comparison. */
|
||||
const normalizeUrl = (url: string): string => {
|
||||
let u = url;
|
||||
try {
|
||||
u = decodeURIComponent(u);
|
||||
} catch {
|
||||
// not decodable — compare raw
|
||||
}
|
||||
return u.replace(/^file:\/\//, "");
|
||||
};
|
||||
|
||||
const externalFilenameMatches = (
|
||||
trackFilename: string | undefined,
|
||||
expectedUrl: string | undefined,
|
||||
): boolean => {
|
||||
if (!trackFilename || !expectedUrl) return false;
|
||||
const a = normalizeUrl(trackFilename);
|
||||
const b = normalizeUrl(expectedUrl);
|
||||
return a === b || a.endsWith(b) || b.endsWith(a);
|
||||
};
|
||||
|
||||
const eq = (a?: string | null, b?: string | null): boolean =>
|
||||
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
|
||||
const embeddedIdentityMatches = (
|
||||
track: PlayerSubtitleTrack,
|
||||
stream: MediaStream,
|
||||
): boolean => {
|
||||
if (eq(track.language, stream.Language)) {
|
||||
// When both carry a title it must agree; otherwise language alone is enough.
|
||||
if (track.title && stream.Title) return eq(track.title, stream.Title);
|
||||
return true;
|
||||
}
|
||||
// No language on one side — fall back to a title match.
|
||||
if (!track.language || !stream.Language) return eq(track.title, stream.Title);
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
||||
* Resolve the player track id for a given Jellyfin subtitle index by matching
|
||||
* against the player's REAL track list (identity), never by positional counting.
|
||||
*
|
||||
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
||||
* We iterate through all subtitles, counting only those in MPV, until we find
|
||||
* the one matching the Jellyfin index.
|
||||
* Why identity, not position: Jellyfin renumbers `MediaStreams` (externals first)
|
||||
* while the player enumerates embedded-from-container first and externals
|
||||
* (`sub-add`) last; and when a library hides embedded subs they vanish from
|
||||
* `MediaStreams` but still physically exist in the file the player demuxes.
|
||||
* Positional Index→id mapping therefore mis-selects (e.g. picking Spanish shows
|
||||
* English — issues #954/#1690/#618/#1467/#976/#1451).
|
||||
*
|
||||
* @param mediaSource - The media source containing subtitle streams
|
||||
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
||||
* @param isTranscoding - Whether the stream is being transcoded
|
||||
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
|
||||
* Strategy:
|
||||
* - disabled (-1/undefined) → `disable`
|
||||
* - external Jellyfin sub → match the player track by `externalFilename`
|
||||
* (exact identity, immune to hidden-embedded shifts); fall back to the
|
||||
* ordinal among *loadable* externals (Swiftfin: externals are the list tail).
|
||||
* - embedded Jellyfin sub → match by language/title among non-external tracks;
|
||||
* fall back to the embedded ordinal (container order aligns on both sides).
|
||||
*
|
||||
* Player-agnostic: pass any player's track list + a URL builder, so the mpv
|
||||
* player and (later) the Chromecast backend share one source of truth.
|
||||
*/
|
||||
export const getMpvSubtitleId = (
|
||||
mediaSource: MediaSourceInfo | null | undefined,
|
||||
jellyfinSubtitleIndex: number | undefined,
|
||||
isTranscoding: boolean,
|
||||
): number | undefined => {
|
||||
// -1 or undefined means disabled
|
||||
export const resolveSubtitleTrack = (params: {
|
||||
subtitleStreams: MediaStream[] | undefined;
|
||||
jellyfinSubtitleIndex: number | undefined;
|
||||
playerTracks: PlayerSubtitleTrack[];
|
||||
/** Build the exact URL/path an external Jellyfin sub was loaded into the player with. */
|
||||
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
||||
}): SubtitleSelection => {
|
||||
const { jellyfinSubtitleIndex, playerTracks, getExpectedExternalUrl } =
|
||||
params;
|
||||
const subtitleStreams = params.subtitleStreams ?? [];
|
||||
|
||||
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||
return -1;
|
||||
return { kind: "disable" };
|
||||
}
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||
if (!target) return { kind: "notFound" };
|
||||
|
||||
// Find the subtitle with the matching Jellyfin index
|
||||
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||
if (isExternalSubtitle(target)) {
|
||||
const playerExternals = playerTracks.filter((t) => t.external === true);
|
||||
|
||||
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
||||
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
||||
return undefined;
|
||||
}
|
||||
// 1) Exact identity by external filename — robust against hidden-embedded offset.
|
||||
const expectedUrl = getExpectedExternalUrl?.(target);
|
||||
const byName = playerExternals.find((t) =>
|
||||
externalFilenameMatches(t.externalFilename, expectedUrl),
|
||||
);
|
||||
if (byName) return { kind: "select", trackId: byName.id };
|
||||
|
||||
// Count MPV track position (1-based)
|
||||
let mpvIndex = 0;
|
||||
for (const sub of allSubs) {
|
||||
if (isSubtitleInMpv(sub, isTranscoding)) {
|
||||
mpvIndex++;
|
||||
if (sub.Index === jellyfinSubtitleIndex) {
|
||||
return mpvIndex;
|
||||
}
|
||||
// 2) Fallback: externals are appended in MediaStreams order → ordinal among
|
||||
// *loadable* externals (those actually added to the player) stays in lockstep
|
||||
// with the player's external list, skipping ones with no DeliveryUrl (#1763).
|
||||
const externalStreams = subtitleStreams.filter(isExternalSubtitle);
|
||||
const loadableExternals = getExpectedExternalUrl
|
||||
? externalStreams.filter((s) => getExpectedExternalUrl(s))
|
||||
: externalStreams;
|
||||
const ordinal = loadableExternals.findIndex(
|
||||
(s) => s.Index === jellyfinSubtitleIndex,
|
||||
);
|
||||
if (ordinal >= 0 && ordinal < playerExternals.length) {
|
||||
return { kind: "select", trackId: playerExternals[ordinal].id };
|
||||
}
|
||||
return { kind: "notFound" };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
// Embedded / in-container subtitle.
|
||||
const embeddedStreams = subtitleStreams.filter((s) => !isExternalSubtitle(s));
|
||||
const playerEmbedded = playerTracks.filter((t) => t.external !== true);
|
||||
|
||||
// 1) Identity by language/title (unique match wins).
|
||||
const identityMatches = playerEmbedded.filter((t) =>
|
||||
embeddedIdentityMatches(t, target),
|
||||
);
|
||||
if (identityMatches.length === 1) {
|
||||
return { kind: "select", trackId: identityMatches[0].id };
|
||||
}
|
||||
|
||||
// 2) Fallback: embedded order is container order on both sides → ordinal.
|
||||
const ordinal = embeddedStreams.findIndex(
|
||||
(s) => s.Index === jellyfinSubtitleIndex,
|
||||
);
|
||||
if (identityMatches.length > 1 && ordinal >= 0) {
|
||||
// Multiple same-language tracks: pick by position among the matches.
|
||||
const idx = Math.min(ordinal, identityMatches.length - 1);
|
||||
return { kind: "select", trackId: identityMatches[idx].id };
|
||||
}
|
||||
if (ordinal >= 0 && ordinal < playerEmbedded.length) {
|
||||
return { kind: "select", trackId: playerEmbedded[ordinal].id };
|
||||
}
|
||||
return { kind: "notFound" };
|
||||
};
|
||||
|
||||
/**
|
||||
* A subtitle track as reported by a concrete player's track-list API
|
||||
* (mpv `getSubtitleTracks`, or a Cast track list). `lang` mirrors mpv's field name.
|
||||
*/
|
||||
export type PlayerSubtitleTrackRaw = {
|
||||
id: number;
|
||||
lang?: string;
|
||||
title?: string;
|
||||
codec?: string;
|
||||
external?: boolean;
|
||||
externalFilename?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal player surface needed to select a subtitle. Satisfied structurally by
|
||||
* the mpv player ref and (later) implementable by the Chromecast backend.
|
||||
*/
|
||||
export interface SubtitleSelectablePlayer {
|
||||
getSubtitleTracks: () => Promise<PlayerSubtitleTrackRaw[] | null | undefined>;
|
||||
setSubtitleTrack: (trackId: number) => unknown;
|
||||
disableSubtitles: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the player's real track list, resolve the Jellyfin subtitle index by
|
||||
* identity ({@link resolveSubtitleTrack}) and apply the result. Single entry point
|
||||
* for both the mobile controls and the player screen, so selection stays
|
||||
* consistent everywhere. Returns the resolution for callers that want to react.
|
||||
*/
|
||||
export const applyMpvSubtitleSelection = async (
|
||||
player: SubtitleSelectablePlayer | null | undefined,
|
||||
params: {
|
||||
subtitleStreams: MediaStream[] | undefined;
|
||||
jellyfinSubtitleIndex: number;
|
||||
/** Build the exact URL/path an external sub was loaded into the player with. */
|
||||
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
||||
},
|
||||
): Promise<SubtitleSelection> => {
|
||||
if (!player) return { kind: "notFound" };
|
||||
|
||||
// Called fire-and-forget (`void applyMpvSubtitleSelection(...)`), so any native
|
||||
// rejection from getSubtitleTracks/setSubtitleTrack/disableSubtitles must be
|
||||
// swallowed here instead of escaping as an unhandled promise rejection.
|
||||
try {
|
||||
const tracks = (await player.getSubtitleTracks()) ?? [];
|
||||
const selection = resolveSubtitleTrack({
|
||||
subtitleStreams: params.subtitleStreams,
|
||||
jellyfinSubtitleIndex: params.jellyfinSubtitleIndex,
|
||||
playerTracks: tracks.map((t) => ({
|
||||
id: t.id,
|
||||
external: t.external,
|
||||
externalFilename: t.externalFilename,
|
||||
language: t.lang,
|
||||
title: t.title,
|
||||
codec: t.codec,
|
||||
})),
|
||||
getExpectedExternalUrl: params.getExpectedExternalUrl,
|
||||
});
|
||||
|
||||
if (selection.kind === "select") {
|
||||
await player.setSubtitleTrack(selection.trackId);
|
||||
} else if (selection.kind === "disable") {
|
||||
await player.disableSubtitles();
|
||||
}
|
||||
// notFound → leave current selection (e.g. image subs burned in while transcoding)
|
||||
return selection;
|
||||
} catch {
|
||||
return { kind: "notFound" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
},
|
||||
],
|
||||
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",
|
||||
Reference in New Issue
Block a user