mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-02 02:22:51 +01:00
Compare commits
13 Commits
feat/andro
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fcb303c6 | ||
|
|
90e9084949 | ||
|
|
115c163aeb | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec |
@@ -5,7 +5,7 @@ import { Image } from "expo-image";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
@@ -33,16 +33,13 @@ import {
|
|||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
AudioTranscodeMode,
|
AudioTranscodeMode,
|
||||||
getActiveVideoPlayer,
|
|
||||||
InactivityTimeout,
|
InactivityTimeout,
|
||||||
type MpvCacheMode,
|
type MpvCacheMode,
|
||||||
type MpvVoDriver,
|
type MpvVoDriver,
|
||||||
TVTypographyScale,
|
TVTypographyScale,
|
||||||
useSettings,
|
useSettings,
|
||||||
VideoPlayer,
|
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
|
||||||
import {
|
import {
|
||||||
getPreviousServers,
|
getPreviousServers,
|
||||||
type SavedServer,
|
type SavedServer,
|
||||||
@@ -265,25 +262,6 @@ export default function SettingsTV() {
|
|||||||
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
||||||
const currentLanguage = settings.preferedLanguage;
|
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
|
// Audio transcoding options
|
||||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -413,23 +391,6 @@ export default function SettingsTV() {
|
|||||||
[t, currentVoDriver],
|
[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
|
// Typography scale options
|
||||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -561,11 +522,6 @@ export default function SettingsTV() {
|
|||||||
return option?.label || t("home.settings.vo_driver.gpu_next");
|
return option?.label || t("home.settings.vo_driver.gpu_next");
|
||||||
}, [voDriverOptions, t]);
|
}, [voDriverOptions, t]);
|
||||||
|
|
||||||
const videoPlayerLabel = useMemo(() => {
|
|
||||||
const option = videoPlayerOptions.find((o) => o.selected);
|
|
||||||
return option?.label || "MPV";
|
|
||||||
}, [videoPlayerOptions]);
|
|
||||||
|
|
||||||
const languageLabel = useMemo(() => {
|
const languageLabel = useMemo(() => {
|
||||||
if (!currentLanguage) return t("home.settings.languages.system");
|
if (!currentLanguage) return t("home.settings.languages.system");
|
||||||
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||||
@@ -630,34 +586,6 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
{/* Audio Section */}
|
{/* Audio Section */}
|
||||||
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
<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
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.audio.transcode_mode.title")}
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
value={audioTranscodeLabel}
|
value={audioTranscodeLabel}
|
||||||
@@ -734,23 +662,20 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isMpv && (
|
<TVSettingsOptionButton
|
||||||
<TVSettingsOptionButton
|
label='Horizontal Alignment'
|
||||||
label='Horizontal Alignment'
|
value={alignXLabel}
|
||||||
value={alignXLabel}
|
onPress={() =>
|
||||||
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
showOptions({
|
||||||
onPress={() =>
|
title: "Horizontal Alignment",
|
||||||
showOptions({
|
options: alignXOptions,
|
||||||
title: "Horizontal Alignment",
|
onSelect: (value) =>
|
||||||
options: alignXOptions,
|
updateSettings({
|
||||||
onSelect: (value) =>
|
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||||
updateSettings({
|
}),
|
||||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
})
|
||||||
}),
|
}
|
||||||
})
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Vertical Alignment'
|
label='Vertical Alignment'
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
@@ -823,24 +748,19 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
|
{/* Video Output Section */}
|
||||||
{isMpv && (
|
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||||
<>
|
<TVSettingsOptionButton
|
||||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
label={t("home.settings.vo_driver.vo_mode")}
|
||||||
<TVSettingsOptionButton
|
value={voDriverLabel}
|
||||||
label={t("home.settings.vo_driver.vo_mode")}
|
onPress={() =>
|
||||||
value={voDriverLabel}
|
showOptions({
|
||||||
onPress={() =>
|
title: t("home.settings.vo_driver.vo_mode"),
|
||||||
showOptions({
|
options: voDriverOptions,
|
||||||
title: t("home.settings.vo_driver.vo_mode"),
|
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
||||||
options: voDriverOptions,
|
})
|
||||||
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
}
|
||||||
})
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.buffer.buffer_duration")}
|
label={t("home.settings.buffer.buffer_duration")}
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
@@ -41,6 +40,7 @@ import {
|
|||||||
type MpvOnErrorEventPayload,
|
type MpvOnErrorEventPayload,
|
||||||
type MpvOnPlaybackStateChangePayload,
|
type MpvOnPlaybackStateChangePayload,
|
||||||
type MpvOnProgressEventPayload,
|
type MpvOnProgressEventPayload,
|
||||||
|
MpvPlayerView,
|
||||||
type MpvPlayerViewRef,
|
type MpvPlayerViewRef,
|
||||||
type MpvVideoSource,
|
type MpvVideoSource,
|
||||||
} from "@/modules";
|
} from "@/modules";
|
||||||
@@ -51,13 +51,13 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
|
applyMpvSubtitleSelection,
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
getMpvSubtitleId,
|
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
@@ -364,13 +364,7 @@ export default function DirectPlayerPage() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
// Match the device profile to the player that will render the
|
deviceProfile: generateDeviceProfile(),
|
||||||
// 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;
|
if (!res) return null;
|
||||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||||
@@ -645,12 +639,9 @@ export default function DirectPlayerPage() {
|
|||||||
).map((s) => s.DeliveryUrl!);
|
).map((s) => s.DeliveryUrl!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate track IDs for initial selection
|
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||||
const initialSubtitleId = getMpvSubtitleId(
|
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||||
mediaSource,
|
// list is known — see applySubtitleSelection / onTracksReady.
|
||||||
subtitleIndex,
|
|
||||||
isTranscoding,
|
|
||||||
);
|
|
||||||
const initialAudioId = getMpvAudioId(
|
const initialAudioId = getMpvAudioId(
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
@@ -668,7 +659,6 @@ export default function DirectPlayerPage() {
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
@@ -716,7 +706,6 @@ export default function DirectPlayerPage() {
|
|||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
api?.accessToken,
|
api?.accessToken,
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
settings.mpvCacheEnabled,
|
settings.mpvCacheEnabled,
|
||||||
@@ -914,30 +903,41 @@ export default function DirectPlayerPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
|
/**
|
||||||
|
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
||||||
|
* it. Identity-based (external by filename, embedded by language/title) so it
|
||||||
|
* stays correct across external/embedded reordering and server-hidden embedded
|
||||||
|
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
||||||
|
* fired again after each external sub-add) and runtime changes.
|
||||||
|
*/
|
||||||
|
const applySubtitleSelection = useCallback(
|
||||||
|
async (jellyfinSubtitleIndex: number) => {
|
||||||
|
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
|
await applyMpvSubtitleSelection(videoRef.current, {
|
||||||
|
subtitleStreams,
|
||||||
|
jellyfinSubtitleIndex,
|
||||||
|
// The exact URL each external sub was loaded into MPV with — mirrors the
|
||||||
|
// externalSubtitles array built in videoSource (online: basePath +
|
||||||
|
// DeliveryUrl, offline: local DeliveryUrl).
|
||||||
|
getExpectedExternalUrl: (s) => {
|
||||||
|
if (!s.DeliveryUrl) return undefined;
|
||||||
|
if (offline) return s.DeliveryUrl;
|
||||||
|
return api?.basePath ? `${api.basePath}${s.DeliveryUrl}` : undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[stream?.mediaSource, offline, api?.basePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV/mobile subtitle track change handler
|
||||||
const handleSubtitleIndexChange = useCallback(
|
const handleSubtitleIndexChange = useCallback(
|
||||||
async (index: number) => {
|
async (index: number) => {
|
||||||
setCurrentSubtitleIndex(index);
|
setCurrentSubtitleIndex(index);
|
||||||
|
await applySubtitleSelection(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[stream?.mediaSource],
|
[applySubtitleSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
@@ -1283,7 +1283,7 @@ export default function DirectPlayerPage() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoPlayerView
|
<MpvPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
@@ -1302,6 +1302,10 @@ export default function DirectPlayerPage() {
|
|||||||
}}
|
}}
|
||||||
onTracksReady={() => {
|
onTracksReady={() => {
|
||||||
setTracksReady(true);
|
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 && (
|
{!hasPlaybackStarted && (
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
@@ -232,12 +233,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return streams ?? [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
||||||
|
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Subtitle",
|
(s) => s.Type === "Subtitle",
|
||||||
);
|
);
|
||||||
return streams ?? [];
|
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Store handleSubtitleChange in a ref for stable callback reference
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)
|
)
|
||||||
: freshItem.MediaSources?.[0];
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
// Get subtitle streams from the fresh data
|
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||||
const streams =
|
// (embedded first, externals last) — same as the initial list.
|
||||||
mediaSource?.MediaStreams?.filter(
|
const streams = [
|
||||||
|
...(mediaSource?.MediaStreams?.filter(
|
||||||
(s: MediaStream) => s.Type === "Subtitle",
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
) ?? [];
|
) ?? []),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
const tracks: Track[] = streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { BITRATES } from "./BitRateSheet";
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
(x) => x.Type === "Subtitle",
|
[
|
||||||
) || [],
|
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || []),
|
||||||
|
].sort(compareTracksForMenu),
|
||||||
[selectedOptions.mediaSource],
|
[selectedOptions.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
return subs ? [...subs].sort(compareTracksForMenu) : subs;
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
|
|||||||
@@ -44,10 +44,8 @@ export interface TVNextEpisodeCountdownProps {
|
|||||||
playButtonRef?: RNView | null;
|
playButtonRef?: RNView | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants — kept in sync with TVSkipSegmentCard (the two are
|
// Position constants
|
||||||
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
|
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
||||||
// rationale (220 sits just above the controls bar; 300 floated too high).
|
|
||||||
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
|
||||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||||
|
|||||||
@@ -33,15 +33,9 @@ export interface TVSkipSegmentCardProps {
|
|||||||
playButtonRef?: View | null;
|
playButtonRef?: View | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
|
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
||||||
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
|
const BOTTOM_WITH_CONTROLS = 300;
|
||||||
// card that floats far above the controls.
|
const BOTTOM_WITHOUT_CONTROLS = 120;
|
||||||
//
|
|
||||||
// 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> = ({
|
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
||||||
show,
|
show,
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
export const VideoPlayerView = React.forwardRef<
|
|
||||||
MpvPlayerViewRef,
|
|
||||||
MpvPlayerViewProps
|
|
||||||
>(function VideoPlayerView(props, ref) {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// ExoPlayer's native module only ships for Android TV. Even if a user
|
|
||||||
// somehow ends up with `videoPlayer: ExoPlayer` set on another platform
|
|
||||||
// (shouldn't happen — the selector is hidden outside Android TV — but
|
|
||||||
// MMKV-persisted settings can roam), fall back to MPV rather than
|
|
||||||
// crash on requireNativeView().
|
|
||||||
const isExoSupported = Platform.OS === "android" && Platform.isTV;
|
|
||||||
const useExo =
|
|
||||||
isExoSupported && getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
|
|
||||||
|
|
||||||
const Player = useExo ? ExoPlayerView : MpvPlayerView;
|
|
||||||
return <Player ref={ref} {...props} />;
|
|
||||||
});
|
|
||||||
@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useVideoContext } from "./contexts/VideoContext";
|
import { useVideoContext } from "./contexts/VideoContext";
|
||||||
@@ -317,8 +318,10 @@ export const Controls: FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||||
// Skip streams without a real index: `?? -1` would alias them to the
|
// Skip streams without a real index: `?? -1` would alias them to the
|
||||||
// "disable subtitles" sentinel and mis-route selection.
|
// "disable subtitles" sentinel and mis-route selection. Order like
|
||||||
return streams
|
// jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
return [...streams]
|
||||||
|
.sort(compareTracksForMenu)
|
||||||
.filter((stream) => typeof stream.Index === "number")
|
.filter((stream) => typeof stream.Index === "number")
|
||||||
.map((stream) => {
|
.map((stream) => {
|
||||||
const index = stream.Index as number;
|
const index = stream.Index as number;
|
||||||
@@ -1129,16 +1132,7 @@ export const Controls: FC<Props> = ({
|
|||||||
{/* Skip intro card */}
|
{/* Skip intro card */}
|
||||||
<TVSkipSegmentCard
|
<TVSkipSegmentCard
|
||||||
show={showSkipButton && !isCountdownActive}
|
show={showSkipButton && !isCountdownActive}
|
||||||
onPress={() => {
|
onPress={skipIntro}
|
||||||
// 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'
|
type='intro'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
@@ -1153,11 +1147,7 @@ export const Controls: FC<Props> = ({
|
|||||||
(hasContentAfterCredits || !nextItem) &&
|
(hasContentAfterCredits || !nextItem) &&
|
||||||
!isCountdownActive
|
!isCountdownActive
|
||||||
}
|
}
|
||||||
onPress={() => {
|
onPress={skipCredit}
|
||||||
// See the intro card above for the focus-handoff rationale.
|
|
||||||
if (showControls) setFocusPlayButton(true);
|
|
||||||
skipCredit();
|
|
||||||
}}
|
|
||||||
type='credits'
|
type='credits'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
type SubtitleSearchResult,
|
type SubtitleSearchResult,
|
||||||
useRemoteSubtitles,
|
useRemoteSubtitles,
|
||||||
} from "@/hooks/useRemoteSubtitles";
|
} from "@/hooks/useRemoteSubtitles";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||||
|
|
||||||
interface TVSubtitleSheetProps {
|
interface TVSubtitleSheetProps {
|
||||||
@@ -96,13 +97,19 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||||
|
|
||||||
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
const sortedTracks = useMemo(
|
||||||
|
() => [...subtitleTracks].sort(compareTracksForMenu),
|
||||||
|
[subtitleTracks],
|
||||||
|
);
|
||||||
|
|
||||||
const initialSelectedTrackIndex = useMemo(() => {
|
const initialSelectedTrackIndex = useMemo(() => {
|
||||||
if (currentSubtitleIndex === -1) return 0;
|
if (currentSubtitleIndex === -1) return 0;
|
||||||
const trackIdx = subtitleTracks.findIndex(
|
const trackIdx = sortedTracks.findIndex(
|
||||||
(t) => t.Index === currentSubtitleIndex,
|
(t) => t.Index === currentSubtitleIndex,
|
||||||
);
|
);
|
||||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||||
}, [subtitleTracks, currentSubtitleIndex]);
|
}, [sortedTracks, currentSubtitleIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -215,7 +222,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
value: -1,
|
value: -1,
|
||||||
selected: currentSubtitleIndex === -1,
|
selected: currentSubtitleIndex === -1,
|
||||||
};
|
};
|
||||||
const options = subtitleTracks.map((track) => ({
|
const options = sortedTracks.map((track) => ({
|
||||||
label:
|
label:
|
||||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||||
sublabel: track.Codec?.toUpperCase(),
|
sublabel: track.Codec?.toUpperCase(),
|
||||||
@@ -223,7 +230,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
selected: track.Index === currentSubtitleIndex,
|
selected: track.Index === currentSubtitleIndex,
|
||||||
}));
|
}));
|
||||||
return [noneOption, ...options];
|
return [noneOption, ...options];
|
||||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
}, [sortedTracks, currentSubtitleIndex, t]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -213,10 +213,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
container: mediaSource.Container,
|
||||||
videoRange: videoStream?.VideoRangeType,
|
videoRange: videoStream?.VideoRangeType,
|
||||||
bitDepth: videoStream?.BitDepth,
|
bitDepth: videoStream?.BitDepth,
|
||||||
audioChannels: audioStream?.Channels,
|
audioChannels: audioStream?.Channels,
|
||||||
|
audioCodecFromSource: audioStream?.Codec,
|
||||||
subtitleCodec: subtitleStream?.Codec,
|
subtitleCodec: subtitleStream?.Codec,
|
||||||
|
subtitleTitle: subtitleStream?.DisplayTitle,
|
||||||
};
|
};
|
||||||
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
||||||
|
|
||||||
@@ -302,13 +305,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{info.videoWidth}x{info.videoHeight}
|
{info.videoWidth}x{info.videoHeight}
|
||||||
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
||||||
{/* Prefer the player-reported HDR format (authoritative —
|
{formatVideoRange(streamInfo?.videoRange)
|
||||||
what's actually being decoded) over Jellyfin metadata. */}
|
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
||||||
{info?.hdrFormat
|
: ""}
|
||||||
? ` ${info.hdrFormat}`
|
|
||||||
: formatVideoRange(streamInfo?.videoRange)
|
|
||||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
|
||||||
: ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
@@ -320,15 +319,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Audio: {formatCodec(info.audioCodec)}
|
Audio: {formatCodec(info.audioCodec)}
|
||||||
{/* Prefer player-reported channel count; fall back to
|
{streamInfo?.audioChannels
|
||||||
Jellyfin metadata for MPV which doesn't populate it. */}
|
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||||
{(info.audioChannels ?? streamInfo?.audioChannels)
|
|
||||||
? ` ${formatAudioChannels(
|
|
||||||
info.audioChannels ?? streamInfo!.audioChannels!,
|
|
||||||
)}`
|
|
||||||
: ""}
|
|
||||||
{info.audioSampleRate
|
|
||||||
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
|
||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -347,17 +339,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
: "N/A"}
|
: "N/A"}
|
||||||
</Text>
|
</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 && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
@@ -375,12 +356,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.decoderName && (
|
|
||||||
<Text style={textStyle}>
|
|
||||||
Decoder: {info.decoderName}
|
|
||||||
{info.decoderType ? ` (${info.decoderType})` : ""}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{info?.estimatedVfFps !== undefined && (
|
{info?.estimatedVfFps !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||||
|
|||||||
@@ -23,32 +23,29 @@
|
|||||||
* - Used to report playback state to Jellyfin server
|
* - Used to report playback state to Jellyfin server
|
||||||
* - Value of -1 means disabled/none
|
* - Value of -1 means disabled/none
|
||||||
*
|
*
|
||||||
* 2. MPV INDEX (track.mpvIndex)
|
* 2. PLAYER TRACK (selected by IDENTITY, not position)
|
||||||
* - MPV's internal track ID
|
* - Selection resolves the server Index against MPV's REAL track list via
|
||||||
* - MPV orders tracks as: [all embedded, then all external]
|
* applyMpvSubtitleSelection: externals matched by external-filename,
|
||||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
* embedded by language/title. `track.mpvIndex` is no longer used to select
|
||||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
* (kept -1) — positional mapping mis-selected when externals/embedded were
|
||||||
|
* reordered or the server hid embedded subs (#954 et al.).
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* SUBTITLE HANDLING
|
* SUBTITLE HANDLING
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*
|
*
|
||||||
* Embedded (DeliveryMethod.Embed):
|
* Embedded & External:
|
||||||
* - Already in MPV's track list
|
* - Selected via applyMpvSubtitleSelection (identity match against the live
|
||||||
* - Select via setSubtitleTrack(mpvId)
|
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
|
||||||
*
|
* embedded first, externals last, forced/default float up).
|
||||||
* External (DeliveryMethod.External):
|
|
||||||
* - Loaded into MPV on video start
|
|
||||||
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
|
||||||
*
|
*
|
||||||
* Image-based during transcoding:
|
* Image-based during transcoding:
|
||||||
* - Burned into video by Jellyfin, not in MPV
|
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
||||||
* - Requires replacePlayer() to change
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { File } from "expo-file-system";
|
import { File } from "expo-file-system";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -61,9 +58,14 @@ import {
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import type { MpvAudioTrack } from "@/modules";
|
import type { MpvAudioTrack } from "@/modules";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
import {
|
||||||
|
applyMpvSubtitleSelection,
|
||||||
|
compareTracksForMenu,
|
||||||
|
isImageBasedSubtitle,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||||
const playerControls = usePlayerControls();
|
const playerControls = usePlayerControls();
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||||
@@ -141,6 +144,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tracksReady) return;
|
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 () => {
|
const fetchTracks = async () => {
|
||||||
// Check if this is offline transcoded content
|
// Check if this is offline transcoded content
|
||||||
// For transcoded offline content, only ONE audio track exists in the file
|
// For transcoded offline content, only ONE audio track exists in the file
|
||||||
@@ -166,10 +182,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setAudioTracks(audio);
|
commitAudioTracks(audio);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: show no audio tracks if the stored track wasn't found
|
// Fallback: show no audio tracks if the stored track wasn't found
|
||||||
setAudioTracks([]);
|
commitAudioTracks([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For subtitles in transcoded offline content:
|
// For subtitles in transcoded offline content:
|
||||||
@@ -179,6 +195,24 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
downloadedItem.userData.subtitleStreamIndex;
|
downloadedItem.userData.subtitleStreamIndex;
|
||||||
const subs: Track[] = [];
|
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
|
// Add "Disable" option
|
||||||
subs.push({
|
subs.push({
|
||||||
name: "Disable",
|
name: "Disable",
|
||||||
@@ -190,123 +224,82 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// For text-based subs, they should still be available in the file
|
// Text subs are muxed into the transcoded file and switchable; resolve by
|
||||||
let subIdx = 1;
|
// identity against MPV's real track list (same as online). Order matches web.
|
||||||
for (const sub of allSubs) {
|
// Image subs aren't in the transcoded file (only the burned one was, handled
|
||||||
if (sub.IsTextSubtitleStream) {
|
// above), so skip them here.
|
||||||
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
|
if (!isImageBasedSubtitle(sub)) {
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: subIdx,
|
mpvIndex: -1,
|
||||||
setTrack: () => {
|
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) });
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
|
void applyMpvSubtitleSelection(playerControls, {
|
||||||
|
subtitleStreams: allSubs,
|
||||||
|
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||||
|
getExpectedExternalUrl: (s) => {
|
||||||
|
if (!s.DeliveryUrl) return undefined;
|
||||||
|
if (offline) return s.DeliveryUrl;
|
||||||
|
return api?.basePath
|
||||||
|
? `${api.basePath}${s.DeliveryUrl}`
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
commitSubtitleTracks(subs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MPV track handling
|
// MPV track handling
|
||||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||||
|
if (cancelled) return;
|
||||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
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[] = [];
|
const subs: Track[] = [];
|
||||||
|
|
||||||
// Process all Jellyfin subtitles
|
// Process all Jellyfin subtitles. Selection resolves against MPV's real
|
||||||
for (const sub of allSubs) {
|
// track list by identity (applyMpvSubtitleSelection) — never positional
|
||||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
// index math, which mis-selects across external/embedded reordering and
|
||||||
const isExternal =
|
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
// For image-based subs during transcoding, need to refresh player
|
// Image-based subs during transcoding are burned into the video by the
|
||||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
// server; both switching TO one and switching AWAY from a currently
|
||||||
subs.push({
|
// active one require a player refresh (re-transcode), not a track change.
|
||||||
name: sub.DisplayTitle || "Unknown",
|
const needsReplace =
|
||||||
index: sub.Index ?? -1,
|
isTranscoding &&
|
||||||
mpvIndex: -1,
|
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
|
||||||
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({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: mpvId,
|
mpvIndex: -1,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
// Transcoding + switching to/from image-based sub
|
if (needsReplace) {
|
||||||
if (
|
|
||||||
isTranscoding &&
|
|
||||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
|
||||||
) {
|
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
// Direct switch in player
|
void applyMpvSubtitleSelection(playerControls, {
|
||||||
if (mpvId !== -1) {
|
subtitleStreams: allSubs,
|
||||||
playerControls.setSubtitleTrack(mpvId);
|
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
// Mirror how external subs are loaded into MPV (online: basePath +
|
||||||
return;
|
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
|
||||||
}
|
// external-filename lines up.
|
||||||
|
getExpectedExternalUrl: (s) => {
|
||||||
// Fallback - refresh player
|
if (!s.DeliveryUrl) return undefined;
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
if (offline) return s.DeliveryUrl;
|
||||||
|
return api?.basePath
|
||||||
|
? `${api.basePath}${s.DeliveryUrl}`
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -374,12 +367,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
||||||
setAudioTracks(audio);
|
// at the front (unshifted), local downloaded subs at the end.
|
||||||
|
commitSubtitleTracks(subs);
|
||||||
|
commitAudioTracks(audio);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
|
||||||
|
// API is ready so online externals don't resolve with undefined.
|
||||||
|
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
|
||||||
|
// decision — rebuild when it flips so we refresh the stream when we should.
|
||||||
|
}, [
|
||||||
|
tracksReady,
|
||||||
|
mediaSource,
|
||||||
|
offline,
|
||||||
|
downloadedItem,
|
||||||
|
itemId,
|
||||||
|
api?.basePath,
|
||||||
|
isCurrentSubImageBased,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,905 +0,0 @@
|
|||||||
@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
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
p.setMediaSource(mediaSource)
|
|
||||||
p.prepare()
|
|
||||||
|
|
||||||
// Apply initial playback position
|
|
||||||
config.startPosition?.let { startPosSec ->
|
|
||||||
if (startPosSec > 0) {
|
|
||||||
p.seekTo((startPosSec * 1000).toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()) {
|
|
||||||
builder.setSubtitleConfigurations(subtitleConfigs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
subtitleTrackList = emptyList()
|
|
||||||
audioTrackList = emptyList()
|
|
||||||
currentSubtitleId = 0
|
|
||||||
currentAudioId = 0
|
|
||||||
videoDecoderName = null
|
|
||||||
audioDecoderName = null
|
|
||||||
cumulativeDroppedFrames = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekTo(positionSec: Double) {
|
|
||||||
player?.seekTo((positionSec * 1000).toLong())
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
// Media3 does not expose the current MediaItem's existing
|
|
||||||
// SubtitleConfigurations, so we cannot append a side-loaded
|
|
||||||
// subtitle to a running item without losing the originals.
|
|
||||||
// For TV, external subs are bundled at load time via
|
|
||||||
// VideoLoadConfig.externalSubtitles (see buildMediaItem). This
|
|
||||||
// method rebuilds the current MediaItem with just the new
|
|
||||||
// subtitle config — acceptable when no other external subs are
|
|
||||||
// in play, which is the typical TV case.
|
|
||||||
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()
|
|
||||||
|
|
||||||
val rebuilt = currentMediaItem.buildUpon()
|
|
||||||
.setSubtitleConfigurations(listOf(newSubConfig))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val wasPlaying = p.isPlaying
|
|
||||||
val pos = p.currentPosition
|
|
||||||
p.setMediaItem(rebuilt, pos)
|
|
||||||
p.prepare()
|
|
||||||
if (wasPlaying) p.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["android"],
|
|
||||||
"android": {
|
|
||||||
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// 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";
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { requireNativeView } from "expo";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useImperativeHandle, useRef } from "react";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
MpvPlayerViewProps,
|
|
||||||
MpvPlayerViewRef,
|
|
||||||
} from "../mpv-player/src/MpvPlayer.types";
|
|
||||||
|
|
||||||
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,8 +7,6 @@ export type {
|
|||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
// ExoPlayer (Android TV)
|
|
||||||
export { ExoPlayerView } from "./exoplayer-player";
|
|
||||||
// Glass Poster (tvOS 26+)
|
// Glass Poster (tvOS 26+)
|
||||||
export type { GlassPosterViewProps } from "./glass-poster";
|
export type { GlassPosterViewProps } from "./glass-poster";
|
||||||
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
||||||
|
|||||||
@@ -532,16 +532,27 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
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/lang")?.let { track["lang"] = it }
|
||||||
|
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
|
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||||
|
// (instead of fragile positional counting). `external` + `external-filename`
|
||||||
|
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
||||||
|
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
|
||||||
|
track["external"] = external
|
||||||
|
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
|
||||||
|
track["externalFilename"] = it
|
||||||
|
}
|
||||||
|
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
|
||||||
|
|
||||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -770,12 +770,32 @@ final class MPVLayerRenderer {
|
|||||||
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
||||||
track["lang"] = lang
|
track["lang"] = lang
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
|
||||||
|
track["codec"] = codec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||||
|
// (instead of fragile positional counting). `external` + `external-filename`
|
||||||
|
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
|
||||||
|
var external: Int32 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
|
||||||
|
track["external"] = external != 0
|
||||||
|
|
||||||
|
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
|
||||||
|
track["externalFilename"] = extFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
var ffIndex: Int64 = 0
|
||||||
|
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
|
||||||
|
track["ffIndex"] = Int(ffIndex)
|
||||||
|
}
|
||||||
|
|
||||||
var selected: Int32 = 0
|
var selected: Int32 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||||
track["selected"] = selected != 0
|
track["selected"] = selected != 0
|
||||||
|
|
||||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
|
||||||
tracks.append(track)
|
tracks.append(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ export type SubtitleTrack = {
|
|||||||
id: number;
|
id: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
lang?: 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;
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,28 +183,4 @@ export type TechnicalInfo = {
|
|||||||
hwdec?: string;
|
hwdec?: string;
|
||||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||||
estimatedVfFps?: number;
|
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 { createContext, useCallback, useContext, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { Bitrate } from "@/components/BitrateSelector";
|
import type { Bitrate } from "@/components/BitrateSelector";
|
||||||
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings";
|
import { settingsAtom } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { generateDeviceProfile } from "../utils/profiles/native";
|
import { generateDeviceProfile } from "../utils/profiles/native";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
@@ -78,11 +78,10 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Match the device profile to the actually-active player so the
|
// Generate device profile for MPV player
|
||||||
// server picks codecs/containers the player can decode.
|
|
||||||
const native = generateDeviceProfile({
|
const native = generateDeviceProfile({
|
||||||
platform: Platform.OS as "ios" | "android",
|
platform: Platform.OS as "ios" | "android",
|
||||||
player: getActivePlayerType(settings),
|
player: "mpv",
|
||||||
audioMode: settings.audioTranscodeMode,
|
audioMode: settings.audioTranscodeMode,
|
||||||
});
|
});
|
||||||
const data = await getStreamUrl({
|
const data = await getStreamUrl({
|
||||||
|
|||||||
@@ -199,13 +199,6 @@
|
|||||||
"rewind_length": "Rewind length",
|
"rewind_length": "Rewind length",
|
||||||
"seconds_unit": "s"
|
"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": {
|
"buffer": {
|
||||||
"title": "Buffer settings",
|
"title": "Buffer settings",
|
||||||
"cache_mode": "Cache mode",
|
"cache_mode": "Cache mode",
|
||||||
|
|||||||
@@ -171,38 +171,11 @@ export type HomeSectionLatestResolver = {
|
|||||||
includeItemTypes?: Array<BaseItemKind>;
|
includeItemTypes?: Array<BaseItemKind>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Video player enum. MPV is the universal default; ExoPlayer is an
|
// Video player enum - currently only MPV is supported
|
||||||
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
|
|
||||||
export enum VideoPlayer {
|
export enum VideoPlayer {
|
||||||
MPV = 0,
|
MPV = 0,
|
||||||
ExoPlayer = 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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. Centralized here so the rule has
|
|
||||||
* one source of truth (used by VideoPlayerView, direct-player's device
|
|
||||||
* profile, and the TV settings UI).
|
|
||||||
*/
|
|
||||||
export const getActiveVideoPlayer = (
|
|
||||||
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
|
||||||
): VideoPlayer => {
|
|
||||||
return settings?.videoPlayer ?? 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
|
// TV Typography scale presets
|
||||||
export enum TVTypographyScale {
|
export enum TVTypographyScale {
|
||||||
Small = "small",
|
Small = "small",
|
||||||
@@ -245,8 +218,6 @@ export type Settings = {
|
|||||||
mediaListCollectionIds?: string[];
|
mediaListCollectionIds?: string[];
|
||||||
preferedLanguage?: string;
|
preferedLanguage?: string;
|
||||||
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
||||||
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
|
|
||||||
videoPlayer?: VideoPlayer;
|
|
||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
streamyStatsServerUrl?: string;
|
streamyStatsServerUrl?: string;
|
||||||
streamyStatsMovieRecommendations?: boolean;
|
streamyStatsMovieRecommendations?: boolean;
|
||||||
@@ -344,8 +315,6 @@ export const defaultValues: Settings = {
|
|||||||
mediaListCollectionIds: [],
|
mediaListCollectionIds: [],
|
||||||
preferedLanguage: undefined,
|
preferedLanguage: undefined,
|
||||||
searchEngine: "Jellyfin",
|
searchEngine: "Jellyfin",
|
||||||
// videoPlayer intentionally undefined — resolved at runtime via
|
|
||||||
// getActiveVideoPlayer() so existing installs are unaffected.
|
|
||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
streamyStatsServerUrl: "",
|
streamyStatsServerUrl: "",
|
||||||
streamyStatsMovieRecommendations: false,
|
streamyStatsMovieRecommendations: false,
|
||||||
|
|||||||
221
utils/jellyfin/subtitleUtils.test.ts
Normal file
221
utils/jellyfin/subtitleUtils.test.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type {
|
||||||
|
MediaStream,
|
||||||
|
SubtitleDeliveryMethod,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
compareTracksForMenu,
|
||||||
|
isExternalSubtitle,
|
||||||
|
type PlayerSubtitleTrack,
|
||||||
|
resolveSubtitleTrack,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
|
|
||||||
|
// String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts).
|
||||||
|
const External = "External" as SubtitleDeliveryMethod;
|
||||||
|
const Embed = "Embed" as SubtitleDeliveryMethod;
|
||||||
|
|
||||||
|
// --- fixtures --------------------------------------------------------------
|
||||||
|
|
||||||
|
const sub = (o: Partial<MediaStream> & { Index: number }): MediaStream =>
|
||||||
|
({ Type: "Subtitle", ...o }) as MediaStream;
|
||||||
|
|
||||||
|
const ext = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
||||||
|
sub({
|
||||||
|
Index,
|
||||||
|
DeliveryMethod: External,
|
||||||
|
IsExternal: true,
|
||||||
|
DeliveryUrl: `/sub/${Index}.srt`,
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emb = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
|
||||||
|
sub({ Index, DeliveryMethod: Embed, ...o });
|
||||||
|
|
||||||
|
const track = (o: PlayerSubtitleTrack): PlayerSubtitleTrack => o;
|
||||||
|
|
||||||
|
// Mirror direct-player.tsx online URL builder.
|
||||||
|
const urlBuilder =
|
||||||
|
(base: string) =>
|
||||||
|
(s: MediaStream): string | undefined =>
|
||||||
|
s.DeliveryUrl ? `${base}${s.DeliveryUrl}` : undefined;
|
||||||
|
|
||||||
|
const resolve = (
|
||||||
|
streams: MediaStream[],
|
||||||
|
index: number | undefined,
|
||||||
|
player: PlayerSubtitleTrack[],
|
||||||
|
getExpectedExternalUrl = urlBuilder("http://srv"),
|
||||||
|
) =>
|
||||||
|
resolveSubtitleTrack({
|
||||||
|
subtitleStreams: streams,
|
||||||
|
jellyfinSubtitleIndex: index,
|
||||||
|
playerTracks: player,
|
||||||
|
getExpectedExternalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("isExternalSubtitle", () => {
|
||||||
|
test("true for External delivery or the IsExternal flag, not a bare DeliveryUrl", () => {
|
||||||
|
expect(isExternalSubtitle(ext(0))).toBe(true);
|
||||||
|
expect(isExternalSubtitle(sub({ Index: 1, IsExternal: true }))).toBe(true);
|
||||||
|
expect(isExternalSubtitle(emb(2))).toBe(false);
|
||||||
|
// A DeliveryUrl alone (e.g. an Hls-delivered sub) is NOT a sub-added sidecar.
|
||||||
|
expect(isExternalSubtitle(sub({ Index: 3, DeliveryUrl: "/x.srt" }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — disable / notFound", () => {
|
||||||
|
test("index -1 or undefined disables", () => {
|
||||||
|
expect(resolve([], -1, [])).toEqual({ kind: "disable" });
|
||||||
|
expect(resolve([], undefined, [])).toEqual({ kind: "disable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index not present returns notFound", () => {
|
||||||
|
expect(resolve([emb(0)], 99, [track({ id: 1 })])).toEqual({
|
||||||
|
kind: "notFound",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — hidden embedded (#954)", () => {
|
||||||
|
// Server hides embedded subs: MediaStreams lists only the 3 externals,
|
||||||
|
// but mpv still demuxes the 3 embedded from the file → externals get ids 4,5,6.
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "por" }),
|
||||||
|
ext(1, { Language: "eng" }),
|
||||||
|
ext(2, { Language: "eng", Title: "SDH" }),
|
||||||
|
];
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false, language: "eng", title: "CC" }),
|
||||||
|
track({ id: 2, external: false, language: "spa" }),
|
||||||
|
track({ id: 3, external: false, language: "fre" }),
|
||||||
|
track({ id: 4, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
||||||
|
track({ id: 5, external: true, externalFilename: "http://srv/sub/1.srt" }),
|
||||||
|
track({ id: 6, external: true, externalFilename: "http://srv/sub/2.srt" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("each external maps to the right player id by filename (not 1,2,3)", () => {
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 5 });
|
||||||
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to external ordinal when filenames are unavailable", () => {
|
||||||
|
const noNames = player.map((t) =>
|
||||||
|
t.external ? { ...t, externalFilename: undefined } : t,
|
||||||
|
);
|
||||||
|
expect(resolve(streams, 1, noNames)).toEqual({
|
||||||
|
kind: "select",
|
||||||
|
trackId: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — external/embed reversal (non-hidden)", () => {
|
||||||
|
// Jellyfin lists externals first; mpv lists embedded first then externals.
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "eng" }),
|
||||||
|
emb(1, { Language: "spa" }),
|
||||||
|
emb(2, { Language: "fre" }),
|
||||||
|
];
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false, language: "spa" }),
|
||||||
|
track({ id: 2, external: false, language: "fre" }),
|
||||||
|
track({ id: 3, external: true, externalFilename: "http://srv/sub/0.srt" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("external resolves by filename, embedded by language", () => {
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 3 });
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 });
|
||||||
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — external without DeliveryUrl (#1763 CodeRabbit)", () => {
|
||||||
|
// Middle external has no DeliveryUrl → never loaded into the player.
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "eng", DeliveryUrl: "/sub/a.srt" }),
|
||||||
|
sub({ Index: 1, DeliveryMethod: External, IsExternal: true }),
|
||||||
|
ext(2, { Language: "fre", DeliveryUrl: "/sub/c.srt" }),
|
||||||
|
];
|
||||||
|
const player = [
|
||||||
|
track({ id: 4, external: true, externalFilename: "http://srv/sub/a.srt" }),
|
||||||
|
track({ id: 5, external: true, externalFilename: "http://srv/sub/c.srt" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("loaded externals still map correctly despite the gap", () => {
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
|
||||||
|
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting the unloaded external returns notFound", () => {
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "notFound" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTrack — embedded matching", () => {
|
||||||
|
test("unique language match wins even when player order differs (not positional)", () => {
|
||||||
|
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "jpn" })];
|
||||||
|
// Player lists them in the OPPOSITE order — a positional map would mis-pick.
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false, language: "jpn" }),
|
||||||
|
track({ id: 2, external: false, language: "eng" }),
|
||||||
|
];
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 }); // eng
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 }); // jpn
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-language tracks with no distinguishing title fall back to ordinal among matches", () => {
|
||||||
|
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "eng" })];
|
||||||
|
// Both eng, no title → identity can't disambiguate → ordinal among matches.
|
||||||
|
const player = [
|
||||||
|
track({ id: 5, external: false, language: "eng" }),
|
||||||
|
track({ id: 6, external: false, language: "eng" }),
|
||||||
|
];
|
||||||
|
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 5 });
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to embedded ordinal when no language/title info", () => {
|
||||||
|
const streams = [emb(0), emb(1)];
|
||||||
|
const player = [
|
||||||
|
track({ id: 1, external: false }),
|
||||||
|
track({ id: 2, external: false }),
|
||||||
|
];
|
||||||
|
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compareTracksForMenu — jellyfin-web order", () => {
|
||||||
|
test("externals sort after embedded despite lower Index", () => {
|
||||||
|
const sorted = [
|
||||||
|
ext(0, { Language: "eng" }),
|
||||||
|
emb(7, { Language: "fra" }),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
expect(sorted.map((s) => s.Index)).toEqual([7, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forced then default float to the top within a group", () => {
|
||||||
|
const sorted = [
|
||||||
|
emb(2, { Language: "eng" }),
|
||||||
|
emb(1, { Language: "eng", IsDefault: true }),
|
||||||
|
emb(0, { Language: "eng", IsForced: true }),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
expect(sorted.map((s) => s.Index)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full Okiku order: embedded first, externals last by Index", () => {
|
||||||
|
const streams = [
|
||||||
|
ext(0, { Language: "eng" }),
|
||||||
|
ext(1, { Language: "eng" }),
|
||||||
|
ext(2, { Language: "fra" }),
|
||||||
|
ext(3, { Language: "fra" }),
|
||||||
|
emb(7, { Language: "fra", Title: "French" }),
|
||||||
|
];
|
||||||
|
expect([...streams].sort(compareTracksForMenu).map((s) => s.Index)).toEqual(
|
||||||
|
[7, 0, 1, 2, 3],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,91 +1,287 @@
|
|||||||
/**
|
/**
|
||||||
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
|
||||||
|
* the *player's real track list* by identity — never by positional counting.
|
||||||
*
|
*
|
||||||
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
|
* Why: Jellyfin renumbers MediaStreams (externals first); the player enumerates
|
||||||
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
|
* embedded-from-container first and externals (`sub-add`) last; and a library that
|
||||||
|
* hides embedded subs drops them from MediaStreams while the player still demuxes
|
||||||
|
* them from the file. Positional Index→id mapping therefore mis-selects (e.g.
|
||||||
|
* picking Spanish shows English). See {@link resolveSubtitleTrack}.
|
||||||
*
|
*
|
||||||
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
||||||
* and NOT available in MPV's track list.
|
* and absent from the player's track list.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
type MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
type MediaStream,
|
MediaStream,
|
||||||
SubtitleDeliveryMethod,
|
SubtitleDeliveryMethod,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
// "External" is the value of SubtitleDeliveryMethod.External. Compared as a typed
|
||||||
|
// literal so this util needs no *runtime* import of the SDK barrel — which pulls in
|
||||||
|
// the axios-dependent `/api` modules and breaks unit tests under `bun test`.
|
||||||
|
const EXTERNAL_DELIVERY = "External" as SubtitleDeliveryMethod;
|
||||||
|
|
||||||
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||||
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
|
||||||
sub.IsTextSubtitleStream === false;
|
sub.IsTextSubtitleStream === false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a subtitle will be available in MPV's track list.
|
* A Jellyfin subtitle stream is "external" when the server delivers it as a
|
||||||
|
* sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal`
|
||||||
|
* flag before a device-specific delivery method is assigned).
|
||||||
*
|
*
|
||||||
* A subtitle is in MPV if:
|
* Deliberately NOT keyed on `DeliveryUrl`: an Hls-delivered sub also carries a
|
||||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
* `DeliveryUrl` but lives inside the player's track list (not `sub-add`-ed), so
|
||||||
|
* it must resolve through the embedded path. Keeping this in lockstep with the
|
||||||
|
* load sites (which only `sub-add` `DeliveryMethod === External`) and with the
|
||||||
|
* menu comparator below avoids a sub being sorted as embedded yet resolved as
|
||||||
|
* external (→ `notFound`).
|
||||||
*/
|
*/
|
||||||
export const isSubtitleInMpv = (
|
export const isExternalSubtitle = (sub: MediaStream): boolean =>
|
||||||
sub: MediaStream,
|
sub.DeliveryMethod === EXTERNAL_DELIVERY || sub.IsExternal === true;
|
||||||
isTranscoding: boolean,
|
|
||||||
): boolean => {
|
|
||||||
// During transcoding, image-based subs are burned in, not in MPV
|
|
||||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
/**
|
||||||
return (
|
* Order subtitle MediaStreams for the selection menu exactly like jellyfin-web's
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
* `itemHelper.sortTracks`: in-container tracks first then external, and within
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
* each group forced first, then default, then `Index` ascending. Callers prepend
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
* their own "None/Off" entry separately.
|
||||||
);
|
*
|
||||||
|
* The Jellyfin server inserts external (sidecar) streams at the FRONT of
|
||||||
|
* `MediaStreams` (low indices), so raw Index order shows externals first — this
|
||||||
|
* comparator flips that to match web (externals last). Uses {@link isExternalSubtitle}
|
||||||
|
* (not the raw `IsExternal` flag) so ordering and resolution agree.
|
||||||
|
*/
|
||||||
|
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
|
||||||
|
Number(isExternalSubtitle(a)) - Number(isExternalSubtitle(b)) ||
|
||||||
|
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
|
||||||
|
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
|
||||||
|
(a.Index ?? 0) - (b.Index ?? 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity of a subtitle track as reported by the *player's real track list*
|
||||||
|
* (mpv `track-list`, or a Cast media-track list). Player-agnostic on purpose so
|
||||||
|
* the same resolver can drive the mpv player today and the Chromecast backend later.
|
||||||
|
*/
|
||||||
|
export type PlayerSubtitleTrack = {
|
||||||
|
/** Player-side id used to actually select the track (mpv `sid`, cast trackId). */
|
||||||
|
id: number;
|
||||||
|
/** True if loaded from a separate file (mpv `external`). */
|
||||||
|
external?: boolean;
|
||||||
|
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
||||||
|
externalFilename?: string;
|
||||||
|
language?: string;
|
||||||
|
title?: string;
|
||||||
|
codec?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubtitleSelection =
|
||||||
|
| { kind: "select"; trackId: number }
|
||||||
|
| { kind: "disable" }
|
||||||
|
| { kind: "notFound" };
|
||||||
|
|
||||||
|
/** Decode percent-encoding and strip a leading `file://` scheme for tolerant comparison. */
|
||||||
|
const normalizeUrl = (url: string): string => {
|
||||||
|
let u = url;
|
||||||
|
try {
|
||||||
|
u = decodeURIComponent(u);
|
||||||
|
} catch {
|
||||||
|
// not decodable — compare raw
|
||||||
|
}
|
||||||
|
return u.replace(/^file:\/\//, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalFilenameMatches = (
|
||||||
|
trackFilename: string | undefined,
|
||||||
|
expectedUrl: string | undefined,
|
||||||
|
): boolean => {
|
||||||
|
if (!trackFilename || !expectedUrl) return false;
|
||||||
|
const a = normalizeUrl(trackFilename);
|
||||||
|
const b = normalizeUrl(expectedUrl);
|
||||||
|
return a === b || a.endsWith(b) || b.endsWith(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const eq = (a?: string | null, b?: string | null): boolean =>
|
||||||
|
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||||
|
|
||||||
|
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
|
||||||
|
const embeddedIdentityMatches = (
|
||||||
|
track: PlayerSubtitleTrack,
|
||||||
|
stream: MediaStream,
|
||||||
|
): boolean => {
|
||||||
|
if (eq(track.language, stream.Language)) {
|
||||||
|
// When both carry a title it must agree; otherwise language alone is enough.
|
||||||
|
if (track.title && stream.Title) return eq(track.title, stream.Title);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// No language on one side — fall back to a title match.
|
||||||
|
if (!track.language || !stream.Language) return eq(track.title, stream.Title);
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
* Resolve the player track id for a given Jellyfin subtitle index by matching
|
||||||
|
* against the player's REAL track list (identity), never by positional counting.
|
||||||
*
|
*
|
||||||
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
* Why identity, not position: Jellyfin renumbers `MediaStreams` (externals first)
|
||||||
* We iterate through all subtitles, counting only those in MPV, until we find
|
* while the player enumerates embedded-from-container first and externals
|
||||||
* the one matching the Jellyfin index.
|
* (`sub-add`) last; and when a library hides embedded subs they vanish from
|
||||||
|
* `MediaStreams` but still physically exist in the file the player demuxes.
|
||||||
|
* Positional Index→id mapping therefore mis-selects (e.g. picking Spanish shows
|
||||||
|
* English — issues #954/#1690/#618/#1467/#976/#1451).
|
||||||
*
|
*
|
||||||
* @param mediaSource - The media source containing subtitle streams
|
* Strategy:
|
||||||
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
* - disabled (-1/undefined) → `disable`
|
||||||
* @param isTranscoding - Whether the stream is being transcoded
|
* - external Jellyfin sub → match the player track by `externalFilename`
|
||||||
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
|
* (exact identity, immune to hidden-embedded shifts); fall back to the
|
||||||
|
* ordinal among *loadable* externals (Swiftfin: externals are the list tail).
|
||||||
|
* - embedded Jellyfin sub → match by language/title among non-external tracks;
|
||||||
|
* fall back to the embedded ordinal (container order aligns on both sides).
|
||||||
|
*
|
||||||
|
* Player-agnostic: pass any player's track list + a URL builder, so the mpv
|
||||||
|
* player and (later) the Chromecast backend share one source of truth.
|
||||||
*/
|
*/
|
||||||
export const getMpvSubtitleId = (
|
export const resolveSubtitleTrack = (params: {
|
||||||
mediaSource: MediaSourceInfo | null | undefined,
|
subtitleStreams: MediaStream[] | undefined;
|
||||||
jellyfinSubtitleIndex: number | undefined,
|
jellyfinSubtitleIndex: number | undefined;
|
||||||
isTranscoding: boolean,
|
playerTracks: PlayerSubtitleTrack[];
|
||||||
): number | undefined => {
|
/** Build the exact URL/path an external Jellyfin sub was loaded into the player with. */
|
||||||
// -1 or undefined means disabled
|
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
||||||
|
}): SubtitleSelection => {
|
||||||
|
const { jellyfinSubtitleIndex, playerTracks, getExpectedExternalUrl } =
|
||||||
|
params;
|
||||||
|
const subtitleStreams = params.subtitleStreams ?? [];
|
||||||
|
|
||||||
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||||
return -1;
|
return { kind: "disable" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSubs =
|
const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
if (!target) return { kind: "notFound" };
|
||||||
|
|
||||||
// Find the subtitle with the matching Jellyfin index
|
if (isExternalSubtitle(target)) {
|
||||||
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
|
const playerExternals = playerTracks.filter((t) => t.external === true);
|
||||||
|
|
||||||
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
// 1) Exact identity by external filename — robust against hidden-embedded offset.
|
||||||
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
const expectedUrl = getExpectedExternalUrl?.(target);
|
||||||
return undefined;
|
const byName = playerExternals.find((t) =>
|
||||||
}
|
externalFilenameMatches(t.externalFilename, expectedUrl),
|
||||||
|
);
|
||||||
|
if (byName) return { kind: "select", trackId: byName.id };
|
||||||
|
|
||||||
// Count MPV track position (1-based)
|
// 2) Fallback: externals are appended in MediaStreams order → ordinal among
|
||||||
let mpvIndex = 0;
|
// *loadable* externals (those actually added to the player) stays in lockstep
|
||||||
for (const sub of allSubs) {
|
// with the player's external list, skipping ones with no DeliveryUrl (#1763).
|
||||||
if (isSubtitleInMpv(sub, isTranscoding)) {
|
const externalStreams = subtitleStreams.filter(isExternalSubtitle);
|
||||||
mpvIndex++;
|
const loadableExternals = getExpectedExternalUrl
|
||||||
if (sub.Index === jellyfinSubtitleIndex) {
|
? externalStreams.filter((s) => getExpectedExternalUrl(s))
|
||||||
return mpvIndex;
|
: externalStreams;
|
||||||
}
|
const ordinal = loadableExternals.findIndex(
|
||||||
|
(s) => s.Index === jellyfinSubtitleIndex,
|
||||||
|
);
|
||||||
|
if (ordinal >= 0 && ordinal < playerExternals.length) {
|
||||||
|
return { kind: "select", trackId: playerExternals[ordinal].id };
|
||||||
}
|
}
|
||||||
|
return { kind: "notFound" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
// Embedded / in-container subtitle.
|
||||||
|
const embeddedStreams = subtitleStreams.filter((s) => !isExternalSubtitle(s));
|
||||||
|
const playerEmbedded = playerTracks.filter((t) => t.external !== true);
|
||||||
|
|
||||||
|
// 1) Identity by language/title (unique match wins).
|
||||||
|
const identityMatches = playerEmbedded.filter((t) =>
|
||||||
|
embeddedIdentityMatches(t, target),
|
||||||
|
);
|
||||||
|
if (identityMatches.length === 1) {
|
||||||
|
return { kind: "select", trackId: identityMatches[0].id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: embedded order is container order on both sides → ordinal.
|
||||||
|
const ordinal = embeddedStreams.findIndex(
|
||||||
|
(s) => s.Index === jellyfinSubtitleIndex,
|
||||||
|
);
|
||||||
|
if (identityMatches.length > 1 && ordinal >= 0) {
|
||||||
|
// Multiple same-language tracks: pick by position among the matches.
|
||||||
|
const idx = Math.min(ordinal, identityMatches.length - 1);
|
||||||
|
return { kind: "select", trackId: identityMatches[idx].id };
|
||||||
|
}
|
||||||
|
if (ordinal >= 0 && ordinal < playerEmbedded.length) {
|
||||||
|
return { kind: "select", trackId: playerEmbedded[ordinal].id };
|
||||||
|
}
|
||||||
|
return { kind: "notFound" };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subtitle track as reported by a concrete player's track-list API
|
||||||
|
* (mpv `getSubtitleTracks`, or a Cast track list). `lang` mirrors mpv's field name.
|
||||||
|
*/
|
||||||
|
export type PlayerSubtitleTrackRaw = {
|
||||||
|
id: number;
|
||||||
|
lang?: string;
|
||||||
|
title?: string;
|
||||||
|
codec?: string;
|
||||||
|
external?: boolean;
|
||||||
|
externalFilename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal player surface needed to select a subtitle. Satisfied structurally by
|
||||||
|
* the mpv player ref and (later) implementable by the Chromecast backend.
|
||||||
|
*/
|
||||||
|
export interface SubtitleSelectablePlayer {
|
||||||
|
getSubtitleTracks: () => Promise<PlayerSubtitleTrackRaw[] | null | undefined>;
|
||||||
|
setSubtitleTrack: (trackId: number) => unknown;
|
||||||
|
disableSubtitles: () => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the player's real track list, resolve the Jellyfin subtitle index by
|
||||||
|
* identity ({@link resolveSubtitleTrack}) and apply the result. Single entry point
|
||||||
|
* for both the mobile controls and the player screen, so selection stays
|
||||||
|
* consistent everywhere. Returns the resolution for callers that want to react.
|
||||||
|
*/
|
||||||
|
export const applyMpvSubtitleSelection = async (
|
||||||
|
player: SubtitleSelectablePlayer | null | undefined,
|
||||||
|
params: {
|
||||||
|
subtitleStreams: MediaStream[] | undefined;
|
||||||
|
jellyfinSubtitleIndex: number;
|
||||||
|
/** Build the exact URL/path an external sub was loaded into the player with. */
|
||||||
|
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
||||||
|
},
|
||||||
|
): Promise<SubtitleSelection> => {
|
||||||
|
if (!player) return { kind: "notFound" };
|
||||||
|
|
||||||
|
// Called fire-and-forget (`void applyMpvSubtitleSelection(...)`), so any native
|
||||||
|
// rejection from getSubtitleTracks/setSubtitleTrack/disableSubtitles must be
|
||||||
|
// swallowed here instead of escaping as an unhandled promise rejection.
|
||||||
|
try {
|
||||||
|
const tracks = (await player.getSubtitleTracks()) ?? [];
|
||||||
|
const selection = resolveSubtitleTrack({
|
||||||
|
subtitleStreams: params.subtitleStreams,
|
||||||
|
jellyfinSubtitleIndex: params.jellyfinSubtitleIndex,
|
||||||
|
playerTracks: tracks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
external: t.external,
|
||||||
|
externalFilename: t.externalFilename,
|
||||||
|
language: t.lang,
|
||||||
|
title: t.title,
|
||||||
|
codec: t.codec,
|
||||||
|
})),
|
||||||
|
getExpectedExternalUrl: params.getExpectedExternalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selection.kind === "select") {
|
||||||
|
await player.setSubtitleTrack(selection.trackId);
|
||||||
|
} else if (selection.kind === "disable") {
|
||||||
|
await player.disableSubtitles();
|
||||||
|
}
|
||||||
|
// notFound → leave current selection (e.g. image subs burned in while transcoding)
|
||||||
|
return selection;
|
||||||
|
} catch {
|
||||||
|
return { kind: "notFound" };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import MediaTypes from "../../constants/MediaTypes";
|
|||||||
import { getSubtitleProfiles } from "./subtitles";
|
import { getSubtitleProfiles } from "./subtitles";
|
||||||
|
|
||||||
export type PlatformType = "ios" | "android";
|
export type PlatformType = "ios" | "android";
|
||||||
export type PlayerType = "mpv" | "exoplayer";
|
export type PlayerType = "mpv";
|
||||||
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
||||||
|
|
||||||
export interface ProfileOptions {
|
export interface ProfileOptions {
|
||||||
@@ -63,26 +63,6 @@ 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.
|
* Gets the video audio codec configuration based on platform and audio mode.
|
||||||
*
|
*
|
||||||
@@ -109,59 +89,35 @@ const getVideoAudioCodecs = (
|
|||||||
// MPV can decode all codecs - only channel count varies by mode
|
// MPV can decode all codecs - only channel count varies by mode
|
||||||
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
||||||
|
|
||||||
return {
|
switch (audioMode) {
|
||||||
directPlayCodec: allCodecs,
|
case "stereo":
|
||||||
maxAudioChannels: maxChannelsForMode(audioMode),
|
// Limit to 2 channels - MPV will decode and downmix
|
||||||
};
|
return {
|
||||||
};
|
directPlayCodec: allCodecs,
|
||||||
|
maxAudioChannels: "2",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
case "5.1":
|
||||||
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV.
|
// Limit to 6 channels
|
||||||
*
|
return {
|
||||||
* Codec set aligned with Media3's documented supported-formats list:
|
directPlayCodec: allCodecs,
|
||||||
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
|
maxAudioChannels: "6",
|
||||||
* - 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";
|
|
||||||
|
|
||||||
return {
|
case "passthrough":
|
||||||
video: {
|
// Allow up to 8 channels - for external DAC/receiver setups
|
||||||
Type: MediaTypes.Video,
|
return {
|
||||||
Container: "mp4,mkv,webm,ts,mpegts,mov",
|
directPlayCodec: allCodecs,
|
||||||
VideoCodec: "h263,h264,hevc,vp8,vp9,av1",
|
maxAudioChannels: "8",
|
||||||
AudioCodec: audioCodecs,
|
};
|
||||||
},
|
|
||||||
audio: {
|
default:
|
||||||
Type: MediaTypes.Audio,
|
// Auto mode: default to 5.1 (6 channels)
|
||||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
return {
|
||||||
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac",
|
directPlayCodec: allCodecs,
|
||||||
},
|
maxAudioChannels: "6",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,63 +126,6 @@ const getExoPlayerDirectPlayProfile = () => {
|
|||||||
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||||
const audioMode = options.audioMode || "auto";
|
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,hevc,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" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||||
platform,
|
platform,
|
||||||
@@ -299,3 +198,6 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
|||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default export for backward compatibility
|
||||||
|
export default generateDeviceProfile();
|
||||||
|
|||||||
Reference in New Issue
Block a user