mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 10:44:18 +01:00
Compare commits
14 Commits
fix/skip-b
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5641afe0 | ||
|
|
33b07f4a99 | ||
|
|
dd0a25978c | ||
|
|
c884036d05 | ||
|
|
ad8f93e1e0 | ||
|
|
7d0f89148d | ||
|
|
0fea901133 | ||
|
|
053760829f | ||
|
|
7a88ae19cb | ||
|
|
c13da89307 | ||
|
|
9a4381fef3 | ||
|
|
c4e5cb9c14 | ||
|
|
4f31cd2b32 | ||
|
|
faa250bfdd |
@@ -13,7 +13,7 @@ build:
|
||||
|
||||
- run:
|
||||
name: Prebuild (Android, bun)
|
||||
command: bun x expo prebuild --platform android --no-install
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
|
||||
- eas/configure_android_version
|
||||
- eas/inject_android_credentials
|
||||
|
||||
@@ -15,7 +15,7 @@ build:
|
||||
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
||||
- run:
|
||||
name: Prebuild (Android TV, bun)
|
||||
command: bun x expo prebuild --platform android --no-install
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
|
||||
- eas/configure_android_version
|
||||
- eas/inject_android_credentials
|
||||
|
||||
@@ -18,10 +18,10 @@ build:
|
||||
command: bun install --frozen-lockfile
|
||||
|
||||
# android/ is gitignored, so generate native code fresh. --no-install
|
||||
# because deps are already installed above; bun x keeps it on bun.
|
||||
# because deps are already installed above; bunx keeps it on bun.
|
||||
- run:
|
||||
name: Prebuild (Android, bun)
|
||||
command: bun x expo prebuild --platform android --no-install
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
|
||||
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
||||
# in eas.json) into the freshly prebuilt android/ project.
|
||||
|
||||
@@ -26,7 +26,7 @@ build:
|
||||
# skips JS + pod install; we install pods explicitly below with bun deps.
|
||||
- run:
|
||||
name: Prebuild (iOS/tvOS, bun)
|
||||
command: bun x expo prebuild --platform ios --no-install
|
||||
command: bunx expo prebuild --platform ios --no-install
|
||||
|
||||
- run:
|
||||
name: Install CocoaPods
|
||||
|
||||
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.55.0",
|
||||
"version": "0.54.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||
@@ -33,13 +33,16 @@ import {
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
AudioTranscodeMode,
|
||||
getActiveVideoPlayer,
|
||||
InactivityTimeout,
|
||||
type MpvCacheMode,
|
||||
type MpvVoDriver,
|
||||
TVTypographyScale,
|
||||
useSettings,
|
||||
VideoPlayer,
|
||||
} from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
import {
|
||||
getPreviousServers,
|
||||
type SavedServer,
|
||||
@@ -262,6 +265,25 @@ export default function SettingsTV() {
|
||||
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
||||
const currentLanguage = settings.preferedLanguage;
|
||||
|
||||
// Video player selection. MPV is the default; ExoPlayer is only offered
|
||||
// as an opt-in alternative on Android TV. The selector is hidden on
|
||||
// other platforms.
|
||||
const isAndroidTv = Platform.OS === "android" && Platform.isTV;
|
||||
const currentVideoPlayer = getActiveVideoPlayer(settings);
|
||||
const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer;
|
||||
|
||||
// Shared style for the ExoPlayer / MPV limitation notes shown under the
|
||||
// selector when the respective player is active. All pixel values scaled
|
||||
// so the layout holds on 4K TVs (see utils/scaleSize.ts).
|
||||
const playerNoteStyle = {
|
||||
color: "#9CA3AF",
|
||||
fontSize: typography.callout - 2,
|
||||
marginTop: scaleSize(4),
|
||||
marginBottom: scaleSize(12),
|
||||
marginLeft: scaleSize(8),
|
||||
marginRight: scaleSize(8),
|
||||
} as const;
|
||||
|
||||
// Audio transcoding options
|
||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||
() => [
|
||||
@@ -403,6 +425,23 @@ export default function SettingsTV() {
|
||||
[t, currentVoDriver],
|
||||
);
|
||||
|
||||
// Video player backend options (Android TV only)
|
||||
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.video_player.exoplayer"),
|
||||
value: VideoPlayer.ExoPlayer,
|
||||
selected: currentVideoPlayer === VideoPlayer.ExoPlayer,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.video_player.mpv"),
|
||||
value: VideoPlayer.MPV,
|
||||
selected: currentVideoPlayer === VideoPlayer.MPV,
|
||||
},
|
||||
],
|
||||
[t, currentVideoPlayer],
|
||||
);
|
||||
|
||||
// Typography scale options
|
||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||
() => [
|
||||
@@ -534,6 +573,11 @@ export default function SettingsTV() {
|
||||
return option?.label || t("home.settings.vo_driver.gpu_next");
|
||||
}, [voDriverOptions, t]);
|
||||
|
||||
const videoPlayerLabel = useMemo(() => {
|
||||
const option = videoPlayerOptions.find((o) => o.selected);
|
||||
return option?.label || "MPV";
|
||||
}, [videoPlayerOptions]);
|
||||
|
||||
const languageLabel = useMemo(() => {
|
||||
if (!currentLanguage) return t("home.settings.languages.system");
|
||||
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||
@@ -598,6 +642,34 @@ export default function SettingsTV() {
|
||||
|
||||
{/* Audio Section */}
|
||||
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||
|
||||
{/* Video Player selector — Android TV only */}
|
||||
{isAndroidTv && (
|
||||
<>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.video_player.title")}
|
||||
value={videoPlayerLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.video_player.title"),
|
||||
options: videoPlayerOptions,
|
||||
onSelect: (value) => updateSettings({ videoPlayer: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!isMpv && (
|
||||
<Text style={playerNoteStyle}>
|
||||
{t("home.settings.video_player.exoplayer_note")}
|
||||
</Text>
|
||||
)}
|
||||
{isMpv && (
|
||||
<Text style={playerNoteStyle}>
|
||||
{t("home.settings.video_player.mpv_note")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.audio.transcode_mode.title")}
|
||||
value={audioTranscodeLabel}
|
||||
@@ -674,20 +746,23 @@ export default function SettingsTV() {
|
||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||
}}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
value={alignXLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{isMpv && (
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
value={alignXLabel}
|
||||
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
value={alignYLabel}
|
||||
@@ -760,19 +835,24 @@ export default function SettingsTV() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Video Output Section */}
|
||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.vo_driver.vo_mode")}
|
||||
value={voDriverLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.vo_driver.vo_mode"),
|
||||
options: voDriverOptions,
|
||||
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
|
||||
{isMpv && (
|
||||
<>
|
||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.vo_driver.vo_mode")}
|
||||
value={voDriverLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.vo_driver.vo_mode"),
|
||||
options: voDriverOptions,
|
||||
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TVSettingsStepper
|
||||
label={t("home.settings.buffer.buffer_duration")}
|
||||
value={settings.mpvCacheSeconds ?? 10}
|
||||
|
||||
@@ -26,14 +26,11 @@ import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import {
|
||||
LOCAL_SUBTITLE_INDEX_START,
|
||||
toServerSubtitleIndex,
|
||||
} from "@/components/video-player/controls/types";
|
||||
import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
@@ -45,7 +42,6 @@ import {
|
||||
type MpvOnErrorEventPayload,
|
||||
type MpvOnPlaybackStateChangePayload,
|
||||
type MpvOnProgressEventPayload,
|
||||
MpvPlayerView,
|
||||
type MpvPlayerViewRef,
|
||||
type MpvVideoSource,
|
||||
} from "@/modules";
|
||||
@@ -54,16 +50,15 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
getExternalSubtitleUrl,
|
||||
getMpvAudioId,
|
||||
isImageBasedSubtitle,
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
@@ -401,7 +396,13 @@ export default function DirectPlayerPage() {
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
// Match the device profile to the player that will render the
|
||||
// stream so the server picks a codec/container the player can
|
||||
// actually decode.
|
||||
deviceProfile: generateDeviceProfile({
|
||||
player: getActivePlayerType(settings),
|
||||
audioMode: settings.audioTranscodeMode,
|
||||
}),
|
||||
});
|
||||
if (!res) return null;
|
||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||
@@ -662,20 +663,32 @@ export default function DirectPlayerPage() {
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
||||
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
||||
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
||||
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
||||
)
|
||||
.map((s) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
)
|
||||
.filter((u): u is string => !!u);
|
||||
// Get external subtitle URLs
|
||||
// - Online: prepend API base path to server URLs
|
||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
||||
let externalSubs: string[] | undefined;
|
||||
if (!offline && api?.basePath) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
||||
} else if (offline) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).map((s) => s.DeliveryUrl!);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Calculate track IDs for initial selection
|
||||
const initialSubtitleId = getMpvSubtitleId(
|
||||
mediaSource,
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
const initialAudioId = getMpvAudioId(
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
@@ -693,6 +706,7 @@ export default function DirectPlayerPage() {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
cacheConfig: {
|
||||
@@ -740,6 +754,7 @@ export default function DirectPlayerPage() {
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
api?.accessToken,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
settings.mpvCacheEnabled,
|
||||
@@ -929,9 +944,7 @@ export default function DirectPlayerPage() {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: String(index),
|
||||
// A local (client-downloaded) sub only exists in the dying mpv
|
||||
// instance — the server must be asked for "none" (-1) instead.
|
||||
subtitleIndex: String(toServerSubtitleIndex(currentSubtitleIndex)),
|
||||
subtitleIndex: String(currentSubtitleIndex),
|
||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString() ?? "",
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
@@ -967,103 +980,30 @@ 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",
|
||||
);
|
||||
return applyMpvSubtitleSelection(videoRef.current, {
|
||||
subtitleStreams,
|
||||
jellyfinSubtitleIndex,
|
||||
getExpectedExternalUrl: (s) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
});
|
||||
},
|
||||
[stream?.mediaSource, offline, api?.basePath],
|
||||
);
|
||||
|
||||
// Re-negotiate the stream with new track params (server re-processes it,
|
||||
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
|
||||
// replacePlayer, resuming at the live position.
|
||||
const replaceWithTrackSelection = useCallback(
|
||||
(params: { subtitleIndex?: string; audioIndex?: string }) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
|
||||
subtitleIndex:
|
||||
params.subtitleIndex ??
|
||||
String(toServerSubtitleIndex(currentSubtitleIndex)),
|
||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString() ?? "",
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
}).toString();
|
||||
// Destroy the current mpv instance before re-navigating, same rationale as
|
||||
// goToNextItem: Expo Router briefly holds two players during the
|
||||
// transition and two decoders/surfaces OOM-kill low-RAM devices.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[
|
||||
item?.Id,
|
||||
currentAudioIndex,
|
||||
currentSubtitleIndex,
|
||||
stream?.mediaSource?.Id,
|
||||
bitrateValue,
|
||||
router,
|
||||
progress,
|
||||
],
|
||||
);
|
||||
|
||||
// TV/mobile subtitle track change handler
|
||||
const handleSubtitleIndexChange = useCallback(
|
||||
async (index: number) => {
|
||||
// Local (client-downloaded) subs are loaded via addSubtitleFile, not
|
||||
// resolvable against server streams — just track the live index.
|
||||
if (index <= LOCAL_SUBTITLE_INDEX_START) {
|
||||
setCurrentSubtitleIndex(index);
|
||||
return;
|
||||
}
|
||||
|
||||
const subs = stream?.mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle",
|
||||
);
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
const target = subs?.find((s) => s.Index === index);
|
||||
const current = subs?.find((s) => s.Index === currentSubtitleIndex);
|
||||
// Burned-in subs are pixels, not tracks: switching TO one and switching
|
||||
// AWAY from an active one both need a server re-process (same guard as
|
||||
// VideoContext's needsReplace on the mobile menu path).
|
||||
const needsReplace =
|
||||
isTranscoding &&
|
||||
((target && isImageBasedSubtitle(target)) ||
|
||||
(current && isImageBasedSubtitle(current)));
|
||||
if (needsReplace) {
|
||||
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentSubtitleIndex(index);
|
||||
const result = await applySubtitleSelection(index);
|
||||
// Safety net: a menu-listed sub the player can't select (server-burned
|
||||
// Encode, sidecar never sub-added) needs the server to re-process the
|
||||
// stream with it.
|
||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||
replaceWithTrackSelection({ subtitleIndex: String(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);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
applySubtitleSelection,
|
||||
replaceWithTrackSelection,
|
||||
stream?.mediaSource,
|
||||
currentSubtitleIndex,
|
||||
],
|
||||
[stream?.mediaSource],
|
||||
);
|
||||
|
||||
// Technical info toggle handler
|
||||
@@ -1182,10 +1122,6 @@ export default function DirectPlayerPage() {
|
||||
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
// Free the current mpv instance before navigating, matching goToNextItem —
|
||||
// otherwise two decoders/surfaces overlap during the transition and can
|
||||
// OOM-kill low-RAM devices.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
}, [
|
||||
previousItem,
|
||||
@@ -1198,24 +1134,9 @@ export default function DirectPlayerPage() {
|
||||
]);
|
||||
|
||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||
const addSubtitleFile = useCallback(
|
||||
async (path: string) => {
|
||||
// Set the live index to the new local sub's REAL index BEFORE the add.
|
||||
// Local subs are keyed LOCAL_SUBTITLE_INDEX_START - position, so use the
|
||||
// downloaded path's position (not a blanket sentinel, which would collide
|
||||
// with the first local sub at -100 and mis-record the selection). Any
|
||||
// local index resolves to notFound on the onTracksReady re-apply, so it
|
||||
// still doesn't clobber the freshly selected track; carry-over now keeps
|
||||
// the correct local sub.
|
||||
const locals = itemId ? getSubtitlesForItem(itemId) : [];
|
||||
const pos = locals.findIndex((s) => s.filePath === path);
|
||||
setCurrentSubtitleIndex(
|
||||
LOCAL_SUBTITLE_INDEX_START - (pos >= 0 ? pos : 0),
|
||||
);
|
||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||
},
|
||||
[itemId],
|
||||
);
|
||||
const addSubtitleFile = useCallback(async (path: string) => {
|
||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||
}, []);
|
||||
|
||||
// TV: Refresh subtitle tracks after server-side subtitle download
|
||||
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||
@@ -1428,7 +1349,7 @@ export default function DirectPlayerPage() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MpvPlayerView
|
||||
<VideoPlayerView
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
@@ -1447,10 +1368,6 @@ 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 && (
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -647,23 +646,10 @@ export default function TVSubtitleModal() {
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(option: { setTrack?: () => void }) => {
|
||||
if (modalState?.deferApplyUntilDismissed) {
|
||||
// Player: setTrack can navigate (replacePlayer for a burn-in switch
|
||||
// while transcoding); a router.replace fired while this modal is the
|
||||
// active route targets the MODAL and is swallowed. Close FIRST, apply
|
||||
// after dismissal.
|
||||
handleClose();
|
||||
InteractionManager.runAfterInteractions(() => option.setTrack?.());
|
||||
return;
|
||||
}
|
||||
// Detail page: setTrack only updates state. Run it BEFORE closing so the
|
||||
// re-render happens while the modal is up; deferring it until after
|
||||
// dismissal re-renders the detail page after focus returns and yanks TV
|
||||
// focus, leaving navigation stuck.
|
||||
option.setTrack?.();
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, modalState?.deferApplyUntilDismissed],
|
||||
[handleClose],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
|
||||
@@ -40,10 +40,7 @@ import {
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
} from "@/components/tv";
|
||||
import {
|
||||
LOCAL_SUBTITLE_INDEX_START,
|
||||
type Track,
|
||||
} from "@/components/video-player/controls/types";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -59,7 +56,6 @@ 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");
|
||||
@@ -236,13 +232,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
return streams ?? [];
|
||||
}, [selectedOptions?.mediaSource]);
|
||||
|
||||
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
||||
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
||||
const subtitleStreams = useMemo(() => {
|
||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle",
|
||||
);
|
||||
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||
return streams ?? [];
|
||||
}, [selectedOptions?.mediaSource]);
|
||||
|
||||
// Store handleSubtitleChange in a ref for stable callback reference
|
||||
@@ -253,6 +248,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
// State to trigger refresh of local subtitles list
|
||||
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
||||
|
||||
// Starting index for local (client-downloaded) subtitles
|
||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||
@@ -413,13 +411,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
)
|
||||
: freshItem.MediaSources?.[0];
|
||||
|
||||
// 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(
|
||||
// Get subtitle streams from the fresh data
|
||||
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,7 +7,6 @@ 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 "./BitrateSelector";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
@@ -64,12 +63,9 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
[
|
||||
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || []),
|
||||
].sort(compareTracksForMenu),
|
||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || [],
|
||||
[selectedOptions.mediaSource],
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -23,9 +22,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
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;
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
|
||||
@@ -44,8 +44,10 @@ export interface TVNextEpisodeCountdownProps {
|
||||
playButtonRef?: RNView | null;
|
||||
}
|
||||
|
||||
// Position constants
|
||||
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
||||
// Position constants — kept in sync with TVSkipSegmentCard (the two are
|
||||
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
|
||||
// rationale (220 sits just above the controls bar; 300 floated too high).
|
||||
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||
|
||||
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
|
||||
@@ -33,9 +33,15 @@ export interface TVSkipSegmentCardProps {
|
||||
playButtonRef?: View | null;
|
||||
}
|
||||
|
||||
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
||||
const BOTTOM_WITH_CONTROLS = 300;
|
||||
const BOTTOM_WITHOUT_CONTROLS = 120;
|
||||
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
|
||||
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
|
||||
// card that floats far above the controls.
|
||||
//
|
||||
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
|
||||
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
|
||||
// left the card hovering ~100px above the controls.
|
||||
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||
|
||||
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
||||
show,
|
||||
|
||||
30
components/video-player/VideoPlayerView.tsx
Normal file
30
components/video-player/VideoPlayerView.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules";
|
||||
import { MpvPlayerView } from "@/modules";
|
||||
import { ExoPlayerView } from "@/modules/exoplayer-player";
|
||||
import {
|
||||
getActiveVideoPlayer,
|
||||
useSettings,
|
||||
VideoPlayer,
|
||||
} from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Unified video player view. MPV is the default on every platform; users
|
||||
* can opt into ExoPlayer on Android TV via settings.videoPlayer. Both
|
||||
* children conform to the same `MpvPlayerViewRef` interface, so the ref
|
||||
* is forwarded transparently regardless of which player is rendered.
|
||||
*
|
||||
* The Android-TV capability gate lives in getActiveVideoPlayer so that
|
||||
* the same resolver used for device-profile advertisement guarantees the
|
||||
* rendered backend matches what Jellyfin was told to stream for.
|
||||
*/
|
||||
export const VideoPlayerView = React.forwardRef<
|
||||
MpvPlayerViewRef,
|
||||
MpvPlayerViewProps
|
||||
>(function VideoPlayerView(props, ref) {
|
||||
const { settings } = useSettings();
|
||||
const useExo = getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
|
||||
|
||||
const Player = useExo ? ExoPlayerView : MpvPlayerView;
|
||||
return <Player ref={ref} {...props} />;
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
@@ -33,8 +34,11 @@ interface BottomControlsProps {
|
||||
showRemoteBubble: boolean;
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
@@ -82,8 +86,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showRemoteBubble,
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
nextItem,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
@@ -173,6 +180,21 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText={t("player.skip_intro")}
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
- Show "Next Episode" if credits extend to video end AND next episode exists */}
|
||||
<SkipButton
|
||||
showButton={
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText={t("player.skip_credits")}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
|
||||
@@ -39,7 +39,6 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
|
||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -356,16 +355,6 @@ export const Controls: FC<Props> = ({
|
||||
maxMs,
|
||||
);
|
||||
|
||||
// Whether the "Next Episode" countdown will actually be rendered. The Skip
|
||||
// Credits button yields to it only when this is true; if autoplay is
|
||||
// disabled or its episode limit is reached, Skip Credits must stay available
|
||||
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
|
||||
const willShowNextEpisode =
|
||||
!!nextItem &&
|
||||
settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!item || !settings) {
|
||||
@@ -598,8 +587,11 @@ export const Controls: FC<Props> = ({
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
@@ -619,17 +611,6 @@ export const Controls: FC<Props> = ({
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
/>
|
||||
</Animated.View>
|
||||
{/* Skip Intro / Skip Credits float independently of the controls so
|
||||
they're visible (and tappable) without summoning the controls. */}
|
||||
<SkipSegmentOverlay
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
willShowNextEpisode={willShowNextEpisode}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
controlsVisible={showControls}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
|
||||
@@ -51,7 +51,6 @@ 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";
|
||||
@@ -318,10 +317,8 @@ 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. Order like
|
||||
// jellyfin-web (embedded first, externals last, forced/default up).
|
||||
return [...streams]
|
||||
.sort(compareTracksForMenu)
|
||||
// "disable subtitles" sentinel and mis-route selection.
|
||||
return streams
|
||||
.filter((stream) => typeof stream.Index === "number")
|
||||
.map((stream) => {
|
||||
const index = stream.Index as number;
|
||||
@@ -604,9 +601,6 @@ export const Controls: FC<Props> = ({
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleTracks: tracksWithoutDisable,
|
||||
currentSubtitleIndex: subtitleIndex ?? -1,
|
||||
// In-player selection can navigate (replacePlayer for burn-in switches);
|
||||
// apply it after the modal route is dismissed so it isn't swallowed.
|
||||
deferApplyUntilDismissed: true,
|
||||
onDisableSubtitles: () => {
|
||||
// Find and call the "Disable" track's setTrack from VideoContext
|
||||
const disableTrack = videoContextSubtitleTracks?.find(
|
||||
@@ -1138,7 +1132,16 @@ export const Controls: FC<Props> = ({
|
||||
{/* Skip intro card */}
|
||||
<TVSkipSegmentCard
|
||||
show={showSkipButton && !isCountdownActive}
|
||||
onPress={skipIntro}
|
||||
onPress={() => {
|
||||
// After the seek lands, showSkipButton flips false and this card
|
||||
// unmounts. With controls visible the focus-stealing overlay is
|
||||
// disabled, so without an explicit handoff the focus engine is
|
||||
// stranded. Prime the play button to receive focus on the next
|
||||
// render — when controls are hidden the focus overlay takes over
|
||||
// naturally and this is a harmless no-op.
|
||||
if (showControls) setFocusPlayButton(true);
|
||||
skipIntro();
|
||||
}}
|
||||
type='intro'
|
||||
controlsVisible={showControls}
|
||||
refSetter={setSkipSegmentRef}
|
||||
@@ -1153,7 +1156,11 @@ export const Controls: FC<Props> = ({
|
||||
(hasContentAfterCredits || !nextItem) &&
|
||||
!isCountdownActive
|
||||
}
|
||||
onPress={skipCredit}
|
||||
onPress={() => {
|
||||
// See the intro card above for the focus-handoff rationale.
|
||||
if (showControls) setFocusPlayButton(true);
|
||||
skipCredit();
|
||||
}}
|
||||
type='credits'
|
||||
controlsVisible={showControls}
|
||||
refSetter={setSkipSegmentRef}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import SkipButton from "./SkipButton";
|
||||
|
||||
interface Props {
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
hasContentAfterCredits: boolean;
|
||||
willShowNextEpisode: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
controlsVisible: boolean;
|
||||
}
|
||||
|
||||
// Offsets are relative to the safe-area insets so they hold in both portrait
|
||||
// and landscape (the insets move with the notch / home indicator).
|
||||
//
|
||||
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
|
||||
// spot and was working fine.
|
||||
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
|
||||
// of the chapters icon), while staying below the vertical volume
|
||||
// slider which sits at the vertical middle of the screen.
|
||||
const HIDDEN_BOTTOM = 24;
|
||||
const HIDDEN_RIGHT = 12;
|
||||
const VISIBLE_BOTTOM = 65;
|
||||
const VISIBLE_RIGHT = 42;
|
||||
const ANIM_DURATION = 250;
|
||||
|
||||
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
|
||||
// itself instantly via a `hidden` (display:none) class, which would preempt
|
||||
// the parent's opacity fade-out — lagging the flag keeps the button rendered
|
||||
// (and visible) while the fade plays out.
|
||||
const useDelayedHide = (value: boolean, duration: number): boolean => {
|
||||
const [display, setDisplay] = useState(value);
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setDisplay(true);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setDisplay(false), duration);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, duration]);
|
||||
return value || display;
|
||||
};
|
||||
|
||||
/**
|
||||
* Floating Skip Intro / Skip Credits buttons shown independently of the
|
||||
* player controls. They appear on their own during an intro or credits segment
|
||||
* without the user having to summon the controls.
|
||||
*/
|
||||
export const SkipSegmentOverlay: FC<Props> = ({
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
hasContentAfterCredits,
|
||||
willShowNextEpisode,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
controlsVisible,
|
||||
}) => {
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
const showCredit =
|
||||
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
|
||||
const visible = showSkipButton || showCredit;
|
||||
|
||||
// Drive each SkipButton with a lagged flag so it stays visible while the
|
||||
// opacity fade-out plays, instead of disappearing the instant its segment
|
||||
// ends. `visible` above still drives opacity/pointerEvents immediately.
|
||||
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
|
||||
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
|
||||
|
||||
const opacity = useSharedValue(visible ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(visible ? 1 : 0, {
|
||||
duration: ANIM_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
}, [visible, opacity]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
// Position is recomputed on render (no slide animation) so the button never
|
||||
// sweeps through an overlap zone while the controls toggle.
|
||||
const bottom =
|
||||
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
|
||||
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { right, bottom }, animatedStyle]}
|
||||
pointerEvents={visible ? "box-none" : "none"}
|
||||
>
|
||||
<SkipButton
|
||||
showButton={renderSkip}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
<SkipButton
|
||||
showButton={renderCredit}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
zIndex: 15,
|
||||
},
|
||||
});
|
||||
@@ -215,13 +215,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
);
|
||||
|
||||
return {
|
||||
container: mediaSource.Container,
|
||||
videoRange: videoStream?.VideoRangeType,
|
||||
bitDepth: videoStream?.BitDepth,
|
||||
audioChannels: audioStream?.Channels,
|
||||
audioCodecFromSource: audioStream?.Codec,
|
||||
subtitleCodec: subtitleStream?.Codec,
|
||||
subtitleTitle: subtitleStream?.DisplayTitle,
|
||||
};
|
||||
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
||||
|
||||
@@ -307,9 +304,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
<Text style={textStyle}>
|
||||
{info.videoWidth}x{info.videoHeight}
|
||||
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
||||
{formatVideoRange(streamInfo?.videoRange)
|
||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
||||
: ""}
|
||||
{/* Prefer the player-reported HDR format (authoritative —
|
||||
what's actually being decoded) over Jellyfin metadata. */}
|
||||
{info?.hdrFormat
|
||||
? ` ${info.hdrFormat}`
|
||||
: (() => {
|
||||
const videoRange = formatVideoRange(streamInfo?.videoRange);
|
||||
return videoRange ? ` ${videoRange}` : "";
|
||||
})()}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
@@ -321,8 +323,17 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info?.audioCodec && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
||||
{streamInfo?.audioChannels
|
||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||
{/* Prefer player-reported channel count; fall back to
|
||||
Jellyfin metadata for MPV which doesn't populate it. */}
|
||||
{(() => {
|
||||
const audioChannels =
|
||||
info.audioChannels ?? streamInfo?.audioChannels;
|
||||
return audioChannels
|
||||
? ` ${formatAudioChannels(audioChannels)}`
|
||||
: "";
|
||||
})()}
|
||||
{info.audioSampleRate
|
||||
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
@@ -342,6 +353,17 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
: "N/A"}
|
||||
</Text>
|
||||
)}
|
||||
{(info?.colorSpace || info?.colorRange || info?.colorTransfer) && (
|
||||
<Text style={textStyle}>
|
||||
Color:{" "}
|
||||
{[info.colorSpace, info.colorRange, info.colorTransfer]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoCodecs && (
|
||||
<Text style={textStyle}>Codec tag: {info.videoCodecs}</Text>
|
||||
)}
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.buffer_seconds", {
|
||||
@@ -361,6 +383,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.decoderName && (
|
||||
<Text style={textStyle}>
|
||||
Decoder: {info.decoderName}
|
||||
{info.decoderType ? ` (${info.decoderType})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.estimatedVfFps !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getFinishTime = () => {
|
||||
if (!Number.isFinite(remainingTime)) return "—";
|
||||
const now = new Date();
|
||||
// remainingTime is in ms
|
||||
const finishTime = new Date(now.getTime() + remainingTime);
|
||||
|
||||
@@ -23,29 +23,32 @@
|
||||
* - Used to report playback state to Jellyfin server
|
||||
* - Value of -1 means disabled/none
|
||||
*
|
||||
* 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.).
|
||||
* 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)
|
||||
*
|
||||
* ============================================================================
|
||||
* SUBTITLE HANDLING
|
||||
* ============================================================================
|
||||
*
|
||||
* 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).
|
||||
* 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)
|
||||
*
|
||||
* Image-based during transcoding:
|
||||
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
||||
* - Burned into video by Jellyfin, not in MPV
|
||||
* - Requires 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,
|
||||
@@ -58,18 +61,16 @@ 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 {
|
||||
applyMpvSubtitleSelection,
|
||||
compareTracksForMenu,
|
||||
getExternalSubtitleUrl,
|
||||
isImageBasedSubtitle,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { LOCAL_SUBTITLE_INDEX_START, type Track } from "../types";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
// Starting index for local (client-downloaded) subtitles
|
||||
// Uses negative indices to avoid collision with Jellyfin indices
|
||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
interface VideoContextProps {
|
||||
subtitleTracks: Track[] | null;
|
||||
audioTracks: Track[] | null;
|
||||
@@ -86,7 +87,6 @@ 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 } =
|
||||
@@ -126,17 +126,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
audioIndex?: string;
|
||||
subtitleIndex?: string;
|
||||
}) => {
|
||||
// The URL param can hold a local-sub sentinel (selected via setParams) that
|
||||
// only exists in the dying player — the server must get "none" (-1) instead.
|
||||
// NaN (missing/blank param) fails the comparison and passes through as-is.
|
||||
const fallbackSubtitleIndex =
|
||||
Number.parseInt(subtitleIndex, 10) <= LOCAL_SUBTITLE_INDEX_START
|
||||
? "-1"
|
||||
: subtitleIndex;
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: params.audioIndex ?? audioIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? fallbackSubtitleIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
playbackPosition: playbackPosition,
|
||||
@@ -148,19 +141,6 @@ 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
|
||||
@@ -186,10 +166,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
commitAudioTracks(audio);
|
||||
setAudioTracks(audio);
|
||||
} else {
|
||||
// Fallback: show no audio tracks if the stored track wasn't found
|
||||
commitAudioTracks([]);
|
||||
setAudioTracks([]);
|
||||
}
|
||||
|
||||
// For subtitles in transcoded offline content:
|
||||
@@ -199,24 +179,6 @@ 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",
|
||||
@@ -228,84 +190,123 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
});
|
||||
|
||||
// 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)) {
|
||||
// For text-based subs, they should still be available in the file
|
||||
let subIdx = 1;
|
||||
for (const sub of allSubs) {
|
||||
if (sub.IsTextSubtitleStream) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
mpvIndex: subIdx,
|
||||
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;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
commitSubtitleTracks(subs);
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
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. 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);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
mpvIndex: mpvId,
|
||||
setTrack: () => {
|
||||
if (needsReplace) {
|
||||
// Transcoding + switching to/from image-based sub
|
||||
if (
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||
) {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
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) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
}).then((result) => {
|
||||
// Safety net: a menu-listed sub the player can't select (server-
|
||||
// burned Encode, sidecar never sub-added) only shows up after the
|
||||
// server re-processes the stream with it.
|
||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
}
|
||||
});
|
||||
|
||||
// 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -373,29 +374,12 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
||||
// at the front (unshifted), local downloaded subs at the end.
|
||||
commitSubtitleTracks(subs);
|
||||
commitAudioTracks(audio);
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
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,
|
||||
]);
|
||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
|
||||
@@ -17,7 +17,10 @@ interface UseVideoTimeProps {
|
||||
*/
|
||||
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
// Start at 0 (not Infinity) so the controls' first paint — before the first
|
||||
// progress/max update — shows "0:00" instead of formatting Infinity into
|
||||
// "Infinityh NaNm NaNs" and an Invalid Date for "ends at".
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
|
||||
const lastCurrentTimeRef = useRef(0);
|
||||
const lastRemainingTimeRef = useRef(0);
|
||||
|
||||
@@ -28,20 +28,4 @@ type Track = {
|
||||
localPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Synthetic `Track.index` base for client-side downloaded subtitles (loaded via
|
||||
* `addSubtitleFile`, no matching Jellyfin MediaStream). Local sub k gets
|
||||
* `LOCAL_SUBTITLE_INDEX_START - k`, so `index <= LOCAL_SUBTITLE_INDEX_START`
|
||||
* identifies a local track.
|
||||
*/
|
||||
export const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
/**
|
||||
* Map a client-side subtitle index to what the Jellyfin stream API understands.
|
||||
* Local sentinel indexes have no matching MediaStream on the server, which
|
||||
* expects -1 ("no subtitles") when re-negotiating a stream.
|
||||
*/
|
||||
export const toServerSubtitleIndex = (index: number): number =>
|
||||
index <= LOCAL_SUBTITLE_INDEX_START ? -1 : index;
|
||||
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
||||
|
||||
@@ -14,7 +14,6 @@ interface ShowSubtitleModalParams {
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
refreshSubtitleTracks?: () => Promise<Track[]>;
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
}
|
||||
|
||||
export const useTVSubtitleModal = () => {
|
||||
@@ -31,7 +30,6 @@ export const useTVSubtitleModal = () => {
|
||||
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
|
||||
refreshSubtitleTracks: params.refreshSubtitleTracks,
|
||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||
});
|
||||
router.push("/(auth)/tv-subtitle-modal");
|
||||
},
|
||||
|
||||
68
modules/exoplayer-player/android/build.gradle
Normal file
68
modules/exoplayer-player/android/build.gradle
Normal file
@@ -0,0 +1,68 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.exoplayerplayer'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 26)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.exoplayerplayer"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Media3 (ExoPlayer). The default tracks react-native-track-player's
|
||||
// pinned version (currently 1.10.1) so we don't end up with two media3
|
||||
// versions on the classpath and duplicate-class errors. The
|
||||
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
|
||||
// globally via plugins/withExcludeMedia3Dash.js.
|
||||
def media3Version = safeExtGet('media3Version', '1.10.1')
|
||||
implementation "androidx.media3:media3-exoplayer:${media3Version}"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
|
||||
implementation "androidx.media3:media3-ui:${media3Version}"
|
||||
implementation "androidx.media3:media3-common:${media3Version}"
|
||||
|
||||
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
|
||||
// codecs that MediaCodec doesn't ship with on most Android TVs.
|
||||
//
|
||||
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
|
||||
// with prebuilt native libraries (the upstream androidx artifact is a
|
||||
// stub that requires building FFmpeg yourself). RNTP already pulls
|
||||
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
|
||||
// it guarantees we still get it if RNTP ever drops the dep.
|
||||
//
|
||||
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
|
||||
// resolve media3 core to 1.10.1. RNTP ships the same combination in
|
||||
// production, and Media3 maintains binary compat for Renderer /
|
||||
// RenderersFactory APIs across minor versions, so this works in
|
||||
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
|
||||
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package expo.modules.exoplayerplayer
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class ExoPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExoPlayer")
|
||||
|
||||
// Enables the module to be used as a native view.
|
||||
View(ExoPlayerView::class) {
|
||||
// All video load options are passed via a single "source" prop,
|
||||
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
|
||||
// cacheConfig fields) are silently ignored.
|
||||
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
|
||||
if (source == null) return@Prop
|
||||
|
||||
val urlString = source["url"] as? String ?: return@Prop
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||
|
||||
val config = VideoLoadConfig(
|
||||
url = urlString,
|
||||
headers = source["headers"] as? Map<String, String>,
|
||||
externalSubtitles = source["externalSubtitles"] as? List<String>,
|
||||
startPosition = (source["startPosition"] as? Number)?.toDouble(),
|
||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||
)
|
||||
|
||||
view.loadVideo(config)
|
||||
}
|
||||
|
||||
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
|
||||
// no Control Center equivalent — Android handles media sessions
|
||||
// via MediaSessionCompat which we don't wire up for TV).
|
||||
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
|
||||
// No-op
|
||||
}
|
||||
|
||||
AsyncFunction("play") { view: ExoPlayerView ->
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { view: ExoPlayerView ->
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("destroy") { view: ExoPlayerView ->
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
|
||||
view.seekTo(position)
|
||||
}
|
||||
|
||||
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
|
||||
view.seekBy(offset)
|
||||
}
|
||||
|
||||
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
|
||||
view.setSpeed(speed)
|
||||
}
|
||||
|
||||
AsyncFunction("getSpeed") { view: ExoPlayerView ->
|
||||
view.getSpeed()
|
||||
}
|
||||
|
||||
AsyncFunction("isPaused") { view: ExoPlayerView ->
|
||||
view.isPaused()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
|
||||
view.getCurrentPosition()
|
||||
}
|
||||
|
||||
AsyncFunction("getDuration") { view: ExoPlayerView ->
|
||||
view.getDuration()
|
||||
}
|
||||
|
||||
// Picture in Picture — TV does not use PiP; safe no-ops.
|
||||
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
|
||||
// No-op
|
||||
}
|
||||
|
||||
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
|
||||
// No-op
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
|
||||
false
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
|
||||
false
|
||||
}
|
||||
|
||||
// Subtitle functions
|
||||
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
|
||||
view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
|
||||
view.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
|
||||
view.disableSubtitles()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
|
||||
view.getCurrentSubtitleTrack()
|
||||
}
|
||||
|
||||
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
|
||||
view.addSubtitleFile(url, select)
|
||||
}
|
||||
|
||||
// Subtitle positioning / styling
|
||||
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
|
||||
view.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
|
||||
view.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
|
||||
view.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
|
||||
// No-op — SubtitleView follows authored cue alignment.
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
|
||||
view.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
|
||||
view.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
|
||||
view.setSubtitleBorderStyle(style)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
|
||||
view.setSubtitleBackgroundColor(color)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
|
||||
// No-op — libass-specific, no Media3 equivalent.
|
||||
}
|
||||
|
||||
// Audio track functions
|
||||
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
|
||||
view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
|
||||
view.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
|
||||
view.getCurrentAudioTrack()
|
||||
}
|
||||
|
||||
// Video scaling
|
||||
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
|
||||
view.setZoomedToFill(zoomed)
|
||||
}
|
||||
|
||||
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
|
||||
view.isZoomedToFill()
|
||||
}
|
||||
|
||||
// Technical info
|
||||
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
|
||||
view.getTechnicalInfo()
|
||||
}
|
||||
|
||||
// Events that the view can send to JavaScript — same set as MPV.
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,976 @@
|
||||
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
|
||||
package expo.modules.exoplayerplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ColorInfo
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
/**
|
||||
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
|
||||
* MPV-only fields are accepted and ignored.
|
||||
*/
|
||||
data class VideoLoadConfig(
|
||||
val url: String,
|
||||
val headers: Map<String, String>? = null,
|
||||
val externalSubtitles: List<String>? = null,
|
||||
val startPosition: Double? = null,
|
||||
val autoplay: Boolean = true,
|
||||
val initialSubtitleId: Int? = null,
|
||||
val initialAudioId: Int? = null,
|
||||
val cacheEnabled: String? = null,
|
||||
val cacheSeconds: Int? = null,
|
||||
val demuxerMaxBytes: Int? = null,
|
||||
val demuxerMaxBackBytes: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
|
||||
*
|
||||
* Implements the same JS contract (events, ref methods, 1-based track IDs)
|
||||
* as MpvPlayerView so the React layer can swap between the two without
|
||||
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
|
||||
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
|
||||
*/
|
||||
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerView"
|
||||
private const val PROGRESS_INTERVAL_MS = 1000L
|
||||
}
|
||||
|
||||
// Event dispatchers — names must match the Events() declaration in the module.
|
||||
val onLoad by EventDispatcher()
|
||||
val onPlaybackStateChange by EventDispatcher()
|
||||
val onProgress by EventDispatcher()
|
||||
val onError by EventDispatcher()
|
||||
val onTracksReady by EventDispatcher()
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var player: ExoPlayer? = null
|
||||
private val playerView: PlayerView
|
||||
private val subtitleView: SubtitleView?
|
||||
|
||||
private var currentUrl: String? = null
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var tracksReadyFired: Boolean = false
|
||||
|
||||
// Resume position handed to setMediaSource() on load. seekTo() uses it to
|
||||
// drop the redundant re-seek the JS layer fires once tracks are ready
|
||||
// (direct-player seeks to the same resume position). ExoPlayer re-buffers
|
||||
// on every seek — even a no-op to the current position — so without this
|
||||
// the start stutters.
|
||||
private var loadStartPositionMs: Long = 0L
|
||||
|
||||
// Side-loaded subtitle configurations accumulated across loadVideo and
|
||||
// addSubtitleFile. Media3 doesn't expose the live SubtitleConfiguration
|
||||
// list on a playing MediaItem, so we shadow it here to preserve prior
|
||||
// side-loaded subs when addSubtitleFile rebuilds the MediaItem.
|
||||
private var sideLoadedSubs: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||
|
||||
// 1-based track ID mappings (matching MPV's contract).
|
||||
// Each list is rebuilt on Tracks changed.
|
||||
private var subtitleTrackList: List<TrackEntry> = emptyList()
|
||||
private var audioTrackList: List<TrackEntry> = emptyList()
|
||||
private var currentSubtitleId: Int = 0
|
||||
private var currentAudioId: Int = 0
|
||||
|
||||
// Subtitle styling state — applied to the embedded SubtitleView.
|
||||
private var subtitleScale: Float = 1f
|
||||
private var subtitleFontSizePct: Int? = null // 0-100
|
||||
// Last-write-wins override of the vertical position fraction
|
||||
// (null = fall back to subtitleAlignY). Both setSubtitlePosition
|
||||
// (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY
|
||||
// (px) funnel into this single SubtitleView API.
|
||||
private var subtitleBottomFraction: Float? = null
|
||||
private var subtitleAlignY: String = "bottom"
|
||||
// Background color carries its own alpha (parsed from #RRGGBBAA in
|
||||
// setSubtitleBackgroundColor) so no separate enabled/opacity flags.
|
||||
private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0)
|
||||
private var subtitleBorderStyle: String = "outline-and-shadow"
|
||||
|
||||
private var isZoomedToFill: Boolean = false
|
||||
|
||||
// Captured by analyticsListener; surfaced via getTechnicalInfo().
|
||||
// Reset on destroy() and (for decoder names) on track changes.
|
||||
private var videoDecoderName: String? = null
|
||||
private var audioDecoderName: String? = null
|
||||
private var cumulativeDroppedFrames: Int = 0
|
||||
|
||||
private val analyticsListener = object : AnalyticsListener {
|
||||
override fun onVideoDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
) {
|
||||
videoDecoderName = decoderName
|
||||
}
|
||||
|
||||
override fun onAudioDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
) {
|
||||
audioDecoderName = decoderName
|
||||
}
|
||||
|
||||
override fun onDroppedVideoFrames(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
droppedFrames: Int,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
// Incremental count since last call; accumulate for a cumulative
|
||||
// total that matches MPV's droppedFrames semantics.
|
||||
cumulativeDroppedFrames += droppedFrames
|
||||
}
|
||||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
onPlaybackStateChange(mapOf("isLoading" to true))
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isLoading" to false,
|
||||
"isReadyToSeek" to true
|
||||
))
|
||||
if (!tracksReadyFired) {
|
||||
tracksReadyFired = true
|
||||
rebuildTrackMaps(player?.currentTracks)
|
||||
onTracksReady(emptyMap<String, Any>())
|
||||
}
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isPlaying" to false,
|
||||
"isPaused" to true
|
||||
))
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isPlaying" to isPlaying,
|
||||
"isPaused" to !isPlaying
|
||||
))
|
||||
}
|
||||
|
||||
override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) {
|
||||
val message = error?.message ?: "Unknown playback error"
|
||||
Log.e(TAG, "Player error: $message", error)
|
||||
onError(mapOf("error" to message))
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
rebuildTrackMaps(tracks)
|
||||
// currentSubtitleId is a hand-maintained cache (ExoPlayer has no
|
||||
// mpv-style "sid" property to read live), so re-derive it from the
|
||||
// actual selection on every track change. Without this, any path
|
||||
// that selects a track without going through setSubtitleTrack —
|
||||
// notably addSubtitleFile(select=true), which clears the text
|
||||
// override and lets the new SELECTION_FLAG_DEFAULT sub render —
|
||||
// leaves the cache stale and getCurrentSubtitleTrack() reporting
|
||||
// the old id. Run before applyInitialTrackSelections() so an
|
||||
// explicit initial selection still wins.
|
||||
syncCurrentSubtitleIdFromSelection(tracks)
|
||||
applyInitialTrackSelections()
|
||||
// A track change can re-initialize the codec under a different
|
||||
// name (e.g. adaptive switch from HEVC to AV1). Clear stale
|
||||
// decoder names so getTechnicalInfo() doesn't report the
|
||||
// previous codec until the next onVideoDecoderInitialized fires.
|
||||
videoDecoderName = null
|
||||
audioDecoderName = null
|
||||
}
|
||||
}
|
||||
|
||||
private val progressRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
val p = player ?: return
|
||||
val positionMs = p.currentPosition
|
||||
val durationMs = p.duration
|
||||
val bufferedMs = p.bufferedPosition
|
||||
|
||||
val positionSec = positionMs / 1000.0
|
||||
val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0
|
||||
val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0
|
||||
|
||||
onProgress(mapOf(
|
||||
"position" to positionSec,
|
||||
"duration" to durationSec,
|
||||
"progress" to if (durationSec > 0) positionSec / durationSec else 0.0,
|
||||
"cacheSeconds" to cacheSec
|
||||
))
|
||||
|
||||
mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
playerView = PlayerView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// SurfaceView-backed for parity with MPV (direct surface to
|
||||
// SurfaceFlinger). PlayerView defaults to a SurfaceView, so no
|
||||
// explicit setSurfaceType() call is needed; the int constants
|
||||
// backing it are @IntDef private in Media3.
|
||||
setUseController(false)
|
||||
setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
}
|
||||
subtitleView = playerView.subtitleView
|
||||
addView(playerView)
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
|
||||
fun loadVideo(config: VideoLoadConfig) {
|
||||
if (currentUrl == config.url) return
|
||||
currentUrl = config.url
|
||||
pendingConfig = config
|
||||
ensurePlayer(config)
|
||||
loadInternal(config)
|
||||
}
|
||||
|
||||
private fun ensurePlayer(config: VideoLoadConfig) {
|
||||
if (player != null) return
|
||||
|
||||
val loadControl = buildLoadControl(config)
|
||||
|
||||
// PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD /
|
||||
// AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a
|
||||
// hardware decoder for the format. MediaCodec remains the fallback.
|
||||
val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context)
|
||||
.setExtensionRendererMode(
|
||||
androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
)
|
||||
.setEnableDecoderFallback(true)
|
||||
|
||||
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||
.setLoadControl(loadControl)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true
|
||||
)
|
||||
.build()
|
||||
|
||||
exo.addListener(playerListener)
|
||||
exo.addAnalyticsListener(analyticsListener)
|
||||
exo.repeatMode = Player.REPEAT_MODE_OFF
|
||||
player = exo
|
||||
playerView.player = exo
|
||||
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl {
|
||||
// Map MPV-style cache config to ExoPlayer's LoadControl.
|
||||
val cacheEnabled = when (config.cacheEnabled) {
|
||||
"no" -> false
|
||||
"yes" -> true
|
||||
else -> true // "auto"
|
||||
}
|
||||
|
||||
// Buffer thresholds used as fallbacks when the user's cache config
|
||||
// doesn't override them. Media3's own defaults changed in 1.6.0
|
||||
// (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a
|
||||
// faster start; we intentionally keep the older 2500/5000 here
|
||||
// because low-RAM Android TVs with slow tuners benefit from the
|
||||
// extra headroom before playback kicks in. Media3's DEFAULT_*
|
||||
// IntDef fields are private, hence the literals.
|
||||
val defaultMinBufferMs = 15000
|
||||
val defaultBufferForPlaybackMs = 2500
|
||||
val defaultBufferForPlaybackAfterRebufferMs = 5000
|
||||
|
||||
val targetBufferMs = if (!cacheEnabled) {
|
||||
50000
|
||||
} else {
|
||||
val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10
|
||||
seconds * 1000
|
||||
}
|
||||
|
||||
val backBufferMs = if (!cacheEnabled) {
|
||||
0
|
||||
} else {
|
||||
val mb = config.demuxerMaxBackBytes ?: 50
|
||||
// Heuristic: 1 MB ≈ 1s of typical 1080p bitrate.
|
||||
(mb * 1000).coerceAtLeast(1000)
|
||||
}
|
||||
|
||||
val builder = DefaultLoadControl.Builder()
|
||||
.setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024))
|
||||
.setBufferDurationsMs(
|
||||
/* minBufferMs = */ defaultMinBufferMs,
|
||||
/* maxBufferMs = */ targetBufferMs,
|
||||
/* bufferForPlaybackMs = */ defaultBufferForPlaybackMs,
|
||||
/* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs
|
||||
)
|
||||
if (cacheEnabled) {
|
||||
builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun loadInternal(config: VideoLoadConfig) {
|
||||
val p = player ?: return
|
||||
|
||||
val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
|
||||
.setDefaultRequestProperties(config.headers ?: emptyMap())
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
|
||||
|
||||
val mediaItem = buildMediaItem(config)
|
||||
|
||||
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
|
||||
// Resolve the resume position once and hand it to setMediaSource() so
|
||||
// ExoPlayer prepares from that position directly. Calling prepare()
|
||||
// first (which buffers from 0) and seekTo() afterwards makes the
|
||||
// opening seconds replay from the start before jumping to the resume
|
||||
// point — the "repeats the first few seconds" symptom. C.TIME_UNSET
|
||||
// falls back to the media's default start position (0).
|
||||
val startMs = config.startPosition?.let { sp ->
|
||||
if (sp > 0) (sp * 1000).toLong() else C.TIME_UNSET
|
||||
} ?: C.TIME_UNSET
|
||||
|
||||
loadStartPositionMs = if (startMs != C.TIME_UNSET) startMs else 0L
|
||||
|
||||
p.setMediaSource(mediaSource, startMs)
|
||||
p.prepare()
|
||||
|
||||
if (config.autoplay) {
|
||||
p.play()
|
||||
}
|
||||
|
||||
onLoad(mapOf("url" to config.url))
|
||||
startProgressLoop()
|
||||
}
|
||||
|
||||
private fun buildMediaItem(config: VideoLoadConfig): MediaItem {
|
||||
val builder = MediaItem.Builder().setUri(config.url)
|
||||
|
||||
// External subtitles: add as side-loaded SubtitleConfigurations.
|
||||
// MIME-type sniffed from the file extension.
|
||||
val subs = config.externalSubtitles
|
||||
if (!subs.isNullOrEmpty()) {
|
||||
val subtitleConfigs = subs.mapNotNull { subUrl ->
|
||||
val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null
|
||||
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl))
|
||||
.setMimeType(mime)
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.build()
|
||||
}
|
||||
if (subtitleConfigs.isNotEmpty()) {
|
||||
sideLoadedSubs = subtitleConfigs
|
||||
builder.setSubtitleConfigurations(subtitleConfigs)
|
||||
} else {
|
||||
sideLoadedSubs = emptyList()
|
||||
}
|
||||
} else {
|
||||
sideLoadedSubs = emptyList()
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun mimeTypeForSubtitleUrl(url: String): String? {
|
||||
val lower = url.substringBeforeLast('?').lowercase()
|
||||
return when {
|
||||
lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt"
|
||||
lower.endsWith(".srt") -> "application/x-subrip"
|
||||
lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa"
|
||||
lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
fun play() {
|
||||
player?.play()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stopProgressLoop()
|
||||
player?.release()
|
||||
player = null
|
||||
playerView.player = null
|
||||
tracksReadyFired = false
|
||||
currentUrl = null
|
||||
loadStartPositionMs = 0L
|
||||
sideLoadedSubs = emptyList()
|
||||
subtitleTrackList = emptyList()
|
||||
audioTrackList = emptyList()
|
||||
currentSubtitleId = 0
|
||||
currentAudioId = 0
|
||||
videoDecoderName = null
|
||||
audioDecoderName = null
|
||||
cumulativeDroppedFrames = 0
|
||||
}
|
||||
|
||||
fun seekTo(positionSec: Double) {
|
||||
val targetMs = (positionSec * 1000).toLong()
|
||||
// Drop the redundant initial seek that mirrors the resume position we
|
||||
// already applied via setMediaSource() — see loadInternal. Only the
|
||||
// first seek after load is eligible, so a genuine seek to ~the start
|
||||
// later on still works.
|
||||
if (loadStartPositionMs > 0 && Math.abs(targetMs - loadStartPositionMs) < 1000L) {
|
||||
loadStartPositionMs = 0L
|
||||
return
|
||||
}
|
||||
loadStartPositionMs = 0L
|
||||
player?.seekTo(targetMs)
|
||||
}
|
||||
|
||||
fun seekBy(offsetSec: Double) {
|
||||
val p = player ?: return
|
||||
val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0)
|
||||
p.seekTo(target.toLong())
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
player?.playbackParameters = PlaybackParameters(speed.toFloat())
|
||||
}
|
||||
|
||||
fun getSpeed(): Float {
|
||||
return player?.playbackParameters?.speed ?: 1f
|
||||
}
|
||||
|
||||
fun isPaused(): Boolean {
|
||||
return player?.isPlaying == false
|
||||
}
|
||||
|
||||
fun getCurrentPosition(): Double {
|
||||
return (player?.currentPosition ?: 0L) / 1000.0
|
||||
}
|
||||
|
||||
fun getDuration(): Double {
|
||||
val d = player?.duration ?: 0L
|
||||
return if (d > 0) d / 1000.0 else 0.0
|
||||
}
|
||||
|
||||
// MARK: - Track Mapping (1-based IDs to match MPV's contract)
|
||||
|
||||
data class TrackEntry(
|
||||
val id: Int, // 1-based JS-facing ID
|
||||
val trackGroupIndex: Int,
|
||||
val trackIndex: Int,
|
||||
val format: Format,
|
||||
)
|
||||
|
||||
private fun rebuildTrackMaps(tracks: Tracks?) {
|
||||
if (tracks == null) return
|
||||
|
||||
val subtitles = mutableListOf<TrackEntry>()
|
||||
val audios = mutableListOf<TrackEntry>()
|
||||
|
||||
tracks.groups.forEachIndexed { groupIndex, group ->
|
||||
val rendererType = group.type
|
||||
// Skip groups that have no tracks the player supports
|
||||
for (trackIdx in 0 until group.length) {
|
||||
if (!group.isTrackSupported(trackIdx)) continue
|
||||
val format = group.getTrackFormat(trackIdx)
|
||||
val entry = TrackEntry(
|
||||
id = 0, // assigned per-list below
|
||||
trackGroupIndex = groupIndex,
|
||||
trackIndex = trackIdx,
|
||||
format = format
|
||||
)
|
||||
when (rendererType) {
|
||||
C.TRACK_TYPE_TEXT -> subtitles.add(entry)
|
||||
C.TRACK_TYPE_AUDIO -> audios.add(entry)
|
||||
else -> { /* video / metadata ignored */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign 1-based IDs per track kind.
|
||||
subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) }
|
||||
audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) }
|
||||
|
||||
subtitleTrackList = subtitles
|
||||
audioTrackList = audios
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the player's actually-selected text track into [currentSubtitleId].
|
||||
* Must run after [rebuildTrackMaps] so the 1-based IDs in [subtitleTrackList]
|
||||
* are current for this [tracks] snapshot. Falls back to 0 (subs off) when no
|
||||
* text track is selected.
|
||||
*/
|
||||
private fun syncCurrentSubtitleIdFromSelection(tracks: Tracks) {
|
||||
val groups = tracks.groups
|
||||
val selected = subtitleTrackList.firstOrNull { entry ->
|
||||
groups[entry.trackGroupIndex].isTrackSelected(entry.trackIndex)
|
||||
}
|
||||
currentSubtitleId = selected?.id ?: 0
|
||||
}
|
||||
|
||||
private fun applyInitialTrackSelections() {
|
||||
val p = player ?: return
|
||||
val cfg = pendingConfig ?: return
|
||||
|
||||
// Initial subtitle/audio selection by 1-based ID.
|
||||
if (cfg.initialAudioId != null && cfg.initialAudioId > 0) {
|
||||
setAudioTrack(cfg.initialAudioId)
|
||||
}
|
||||
if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) {
|
||||
disableSubtitles()
|
||||
} else {
|
||||
setSubtitleTrack(cfg.initialSubtitleId)
|
||||
}
|
||||
|
||||
// Only apply once per source load.
|
||||
pendingConfig = null
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
return subtitleTrackList.map { entry ->
|
||||
mapOf(
|
||||
"id" to entry.id,
|
||||
"title" to (entry.format.label ?: ""),
|
||||
"lang" to (entry.format.language ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
val p = player ?: return
|
||||
val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return
|
||||
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||
|
||||
// setOverrideForType replaces any existing override of the same
|
||||
// track type — exactly what we want for single-track subtitle pickers.
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
currentSubtitleId = trackId
|
||||
}
|
||||
|
||||
fun disableSubtitles() {
|
||||
val p = player ?: return
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
currentSubtitleId = 0
|
||||
}
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int = currentSubtitleId
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean) {
|
||||
val p = player ?: return
|
||||
val mime = mimeTypeForSubtitleUrl(url) ?: return
|
||||
val currentMediaItem = p.currentMediaItem ?: return
|
||||
val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
|
||||
.setMimeType(mime)
|
||||
.setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0)
|
||||
.build()
|
||||
|
||||
// Rebuild with the full accumulated list so previously loaded
|
||||
// side-loaded subs (from VideoLoadConfig.externalSubtitles or
|
||||
// earlier addSubtitleFile calls) survive.
|
||||
val combined = sideLoadedSubs + newSubConfig
|
||||
sideLoadedSubs = combined
|
||||
|
||||
val rebuilt = currentMediaItem.buildUpon()
|
||||
.setSubtitleConfigurations(combined)
|
||||
.build()
|
||||
|
||||
val wasPlaying = p.isPlaying
|
||||
val pos = p.currentPosition
|
||||
p.setMediaItem(rebuilt, pos)
|
||||
p.prepare()
|
||||
if (wasPlaying) p.play()
|
||||
|
||||
// Re-enabling text tracks alone isn't enough: a prior text override
|
||||
// (e.g. left in place by disableSubtitles, or set by setSubtitleTrack
|
||||
// before this add) survives the MediaItem rebuild and, since embedded
|
||||
// subtitle groups persist across it, would win over the new sub's
|
||||
// SELECTION_FLAG_DEFAULT. Clear any text override so the new default
|
||||
// sub is the one that renders.
|
||||
if (select) {
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning / Styling
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
// position is 0-100 (MPV convention: 100 = bottom, 0 = top).
|
||||
// Map to SubtitleView's bottom-padding fraction. Reserve a small
|
||||
// margin so 100 doesn't hug the very bottom edge.
|
||||
val clamped = position.coerceIn(0, 100)
|
||||
subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
subtitleScale = scale.toFloat()
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
// Margin in px (approximate). SubtitleView only accepts a single
|
||||
// bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1%
|
||||
// of view height, capped). Last-write-wins vs. setSubtitlePosition.
|
||||
val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f)
|
||||
subtitleBottomFraction = fraction
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
subtitleAlignY = alignment
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
subtitleFontSizePct = size
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(colorHex: String) {
|
||||
subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor)
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleBorderStyle(style: String) {
|
||||
subtitleBorderStyle = style
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
private fun parseColor(hex: String, fallback: Int): Int {
|
||||
return try {
|
||||
when {
|
||||
hex.startsWith("#") && hex.length == 9 -> {
|
||||
// #RRGGBBAA
|
||||
val r = hex.substring(1, 3).toInt(16)
|
||||
val g = hex.substring(3, 5).toInt(16)
|
||||
val b = hex.substring(5, 7).toInt(16)
|
||||
val a = hex.substring(7, 9).toInt(16)
|
||||
Color.argb(a, r, g, b)
|
||||
}
|
||||
hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex)
|
||||
else -> fallback
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySubtitleStyle() {
|
||||
val sv = subtitleView ?: return
|
||||
|
||||
// Text size: explicit % wins; otherwise scale the default.
|
||||
val textSizeFraction = if (subtitleFontSizePct != null) {
|
||||
(subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale
|
||||
}
|
||||
sv.setFractionalTextSize(textSizeFraction)
|
||||
|
||||
// Vertical position: explicit fraction (from setSubtitlePosition /
|
||||
// setSubtitleMarginY) wins; otherwise fall back to alignY mapping.
|
||||
val alignYFraction = when (subtitleAlignY) {
|
||||
"top" -> 0.9f
|
||||
"center" -> 0.5f
|
||||
else -> 0.08f // bottom
|
||||
}
|
||||
val bottomFraction = subtitleBottomFraction ?: alignYFraction
|
||||
sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f))
|
||||
|
||||
// Edge / background style.
|
||||
val foreground = Color.WHITE
|
||||
val edgeType: Int
|
||||
val backgroundColor: Int
|
||||
when (subtitleBorderStyle) {
|
||||
"background-box" -> {
|
||||
edgeType = CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
// subtitleBackgroundColor already carries its own alpha
|
||||
// (parsed from #RRGGBBAA by setSubtitleBackgroundColor).
|
||||
// Alpha 0 → transparent, matching user intent.
|
||||
backgroundColor = subtitleBackgroundColor
|
||||
}
|
||||
else -> {
|
||||
// "outline-and-shadow"
|
||||
edgeType = if (subtitleAlignY == "center")
|
||||
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else
|
||||
CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
backgroundColor = Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
||||
val style = CaptionStyleCompat(
|
||||
foreground,
|
||||
backgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
edgeType,
|
||||
Color.BLACK,
|
||||
Typeface.SANS_SERIF
|
||||
)
|
||||
sv.setApplyEmbeddedStyles(false)
|
||||
sv.setApplyEmbeddedFontSizes(false)
|
||||
sv.setStyle(style)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
return audioTrackList.map { entry ->
|
||||
// channelCount is Format.NO_VALUE (-1) when unknown — report 0.
|
||||
val channels = if (entry.format.channelCount == Format.NO_VALUE) 0
|
||||
else entry.format.channelCount
|
||||
mapOf(
|
||||
"id" to entry.id,
|
||||
"title" to (entry.format.label ?: ""),
|
||||
"lang" to (entry.format.language ?: ""),
|
||||
"codec" to (entry.format.sampleMimeType ?: ""),
|
||||
"channels" to channels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
val p = player ?: return
|
||||
val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return
|
||||
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
currentAudioId = trackId
|
||||
}
|
||||
|
||||
fun getCurrentAudioTrack(): Int = currentAudioId
|
||||
|
||||
// MARK: - Video Scaling
|
||||
|
||||
fun setZoomedToFill(zoomed: Boolean) {
|
||||
isZoomedToFill = zoomed
|
||||
val resizeMode = if (zoomed) {
|
||||
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
} else {
|
||||
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
|
||||
fun isZoomedToFill(): Boolean = isZoomedToFill
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
fun getTechnicalInfo(): Map<String, Any> {
|
||||
val p = player ?: return emptyMap()
|
||||
val tracks = p.currentTracks
|
||||
|
||||
// Prefer the currently-selected track within each renderer group;
|
||||
// fall back to the first supported track if none is selected yet.
|
||||
val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO)
|
||||
val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO)
|
||||
|
||||
val cacheSec = if (p.bufferedPosition > p.currentPosition) {
|
||||
(p.bufferedPosition - p.currentPosition) / 1000.0
|
||||
} else 0.0
|
||||
|
||||
val info = LinkedHashMap<String, Any>()
|
||||
info["cacheSeconds"] = cacheSec
|
||||
|
||||
// Dropped frames — populated by analyticsListener.onDroppedVideoFrames.
|
||||
if (cumulativeDroppedFrames > 0) {
|
||||
info["droppedFrames"] = cumulativeDroppedFrames
|
||||
}
|
||||
|
||||
// Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized.
|
||||
// For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The
|
||||
// FFmpeg extension reports names beginning with "FFmpeg", which we
|
||||
// classify as software; everything else is MediaCodec (hardware).
|
||||
videoDecoderName?.let { name ->
|
||||
info["decoderName"] = name
|
||||
info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) {
|
||||
"software"
|
||||
} else {
|
||||
"hardware"
|
||||
}
|
||||
}
|
||||
|
||||
videoFormat?.let { f ->
|
||||
if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width
|
||||
if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height
|
||||
f.sampleMimeType?.let { info["videoCodec"] = it }
|
||||
// FPS: Format.NO_VALUE (-1f) means unknown — omit so the
|
||||
// overlay skips the row instead of showing "-1".
|
||||
if (f.frameRate > 0f) {
|
||||
info["fps"] = f.frameRate.toDouble()
|
||||
}
|
||||
// Bitrate: prefer average, fall back to peak. Both can be
|
||||
// NO_VALUE for adaptive HLS renditions — omit when unknown
|
||||
// rather than reporting 0 Kbps.
|
||||
val vBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||
f.averageBitrate
|
||||
} else {
|
||||
f.peakBitrate
|
||||
}
|
||||
if (vBitrate != Format.NO_VALUE && vBitrate > 0) {
|
||||
info["videoBitrate"] = vBitrate.toDouble()
|
||||
}
|
||||
|
||||
// Raw codec tag from the container (e.g. "hev1.2.4.L153.B0").
|
||||
// Carries profile / tier / level / constraint bytes — power
|
||||
// users can decode it manually to see why a stream hit our
|
||||
// HEVC level cap.
|
||||
f.codecs?.let { info["videoCodecs"] = it }
|
||||
|
||||
// HDR / color metadata. Format.colorInfo is the authoritative
|
||||
// source — the file/Jellyfin may claim HDR but the player is
|
||||
// what decides whether the decoder+surface path is HDR-capable.
|
||||
f.colorInfo?.let { ci ->
|
||||
val hdr = deriveHdrFormat(ci)
|
||||
if (hdr != null) info["hdrFormat"] = hdr
|
||||
colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it }
|
||||
colorRangeName(ci.colorRange)?.let { info["colorRange"] = it }
|
||||
colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it }
|
||||
}
|
||||
}
|
||||
|
||||
audioFormat?.let { f ->
|
||||
f.sampleMimeType?.let { info["audioCodec"] = it }
|
||||
val aBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||
f.averageBitrate
|
||||
} else {
|
||||
f.peakBitrate
|
||||
}
|
||||
if (aBitrate != Format.NO_VALUE && aBitrate > 0) {
|
||||
info["audioBitrate"] = aBitrate.toDouble()
|
||||
}
|
||||
if (f.channelCount > 0) info["audioChannels"] = f.channelCount
|
||||
if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the active color transfer to a human-readable HDR format string.
|
||||
* Returns null for SDR / unknown so the overlay can skip the row.
|
||||
*
|
||||
* HDR10 vs HDR10+ distinction isn't possible from Format alone in
|
||||
* Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't
|
||||
* exposed on Format. Both report as "HDR10" here; that matches what
|
||||
* Media3 actually decodes (no HDR10+ tone-mapping).
|
||||
*/
|
||||
private fun deriveHdrFormat(ci: ColorInfo): String? {
|
||||
return when (ci.colorTransfer) {
|
||||
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||
C.COLOR_TRANSFER_ST2084 -> "HDR10"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorSpaceName(value: Int): String? = when (value) {
|
||||
Format.NO_VALUE -> null
|
||||
C.COLOR_SPACE_BT709 -> "BT.709"
|
||||
C.COLOR_SPACE_BT601 -> "BT.601"
|
||||
C.COLOR_SPACE_BT2020 -> "BT.2020"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
private fun colorRangeName(value: Int): String? = when (value) {
|
||||
Format.NO_VALUE -> null
|
||||
C.COLOR_RANGE_LIMITED -> "Limited"
|
||||
C.COLOR_RANGE_FULL -> "Full"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
private fun colorTransferName(value: Int): String? = when (value) {
|
||||
Format.NO_VALUE -> null
|
||||
C.COLOR_TRANSFER_SDR -> "SDR"
|
||||
C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)"
|
||||
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||
C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
private fun pickFormat(tracks: Tracks, type: Int): Format? {
|
||||
val group = tracks.groups.firstOrNull { it.type == type } ?: return null
|
||||
// Selected track wins.
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSelected(i)) return group.getTrackFormat(i)
|
||||
}
|
||||
// Otherwise the first supported track.
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSupported(i)) return group.getTrackFormat(i)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// MARK: - Progress Loop
|
||||
|
||||
private fun startProgressLoop() {
|
||||
stopProgressLoop()
|
||||
mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun stopProgressLoop() {
|
||||
mainHandler.removeCallbacks(progressRunnable)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
6
modules/exoplayer-player/expo-module.config.json
Normal file
6
modules/exoplayer-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
||||
}
|
||||
}
|
||||
19
modules/exoplayer-player/index.ts
Normal file
19
modules/exoplayer-player/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Re-export the shared player contract from mpv-player so ExoPlayer
|
||||
// and MPV present identical surfaces to React. The MPV-prefixed setting
|
||||
// keys keep their names to avoid migrating existing installs.
|
||||
export type {
|
||||
AudioTrack,
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
NowPlayingMetadata,
|
||||
OnErrorEventPayload,
|
||||
OnLoadEventPayload,
|
||||
OnPictureInPictureChangePayload,
|
||||
OnPlaybackStateChangePayload,
|
||||
OnProgressEventPayload,
|
||||
OnTracksReadyEventPayload,
|
||||
SubtitleTrack,
|
||||
TechnicalInfo,
|
||||
VideoSource,
|
||||
} from "../mpv-player/src/MpvPlayer.types";
|
||||
export { default as ExoPlayerView } from "./src/ExoPlayerView";
|
||||
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal file
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import type {
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
} from "@/modules/mpv-player";
|
||||
|
||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||
requireNativeView("ExoPlayer");
|
||||
|
||||
/**
|
||||
* ExoPlayer view wrapper. Exposes the same `MpvPlayerViewRef` interface as
|
||||
* `MpvPlayerView` so callers can swap between the two players without
|
||||
* changing code. PiP / ASS-override methods are forwarded to the native
|
||||
* module which implements them as no-ops.
|
||||
*/
|
||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
function ExoPlayerView(props, ref) {
|
||||
const nativeRef = useRef<any>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
destroy: async () => {
|
||||
await nativeRef.current?.destroy();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
seekBy: async (offset: number) => {
|
||||
await nativeRef.current?.seekBy(offset);
|
||||
},
|
||||
setSpeed: async (speed: number) => {
|
||||
await nativeRef.current?.setSpeed(speed);
|
||||
},
|
||||
getSpeed: async () => {
|
||||
return await nativeRef.current?.getSpeed();
|
||||
},
|
||||
isPaused: async () => {
|
||||
return await nativeRef.current?.isPaused();
|
||||
},
|
||||
getCurrentPosition: async () => {
|
||||
return await nativeRef.current?.getCurrentPosition();
|
||||
},
|
||||
getDuration: async () => {
|
||||
return await nativeRef.current?.getDuration();
|
||||
},
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureSupported();
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureActive();
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
return await nativeRef.current?.getSubtitleTracks();
|
||||
},
|
||||
setSubtitleTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||
},
|
||||
disableSubtitles: async () => {
|
||||
await nativeRef.current?.disableSubtitles();
|
||||
},
|
||||
getCurrentSubtitleTrack: async () => {
|
||||
return await nativeRef.current?.getCurrentSubtitleTrack();
|
||||
},
|
||||
addSubtitleFile: async (url: string, select = true) => {
|
||||
await nativeRef.current?.addSubtitleFile(url, select);
|
||||
},
|
||||
setSubtitlePosition: async (position: number) => {
|
||||
await nativeRef.current?.setSubtitlePosition(position);
|
||||
},
|
||||
setSubtitleScale: async (scale: number) => {
|
||||
await nativeRef.current?.setSubtitleScale(scale);
|
||||
},
|
||||
setSubtitleMarginY: async (margin: number) => {
|
||||
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||
},
|
||||
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||
},
|
||||
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||
},
|
||||
setSubtitleFontSize: async (size: number) => {
|
||||
await nativeRef.current?.setSubtitleFontSize(size);
|
||||
},
|
||||
setSubtitleBackgroundColor: async (color: string) => {
|
||||
await nativeRef.current?.setSubtitleBackgroundColor(color);
|
||||
},
|
||||
setSubtitleBorderStyle: async (
|
||||
style: "outline-and-shadow" | "background-box",
|
||||
) => {
|
||||
await nativeRef.current?.setSubtitleBorderStyle(style);
|
||||
},
|
||||
setSubtitleAssOverride: async (mode: "no" | "force") => {
|
||||
await nativeRef.current?.setSubtitleAssOverride(mode);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
return await nativeRef.current?.getAudioTracks();
|
||||
},
|
||||
setAudioTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackId);
|
||||
},
|
||||
getCurrentAudioTrack: async () => {
|
||||
return await nativeRef.current?.getCurrentAudioTrack();
|
||||
},
|
||||
setZoomedToFill: async (zoomed: boolean) => {
|
||||
await nativeRef.current?.setZoomedToFill(zoomed);
|
||||
},
|
||||
isZoomedToFill: async () => {
|
||||
return await nativeRef.current?.isZoomedToFill();
|
||||
},
|
||||
getTechnicalInfo: async () => {
|
||||
return await nativeRef.current?.getTechnicalInfo();
|
||||
},
|
||||
}));
|
||||
|
||||
return <NativeView ref={nativeRef} {...props} />;
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,8 @@ export type {
|
||||
DownloadStartedEvent,
|
||||
} from "./background-downloader";
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
// ExoPlayer (Android TV)
|
||||
export { ExoPlayerView } from "./exoplayer-player";
|
||||
// Glass Poster (tvOS 26+)
|
||||
export type { GlassPosterViewProps } from "./glass-poster";
|
||||
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
||||
|
||||
@@ -125,6 +125,14 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
private var currentUrl: String? = null
|
||||
private var currentHeaders: Map<String, String>? = null
|
||||
private var pendingExternalSubtitles: List<String> = emptyList()
|
||||
// Persistent record of the external subtitle URLs attached to the
|
||||
// current item. pendingExternalSubtitles above is a one-shot staging
|
||||
// list: load() fills it and the FILE_LOADED handler drains it via
|
||||
// sub-add, so it is always empty by the time a resume-recovery reload
|
||||
// runs. This copy survives that drain so recoverVideoOutput() can hand
|
||||
// the same sidecar URLs back to load() and the tracks re-attach after
|
||||
// the decoder reset.
|
||||
private var activeExternalSubtitles: List<String> = emptyList()
|
||||
private var initialSubtitleId: Int? = null
|
||||
private var initialAudioId: Int? = null
|
||||
|
||||
@@ -142,6 +150,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
*/
|
||||
private var voDriver: String = "gpu-next"
|
||||
|
||||
/**
|
||||
* True on Android TV form-factor devices. Drives both the hwdec selection
|
||||
* in [start] and the TV-only resume-recovery gating in [MpvPlayerView].
|
||||
* Computed once from the system UI mode.
|
||||
*/
|
||||
val isTv: Boolean = isTvDevice()
|
||||
|
||||
fun start(voDriver: String = "gpu-next") {
|
||||
if (isRunning) return
|
||||
|
||||
@@ -152,13 +167,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.mpv = mpv
|
||||
mpv.addObserver(this)
|
||||
|
||||
// Resolved once — TV gets the memory-pressure customizations
|
||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||
// audio-buffer) that would be counterproductive on higher-RAM
|
||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||
// those come from user settings via load().
|
||||
val isTV = isTvDevice()
|
||||
|
||||
// mpv config directory — used by the config-dir option below and
|
||||
// as XDG_CONFIG_HOME for fontconfig.
|
||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||
@@ -212,7 +220,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||
when {
|
||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||
isTV -> {
|
||||
isTv -> {
|
||||
mpv?.setOptionString("hwdec", "mediacodec")
|
||||
mpv?.setOptionString("profile", "fast")
|
||||
// Don't retain already-played content for backward
|
||||
@@ -270,6 +278,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
currentUrl = null
|
||||
currentHeaders = null
|
||||
pendingExternalSubtitles = emptyList()
|
||||
activeExternalSubtitles = emptyList()
|
||||
initialSubtitleId = null
|
||||
initialAudioId = null
|
||||
cachedPosition = 0.0
|
||||
@@ -377,18 +386,67 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Force mpv to render a frame to the current surface.
|
||||
* Steps forward one frame then seeks back to the original position.
|
||||
* Used after PiP entry to work around mpv stopping pixel output.
|
||||
* Restore video after a system-initiated surface loss (Android TV
|
||||
* screensaver / app background while paused). Triggered from the host
|
||||
* activity's onResume via [MpvPlayerView.runResumeRecovery].
|
||||
*
|
||||
* On TV, `hwdec=mediacodec` (zero-copy) binds MediaCodec directly to the
|
||||
* display surface. When the screensaver invalidates that surface, the
|
||||
* decoder is left bound to dead buffers and mpv auto-disables the video
|
||||
* track (vid=no). Re-attaching the surface + cycling hwdec + re-selecting
|
||||
* vid + seeking rebuilds the pipeline but leaves the video chain at EOF
|
||||
* ("video=eof" in playback-restart) — the recreated MediaCodec produces no
|
||||
* frames. Only a fresh `loadfile` deterministically recreates the decoder
|
||||
* against the live surface, so we reload at the cached position.
|
||||
*
|
||||
* This is the Android counterpart to iOS's `performDecoderReset()`, which
|
||||
* can get away with a `hwdec` cycle because VideoToolbox reinitializes
|
||||
* cleanly; zero-copy MediaCodec bound to a lost surface does not.
|
||||
*/
|
||||
fun forceRedraw() {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
mpv?.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
fun recoverVideoOutput(surface: Surface?) {
|
||||
if (!isRunning) {
|
||||
Log.w(TAG, "[Recover] recoverVideoOutput — renderer not running, skipping")
|
||||
return
|
||||
}
|
||||
val url = currentUrl ?: run {
|
||||
Log.w(TAG, "[Recover] recoverVideoOutput — no URL loaded, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"[Recover] reload recovery — pos=$cachedPosition, aid=${getCurrentAudioTrack()}, sid=${getCurrentSubtitleTrack()}"
|
||||
)
|
||||
|
||||
// Re-attach the surface first (covers the case where the surface
|
||||
// survived and surfaceCreated never fired). The reload below fully
|
||||
// rebuilds the pipeline anyway, but this keeps the VO target current
|
||||
// while the load is in flight.
|
||||
surface?.takeIf { it.isValid }?.let { mpv?.attachSurface(it) }
|
||||
|
||||
// Preserve the user's current audio/subtitle selection across the
|
||||
// reload — pass them INTO load() (not via the field: load() overwrites
|
||||
// the initial IDs from its parameters). They're re-applied when
|
||||
// FILE_LOADED fires. (0 / "no" means "off", which setSubtitleTrack /
|
||||
// setAudioTrack handle correctly.)
|
||||
val savedAid = getCurrentAudioTrack()
|
||||
val savedSid = getCurrentSubtitleTrack()
|
||||
|
||||
// Full reload at the paused position. With keep-open=always +
|
||||
// cache-pause-initial=yes, mpv seeks to the position and holds on the
|
||||
// first decoded frame, so the paused frame reappears.
|
||||
load(
|
||||
url = url,
|
||||
headers = currentHeaders,
|
||||
startPosition = cachedPosition,
|
||||
initialAudioId = savedAid,
|
||||
initialSubtitleId = savedSid,
|
||||
externalSubtitles = activeExternalSubtitles
|
||||
)
|
||||
|
||||
// Hold the paused state explicitly — we only get here while paused,
|
||||
// and load() doesn't touch the pause property.
|
||||
pause()
|
||||
}
|
||||
|
||||
fun load(
|
||||
@@ -406,6 +464,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
currentUrl = url
|
||||
currentHeaders = headers
|
||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||
activeExternalSubtitles = pendingExternalSubtitles
|
||||
this.initialSubtitleId = initialSubtitleId
|
||||
this.initialAudioId = initialAudioId
|
||||
|
||||
@@ -535,19 +594,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
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` is exposed for
|
||||
// diagnostics / potential future exact-index matching; the current
|
||||
// resolver matches embedded tracks by language/title, not ff-index.
|
||||
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
|
||||
@@ -578,6 +624,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
val flag = if (select) "select" else "cached"
|
||||
mpv?.command(arrayOf("sub-add", url, flag))
|
||||
// Track runtime side-loads too, so they survive a resume-recovery
|
||||
// reload just like external subs passed to load().
|
||||
if (url.isNotEmpty() && url !in activeExternalSubtitles) {
|
||||
activeExternalSubtitles = activeExternalSubtitles + url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
@@ -853,13 +904,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||
|
||||
// The disable above can race a JS-side identity selection that
|
||||
// landed before FILE_LOADED (JS no longer passes an initial sid).
|
||||
// Re-emit tracksReady so the idempotent JS re-apply always runs
|
||||
// after it — for embedded-only files this is the only
|
||||
// post-FILE_LOADED fire.
|
||||
mainHandler.post { delegate?.onTracksReady() }
|
||||
|
||||
if (!isReadyToSeek) {
|
||||
isReadyToSeek = true
|
||||
mainHandler.post { delegate?.onReadyToSeek() }
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
@@ -51,6 +55,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerView"
|
||||
|
||||
// Grace window after onActivityResumed before running the resume
|
||||
// recovery, so surfaceCreated (surface-destroyed case) has fired and
|
||||
// the holder has a valid surface. If the surface survived the
|
||||
// screensaver and surfaceCreated never fires, this still runs.
|
||||
private const val RESUME_RECOVERY_DELAY_MS = 300L
|
||||
}
|
||||
|
||||
// Event dispatchers
|
||||
@@ -77,6 +87,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
// PiP state tracking
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Resume-recovery state: recreate the decoder when returning from the
|
||||
// screensaver / app background while paused. See
|
||||
// MPVLayerRenderer.recoverVideoOutput for why zero-copy hwdec=mediacodec
|
||||
// needs this.
|
||||
private var hostActivity: Activity? = null
|
||||
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||
private var lifecycleRegistered = false
|
||||
private val recoverResumeRunnable = Runnable { runResumeRecovery() }
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
@@ -145,6 +164,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||
renderer = MPVLayerRenderer(context)
|
||||
renderer?.delegate = this
|
||||
|
||||
// Watch the host activity's lifecycle to recover the video pipeline
|
||||
// when returning from the screensaver while paused.
|
||||
registerLifecycleCallbacks()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -527,6 +550,107 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
onError(mapOf("error" to message))
|
||||
}
|
||||
|
||||
// MARK: - Resume Recovery
|
||||
|
||||
/**
|
||||
* Recreate the decoder when returning from the Android TV screensaver (or
|
||||
* app background) while paused. Triggered from the host activity's
|
||||
* onResume; the work is in [MPVLayerRenderer.recoverVideoOutput]. The
|
||||
* playing case is skipped — the render thread re-primes the VO on surface
|
||||
* reattach by itself — as is PiP (it owns its surface lifecycle).
|
||||
*/
|
||||
private fun runResumeRecovery() {
|
||||
if (!rendererStarted) return
|
||||
if (pipController?.isPictureInPictureActive() == true) return
|
||||
if (intendedPlayState) return // playing self-heals
|
||||
val surface = surfaceView.holder.surface?.takeIf { it.isValid }
|
||||
Log.i(TAG, "[Recover] onResume recovery — paused, surfaceValid=${surface != null}")
|
||||
renderer?.recoverVideoOutput(surface)
|
||||
}
|
||||
|
||||
private fun registerLifecycleCallbacks() {
|
||||
if (lifecycleRegistered) return
|
||||
// Resume-recovery is TV-only. Only TV's zero-copy hwdec=mediacodec
|
||||
// binds MediaCodec directly to the display surface, so only TV needs
|
||||
// decoder recreation after a system-initiated surface loss (screensaver
|
||||
// / app background while paused). Phones (mediacodec-copy) and the
|
||||
// emulator self-heal via the surfaceCreated → attachSurface path, so we
|
||||
// don't even register there — no callback overhead, no spurious resets.
|
||||
if (renderer?.isTv != true) {
|
||||
Log.i(TAG, "[Recover] skipping lifecycle registration (isTv=${renderer?.isTv})")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "[Recover] registering lifecycle recovery (TV)")
|
||||
val app = context.applicationContext as? Application ?: run {
|
||||
Log.w(TAG, "Cannot register lifecycle callbacks: no Application")
|
||||
return
|
||||
}
|
||||
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
val host = hostActivity ?: findActivity().also { hostActivity = it }
|
||||
if (activity !== host) {
|
||||
if (host == null) {
|
||||
Log.i(TAG, "[Recover] onActivityResumed — host unresolved, activity=${activity.javaClass.simpleName}; skipping")
|
||||
}
|
||||
return
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"[Recover] onActivityResumed — host resumed, hasMedia=${currentUrl != null}, pip=${pipController?.isPictureInPictureActive()}, paused=${!intendedPlayState}"
|
||||
)
|
||||
// Only recover when there's loaded media, we're paused, and not
|
||||
// in PiP. Playing self-heals; nothing loaded yet = nothing to
|
||||
// recover.
|
||||
if (currentUrl == null) return
|
||||
if (pipController?.isPictureInPictureActive() == true) return
|
||||
if (intendedPlayState) return
|
||||
// Post past the resume/surfaceCreated race so the holder has a
|
||||
// valid surface, then recreate the decoder against it.
|
||||
pipHandler.removeCallbacks(recoverResumeRunnable)
|
||||
pipHandler.postDelayed(recoverResumeRunnable, RESUME_RECOVERY_DELAY_MS)
|
||||
}
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
if (activity === hostActivity) {
|
||||
Log.i(TAG, "[Recover] onActivityPaused — host paused")
|
||||
}
|
||||
}
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
if (activity === hostActivity) {
|
||||
Log.i(TAG, "[Recover] onActivityStopped — host stopped")
|
||||
}
|
||||
}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
}
|
||||
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||
lifecycleRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterLifecycleCallbacks() {
|
||||
pipHandler.removeCallbacks(recoverResumeRunnable)
|
||||
if (!lifecycleRegistered) return
|
||||
val app = context.applicationContext as? Application
|
||||
lifecycleCallbacks?.let { app?.unregisterActivityLifecycleCallbacks(it) }
|
||||
lifecycleCallbacks = null
|
||||
lifecycleRegistered = false
|
||||
}
|
||||
|
||||
private fun findActivity(): Activity? {
|
||||
// Prefer Expo's currentActivity. The view's Context is a ReactContext
|
||||
// whose base is the Application, not the Activity, so walking the
|
||||
// context chain does not reliably reach the Activity. Mirrors
|
||||
// PiPController.getActivity().
|
||||
appContext.currentActivity?.let { return it }
|
||||
var ctx: Context = context
|
||||
while (ctx is ContextWrapper) {
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/**
|
||||
@@ -538,6 +662,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
*/
|
||||
fun cleanup() {
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
unregisterLifecycleCallbacks()
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
renderer?.delegate = null
|
||||
|
||||
@@ -508,15 +508,6 @@ final class MPVLayerRenderer {
|
||||
} else {
|
||||
disableSubtitles()
|
||||
}
|
||||
// The disable above can race a JS-side identity selection that
|
||||
// landed before FILE_LOADED (JS no longer passes an initial sid).
|
||||
// Re-emit tracksReady so the idempotent JS re-apply always runs
|
||||
// after it — for embedded-only files this is the only
|
||||
// post-FILE_LOADED fire.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||
}
|
||||
if !isReadyToSeek {
|
||||
isReadyToSeek = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -768,7 +759,7 @@ final class MPVLayerRenderer {
|
||||
trackType == "sub" else { continue }
|
||||
|
||||
var trackId: Int64 = 0
|
||||
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||
|
||||
var track: [String: Any] = ["id": Int(trackId)]
|
||||
|
||||
@@ -780,33 +771,11 @@ final class MPVLayerRenderer {
|
||||
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` is exposed for
|
||||
// diagnostics / potential future exact-index matching; the current
|
||||
// resolver matches embedded tracks by language/title, not ff-index.
|
||||
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"), external=\(external != 0)", type: "Info")
|
||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
||||
tracks.append(track)
|
||||
}
|
||||
|
||||
@@ -903,7 +872,7 @@ final class MPVLayerRenderer {
|
||||
trackType == "audio" else { continue }
|
||||
|
||||
var trackId: Int64 = 0
|
||||
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||
|
||||
var track: [String: Any] = ["id": Int(trackId)]
|
||||
|
||||
|
||||
@@ -141,14 +141,6 @@ 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;
|
||||
};
|
||||
|
||||
@@ -183,4 +175,28 @@ export type TechnicalInfo = {
|
||||
hwdec?: string;
|
||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||
estimatedVfFps?: number;
|
||||
// ---- Extended fields (primarily ExoPlayer-backed; MPV may fill some) ----
|
||||
/** Derived HDR format: "SDR" | "HDR10" | "HDR10+" | "HLG" | null */
|
||||
hdrFormat?: string;
|
||||
/** Color space, e.g. "BT.709" / "BT.2020" */
|
||||
colorSpace?: string;
|
||||
/** Color range: "Limited" / "Full" */
|
||||
colorRange?: string;
|
||||
/** Color transfer: "SDR" / "ST2084 (PQ)" / "HLG" */
|
||||
colorTransfer?: string;
|
||||
/** Decoder path: "hardware" (MediaCodec) or "software" (FFmpeg extension) */
|
||||
decoderType?: string;
|
||||
/** Instantiated decoder name, e.g. "c2.amlogic.hevc.decoder" */
|
||||
decoderName?: string;
|
||||
/** Active audio channel count (2 = stereo, 6 = 5.1, 8 = 7.1) */
|
||||
audioChannels?: number;
|
||||
/** Active audio sample rate in Hz */
|
||||
audioSampleRate?: number;
|
||||
/**
|
||||
* Raw codec tag from the container, e.g. "hev1.2.4.L153.B0". Encodes
|
||||
* profile / tier / level / constraint bytes per ISO/IEC 14496-15. Power
|
||||
* users can decode this manually; it's how Jellyfin's HEVC level cap
|
||||
* (153 = Level 5.1) is checked against the file.
|
||||
*/
|
||||
videoCodecs?: string;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import type React from "react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { Bitrate } from "@/components/BitrateSelector";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { generateDeviceProfile } from "../utils/profiles/native";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
@@ -78,10 +78,11 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate device profile for MPV player
|
||||
// Match the device profile to the actually-active player so the
|
||||
// server picks codecs/containers the player can decode.
|
||||
const native = generateDeviceProfile({
|
||||
platform: Platform.OS as "ios" | "android",
|
||||
player: "mpv",
|
||||
player: getActivePlayerType(settings),
|
||||
audioMode: settings.audioTranscodeMode,
|
||||
});
|
||||
const data = await getStreamUrl({
|
||||
|
||||
@@ -213,6 +213,13 @@
|
||||
"rewind_length": "Rewind length",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"exoplayer": "ExoPlayer",
|
||||
"mpv": "MPV",
|
||||
"exoplayer_note": "ExoPlayer does not support advanced ASS/SSA subtitle styling or horizontal subtitle alignment. Switch to MPV if you need those.",
|
||||
"mpv_note": "MPV on TV does not currently pass HDR metadata to the display — HDR10/HDR10+ content is tone-mapped to SDR. Switch to ExoPlayer for HDR output."
|
||||
},
|
||||
"buffer": {
|
||||
"title": "Buffer settings",
|
||||
"cache_mode": "Cache mode",
|
||||
|
||||
@@ -171,11 +171,52 @@ export type HomeSectionLatestResolver = {
|
||||
includeItemTypes?: Array<BaseItemKind>;
|
||||
};
|
||||
|
||||
// Video player enum - currently only MPV is supported
|
||||
// Video player enum. MPV is the universal default; ExoPlayer is an
|
||||
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
|
||||
export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
ExoPlayer = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether ExoPlayer's native module is available on the current platform.
|
||||
* ExoPlayer only ships for Android TV; on any other platform a persisted
|
||||
* `videoPlayer: ExoPlayer` preference (e.g. MMKV roaming) must fall back
|
||||
* to MPV rather than crash on requireNativeView().
|
||||
*/
|
||||
export const isExoPlayerSupported =
|
||||
Platform.OS === "android" && Platform.isTV === true;
|
||||
|
||||
/**
|
||||
* Resolve the actually-active video player for the current settings.
|
||||
* MPV is the default on every platform; users can opt into ExoPlayer on
|
||||
* Android TV via settings.videoPlayer. The Android-TV capability gate is
|
||||
* folded in here so callers (VideoPlayerView, direct-player's device
|
||||
* profile, PlaySettingsProvider) can never advertise ExoPlayer on a
|
||||
* platform where MPV is actually rendering — that mismatch would let
|
||||
* Jellyfin pick a stream for the wrong renderer.
|
||||
*/
|
||||
export const getActiveVideoPlayer = (
|
||||
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
||||
): VideoPlayer => {
|
||||
if (isExoPlayerSupported && settings?.videoPlayer === VideoPlayer.ExoPlayer) {
|
||||
return VideoPlayer.ExoPlayer;
|
||||
}
|
||||
return VideoPlayer.MPV;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same selection as getActiveVideoPlayer but returns the lowercase
|
||||
* player-type identifier that `generateDeviceProfile` expects.
|
||||
*/
|
||||
export const getActivePlayerType = (
|
||||
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
||||
): "mpv" | "exoplayer" => {
|
||||
return getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer
|
||||
? "exoplayer"
|
||||
: "mpv";
|
||||
};
|
||||
|
||||
// TV Typography scale presets
|
||||
export enum TVTypographyScale {
|
||||
Small = "small",
|
||||
@@ -218,6 +259,8 @@ export type Settings = {
|
||||
mediaListCollectionIds?: string[];
|
||||
preferedLanguage?: string;
|
||||
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
||||
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
|
||||
videoPlayer?: VideoPlayer;
|
||||
marlinServerUrl?: string;
|
||||
streamyStatsServerUrl?: string;
|
||||
streamyStatsMovieRecommendations?: boolean;
|
||||
@@ -318,6 +361,8 @@ export const defaultValues: Settings = {
|
||||
mediaListCollectionIds: [],
|
||||
preferedLanguage: undefined,
|
||||
searchEngine: "Jellyfin",
|
||||
// videoPlayer intentionally undefined — resolved at runtime via
|
||||
// getActiveVideoPlayer() so existing installs are unaffected.
|
||||
marlinServerUrl: "",
|
||||
streamyStatsServerUrl: "",
|
||||
streamyStatsMovieRecommendations: false,
|
||||
|
||||
@@ -11,14 +11,6 @@ export type TVSubtitleModalState = {
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
refreshSubtitleTracks?: () => Promise<Track[]>;
|
||||
/**
|
||||
* Run the selection callback AFTER the modal route is dismissed. Needed when
|
||||
* `setTrack` navigates (the player's replacePlayer for a burn-in switch),
|
||||
* which would be swallowed by the still-active modal route. Leave false for
|
||||
* callers whose selection only updates state (the item detail page), so the
|
||||
* update runs before dismissal and doesn't yank focus after it returns.
|
||||
*/
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
} | null;
|
||||
|
||||
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
MediaStream,
|
||||
SubtitleDeliveryMethod,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
compareTracksForMenu,
|
||||
getExternalSubtitleUrl,
|
||||
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;
|
||||
const Encode = "Encode" as SubtitleDeliveryMethod;
|
||||
const Hls = "Hls" 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,
|
||||
);
|
||||
});
|
||||
|
||||
test("a sidecar re-delivered by the server (Hls/Encode) is NOT external", () => {
|
||||
// IsExternal only wins while no device-specific delivery method is assigned;
|
||||
// once the server picks Hls (inside the stream) or Encode (burned), the
|
||||
// track is not a sub-added sidecar and must not use the external path.
|
||||
expect(
|
||||
isExternalSubtitle(
|
||||
sub({ Index: 0, IsExternal: true, DeliveryMethod: Hls }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isExternalSubtitle(
|
||||
sub({ Index: 0, IsExternal: true, DeliveryMethod: Encode }),
|
||||
),
|
||||
).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],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — same-identity group ordinal (duplicate languages)", () => {
|
||||
// [jpn, eng, eng] with no titles: the eng group starts at embedded position 1,
|
||||
// so the group ordinal (not the global one) must drive the pick.
|
||||
const streams = [
|
||||
emb(0, { Language: "jpn" }),
|
||||
emb(1, { Language: "eng" }),
|
||||
emb(2, { Language: "eng" }),
|
||||
];
|
||||
const playerTracks = [
|
||||
track({ id: 1, language: "jpn" }),
|
||||
track({ id: 2, language: "eng" }),
|
||||
track({ id: 3, language: "eng" }),
|
||||
];
|
||||
|
||||
test("first eng selects the first matching player track", () => {
|
||||
expect(resolve(streams, 1, playerTracks)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("second eng selects the second matching player track", () => {
|
||||
expect(resolve(streams, 2, playerTracks)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — server-burned (Encode) streams", () => {
|
||||
const burned = emb(2, {
|
||||
DeliveryMethod: Encode,
|
||||
IsTextSubtitleStream: false,
|
||||
Codec: "pgssub",
|
||||
});
|
||||
|
||||
test("selecting a burned-in sub returns burnedIn (a stream refresh, not a track)", () => {
|
||||
expect(resolve([burned, emb(3)], 2, [track({ id: 1 })])).toEqual({
|
||||
kind: "burnedIn",
|
||||
});
|
||||
});
|
||||
|
||||
test("burned-in streams do not shift the embedded ordinal", () => {
|
||||
// Player only demuxes the two untagged text subs; the burned PGS is pixels.
|
||||
const streams = [burned, emb(3), emb(4)];
|
||||
const playerTracks = [track({ id: 1 }), track({ id: 2 })];
|
||||
expect(resolve(streams, 3, playerTracks)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
expect(resolve([burned, emb(3)], 3, [track({ id: 1 })])).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — Hls-delivered sidecar goes through the embedded path", () => {
|
||||
test("resolves by identity against the player's in-stream track", () => {
|
||||
const hlsSidecar = sub({
|
||||
Index: 0,
|
||||
IsExternal: true,
|
||||
DeliveryMethod: Hls,
|
||||
DeliveryUrl: "/videos/x/subs/0.vtt",
|
||||
Language: "fre",
|
||||
});
|
||||
const r = resolve([hlsSidecar, emb(1, { Language: "eng" })], 0, [
|
||||
track({ id: 1, language: "fre" }),
|
||||
track({ id: 2, language: "eng" }),
|
||||
]);
|
||||
expect(r).toEqual({ kind: "select", trackId: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMpvSubtitleSelection — short-circuits", () => {
|
||||
test("disable (-1) never reads the player track list", async () => {
|
||||
let enumerated = false;
|
||||
let disabled = false;
|
||||
const r = await applyMpvSubtitleSelection(
|
||||
{
|
||||
getSubtitleTracks: async () => {
|
||||
enumerated = true;
|
||||
return [];
|
||||
},
|
||||
setSubtitleTrack: () => {},
|
||||
disableSubtitles: () => {
|
||||
disabled = true;
|
||||
},
|
||||
},
|
||||
{ subtitleStreams: [], jellyfinSubtitleIndex: -1 },
|
||||
);
|
||||
expect(r).toEqual({ kind: "disable" });
|
||||
expect(disabled).toBe(true);
|
||||
expect(enumerated).toBe(false);
|
||||
});
|
||||
|
||||
test("burned-in target returns without touching the player", async () => {
|
||||
let enumerated = false;
|
||||
const r = await applyMpvSubtitleSelection(
|
||||
{
|
||||
getSubtitleTracks: async () => {
|
||||
enumerated = true;
|
||||
return [];
|
||||
},
|
||||
setSubtitleTrack: () => {},
|
||||
disableSubtitles: () => {},
|
||||
},
|
||||
{
|
||||
subtitleStreams: [
|
||||
emb(2, { DeliveryMethod: Encode, IsTextSubtitleStream: false }),
|
||||
],
|
||||
jellyfinSubtitleIndex: 2,
|
||||
},
|
||||
);
|
||||
expect(r).toEqual({ kind: "burnedIn" });
|
||||
expect(enumerated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExternalSubtitleUrl — server contract (MediaInfoHelper)", () => {
|
||||
test("server-relative DeliveryUrl gets the basePath prefix", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(ext(0, { DeliveryUrl: "/sub/0.srt" }), {
|
||||
offline: false,
|
||||
basePath: "http://srv",
|
||||
}),
|
||||
).toBe("http://srv/sub/0.srt");
|
||||
});
|
||||
|
||||
test("IsExternalUrl means DeliveryUrl is already absolute — no prefix", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(
|
||||
ext(0, {
|
||||
DeliveryUrl: "https://cdn.example/sub.srt",
|
||||
IsExternalUrl: true,
|
||||
}),
|
||||
{ offline: false, basePath: "http://srv" },
|
||||
),
|
||||
).toBe("https://cdn.example/sub.srt");
|
||||
});
|
||||
|
||||
test("offline returns the stored local path as-is", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(ext(0, { DeliveryUrl: "file:///subs/0.srt" }), {
|
||||
offline: true,
|
||||
basePath: "http://srv",
|
||||
}),
|
||||
).toBe("file:///subs/0.srt");
|
||||
});
|
||||
|
||||
test("no DeliveryUrl or no basePath online → undefined", () => {
|
||||
expect(
|
||||
getExternalSubtitleUrl(sub({ Index: 0, IsExternal: true }), {
|
||||
offline: false,
|
||||
basePath: "http://srv",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getExternalSubtitleUrl(ext(0), { offline: false, basePath: undefined }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtitleTrack — language tag variants (ISO 639-1 / 639-2 B-T / IETF)", () => {
|
||||
test("Jellyfin 639-2/B matches mpv 639-2/T and 639-1 tags", () => {
|
||||
// Server says "ger" (639-2/B); muxers can surface "deu" (/T) or "de" (639-1).
|
||||
const streams = [emb(0, { Language: "ger" }), emb(1, { Language: "fre" })];
|
||||
const playerT = [
|
||||
track({ id: 1, language: "deu" }),
|
||||
track({ id: 2, language: "fra" }),
|
||||
];
|
||||
expect(resolve(streams, 0, playerT)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
expect(resolve(streams, 1, playerT)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 2,
|
||||
});
|
||||
|
||||
const player1 = [
|
||||
track({ id: 1, language: "de" }),
|
||||
track({ id: 2, language: "fr" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player1)).toEqual({
|
||||
kind: "select",
|
||||
trackId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("IETF tags reduce to their primary subtag", () => {
|
||||
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "spa" })];
|
||||
const player = [
|
||||
track({ id: 1, language: "en-US" }),
|
||||
track({ id: 2, language: "es-419" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 1 });
|
||||
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
||||
});
|
||||
|
||||
test("different languages still never match ('ger' vs 'gre')", () => {
|
||||
const streams = [emb(0, { Language: "ger" })];
|
||||
const player = [
|
||||
track({ id: 1, language: "gre" }),
|
||||
track({ id: 2, language: "ger" }),
|
||||
];
|
||||
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareTracksForMenu — stable order across play methods (8 Mile live find)", () => {
|
||||
test("transcode re-delivery (SRT→External, PGS→Encode) must not reshuffle the menu", () => {
|
||||
// In-file order: srt(1) srt(2) pgs(3) pgs(4), plus a real sidecar ext(0).
|
||||
const directPlay = [
|
||||
ext(0, { Language: "eng" }),
|
||||
emb(1, { Language: "fre" }),
|
||||
emb(2, { Language: "eng" }),
|
||||
emb(3, { Language: "fre", IsTextSubtitleStream: false }),
|
||||
emb(4, { Language: "eng", IsTextSubtitleStream: false }),
|
||||
];
|
||||
// Same file while transcoding: server re-delivers embedded text as External
|
||||
// (extracted) and burns image subs (Encode). IsExternal stays a FILE property.
|
||||
const transcoding = [
|
||||
ext(0, { Language: "eng" }),
|
||||
emb(1, { Language: "fre", DeliveryMethod: External, DeliveryUrl: "/x1" }),
|
||||
emb(2, { Language: "eng", DeliveryMethod: External, DeliveryUrl: "/x2" }),
|
||||
emb(3, {
|
||||
Language: "fre",
|
||||
IsTextSubtitleStream: false,
|
||||
DeliveryMethod: Encode,
|
||||
}),
|
||||
emb(4, {
|
||||
Language: "eng",
|
||||
IsTextSubtitleStream: false,
|
||||
DeliveryMethod: Encode,
|
||||
}),
|
||||
];
|
||||
const order = (streams: MediaStream[]) =>
|
||||
[...streams].sort(compareTracksForMenu).map((s) => s.Index);
|
||||
expect(order(directPlay)).toEqual([1, 2, 3, 4, 0]);
|
||||
expect(order(transcoding)).toEqual(order(directPlay));
|
||||
});
|
||||
});
|
||||
@@ -1,584 +1,120 @@
|
||||
/**
|
||||
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
|
||||
* the *player's real track list* by identity — never by positional counting.
|
||||
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
||||
*
|
||||
* 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}.
|
||||
* 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.
|
||||
*
|
||||
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
||||
* and absent from the player's track list.
|
||||
* and NOT available in MPV's track list.
|
||||
*/
|
||||
|
||||
import type {
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
import {
|
||||
type MediaSourceInfo,
|
||||
type 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;
|
||||
const ENCODE_DELIVERY = "Encode" as SubtitleDeliveryMethod;
|
||||
|
||||
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.IsTextSubtitleStream === false;
|
||||
|
||||
/**
|
||||
* Burned into the video by the server (`DeliveryMethod === Encode`, e.g. image
|
||||
* subs while transcoding, or sidecar formats no profile can deliver). Never a
|
||||
* selectable player track — switching to/away requires a stream refresh.
|
||||
*/
|
||||
export const isBurnedInSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.DeliveryMethod === ENCODE_DELIVERY;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Determine if a subtitle will be available in MPV's track list.
|
||||
*
|
||||
* 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`).
|
||||
* A subtitle is in MPV if:
|
||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
||||
*/
|
||||
export const isExternalSubtitle = (sub: MediaStream): boolean =>
|
||||
sub.DeliveryMethod === EXTERNAL_DELIVERY ||
|
||||
(sub.DeliveryMethod == null && sub.IsExternal === true);
|
||||
|
||||
/**
|
||||
* The exact URL/path an external sub is (or would be) loaded into the player
|
||||
* with. Single source of truth for BOTH the load site (`videoSource`
|
||||
* externalSubtitles) and identity matching (`getExpectedExternalUrl`) — the
|
||||
* filename match only works while the two stay byte-identical.
|
||||
*
|
||||
* Server contract (MediaInfoHelper.SetDeviceSpecificSubtitleInfo): DeliveryUrl
|
||||
* is server-relative (`/Videos/...`, may carry `?ApiKey=`) UNLESS
|
||||
* `IsExternalUrl` is set — then it is already an absolute URL and must not be
|
||||
* prefixed. Offline: DeliveryUrl holds the local file path.
|
||||
*/
|
||||
export const getExternalSubtitleUrl = (
|
||||
export const isSubtitleInMpv = (
|
||||
sub: MediaStream,
|
||||
opts: { offline: boolean; basePath?: string | null },
|
||||
): string | undefined => {
|
||||
if (!sub.DeliveryUrl) return undefined;
|
||||
if (opts.offline || sub.IsExternalUrl) return sub.DeliveryUrl;
|
||||
return opts.basePath ? `${opts.basePath}${sub.DeliveryUrl}` : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 the raw `IsExternal`
|
||||
* flag exactly like web's `sortTracks` (itemHelper.js): it is a property of the
|
||||
* FILE, so the menu order stays identical between direct play and transcode —
|
||||
* delivery-based grouping would reshuffle entries when the server re-delivers
|
||||
* extracted text subs as External and burns image subs (Encode). Ordering is
|
||||
* purely cosmetic; selection resolves by `Index` identity regardless.
|
||||
*/
|
||||
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
|
||||
Number(a.IsExternal ?? false) - Number(b.IsExternal ?? false) ||
|
||||
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
|
||||
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
|
||||
// Missing Index sorts to the end (not 0, which would float it to the top and
|
||||
// collide with a real Index 0).
|
||||
(a.Index ?? Number.MAX_SAFE_INTEGER) - (b.Index ?? Number.MAX_SAFE_INTEGER);
|
||||
|
||||
/**
|
||||
* 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" }
|
||||
/** Target is server-burned (Encode) — only a stream refresh can show/hide it. */
|
||||
| { kind: "burnedIn" }
|
||||
| { 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,
|
||||
isTranscoding: boolean,
|
||||
): 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();
|
||||
|
||||
/**
|
||||
* ISO 639-1 (2-letter) and ISO 639-2/T tags mapped to their ISO 639-2/B form,
|
||||
* so tags from different muxers/servers compare equal ("de"/"deu"/"ger" → "ger").
|
||||
* Jellyfin normalizes probe results to 639-2/B, but mkv `LanguageIETF` tags
|
||||
* ("en-US") and some muxers' 639-1 or /T tags leak through to mpv's `lang`.
|
||||
*/
|
||||
const LANG_CANONICAL: Record<string, string> = {
|
||||
// ISO 639-1 → 639-2/B
|
||||
aa: "aar",
|
||||
ab: "abk",
|
||||
ae: "ave",
|
||||
af: "afr",
|
||||
ak: "aka",
|
||||
am: "amh",
|
||||
an: "arg",
|
||||
ar: "ara",
|
||||
as: "asm",
|
||||
av: "ava",
|
||||
ay: "aym",
|
||||
az: "aze",
|
||||
ba: "bak",
|
||||
be: "bel",
|
||||
bg: "bul",
|
||||
bh: "bih",
|
||||
bi: "bis",
|
||||
bm: "bam",
|
||||
bn: "ben",
|
||||
bo: "tib",
|
||||
br: "bre",
|
||||
bs: "bos",
|
||||
ca: "cat",
|
||||
ce: "che",
|
||||
ch: "cha",
|
||||
co: "cos",
|
||||
cr: "cre",
|
||||
cs: "cze",
|
||||
cu: "chu",
|
||||
cv: "chv",
|
||||
cy: "wel",
|
||||
da: "dan",
|
||||
de: "ger",
|
||||
dv: "div",
|
||||
dz: "dzo",
|
||||
ee: "ewe",
|
||||
el: "gre",
|
||||
en: "eng",
|
||||
eo: "epo",
|
||||
es: "spa",
|
||||
et: "est",
|
||||
eu: "baq",
|
||||
fa: "per",
|
||||
ff: "ful",
|
||||
fi: "fin",
|
||||
fj: "fij",
|
||||
fo: "fao",
|
||||
fr: "fre",
|
||||
fy: "fry",
|
||||
ga: "gle",
|
||||
gd: "gla",
|
||||
gl: "glg",
|
||||
gn: "grn",
|
||||
gu: "guj",
|
||||
gv: "glv",
|
||||
ha: "hau",
|
||||
he: "heb",
|
||||
hi: "hin",
|
||||
ho: "hmo",
|
||||
hr: "hrv",
|
||||
ht: "hat",
|
||||
hu: "hun",
|
||||
hy: "arm",
|
||||
hz: "her",
|
||||
ia: "ina",
|
||||
id: "ind",
|
||||
ie: "ile",
|
||||
ig: "ibo",
|
||||
ii: "iii",
|
||||
ik: "ipk",
|
||||
io: "ido",
|
||||
is: "ice",
|
||||
it: "ita",
|
||||
iu: "iku",
|
||||
ja: "jpn",
|
||||
jv: "jav",
|
||||
ka: "geo",
|
||||
kg: "kon",
|
||||
ki: "kik",
|
||||
kj: "kua",
|
||||
kk: "kaz",
|
||||
kl: "kal",
|
||||
km: "khm",
|
||||
kn: "kan",
|
||||
ko: "kor",
|
||||
kr: "kau",
|
||||
ks: "kas",
|
||||
ku: "kur",
|
||||
kv: "kom",
|
||||
kw: "cor",
|
||||
ky: "kir",
|
||||
la: "lat",
|
||||
lb: "ltz",
|
||||
lg: "lug",
|
||||
li: "lim",
|
||||
ln: "lin",
|
||||
lo: "lao",
|
||||
lt: "lit",
|
||||
lu: "lub",
|
||||
lv: "lav",
|
||||
mg: "mlg",
|
||||
mh: "mah",
|
||||
mi: "mao",
|
||||
mk: "mac",
|
||||
ml: "mal",
|
||||
mn: "mon",
|
||||
mr: "mar",
|
||||
ms: "may",
|
||||
mt: "mlt",
|
||||
my: "bur",
|
||||
na: "nau",
|
||||
nb: "nob",
|
||||
nd: "nde",
|
||||
ne: "nep",
|
||||
ng: "ndo",
|
||||
nl: "dut",
|
||||
nn: "nno",
|
||||
no: "nor",
|
||||
nr: "nbl",
|
||||
nv: "nav",
|
||||
ny: "nya",
|
||||
oc: "oci",
|
||||
oj: "oji",
|
||||
om: "orm",
|
||||
or: "ori",
|
||||
os: "oss",
|
||||
pa: "pan",
|
||||
pi: "pli",
|
||||
pl: "pol",
|
||||
ps: "pus",
|
||||
pt: "por",
|
||||
qu: "que",
|
||||
rm: "roh",
|
||||
rn: "run",
|
||||
ro: "rum",
|
||||
ru: "rus",
|
||||
rw: "kin",
|
||||
sa: "san",
|
||||
sc: "srd",
|
||||
sd: "snd",
|
||||
se: "sme",
|
||||
sg: "sag",
|
||||
si: "sin",
|
||||
sk: "slo",
|
||||
sl: "slv",
|
||||
sm: "smo",
|
||||
sn: "sna",
|
||||
so: "som",
|
||||
sq: "alb",
|
||||
sr: "srp",
|
||||
ss: "ssw",
|
||||
st: "sot",
|
||||
su: "sun",
|
||||
sv: "swe",
|
||||
sw: "swa",
|
||||
ta: "tam",
|
||||
te: "tel",
|
||||
tg: "tgk",
|
||||
th: "tha",
|
||||
ti: "tir",
|
||||
tk: "tuk",
|
||||
tl: "tgl",
|
||||
tn: "tsn",
|
||||
to: "ton",
|
||||
tr: "tur",
|
||||
ts: "tso",
|
||||
tt: "tat",
|
||||
tw: "twi",
|
||||
ty: "tah",
|
||||
ug: "uig",
|
||||
uk: "ukr",
|
||||
ur: "urd",
|
||||
uz: "uzb",
|
||||
ve: "ven",
|
||||
vi: "vie",
|
||||
vo: "vol",
|
||||
wa: "wln",
|
||||
wo: "wol",
|
||||
xh: "xho",
|
||||
yi: "yid",
|
||||
yo: "yor",
|
||||
za: "zha",
|
||||
zh: "chi",
|
||||
zu: "zul",
|
||||
// ISO 639-2/T → /B (the 20 languages with two distinct 3-letter codes)
|
||||
bod: "tib",
|
||||
ces: "cze",
|
||||
cym: "wel",
|
||||
deu: "ger",
|
||||
ell: "gre",
|
||||
eus: "baq",
|
||||
fas: "per",
|
||||
fra: "fre",
|
||||
hye: "arm",
|
||||
isl: "ice",
|
||||
kat: "geo",
|
||||
mkd: "mac",
|
||||
mri: "mao",
|
||||
msa: "may",
|
||||
mya: "bur",
|
||||
nld: "dut",
|
||||
ron: "rum",
|
||||
slk: "slo",
|
||||
sqi: "alb",
|
||||
zho: "chi",
|
||||
};
|
||||
|
||||
/** Canonicalize a language tag: lowercase, primary subtag ("en-US" → "en"), 639-2/B. */
|
||||
const canonicalLang = (raw: string): string => {
|
||||
const primary = raw.trim().toLowerCase().split("-")[0];
|
||||
return LANG_CANONICAL[primary] ?? primary;
|
||||
};
|
||||
|
||||
/** Language-tag comparison across ISO 639-1 / 639-2 B/T / IETF variants. */
|
||||
const langEq = (a?: string | null, b?: string | null): boolean =>
|
||||
!!a && !!b && canonicalLang(a) === canonicalLang(b);
|
||||
|
||||
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
|
||||
const embeddedIdentityMatches = (
|
||||
track: PlayerSubtitleTrack,
|
||||
stream: MediaStream,
|
||||
): boolean => {
|
||||
if (langEq(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;
|
||||
// During transcoding, image-based subs are burned in, not in MPV
|
||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||
return false;
|
||||
}
|
||||
// No language on one side — fall back to a title match.
|
||||
if (!track.language || !stream.Language) return eq(track.title, stream.Title);
|
||||
return false;
|
||||
|
||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
||||
return (
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Calculate the MPV track ID for a given Jellyfin subtitle 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).
|
||||
* MPV track IDs are 1-based, but MPV's track list is NOT in MediaStreams order:
|
||||
* 1. Embedded/HLS subs are enumerated from the container (or HLS playlist)
|
||||
* first, in MediaStreams order.
|
||||
* 2. External subs are appended via `sub-add` AFTER the file loads, in the
|
||||
* order they are passed to MPV (here, also MediaStreams order — see
|
||||
* direct-player.tsx where the externalSubtitles array is built by
|
||||
* filtering MediaStreams).
|
||||
*
|
||||
* 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).
|
||||
* Iterating in pure MediaStreams order produces the wrong MPV ID whenever an
|
||||
* External sub is listed before an Embed sub in MediaStreams (common when
|
||||
* Jellyfin prepends a converted SRT/VTT ahead of an original PGS/ASS track),
|
||||
* causing e.g. English to select Spanish. We therefore count in two phases
|
||||
* that mirror MPV's actual ordering.
|
||||
*
|
||||
* 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.
|
||||
* Image-based subs (PGS/VOBSUB) during transcoding are burned into the video
|
||||
* and absent from MPV's track list; they are skipped in both phases.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
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 ?? [];
|
||||
|
||||
export const getMpvSubtitleId = (
|
||||
mediaSource: MediaSourceInfo | null | undefined,
|
||||
jellyfinSubtitleIndex: number | undefined,
|
||||
isTranscoding: boolean,
|
||||
): number | undefined => {
|
||||
// -1 or undefined means disabled
|
||||
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||
return { kind: "disable" };
|
||||
return -1;
|
||||
}
|
||||
|
||||
const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||
if (!target) return { kind: "notFound" };
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
|
||||
// Server-burned subs are pixels, not tracks — signal the caller to refresh
|
||||
// the stream instead of hunting for a track that cannot exist.
|
||||
if (isBurnedInSubtitle(target)) return { kind: "burnedIn" };
|
||||
// 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);
|
||||
|
||||
// 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 };
|
||||
|
||||
// 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" };
|
||||
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
||||
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Embedded / in-container subtitle. Burned-in (Encode) streams are excluded:
|
||||
// they are baked into the video and never appear in the player's track list,
|
||||
// so counting them would shift every ordinal below.
|
||||
const embeddedStreams = subtitleStreams.filter(
|
||||
(s) => !isExternalSubtitle(s) && !isBurnedInSubtitle(s),
|
||||
);
|
||||
const playerEmbedded = playerTracks.filter((t) => t.external !== true);
|
||||
const isExternal = (sub: MediaStream) =>
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
|
||||
// 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 };
|
||||
}
|
||||
let mpvIndex = 0;
|
||||
|
||||
// 2) Multiple same-identity tracks: ordinal within the same-identity GROUP —
|
||||
// the k-th matching stream corresponds to the k-th matching player track
|
||||
// (container order is preserved on both sides, filter preserves order).
|
||||
// The group ordinal, not the global one: with [jpn, eng, eng] the first
|
||||
// eng is global position 1 but group position 0.
|
||||
if (identityMatches.length > 1) {
|
||||
const groupStreams = embeddedStreams.filter((s) =>
|
||||
identityMatches.some((t) => embeddedIdentityMatches(t, s)),
|
||||
);
|
||||
const groupOrdinal = groupStreams.findIndex(
|
||||
(s) => s.Index === jellyfinSubtitleIndex,
|
||||
);
|
||||
if (groupOrdinal >= 0) {
|
||||
const idx = Math.min(groupOrdinal, identityMatches.length - 1);
|
||||
return { kind: "select", trackId: identityMatches[idx].id };
|
||||
// Phase 1: embedded / HLS subs — these occupy MPV track IDs first because
|
||||
// they come from the container or HLS playlist.
|
||||
for (const sub of allSubs) {
|
||||
if (isExternal(sub)) continue;
|
||||
if (!isSubtitleInMpv(sub, isTranscoding)) continue;
|
||||
mpvIndex++;
|
||||
if (sub.Index === jellyfinSubtitleIndex) {
|
||||
return mpvIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Fallback: embedded order is container order on both sides → ordinal.
|
||||
const ordinal = embeddedStreams.findIndex(
|
||||
(s) => s.Index === jellyfinSubtitleIndex,
|
||||
);
|
||||
if (ordinal >= 0 && ordinal < playerEmbedded.length) {
|
||||
return { kind: "select", trackId: playerEmbedded[ordinal].id };
|
||||
// Phase 2: external subs — appended via `sub-add` after the file loads,
|
||||
// so they come last in MPV's track list.
|
||||
for (const sub of allSubs) {
|
||||
if (!isExternal(sub)) continue;
|
||||
if (!isSubtitleInMpv(sub, isTranscoding)) continue;
|
||||
mpvIndex++;
|
||||
if (sub.Index === jellyfinSubtitleIndex) {
|
||||
return mpvIndex;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// Short-circuit the outcomes that don't need the player's track list, so
|
||||
// the common subtitles-off case skips a full native enumeration.
|
||||
if (params.jellyfinSubtitleIndex === -1) {
|
||||
await player.disableSubtitles();
|
||||
return { kind: "disable" };
|
||||
}
|
||||
const burnTarget = params.subtitleStreams?.find(
|
||||
(s) => s.Index === params.jellyfinSubtitleIndex,
|
||||
);
|
||||
if (burnTarget && isBurnedInSubtitle(burnTarget)) {
|
||||
return { kind: "burnedIn" };
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
|
||||
export type PlatformType = "ios" | "android";
|
||||
export type PlayerType = "mpv";
|
||||
export type PlayerType = "mpv" | "exoplayer";
|
||||
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
||||
|
||||
export interface ProfileOptions {
|
||||
@@ -63,6 +63,26 @@ const getAudioCodecProfile = (platform: PlatformType) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the MaxAudioChannels string for a given audio transcoding mode.
|
||||
* Used by both the MPV and ExoPlayer profile branches — the channel-cap
|
||||
* rule is player-agnostic (the player decodes; the cap just tells the
|
||||
* server when to transcode down).
|
||||
*/
|
||||
const maxChannelsForMode = (audioMode: AudioTranscodeModeType): string => {
|
||||
switch (audioMode) {
|
||||
case "stereo":
|
||||
return "2";
|
||||
case "5.1":
|
||||
return "6";
|
||||
case "passthrough":
|
||||
return "8";
|
||||
default:
|
||||
// Auto: default to 5.1 (6 channels)
|
||||
return "6";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the video audio codec configuration based on platform and audio mode.
|
||||
*
|
||||
@@ -89,35 +109,59 @@ const getVideoAudioCodecs = (
|
||||
// MPV can decode all codecs - only channel count varies by mode
|
||||
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
||||
|
||||
switch (audioMode) {
|
||||
case "stereo":
|
||||
// Limit to 2 channels - MPV will decode and downmix
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "2",
|
||||
};
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: maxChannelsForMode(audioMode),
|
||||
};
|
||||
};
|
||||
|
||||
case "5.1":
|
||||
// Limit to 6 channels
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
/**
|
||||
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV.
|
||||
*
|
||||
* Codec set aligned with Media3's documented supported-formats list:
|
||||
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
|
||||
* - Audio: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS,
|
||||
* DTS-HD, TrueHD
|
||||
*
|
||||
* Hardware decode (MediaCodec) handles whatever the device ships with;
|
||||
* the rest fall through to FFmpeg software decode via the Jellyfin-published
|
||||
* `org.jellyfin.media3:media3-ffmpeg-decoder` extension wired up with
|
||||
* `DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER` (see
|
||||
* ExoPlayerView.kt:ensurePlayer).
|
||||
*
|
||||
* Cross-checked against the reference-device probe in
|
||||
* docs/research/hdr-dv-atmos-tv-plan.md (Amlogic Android 14 TV; HDMI sink
|
||||
* accepts AC3/EAC3 as bitstream and multichannel PCM up to 7.1 @ 192 kHz,
|
||||
* so software-decoded DTS/DTS-HD/TrueHD reach the sink as PCM).
|
||||
*
|
||||
* Dolby Vision: the CodecProfile below uses `NotEquals VideoRangeType
|
||||
* DOVI`, which in Jellyfin's semantics blocks ONLY pure Profile 5
|
||||
* (IPTPQc2 — the stream that renders purple/green without a DV-aware
|
||||
* decoder). DV Profiles 7/8 with HDR10 or SDR base layers (Jellyfin
|
||||
* reports these as `DOVIWithHDR10`, `DOVIWithHDR10Plus`, `DOVIWithEL`)
|
||||
* are NOT blocked — Media3 1.9.1+ correctly falls back to the AVC/HEVC
|
||||
* base layer.
|
||||
*
|
||||
* Containers limited to Media3's bundled extractors. FLV is intentionally
|
||||
* absent — Media3 has no FLV extractor (MPV claims it via FFmpeg).
|
||||
*/
|
||||
const getExoPlayerDirectPlayProfile = () => {
|
||||
const audioCodecs =
|
||||
"vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd";
|
||||
|
||||
case "passthrough":
|
||||
// Allow up to 8 channels - for external DAC/receiver setups
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "8",
|
||||
};
|
||||
|
||||
default:
|
||||
// Auto mode: default to 5.1 (6 channels)
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
}
|
||||
return {
|
||||
video: {
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,webm,ts,mpegts,mov",
|
||||
VideoCodec: "h263,h264,hevc,vp8,vp9,av1",
|
||||
AudioCodec: audioCodecs,
|
||||
},
|
||||
audio: {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
||||
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -126,6 +170,63 @@ const getVideoAudioCodecs = (
|
||||
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
const audioMode = options.audioMode || "auto";
|
||||
const player = options.player || "mpv";
|
||||
|
||||
// ExoPlayer branch — Media3 capabilities on Android TV.
|
||||
if (player === "exoplayer" && platform === "android") {
|
||||
const exoDirect = getExoPlayerDirectPlayProfile();
|
||||
|
||||
return {
|
||||
Name: "1. ExoPlayer",
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h263,h264,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "hevc,h265",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
Property: "VideoRangeType",
|
||||
// Blocks ONLY pure DV Profile 5 (IPTPQc2). Profiles 7/8 with
|
||||
// HDR10/SDR base layers fall through to Media3's HEVC fallback
|
||||
// (1.9.1+). See getExoPlayerDirectPlayProfile doc above.
|
||||
Value: "DOVI",
|
||||
IsRequired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [exoDirect.video, exoDirect.audio],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264,hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
MaxAudioChannels: maxChannelsForMode(audioMode),
|
||||
},
|
||||
],
|
||||
// Text-only subtitles for direct play. PGS delivered as Encode
|
||||
// (burn-in) because Media3's PGS support is inconsistent.
|
||||
SubtitleProfiles: [
|
||||
{ Format: "srt", Method: "External" },
|
||||
{ Format: "vtt", Method: "External" },
|
||||
{ Format: "ttml", Method: "External" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
],
|
||||
} satisfies DeviceProfile;
|
||||
}
|
||||
|
||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||
platform,
|
||||
@@ -198,6 +299,3 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
// Default export for backward compatibility
|
||||
export default generateDeviceProfile();
|
||||
|
||||
@@ -41,7 +41,7 @@ export const formatTimeString = (
|
||||
t: number | null | undefined,
|
||||
unit: "s" | "ms" | "tick" = "ms",
|
||||
): string => {
|
||||
if (t === null || t === undefined) return "0:00";
|
||||
if (t === null || t === undefined || !Number.isFinite(t)) return "0:00";
|
||||
|
||||
let seconds: number;
|
||||
switch (unit) {
|
||||
|
||||
Reference in New Issue
Block a user