mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 09:23:07 +01:00
Compare commits
11 Commits
chore/i18n
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c884036d05 | ||
|
|
ad8f93e1e0 | ||
|
|
7d0f89148d | ||
|
|
0fea901133 | ||
|
|
053760829f | ||
|
|
7a88ae19cb | ||
|
|
c13da89307 | ||
|
|
9a4381fef3 | ||
|
|
c4e5cb9c14 | ||
|
|
4f31cd2b32 | ||
|
|
faa250bfdd |
@@ -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, ScrollView, View } from "react-native";
|
import { Alert, Platform, 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,13 +33,16 @@ 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,
|
||||||
@@ -262,6 +265,25 @@ 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(
|
||||||
() => [
|
() => [
|
||||||
@@ -391,6 +413,23 @@ 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(
|
||||||
() => [
|
() => [
|
||||||
@@ -522,6 +561,11 @@ 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);
|
||||||
@@ -586,6 +630,34 @@ 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}
|
||||||
@@ -645,7 +717,7 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
label='Vertical Margin'
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
@@ -662,9 +734,11 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{isMpv && (
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
value={alignXLabel}
|
value={alignXLabel}
|
||||||
|
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||||
@@ -676,12 +750,13 @@ export default function SettingsTV() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
label='Vertical Alignment'
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
title: "Vertical Alignment",
|
||||||
options: alignYOptions,
|
options: alignYOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -748,7 +823,9 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video Output Section */}
|
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
|
||||||
|
{isMpv && (
|
||||||
|
<>
|
||||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.vo_driver.vo_mode")}
|
label={t("home.settings.vo_driver.vo_mode")}
|
||||||
@@ -761,6 +838,9 @@ export default function SettingsTV() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.buffer.buffer_duration")}
|
label={t("home.settings.buffer.buffer_duration")}
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -73,7 +72,6 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|||||||
type ViewMode = "player" | "queue";
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -232,9 +230,7 @@ export default function NowPlayingScreen() {
|
|||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
{t("music.no_track_playing")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
@@ -271,7 +267,7 @@ export default function NowPlayingScreen() {
|
|||||||
: "text-neutral-500"
|
: "text-neutral-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("music.now_playing")}
|
Now Playing
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -722,7 +718,6 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onReorderQueue,
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
@@ -836,15 +831,13 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
{history.length > 0
|
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||||
? t("music.playing_from_queue")
|
|
||||||
: t("music.up_next")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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";
|
||||||
@@ -40,7 +41,6 @@ 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,7 +51,7 @@ 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 { useSettings } from "@/utils/atoms/settings";
|
import { getActivePlayerType, 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";
|
||||||
@@ -364,7 +364,13 @@ export default function DirectPlayerPage() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: generateDeviceProfile(),
|
// Match the device profile to the player that will render the
|
||||||
|
// stream so the server picks a codec/container the player can
|
||||||
|
// actually decode.
|
||||||
|
deviceProfile: generateDeviceProfile({
|
||||||
|
player: getActivePlayerType(settings),
|
||||||
|
audioMode: settings.audioTranscodeMode,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res) return null;
|
if (!res) return null;
|
||||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||||
@@ -1305,7 +1311,7 @@ export default function DirectPlayerPage() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MpvPlayerView
|
<VideoPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
@@ -1318,7 +1324,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occurred_while_playing_the_video"),
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -329,7 +328,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
{t("player.hash_match")}
|
Hash Match
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -85,8 +85,7 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowAlert: true,
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -351,12 +350,9 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
// Log only the title — serializing the whole notification touches
|
|
||||||
// the deprecated dataString getter (deprecation warning) and dumps
|
|
||||||
// noisy payloads into the console.
|
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running:",
|
"Notification received while app running",
|
||||||
notification.request.content.title,
|
notification,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { Platform, StyleSheet, TouchableOpacity, 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";
|
||||||
@@ -210,7 +209,6 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
@@ -382,7 +380,7 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative flex-1"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -37,7 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
colors,
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
@@ -170,8 +168,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
const { t } = useTranslation();
|
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
if (!api || !item.Id) return;
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
<View style={styles.centeredView}>
|
<View style={styles.centeredView}>
|
||||||
<View style={styles.modalView}>
|
<View style={styles.modalView}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>Select Session</Text>
|
||||||
{t("home.sessions.select_session")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : !sessions || sessions.length === 0 ? (
|
) : !sessions || sessions.length === 0 ? (
|
||||||
<Text style={styles.noSessionsText}>
|
<Text style={styles.noSessionsText}>
|
||||||
{t("home.sessions.no_active_sessions")}
|
No active sessions found
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{session.NowPlayingItem && (
|
{session.NowPlayingItem && (
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
{t("home.sessions.now_playing")}{" "}
|
Now playing:{" "}
|
||||||
{session.NowPlayingItem.SeriesName
|
{session.NowPlayingItem.SeriesName
|
||||||
? `${session.NowPlayingItem.SeriesName} :`
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { t } from "i18next";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
{isTranscoding && (
|
{isTranscoding && (
|
||||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
<Text className='text-xs text-purple-400'>
|
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||||
{t("home.downloads.transcoding")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
|
||||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
const seriesId = items[0]?.SeriesId;
|
return storage.getString(items[0].SeriesId!);
|
||||||
return seriesId ? storage.getString(seriesId) : undefined;
|
}, []);
|
||||||
}, [items[0]?.SeriesId]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -23,7 +22,6 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
scaleAmount: 1,
|
scaleAmount: 1,
|
||||||
@@ -70,7 +68,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.live")}
|
LIVE
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("live_tv.title")}
|
Live TV
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -89,8 +88,6 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
showDiscover,
|
showDiscover,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!showDiscover) {
|
if (!showDiscover) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -104,13 +101,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.library")}
|
label='Library'
|
||||||
isSelected={searchType === "Library"}
|
isSelected={searchType === "Library"}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.discover")}
|
label='Discover'
|
||||||
isSelected={searchType === "Discover"}
|
isSelected={searchType === "Discover"}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
import { Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -18,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||||
|
|
||||||
const alignXLabels: Record<AlignX, string> = {
|
const alignXLabels: Record<AlignX, string> = {
|
||||||
left: t("home.settings.subtitles.align.left"),
|
left: "Left",
|
||||||
center: t("home.settings.subtitles.align.center"),
|
center: "Center",
|
||||||
right: t("home.settings.subtitles.align.right"),
|
right: "Right",
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignYLabels: Record<AlignY, string> = {
|
const alignYLabels: Record<AlignY, string> = {
|
||||||
top: t("home.settings.subtitles.align.top"),
|
top: "Top",
|
||||||
center: t("home.settings.subtitles.align.center"),
|
center: "Center",
|
||||||
bottom: t("home.settings.subtitles.align.bottom"),
|
bottom: "Bottom",
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignXOptionGroups = useMemo(() => {
|
const alignXOptionGroups = useMemo(() => {
|
||||||
@@ -62,18 +60,16 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.subtitles.mpv_settings_title")}
|
title='MPV Subtitle Settings'
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
{t("home.settings.subtitles.mpv_settings_description")}
|
Advanced subtitle customization for MPV player
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isTv && (
|
{!isTv && (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem title='Vertical Margin'>
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
|
||||||
>
|
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -85,7 +81,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
<ListItem title='Horizontal Alignment'>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignXOptionGroups}
|
groups={alignXOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -100,11 +96,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
title='Horizontal Alignment'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
<ListItem title='Vertical Alignment'>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignYOptionGroups}
|
groups={alignYOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -119,13 +115,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
title='Vertical Alignment'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
<ListItem title='Opaque Background'>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -135,7 +131,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{settings.mpvSubtitleBackgroundEnabled && (
|
{settings.mpvSubtitleBackgroundEnabled && (
|
||||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
<ListItem title='Background Opacity'>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||||
step={5}
|
step={5}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Keep the bar moving while a download is writing to disk.
|
|
||||||
refetchInterval: 10 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = () => {
|
const onDeleteClicked = async () => {
|
||||||
Alert.alert(
|
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.ok"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
} finally {
|
|
||||||
// Reflect the freed space immediately instead of waiting for
|
|
||||||
// the next poll.
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export interface TVNextEpisodeCountdownProps {
|
|||||||
playButtonRef?: RNView | null;
|
playButtonRef?: RNView | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants
|
// Position constants — kept in sync with TVSkipSegmentCard (the two are
|
||||||
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
|
||||||
|
// rationale (220 sits just above the controls bar; 300 floated too high).
|
||||||
|
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ export interface TVSkipSegmentCardProps {
|
|||||||
playButtonRef?: View | null;
|
playButtonRef?: View | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
|
||||||
const BOTTOM_WITH_CONTROLS = 300;
|
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
|
||||||
const BOTTOM_WITHOUT_CONTROLS = 120;
|
// card that floats far above the controls.
|
||||||
|
//
|
||||||
|
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
|
||||||
|
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
|
||||||
|
// left the card hovering ~100px above the controls.
|
||||||
|
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||||
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
||||||
show,
|
show,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -29,7 +28,6 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
const styles = createStyles(typography);
|
const styles = createStyles(typography);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -154,7 +152,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.flagText}>{t("player.hash_match")}</Text>
|
<Text style={styles.flagText}>Hash Match</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{result.hearingImpaired && (
|
{result.hearingImpaired && (
|
||||||
|
|||||||
30
components/video-player/VideoPlayerView.tsx
Normal file
30
components/video-player/VideoPlayerView.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules";
|
||||||
|
import { MpvPlayerView } from "@/modules";
|
||||||
|
import { ExoPlayerView } from "@/modules/exoplayer-player";
|
||||||
|
import {
|
||||||
|
getActiveVideoPlayer,
|
||||||
|
useSettings,
|
||||||
|
VideoPlayer,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified video player view. MPV is the default on every platform; users
|
||||||
|
* can opt into ExoPlayer on Android TV via settings.videoPlayer. Both
|
||||||
|
* children conform to the same `MpvPlayerViewRef` interface, so the ref
|
||||||
|
* is forwarded transparently regardless of which player is rendered.
|
||||||
|
*
|
||||||
|
* The Android-TV capability gate lives in getActiveVideoPlayer so that
|
||||||
|
* the same resolver used for device-profile advertisement guarantees the
|
||||||
|
* rendered backend matches what Jellyfin was told to stream for.
|
||||||
|
*/
|
||||||
|
export const VideoPlayerView = React.forwardRef<
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
MpvPlayerViewProps
|
||||||
|
>(function VideoPlayerView(props, ref) {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const useExo = getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
|
||||||
|
|
||||||
|
const Player = useExo ? ExoPlayerView : MpvPlayerView;
|
||||||
|
return <Player ref={ref} {...props} />;
|
||||||
|
});
|
||||||
@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText={t("player.skip_intro")}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={t("player.skip_credits")}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
{t("player.still_watching")}
|
Are you still watching ?
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -1132,7 +1132,16 @@ export const Controls: FC<Props> = ({
|
|||||||
{/* Skip intro card */}
|
{/* Skip intro card */}
|
||||||
<TVSkipSegmentCard
|
<TVSkipSegmentCard
|
||||||
show={showSkipButton && !isCountdownActive}
|
show={showSkipButton && !isCountdownActive}
|
||||||
onPress={skipIntro}
|
onPress={() => {
|
||||||
|
// After the seek lands, showSkipButton flips false and this card
|
||||||
|
// unmounts. With controls visible the focus-stealing overlay is
|
||||||
|
// disabled, so without an explicit handoff the focus engine is
|
||||||
|
// stranded. Prime the play button to receive focus on the next
|
||||||
|
// render — when controls are hidden the focus overlay takes over
|
||||||
|
// naturally and this is a harmless no-op.
|
||||||
|
if (showControls) setFocusPlayButton(true);
|
||||||
|
skipIntro();
|
||||||
|
}}
|
||||||
type='intro'
|
type='intro'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
@@ -1147,7 +1156,11 @@ export const Controls: FC<Props> = ({
|
|||||||
(hasContentAfterCredits || !nextItem) &&
|
(hasContentAfterCredits || !nextItem) &&
|
||||||
!isCountdownActive
|
!isCountdownActive
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={() => {
|
||||||
|
// 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}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
@@ -58,7 +57,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -129,8 +127,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
onPress={toggleOrientation}
|
onPress={toggleOrientation}
|
||||||
disabled={isTogglingOrientation}
|
disabled={isTogglingOrientation}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
accessibilityLabel={t("accessibility.toggle_orientation")}
|
accessibilityLabel='Toggle screen orientation'
|
||||||
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name='screen-rotation'
|
name='screen-rotation'
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -185,7 +184,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
@@ -215,13 +213,10 @@ 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]);
|
||||||
|
|
||||||
@@ -307,34 +302,47 @@ 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` : ""}
|
||||||
{formatVideoRange(streamInfo?.videoRange)
|
{/* Prefer the player-reported HDR format (authoritative —
|
||||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
what's actually being decoded) over Jellyfin metadata. */}
|
||||||
: ""}
|
{info?.hdrFormat
|
||||||
|
? ` ${info.hdrFormat}`
|
||||||
|
: (() => {
|
||||||
|
const videoRange = formatVideoRange(streamInfo?.videoRange);
|
||||||
|
return videoRange ? ` ${videoRange}` : "";
|
||||||
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
Video: {formatCodec(info.videoCodec)}
|
||||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
Audio: {formatCodec(info.audioCodec)}
|
||||||
{streamInfo?.audioChannels
|
{/* Prefer player-reported channel count; fall back to
|
||||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
Jellyfin metadata for MPV which doesn't populate it. */}
|
||||||
|
{(() => {
|
||||||
|
const audioChannels =
|
||||||
|
info.audioChannels ?? streamInfo?.audioChannels;
|
||||||
|
return audioChannels
|
||||||
|
? ` ${formatAudioChannels(audioChannels)}`
|
||||||
|
: "";
|
||||||
|
})()}
|
||||||
|
{info.audioSampleRate
|
||||||
|
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{streamInfo?.subtitleCodec && (
|
{streamInfo?.subtitleCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.subtitle")}{" "}
|
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
||||||
{formatCodec(streamInfo.subtitleCodec)}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.bitrate")}{" "}
|
Bitrate:{" "}
|
||||||
{info.videoBitrate
|
{info.videoBitrate
|
||||||
? formatBitrate(info.videoBitrate)
|
? formatBitrate(info.videoBitrate)
|
||||||
: info.audioBitrate
|
: info.audioBitrate
|
||||||
@@ -342,11 +350,20 @@ 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}>
|
||||||
{t("player.technical_info.buffer_seconds", {
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
seconds: info.cacheSeconds.toFixed(1),
|
|
||||||
})}
|
|
||||||
{info?.demuxerMaxBytes !== undefined
|
{info?.demuxerMaxBytes !== undefined
|
||||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||||
@@ -357,10 +374,16 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.vo")} {info.voDriver}
|
VO: {info.voDriver}
|
||||||
{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)}
|
||||||
@@ -369,14 +392,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
{t("player.technical_info.dropped_frames", {
|
Dropped: {info.droppedFrames} frames
|
||||||
count: info.droppedFrames,
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!info && !playMethod && (
|
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||||
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
|
if (!Number.isFinite(remainingTime)) return "—";
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// remainingTime is in ms
|
// remainingTime is in ms
|
||||||
const finishTime = new Date(now.getTime() + remainingTime);
|
const finishTime = new Date(now.getTime() + remainingTime);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
@@ -55,7 +54,6 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
onRatioChange,
|
onRatioChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||||
@@ -68,10 +66,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
{
|
{
|
||||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label:
|
label: option.label,
|
||||||
option.id === "default"
|
|
||||||
? t("player.aspect_ratio_original")
|
|
||||||
: option.label,
|
|
||||||
value: option.id,
|
value: option.id,
|
||||||
selected: option.id === currentRatio,
|
selected: option.id === currentRatio,
|
||||||
onPress: () => handleRatioSelect(option.id),
|
onPress: () => handleRatioSelect(option.id),
|
||||||
@@ -99,7 +94,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title={t("player.aspect_ratio")}
|
title='Aspect Ratio'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
bottomSheetConfig={{
|
bottomSheetConfig={{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +47,6 @@ const DropdownView = ({
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -103,7 +101,7 @@ const DropdownView = ({
|
|||||||
// Quality Section
|
// Quality Section
|
||||||
if (!isOffline) {
|
if (!isOffline) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.quality"),
|
title: "Quality",
|
||||||
options:
|
options:
|
||||||
BITRATES?.map((bitrate) => ({
|
BITRATES?.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -118,7 +116,7 @@ const DropdownView = ({
|
|||||||
// Subtitle Section
|
// Subtitle Section
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.subtitles"),
|
title: "Subtitles",
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: subtitleTracks.map((sub) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: sub.name,
|
label: sub.name,
|
||||||
@@ -130,7 +128,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
// Subtitle Scale Section
|
// Subtitle Scale Section
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.subtitle_scale"),
|
title: "Subtitle Scale",
|
||||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: preset.label,
|
label: preset.label,
|
||||||
@@ -144,7 +142,7 @@ const DropdownView = ({
|
|||||||
// Audio Section
|
// Audio Section
|
||||||
if (audioTracks && audioTracks.length > 0) {
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.audio"),
|
title: "Audio",
|
||||||
options: audioTracks.map((track) => ({
|
options: audioTracks.map((track) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: track.name,
|
label: track.name,
|
||||||
@@ -158,7 +156,7 @@ const DropdownView = ({
|
|||||||
// Speed Section
|
// Speed Section
|
||||||
if (setPlaybackSpeed) {
|
if (setPlaybackSpeed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.speed"),
|
title: "Speed",
|
||||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: speed.label,
|
label: speed.label,
|
||||||
@@ -176,8 +174,8 @@ const DropdownView = ({
|
|||||||
{
|
{
|
||||||
type: "action" as const,
|
type: "action" as const,
|
||||||
label: showTechnicalInfo
|
label: showTechnicalInfo
|
||||||
? t("player.menu.hide_technical_info")
|
? "Hide Technical Info"
|
||||||
: t("player.menu.show_technical_info"),
|
: "Show Technical Info",
|
||||||
onPress: onToggleTechnicalInfo,
|
onPress: onToggleTechnicalInfo,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -187,7 +185,6 @@ const DropdownView = ({
|
|||||||
return groups;
|
return groups;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
t,
|
|
||||||
isOffline,
|
isOffline,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
changeBitrate,
|
changeBitrate,
|
||||||
@@ -220,7 +217,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title={t("player.menu.playback_options")}
|
title='Playback Options'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
expoUIConfig={{}}
|
expoUIConfig={{}}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Alert } from "react-native";
|
|||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||||
import i18n from "@/i18n";
|
|
||||||
|
|
||||||
interface UseRemoteControlProps {
|
interface UseRemoteControlProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -125,23 +124,17 @@ export function useRemoteControl({
|
|||||||
|
|
||||||
// Controls are hidden, so confirm before leaving playback.
|
// Controls are hidden, so confirm before leaving playback.
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
i18n.t("player.stopPlayback"),
|
"Stop Playback",
|
||||||
videoTitleRef.current
|
videoTitleRef.current
|
||||||
? i18n.t("player.stopPlayingTitle", {
|
? `Stop playing "${videoTitleRef.current}"?`
|
||||||
title: videoTitleRef.current,
|
: "Are you sure you want to stop playback?",
|
||||||
})
|
|
||||||
: i18n.t("player.stopPlayingConfirm"),
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: i18n.t("common.cancel"),
|
text: "Cancel",
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
onPress: () => onCancelExitRef.current?.(),
|
onPress: () => onCancelExitRef.current?.(),
|
||||||
},
|
},
|
||||||
{
|
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||||
text: i18n.t("common.stop"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: onBackRef.current,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ interface UseVideoTimeProps {
|
|||||||
*/
|
*/
|
||||||
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
// Start at 0 (not Infinity) so the controls' first paint — before the first
|
||||||
|
// progress/max update — shows "0:00" instead of formatting Infinity into
|
||||||
|
// "Infinityh NaNm NaNs" and an Invalid Date for "ends at".
|
||||||
|
const [remainingTime, setRemainingTime] = useState(0);
|
||||||
|
|
||||||
const lastCurrentTimeRef = useRef(0);
|
const lastCurrentTimeRef = useRef(0);
|
||||||
const lastRemainingTimeRef = useRef(0);
|
const lastRemainingTimeRef = useRef(0);
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
|||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version < "2.0.0") {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t(
|
||||||
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||||
);
|
);
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
|
|||||||
68
modules/exoplayer-player/android/build.gradle
Normal file
68
modules/exoplayer-player/android/build.gradle
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'expo.modules.exoplayerplayer'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
def useManagedAndroidSdkVersions = false
|
||||||
|
if (useManagedAndroidSdkVersions) {
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
} else {
|
||||||
|
buildscript {
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 26)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.exoplayerplayer"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.1.0"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Media3 (ExoPlayer). The default tracks react-native-track-player's
|
||||||
|
// pinned version (currently 1.10.1) so we don't end up with two media3
|
||||||
|
// versions on the classpath and duplicate-class errors. The
|
||||||
|
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
|
||||||
|
// globally via plugins/withExcludeMedia3Dash.js.
|
||||||
|
def media3Version = safeExtGet('media3Version', '1.10.1')
|
||||||
|
implementation "androidx.media3:media3-exoplayer:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-ui:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-common:${media3Version}"
|
||||||
|
|
||||||
|
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
|
||||||
|
// codecs that MediaCodec doesn't ship with on most Android TVs.
|
||||||
|
//
|
||||||
|
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
|
||||||
|
// with prebuilt native libraries (the upstream androidx artifact is a
|
||||||
|
// stub that requires building FFmpeg yourself). RNTP already pulls
|
||||||
|
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
|
||||||
|
// it guarantees we still get it if RNTP ever drops the dep.
|
||||||
|
//
|
||||||
|
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
|
||||||
|
// resolve media3 core to 1.10.1. RNTP ships the same combination in
|
||||||
|
// production, and Media3 maintains binary compat for Renderer /
|
||||||
|
// RenderersFactory APIs across minor versions, so this works in
|
||||||
|
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
|
||||||
|
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package expo.modules.exoplayerplayer
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExoPlayerModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExoPlayer")
|
||||||
|
|
||||||
|
// Enables the module to be used as a native view.
|
||||||
|
View(ExoPlayerView::class) {
|
||||||
|
// All video load options are passed via a single "source" prop,
|
||||||
|
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
|
||||||
|
// cacheConfig fields) are silently ignored.
|
||||||
|
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
|
||||||
|
if (source == null) return@Prop
|
||||||
|
|
||||||
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||||
|
|
||||||
|
val config = VideoLoadConfig(
|
||||||
|
url = urlString,
|
||||||
|
headers = source["headers"] as? Map<String, String>,
|
||||||
|
externalSubtitles = source["externalSubtitles"] as? List<String>,
|
||||||
|
startPosition = (source["startPosition"] as? Number)?.toDouble(),
|
||||||
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||||
|
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||||
|
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
view.loadVideo(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
|
||||||
|
// no Control Center equivalent — Android handles media sessions
|
||||||
|
// via MediaSessionCompat which we don't wire up for TV).
|
||||||
|
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("play") { view: ExoPlayerView ->
|
||||||
|
view.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("pause") { view: ExoPlayerView ->
|
||||||
|
view.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("destroy") { view: ExoPlayerView ->
|
||||||
|
view.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
|
||||||
|
view.seekTo(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
|
||||||
|
view.seekBy(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
|
||||||
|
view.setSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getSpeed") { view: ExoPlayerView ->
|
||||||
|
view.getSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPaused") { view: ExoPlayerView ->
|
||||||
|
view.isPaused()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getDuration") { view: ExoPlayerView ->
|
||||||
|
view.getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picture in Picture — TV does not use PiP; safe no-ops.
|
||||||
|
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle functions
|
||||||
|
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
|
||||||
|
view.getSubtitleTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
|
||||||
|
view.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
|
||||||
|
view.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentSubtitleTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
|
||||||
|
view.addSubtitleFile(url, select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle positioning / styling
|
||||||
|
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
|
||||||
|
view.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
|
||||||
|
view.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
|
||||||
|
view.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
|
||||||
|
// No-op — SubtitleView follows authored cue alignment.
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
|
||||||
|
view.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
|
||||||
|
view.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
|
||||||
|
view.setSubtitleBorderStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
|
||||||
|
view.setSubtitleBackgroundColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
|
||||||
|
// No-op — libass-specific, no Media3 equivalent.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track functions
|
||||||
|
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
|
||||||
|
view.getAudioTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
|
||||||
|
view.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentAudioTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video scaling
|
||||||
|
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
|
||||||
|
view.setZoomedToFill(zoomed)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
|
||||||
|
view.isZoomedToFill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technical info
|
||||||
|
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
|
||||||
|
view.getTechnicalInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events that the view can send to JavaScript — same set as MPV.
|
||||||
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,976 @@
|
|||||||
|
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
|
||||||
|
package expo.modules.exoplayerplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.ColorInfo
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackParameters
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.ui.CaptionStyleCompat
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.media3.ui.SubtitleView
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
|
||||||
|
* MPV-only fields are accepted and ignored.
|
||||||
|
*/
|
||||||
|
data class VideoLoadConfig(
|
||||||
|
val url: String,
|
||||||
|
val headers: Map<String, String>? = null,
|
||||||
|
val externalSubtitles: List<String>? = null,
|
||||||
|
val startPosition: Double? = null,
|
||||||
|
val autoplay: Boolean = true,
|
||||||
|
val initialSubtitleId: Int? = null,
|
||||||
|
val initialAudioId: Int? = null,
|
||||||
|
val cacheEnabled: String? = null,
|
||||||
|
val cacheSeconds: Int? = null,
|
||||||
|
val demuxerMaxBytes: Int? = null,
|
||||||
|
val demuxerMaxBackBytes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
|
||||||
|
*
|
||||||
|
* Implements the same JS contract (events, ref methods, 1-based track IDs)
|
||||||
|
* as MpvPlayerView so the React layer can swap between the two without
|
||||||
|
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
|
||||||
|
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
|
||||||
|
*/
|
||||||
|
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ExoPlayerView"
|
||||||
|
private const val PROGRESS_INTERVAL_MS = 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event dispatchers — names must match the Events() declaration in the module.
|
||||||
|
val onLoad by EventDispatcher()
|
||||||
|
val onPlaybackStateChange by EventDispatcher()
|
||||||
|
val onProgress by EventDispatcher()
|
||||||
|
val onError by EventDispatcher()
|
||||||
|
val onTracksReady by EventDispatcher()
|
||||||
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private var player: ExoPlayer? = null
|
||||||
|
private val playerView: PlayerView
|
||||||
|
private val subtitleView: SubtitleView?
|
||||||
|
|
||||||
|
private var currentUrl: String? = null
|
||||||
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
|
private var tracksReadyFired: Boolean = false
|
||||||
|
|
||||||
|
// Resume position handed to setMediaSource() on load. seekTo() uses it to
|
||||||
|
// drop the redundant re-seek the JS layer fires once tracks are ready
|
||||||
|
// (direct-player seeks to the same resume position). ExoPlayer re-buffers
|
||||||
|
// on every seek — even a no-op to the current position — so without this
|
||||||
|
// the start stutters.
|
||||||
|
private var loadStartPositionMs: Long = 0L
|
||||||
|
|
||||||
|
// Side-loaded subtitle configurations accumulated across loadVideo and
|
||||||
|
// addSubtitleFile. Media3 doesn't expose the live SubtitleConfiguration
|
||||||
|
// list on a playing MediaItem, so we shadow it here to preserve prior
|
||||||
|
// side-loaded subs when addSubtitleFile rebuilds the MediaItem.
|
||||||
|
private var sideLoadedSubs: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||||
|
|
||||||
|
// 1-based track ID mappings (matching MPV's contract).
|
||||||
|
// Each list is rebuilt on Tracks changed.
|
||||||
|
private var subtitleTrackList: List<TrackEntry> = emptyList()
|
||||||
|
private var audioTrackList: List<TrackEntry> = emptyList()
|
||||||
|
private var currentSubtitleId: Int = 0
|
||||||
|
private var currentAudioId: Int = 0
|
||||||
|
|
||||||
|
// Subtitle styling state — applied to the embedded SubtitleView.
|
||||||
|
private var subtitleScale: Float = 1f
|
||||||
|
private var subtitleFontSizePct: Int? = null // 0-100
|
||||||
|
// Last-write-wins override of the vertical position fraction
|
||||||
|
// (null = fall back to subtitleAlignY). Both setSubtitlePosition
|
||||||
|
// (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY
|
||||||
|
// (px) funnel into this single SubtitleView API.
|
||||||
|
private var subtitleBottomFraction: Float? = null
|
||||||
|
private var subtitleAlignY: String = "bottom"
|
||||||
|
// Background color carries its own alpha (parsed from #RRGGBBAA in
|
||||||
|
// setSubtitleBackgroundColor) so no separate enabled/opacity flags.
|
||||||
|
private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0)
|
||||||
|
private var subtitleBorderStyle: String = "outline-and-shadow"
|
||||||
|
|
||||||
|
private var isZoomedToFill: Boolean = false
|
||||||
|
|
||||||
|
// Captured by analyticsListener; surfaced via getTechnicalInfo().
|
||||||
|
// Reset on destroy() and (for decoder names) on track changes.
|
||||||
|
private var videoDecoderName: String? = null
|
||||||
|
private var audioDecoderName: String? = null
|
||||||
|
private var cumulativeDroppedFrames: Int = 0
|
||||||
|
|
||||||
|
private val analyticsListener = object : AnalyticsListener {
|
||||||
|
override fun onVideoDecoderInitialized(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderName: String,
|
||||||
|
initializedTimestampMs: Long,
|
||||||
|
) {
|
||||||
|
videoDecoderName = decoderName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDecoderInitialized(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderName: String,
|
||||||
|
initializedTimestampMs: Long,
|
||||||
|
) {
|
||||||
|
audioDecoderName = decoderName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDroppedVideoFrames(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
droppedFrames: Int,
|
||||||
|
elapsedMs: Long,
|
||||||
|
) {
|
||||||
|
// Incremental count since last call; accumulate for a cumulative
|
||||||
|
// total that matches MPV's droppedFrames semantics.
|
||||||
|
cumulativeDroppedFrames += droppedFrames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playerListener = object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
when (playbackState) {
|
||||||
|
Player.STATE_BUFFERING -> {
|
||||||
|
onPlaybackStateChange(mapOf("isLoading" to true))
|
||||||
|
}
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isLoading" to false,
|
||||||
|
"isReadyToSeek" to true
|
||||||
|
))
|
||||||
|
if (!tracksReadyFired) {
|
||||||
|
tracksReadyFired = true
|
||||||
|
rebuildTrackMaps(player?.currentTracks)
|
||||||
|
onTracksReady(emptyMap<String, Any>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Player.STATE_ENDED -> {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isPlaying" to false,
|
||||||
|
"isPaused" to true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Player.STATE_IDLE -> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isPlaying" to isPlaying,
|
||||||
|
"isPaused" to !isPlaying
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) {
|
||||||
|
val message = error?.message ?: "Unknown playback error"
|
||||||
|
Log.e(TAG, "Player error: $message", error)
|
||||||
|
onError(mapOf("error" to message))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
|
rebuildTrackMaps(tracks)
|
||||||
|
// currentSubtitleId is a hand-maintained cache (ExoPlayer has no
|
||||||
|
// mpv-style "sid" property to read live), so re-derive it from the
|
||||||
|
// actual selection on every track change. Without this, any path
|
||||||
|
// that selects a track without going through setSubtitleTrack —
|
||||||
|
// notably addSubtitleFile(select=true), which clears the text
|
||||||
|
// override and lets the new SELECTION_FLAG_DEFAULT sub render —
|
||||||
|
// leaves the cache stale and getCurrentSubtitleTrack() reporting
|
||||||
|
// the old id. Run before applyInitialTrackSelections() so an
|
||||||
|
// explicit initial selection still wins.
|
||||||
|
syncCurrentSubtitleIdFromSelection(tracks)
|
||||||
|
applyInitialTrackSelections()
|
||||||
|
// A track change can re-initialize the codec under a different
|
||||||
|
// name (e.g. adaptive switch from HEVC to AV1). Clear stale
|
||||||
|
// decoder names so getTechnicalInfo() doesn't report the
|
||||||
|
// previous codec until the next onVideoDecoderInitialized fires.
|
||||||
|
videoDecoderName = null
|
||||||
|
audioDecoderName = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val progressRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val p = player ?: return
|
||||||
|
val positionMs = p.currentPosition
|
||||||
|
val durationMs = p.duration
|
||||||
|
val bufferedMs = p.bufferedPosition
|
||||||
|
|
||||||
|
val positionSec = positionMs / 1000.0
|
||||||
|
val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0
|
||||||
|
val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0
|
||||||
|
|
||||||
|
onProgress(mapOf(
|
||||||
|
"position" to positionSec,
|
||||||
|
"duration" to durationSec,
|
||||||
|
"progress" to if (durationSec > 0) positionSec / durationSec else 0.0,
|
||||||
|
"cacheSeconds" to cacheSec
|
||||||
|
))
|
||||||
|
|
||||||
|
mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
|
playerView = PlayerView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
// SurfaceView-backed for parity with MPV (direct surface to
|
||||||
|
// SurfaceFlinger). PlayerView defaults to a SurfaceView, so no
|
||||||
|
// explicit setSurfaceType() call is needed; the int constants
|
||||||
|
// backing it are @IntDef private in Media3.
|
||||||
|
setUseController(false)
|
||||||
|
setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||||
|
}
|
||||||
|
subtitleView = playerView.subtitleView
|
||||||
|
addView(playerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Video Loading
|
||||||
|
|
||||||
|
fun loadVideo(config: VideoLoadConfig) {
|
||||||
|
if (currentUrl == config.url) return
|
||||||
|
currentUrl = config.url
|
||||||
|
pendingConfig = config
|
||||||
|
ensurePlayer(config)
|
||||||
|
loadInternal(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensurePlayer(config: VideoLoadConfig) {
|
||||||
|
if (player != null) return
|
||||||
|
|
||||||
|
val loadControl = buildLoadControl(config)
|
||||||
|
|
||||||
|
// PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD /
|
||||||
|
// AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a
|
||||||
|
// hardware decoder for the format. MediaCodec remains the fallback.
|
||||||
|
val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context)
|
||||||
|
.setExtensionRendererMode(
|
||||||
|
androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
|
||||||
|
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||||
|
.setLoadControl(loadControl)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.build(),
|
||||||
|
/* handleAudioFocus = */ true
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
exo.addListener(playerListener)
|
||||||
|
exo.addAnalyticsListener(analyticsListener)
|
||||||
|
exo.repeatMode = Player.REPEAT_MODE_OFF
|
||||||
|
player = exo
|
||||||
|
playerView.player = exo
|
||||||
|
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl {
|
||||||
|
// Map MPV-style cache config to ExoPlayer's LoadControl.
|
||||||
|
val cacheEnabled = when (config.cacheEnabled) {
|
||||||
|
"no" -> false
|
||||||
|
"yes" -> true
|
||||||
|
else -> true // "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer thresholds used as fallbacks when the user's cache config
|
||||||
|
// doesn't override them. Media3's own defaults changed in 1.6.0
|
||||||
|
// (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a
|
||||||
|
// faster start; we intentionally keep the older 2500/5000 here
|
||||||
|
// because low-RAM Android TVs with slow tuners benefit from the
|
||||||
|
// extra headroom before playback kicks in. Media3's DEFAULT_*
|
||||||
|
// IntDef fields are private, hence the literals.
|
||||||
|
val defaultMinBufferMs = 15000
|
||||||
|
val defaultBufferForPlaybackMs = 2500
|
||||||
|
val defaultBufferForPlaybackAfterRebufferMs = 5000
|
||||||
|
|
||||||
|
val targetBufferMs = if (!cacheEnabled) {
|
||||||
|
50000
|
||||||
|
} else {
|
||||||
|
val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10
|
||||||
|
seconds * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
val backBufferMs = if (!cacheEnabled) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
val mb = config.demuxerMaxBackBytes ?: 50
|
||||||
|
// Heuristic: 1 MB ≈ 1s of typical 1080p bitrate.
|
||||||
|
(mb * 1000).coerceAtLeast(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = DefaultLoadControl.Builder()
|
||||||
|
.setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024))
|
||||||
|
.setBufferDurationsMs(
|
||||||
|
/* minBufferMs = */ defaultMinBufferMs,
|
||||||
|
/* maxBufferMs = */ targetBufferMs,
|
||||||
|
/* bufferForPlaybackMs = */ defaultBufferForPlaybackMs,
|
||||||
|
/* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs
|
||||||
|
)
|
||||||
|
if (cacheEnabled) {
|
||||||
|
builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInternal(config: VideoLoadConfig) {
|
||||||
|
val p = player ?: return
|
||||||
|
|
||||||
|
val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
|
||||||
|
.setDefaultRequestProperties(config.headers ?: emptyMap())
|
||||||
|
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
|
||||||
|
|
||||||
|
val mediaItem = buildMediaItem(config)
|
||||||
|
|
||||||
|
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
|
||||||
|
.createMediaSource(mediaItem)
|
||||||
|
|
||||||
|
// Resolve the resume position once and hand it to setMediaSource() so
|
||||||
|
// ExoPlayer prepares from that position directly. Calling prepare()
|
||||||
|
// first (which buffers from 0) and seekTo() afterwards makes the
|
||||||
|
// opening seconds replay from the start before jumping to the resume
|
||||||
|
// point — the "repeats the first few seconds" symptom. C.TIME_UNSET
|
||||||
|
// falls back to the media's default start position (0).
|
||||||
|
val startMs = config.startPosition?.let { sp ->
|
||||||
|
if (sp > 0) (sp * 1000).toLong() else C.TIME_UNSET
|
||||||
|
} ?: C.TIME_UNSET
|
||||||
|
|
||||||
|
loadStartPositionMs = if (startMs != C.TIME_UNSET) startMs else 0L
|
||||||
|
|
||||||
|
p.setMediaSource(mediaSource, startMs)
|
||||||
|
p.prepare()
|
||||||
|
|
||||||
|
if (config.autoplay) {
|
||||||
|
p.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(mapOf("url" to config.url))
|
||||||
|
startProgressLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMediaItem(config: VideoLoadConfig): MediaItem {
|
||||||
|
val builder = MediaItem.Builder().setUri(config.url)
|
||||||
|
|
||||||
|
// External subtitles: add as side-loaded SubtitleConfigurations.
|
||||||
|
// MIME-type sniffed from the file extension.
|
||||||
|
val subs = config.externalSubtitles
|
||||||
|
if (!subs.isNullOrEmpty()) {
|
||||||
|
val subtitleConfigs = subs.mapNotNull { subUrl ->
|
||||||
|
val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null
|
||||||
|
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl))
|
||||||
|
.setMimeType(mime)
|
||||||
|
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
if (subtitleConfigs.isNotEmpty()) {
|
||||||
|
sideLoadedSubs = subtitleConfigs
|
||||||
|
builder.setSubtitleConfigurations(subtitleConfigs)
|
||||||
|
} else {
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeForSubtitleUrl(url: String): String? {
|
||||||
|
val lower = url.substringBeforeLast('?').lowercase()
|
||||||
|
return when {
|
||||||
|
lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt"
|
||||||
|
lower.endsWith(".srt") -> "application/x-subrip"
|
||||||
|
lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa"
|
||||||
|
lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
stopProgressLoop()
|
||||||
|
player?.release()
|
||||||
|
player = null
|
||||||
|
playerView.player = null
|
||||||
|
tracksReadyFired = false
|
||||||
|
currentUrl = null
|
||||||
|
loadStartPositionMs = 0L
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
subtitleTrackList = emptyList()
|
||||||
|
audioTrackList = emptyList()
|
||||||
|
currentSubtitleId = 0
|
||||||
|
currentAudioId = 0
|
||||||
|
videoDecoderName = null
|
||||||
|
audioDecoderName = null
|
||||||
|
cumulativeDroppedFrames = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(positionSec: Double) {
|
||||||
|
val targetMs = (positionSec * 1000).toLong()
|
||||||
|
// Drop the redundant initial seek that mirrors the resume position we
|
||||||
|
// already applied via setMediaSource() — see loadInternal. Only the
|
||||||
|
// first seek after load is eligible, so a genuine seek to ~the start
|
||||||
|
// later on still works.
|
||||||
|
if (loadStartPositionMs > 0 && Math.abs(targetMs - loadStartPositionMs) < 1000L) {
|
||||||
|
loadStartPositionMs = 0L
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadStartPositionMs = 0L
|
||||||
|
player?.seekTo(targetMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekBy(offsetSec: Double) {
|
||||||
|
val p = player ?: return
|
||||||
|
val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0)
|
||||||
|
p.seekTo(target.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeed(speed: Double) {
|
||||||
|
player?.playbackParameters = PlaybackParameters(speed.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSpeed(): Float {
|
||||||
|
return player?.playbackParameters?.speed ?: 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPaused(): Boolean {
|
||||||
|
return player?.isPlaying == false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentPosition(): Double {
|
||||||
|
return (player?.currentPosition ?: 0L) / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDuration(): Double {
|
||||||
|
val d = player?.duration ?: 0L
|
||||||
|
return if (d > 0) d / 1000.0 else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Track Mapping (1-based IDs to match MPV's contract)
|
||||||
|
|
||||||
|
data class TrackEntry(
|
||||||
|
val id: Int, // 1-based JS-facing ID
|
||||||
|
val trackGroupIndex: Int,
|
||||||
|
val trackIndex: Int,
|
||||||
|
val format: Format,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun rebuildTrackMaps(tracks: Tracks?) {
|
||||||
|
if (tracks == null) return
|
||||||
|
|
||||||
|
val subtitles = mutableListOf<TrackEntry>()
|
||||||
|
val audios = mutableListOf<TrackEntry>()
|
||||||
|
|
||||||
|
tracks.groups.forEachIndexed { groupIndex, group ->
|
||||||
|
val rendererType = group.type
|
||||||
|
// Skip groups that have no tracks the player supports
|
||||||
|
for (trackIdx in 0 until group.length) {
|
||||||
|
if (!group.isTrackSupported(trackIdx)) continue
|
||||||
|
val format = group.getTrackFormat(trackIdx)
|
||||||
|
val entry = TrackEntry(
|
||||||
|
id = 0, // assigned per-list below
|
||||||
|
trackGroupIndex = groupIndex,
|
||||||
|
trackIndex = trackIdx,
|
||||||
|
format = format
|
||||||
|
)
|
||||||
|
when (rendererType) {
|
||||||
|
C.TRACK_TYPE_TEXT -> subtitles.add(entry)
|
||||||
|
C.TRACK_TYPE_AUDIO -> audios.add(entry)
|
||||||
|
else -> { /* video / metadata ignored */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign 1-based IDs per track kind.
|
||||||
|
subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) }
|
||||||
|
audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) }
|
||||||
|
|
||||||
|
subtitleTrackList = subtitles
|
||||||
|
audioTrackList = audios
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the player's actually-selected text track into [currentSubtitleId].
|
||||||
|
* Must run after [rebuildTrackMaps] so the 1-based IDs in [subtitleTrackList]
|
||||||
|
* are current for this [tracks] snapshot. Falls back to 0 (subs off) when no
|
||||||
|
* text track is selected.
|
||||||
|
*/
|
||||||
|
private fun syncCurrentSubtitleIdFromSelection(tracks: Tracks) {
|
||||||
|
val groups = tracks.groups
|
||||||
|
val selected = subtitleTrackList.firstOrNull { entry ->
|
||||||
|
groups[entry.trackGroupIndex].isTrackSelected(entry.trackIndex)
|
||||||
|
}
|
||||||
|
currentSubtitleId = selected?.id ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyInitialTrackSelections() {
|
||||||
|
val p = player ?: return
|
||||||
|
val cfg = pendingConfig ?: return
|
||||||
|
|
||||||
|
// Initial subtitle/audio selection by 1-based ID.
|
||||||
|
if (cfg.initialAudioId != null && cfg.initialAudioId > 0) {
|
||||||
|
setAudioTrack(cfg.initialAudioId)
|
||||||
|
}
|
||||||
|
if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) {
|
||||||
|
disableSubtitles()
|
||||||
|
} else {
|
||||||
|
setSubtitleTrack(cfg.initialSubtitleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only apply once per source load.
|
||||||
|
pendingConfig = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
|
return subtitleTrackList.map { entry ->
|
||||||
|
mapOf(
|
||||||
|
"id" to entry.id,
|
||||||
|
"title" to (entry.format.label ?: ""),
|
||||||
|
"lang" to (entry.format.language ?: "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
|
val p = player ?: return
|
||||||
|
val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return
|
||||||
|
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||||
|
|
||||||
|
// setOverrideForType replaces any existing override of the same
|
||||||
|
// track type — exactly what we want for single-track subtitle pickers.
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentSubtitleId = trackId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableSubtitles() {
|
||||||
|
val p = player ?: return
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentSubtitleId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSubtitleTrack(): Int = currentSubtitleId
|
||||||
|
|
||||||
|
fun addSubtitleFile(url: String, select: Boolean) {
|
||||||
|
val p = player ?: return
|
||||||
|
val mime = mimeTypeForSubtitleUrl(url) ?: return
|
||||||
|
val currentMediaItem = p.currentMediaItem ?: return
|
||||||
|
val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
|
||||||
|
.setMimeType(mime)
|
||||||
|
.setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Rebuild with the full accumulated list so previously loaded
|
||||||
|
// side-loaded subs (from VideoLoadConfig.externalSubtitles or
|
||||||
|
// earlier addSubtitleFile calls) survive.
|
||||||
|
val combined = sideLoadedSubs + newSubConfig
|
||||||
|
sideLoadedSubs = combined
|
||||||
|
|
||||||
|
val rebuilt = currentMediaItem.buildUpon()
|
||||||
|
.setSubtitleConfigurations(combined)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val wasPlaying = p.isPlaying
|
||||||
|
val pos = p.currentPosition
|
||||||
|
p.setMediaItem(rebuilt, pos)
|
||||||
|
p.prepare()
|
||||||
|
if (wasPlaying) p.play()
|
||||||
|
|
||||||
|
// Re-enabling text tracks alone isn't enough: a prior text override
|
||||||
|
// (e.g. left in place by disableSubtitles, or set by setSubtitleTrack
|
||||||
|
// before this add) survives the MediaItem rebuild and, since embedded
|
||||||
|
// subtitle groups persist across it, would win over the new sub's
|
||||||
|
// SELECTION_FLAG_DEFAULT. Clear any text override so the new default
|
||||||
|
// sub is the one that renders.
|
||||||
|
if (select) {
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Positioning / Styling
|
||||||
|
|
||||||
|
fun setSubtitlePosition(position: Int) {
|
||||||
|
// position is 0-100 (MPV convention: 100 = bottom, 0 = top).
|
||||||
|
// Map to SubtitleView's bottom-padding fraction. Reserve a small
|
||||||
|
// margin so 100 doesn't hug the very bottom edge.
|
||||||
|
val clamped = position.coerceIn(0, 100)
|
||||||
|
subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleScale(scale: Double) {
|
||||||
|
subtitleScale = scale.toFloat()
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
|
// Margin in px (approximate). SubtitleView only accepts a single
|
||||||
|
// bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1%
|
||||||
|
// of view height, capped). Last-write-wins vs. setSubtitlePosition.
|
||||||
|
val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f)
|
||||||
|
subtitleBottomFraction = fraction
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
|
subtitleAlignY = alignment
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleFontSize(size: Int) {
|
||||||
|
subtitleFontSizePct = size
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleBackgroundColor(colorHex: String) {
|
||||||
|
subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor)
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleBorderStyle(style: String) {
|
||||||
|
subtitleBorderStyle = style
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseColor(hex: String, fallback: Int): Int {
|
||||||
|
return try {
|
||||||
|
when {
|
||||||
|
hex.startsWith("#") && hex.length == 9 -> {
|
||||||
|
// #RRGGBBAA
|
||||||
|
val r = hex.substring(1, 3).toInt(16)
|
||||||
|
val g = hex.substring(3, 5).toInt(16)
|
||||||
|
val b = hex.substring(5, 7).toInt(16)
|
||||||
|
val a = hex.substring(7, 9).toInt(16)
|
||||||
|
Color.argb(a, r, g, b)
|
||||||
|
}
|
||||||
|
hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex)
|
||||||
|
else -> fallback
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySubtitleStyle() {
|
||||||
|
val sv = subtitleView ?: return
|
||||||
|
|
||||||
|
// Text size: explicit % wins; otherwise scale the default.
|
||||||
|
val textSizeFraction = if (subtitleFontSizePct != null) {
|
||||||
|
(subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION
|
||||||
|
} else {
|
||||||
|
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale
|
||||||
|
}
|
||||||
|
sv.setFractionalTextSize(textSizeFraction)
|
||||||
|
|
||||||
|
// Vertical position: explicit fraction (from setSubtitlePosition /
|
||||||
|
// setSubtitleMarginY) wins; otherwise fall back to alignY mapping.
|
||||||
|
val alignYFraction = when (subtitleAlignY) {
|
||||||
|
"top" -> 0.9f
|
||||||
|
"center" -> 0.5f
|
||||||
|
else -> 0.08f // bottom
|
||||||
|
}
|
||||||
|
val bottomFraction = subtitleBottomFraction ?: alignYFraction
|
||||||
|
sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f))
|
||||||
|
|
||||||
|
// Edge / background style.
|
||||||
|
val foreground = Color.WHITE
|
||||||
|
val edgeType: Int
|
||||||
|
val backgroundColor: Int
|
||||||
|
when (subtitleBorderStyle) {
|
||||||
|
"background-box" -> {
|
||||||
|
edgeType = CaptionStyleCompat.EDGE_TYPE_NONE
|
||||||
|
// subtitleBackgroundColor already carries its own alpha
|
||||||
|
// (parsed from #RRGGBBAA by setSubtitleBackgroundColor).
|
||||||
|
// Alpha 0 → transparent, matching user intent.
|
||||||
|
backgroundColor = subtitleBackgroundColor
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// "outline-and-shadow"
|
||||||
|
edgeType = if (subtitleAlignY == "center")
|
||||||
|
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||||
|
else
|
||||||
|
CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||||
|
backgroundColor = Color.TRANSPARENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val style = CaptionStyleCompat(
|
||||||
|
foreground,
|
||||||
|
backgroundColor,
|
||||||
|
Color.TRANSPARENT,
|
||||||
|
edgeType,
|
||||||
|
Color.BLACK,
|
||||||
|
Typeface.SANS_SERIF
|
||||||
|
)
|
||||||
|
sv.setApplyEmbeddedStyles(false)
|
||||||
|
sv.setApplyEmbeddedFontSizes(false)
|
||||||
|
sv.setStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
|
return audioTrackList.map { entry ->
|
||||||
|
// channelCount is Format.NO_VALUE (-1) when unknown — report 0.
|
||||||
|
val channels = if (entry.format.channelCount == Format.NO_VALUE) 0
|
||||||
|
else entry.format.channelCount
|
||||||
|
mapOf(
|
||||||
|
"id" to entry.id,
|
||||||
|
"title" to (entry.format.label ?: ""),
|
||||||
|
"lang" to (entry.format.language ?: ""),
|
||||||
|
"codec" to (entry.format.sampleMimeType ?: ""),
|
||||||
|
"channels" to channels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAudioTrack(trackId: Int) {
|
||||||
|
val p = player ?: return
|
||||||
|
val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return
|
||||||
|
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||||
|
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||||
|
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentAudioId = trackId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentAudioTrack(): Int = currentAudioId
|
||||||
|
|
||||||
|
// MARK: - Video Scaling
|
||||||
|
|
||||||
|
fun setZoomedToFill(zoomed: Boolean) {
|
||||||
|
isZoomedToFill = zoomed
|
||||||
|
val resizeMode = if (zoomed) {
|
||||||
|
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||||
|
} else {
|
||||||
|
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
}
|
||||||
|
playerView.resizeMode = resizeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isZoomedToFill(): Boolean = isZoomedToFill
|
||||||
|
|
||||||
|
// MARK: - Technical Info
|
||||||
|
|
||||||
|
fun getTechnicalInfo(): Map<String, Any> {
|
||||||
|
val p = player ?: return emptyMap()
|
||||||
|
val tracks = p.currentTracks
|
||||||
|
|
||||||
|
// Prefer the currently-selected track within each renderer group;
|
||||||
|
// fall back to the first supported track if none is selected yet.
|
||||||
|
val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO)
|
||||||
|
val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO)
|
||||||
|
|
||||||
|
val cacheSec = if (p.bufferedPosition > p.currentPosition) {
|
||||||
|
(p.bufferedPosition - p.currentPosition) / 1000.0
|
||||||
|
} else 0.0
|
||||||
|
|
||||||
|
val info = LinkedHashMap<String, Any>()
|
||||||
|
info["cacheSeconds"] = cacheSec
|
||||||
|
|
||||||
|
// Dropped frames — populated by analyticsListener.onDroppedVideoFrames.
|
||||||
|
if (cumulativeDroppedFrames > 0) {
|
||||||
|
info["droppedFrames"] = cumulativeDroppedFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized.
|
||||||
|
// For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The
|
||||||
|
// FFmpeg extension reports names beginning with "FFmpeg", which we
|
||||||
|
// classify as software; everything else is MediaCodec (hardware).
|
||||||
|
videoDecoderName?.let { name ->
|
||||||
|
info["decoderName"] = name
|
||||||
|
info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) {
|
||||||
|
"software"
|
||||||
|
} else {
|
||||||
|
"hardware"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoFormat?.let { f ->
|
||||||
|
if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width
|
||||||
|
if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height
|
||||||
|
f.sampleMimeType?.let { info["videoCodec"] = it }
|
||||||
|
// FPS: Format.NO_VALUE (-1f) means unknown — omit so the
|
||||||
|
// overlay skips the row instead of showing "-1".
|
||||||
|
if (f.frameRate > 0f) {
|
||||||
|
info["fps"] = f.frameRate.toDouble()
|
||||||
|
}
|
||||||
|
// Bitrate: prefer average, fall back to peak. Both can be
|
||||||
|
// NO_VALUE for adaptive HLS renditions — omit when unknown
|
||||||
|
// rather than reporting 0 Kbps.
|
||||||
|
val vBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||||
|
f.averageBitrate
|
||||||
|
} else {
|
||||||
|
f.peakBitrate
|
||||||
|
}
|
||||||
|
if (vBitrate != Format.NO_VALUE && vBitrate > 0) {
|
||||||
|
info["videoBitrate"] = vBitrate.toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw codec tag from the container (e.g. "hev1.2.4.L153.B0").
|
||||||
|
// Carries profile / tier / level / constraint bytes — power
|
||||||
|
// users can decode it manually to see why a stream hit our
|
||||||
|
// HEVC level cap.
|
||||||
|
f.codecs?.let { info["videoCodecs"] = it }
|
||||||
|
|
||||||
|
// HDR / color metadata. Format.colorInfo is the authoritative
|
||||||
|
// source — the file/Jellyfin may claim HDR but the player is
|
||||||
|
// what decides whether the decoder+surface path is HDR-capable.
|
||||||
|
f.colorInfo?.let { ci ->
|
||||||
|
val hdr = deriveHdrFormat(ci)
|
||||||
|
if (hdr != null) info["hdrFormat"] = hdr
|
||||||
|
colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it }
|
||||||
|
colorRangeName(ci.colorRange)?.let { info["colorRange"] = it }
|
||||||
|
colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioFormat?.let { f ->
|
||||||
|
f.sampleMimeType?.let { info["audioCodec"] = it }
|
||||||
|
val aBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||||
|
f.averageBitrate
|
||||||
|
} else {
|
||||||
|
f.peakBitrate
|
||||||
|
}
|
||||||
|
if (aBitrate != Format.NO_VALUE && aBitrate > 0) {
|
||||||
|
info["audioBitrate"] = aBitrate.toDouble()
|
||||||
|
}
|
||||||
|
if (f.channelCount > 0) info["audioChannels"] = f.channelCount
|
||||||
|
if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the active color transfer to a human-readable HDR format string.
|
||||||
|
* Returns null for SDR / unknown so the overlay can skip the row.
|
||||||
|
*
|
||||||
|
* HDR10 vs HDR10+ distinction isn't possible from Format alone in
|
||||||
|
* Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't
|
||||||
|
* exposed on Format. Both report as "HDR10" here; that matches what
|
||||||
|
* Media3 actually decodes (no HDR10+ tone-mapping).
|
||||||
|
*/
|
||||||
|
private fun deriveHdrFormat(ci: ColorInfo): String? {
|
||||||
|
return when (ci.colorTransfer) {
|
||||||
|
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||||
|
C.COLOR_TRANSFER_ST2084 -> "HDR10"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorSpaceName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_SPACE_BT709 -> "BT.709"
|
||||||
|
C.COLOR_SPACE_BT601 -> "BT.601"
|
||||||
|
C.COLOR_SPACE_BT2020 -> "BT.2020"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorRangeName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_RANGE_LIMITED -> "Limited"
|
||||||
|
C.COLOR_RANGE_FULL -> "Full"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorTransferName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_TRANSFER_SDR -> "SDR"
|
||||||
|
C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)"
|
||||||
|
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||||
|
C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickFormat(tracks: Tracks, type: Int): Format? {
|
||||||
|
val group = tracks.groups.firstOrNull { it.type == type } ?: return null
|
||||||
|
// Selected track wins.
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (group.isTrackSelected(i)) return group.getTrackFormat(i)
|
||||||
|
}
|
||||||
|
// Otherwise the first supported track.
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (group.isTrackSupported(i)) return group.getTrackFormat(i)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Loop
|
||||||
|
|
||||||
|
private fun startProgressLoop() {
|
||||||
|
stopProgressLoop()
|
||||||
|
mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopProgressLoop() {
|
||||||
|
mainHandler.removeCallbacks(progressRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
6
modules/exoplayer-player/expo-module.config.json
Normal file
6
modules/exoplayer-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
modules/exoplayer-player/index.ts
Normal file
19
modules/exoplayer-player/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Re-export the shared player contract from mpv-player so ExoPlayer
|
||||||
|
// and MPV present identical surfaces to React. The MPV-prefixed setting
|
||||||
|
// keys keep their names to avoid migrating existing installs.
|
||||||
|
export type {
|
||||||
|
AudioTrack,
|
||||||
|
MpvPlayerViewProps,
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
NowPlayingMetadata,
|
||||||
|
OnErrorEventPayload,
|
||||||
|
OnLoadEventPayload,
|
||||||
|
OnPictureInPictureChangePayload,
|
||||||
|
OnPlaybackStateChangePayload,
|
||||||
|
OnProgressEventPayload,
|
||||||
|
OnTracksReadyEventPayload,
|
||||||
|
SubtitleTrack,
|
||||||
|
TechnicalInfo,
|
||||||
|
VideoSource,
|
||||||
|
} from "../mpv-player/src/MpvPlayer.types";
|
||||||
|
export { default as ExoPlayerView } from "./src/ExoPlayerView";
|
||||||
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal file
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { requireNativeView } from "expo";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useImperativeHandle, useRef } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MpvPlayerViewProps,
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
} from "@/modules/mpv-player";
|
||||||
|
|
||||||
|
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||||
|
requireNativeView("ExoPlayer");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExoPlayer view wrapper. Exposes the same `MpvPlayerViewRef` interface as
|
||||||
|
* `MpvPlayerView` so callers can swap between the two players without
|
||||||
|
* changing code. PiP / ASS-override methods are forwarded to the native
|
||||||
|
* module which implements them as no-ops.
|
||||||
|
*/
|
||||||
|
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||||
|
function ExoPlayerView(props, ref) {
|
||||||
|
const nativeRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play: async () => {
|
||||||
|
await nativeRef.current?.play();
|
||||||
|
},
|
||||||
|
pause: async () => {
|
||||||
|
await nativeRef.current?.pause();
|
||||||
|
},
|
||||||
|
destroy: async () => {
|
||||||
|
await nativeRef.current?.destroy();
|
||||||
|
},
|
||||||
|
seekTo: async (position: number) => {
|
||||||
|
await nativeRef.current?.seekTo(position);
|
||||||
|
},
|
||||||
|
seekBy: async (offset: number) => {
|
||||||
|
await nativeRef.current?.seekBy(offset);
|
||||||
|
},
|
||||||
|
setSpeed: async (speed: number) => {
|
||||||
|
await nativeRef.current?.setSpeed(speed);
|
||||||
|
},
|
||||||
|
getSpeed: async () => {
|
||||||
|
return await nativeRef.current?.getSpeed();
|
||||||
|
},
|
||||||
|
isPaused: async () => {
|
||||||
|
return await nativeRef.current?.isPaused();
|
||||||
|
},
|
||||||
|
getCurrentPosition: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentPosition();
|
||||||
|
},
|
||||||
|
getDuration: async () => {
|
||||||
|
return await nativeRef.current?.getDuration();
|
||||||
|
},
|
||||||
|
startPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.startPictureInPicture();
|
||||||
|
},
|
||||||
|
stopPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.stopPictureInPicture();
|
||||||
|
},
|
||||||
|
isPictureInPictureSupported: async () => {
|
||||||
|
return await nativeRef.current?.isPictureInPictureSupported();
|
||||||
|
},
|
||||||
|
isPictureInPictureActive: async () => {
|
||||||
|
return await nativeRef.current?.isPictureInPictureActive();
|
||||||
|
},
|
||||||
|
getSubtitleTracks: async () => {
|
||||||
|
return await nativeRef.current?.getSubtitleTracks();
|
||||||
|
},
|
||||||
|
setSubtitleTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||||
|
},
|
||||||
|
disableSubtitles: async () => {
|
||||||
|
await nativeRef.current?.disableSubtitles();
|
||||||
|
},
|
||||||
|
getCurrentSubtitleTrack: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentSubtitleTrack();
|
||||||
|
},
|
||||||
|
addSubtitleFile: async (url: string, select = true) => {
|
||||||
|
await nativeRef.current?.addSubtitleFile(url, select);
|
||||||
|
},
|
||||||
|
setSubtitlePosition: async (position: number) => {
|
||||||
|
await nativeRef.current?.setSubtitlePosition(position);
|
||||||
|
},
|
||||||
|
setSubtitleScale: async (scale: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleScale(scale);
|
||||||
|
},
|
||||||
|
setSubtitleMarginY: async (margin: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||||
|
},
|
||||||
|
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||||
|
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||||
|
},
|
||||||
|
setSubtitleFontSize: async (size: number) => {
|
||||||
|
await nativeRef.current?.setSubtitleFontSize(size);
|
||||||
|
},
|
||||||
|
setSubtitleBackgroundColor: async (color: string) => {
|
||||||
|
await nativeRef.current?.setSubtitleBackgroundColor(color);
|
||||||
|
},
|
||||||
|
setSubtitleBorderStyle: async (
|
||||||
|
style: "outline-and-shadow" | "background-box",
|
||||||
|
) => {
|
||||||
|
await nativeRef.current?.setSubtitleBorderStyle(style);
|
||||||
|
},
|
||||||
|
setSubtitleAssOverride: async (mode: "no" | "force") => {
|
||||||
|
await nativeRef.current?.setSubtitleAssOverride(mode);
|
||||||
|
},
|
||||||
|
getAudioTracks: async () => {
|
||||||
|
return await nativeRef.current?.getAudioTracks();
|
||||||
|
},
|
||||||
|
setAudioTrack: async (trackId: number) => {
|
||||||
|
await nativeRef.current?.setAudioTrack(trackId);
|
||||||
|
},
|
||||||
|
getCurrentAudioTrack: async () => {
|
||||||
|
return await nativeRef.current?.getCurrentAudioTrack();
|
||||||
|
},
|
||||||
|
setZoomedToFill: async (zoomed: boolean) => {
|
||||||
|
await nativeRef.current?.setZoomedToFill(zoomed);
|
||||||
|
},
|
||||||
|
isZoomedToFill: async () => {
|
||||||
|
return await nativeRef.current?.isZoomedToFill();
|
||||||
|
},
|
||||||
|
getTechnicalInfo: async () => {
|
||||||
|
return await nativeRef.current?.getTechnicalInfo();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <NativeView ref={nativeRef} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -7,6 +7,8 @@ export type {
|
|||||||
DownloadStartedEvent,
|
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";
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
private var currentUrl: String? = null
|
private var currentUrl: String? = null
|
||||||
private var currentHeaders: Map<String, String>? = null
|
private var currentHeaders: Map<String, String>? = null
|
||||||
private var pendingExternalSubtitles: List<String> = emptyList()
|
private var pendingExternalSubtitles: List<String> = emptyList()
|
||||||
|
// Persistent record of the external subtitle URLs attached to the
|
||||||
|
// current item. pendingExternalSubtitles above is a one-shot staging
|
||||||
|
// list: load() fills it and the FILE_LOADED handler drains it via
|
||||||
|
// sub-add, so it is always empty by the time a resume-recovery reload
|
||||||
|
// runs. This copy survives that drain so recoverVideoOutput() can hand
|
||||||
|
// the same sidecar URLs back to load() and the tracks re-attach after
|
||||||
|
// the decoder reset.
|
||||||
|
private var activeExternalSubtitles: List<String> = emptyList()
|
||||||
private var initialSubtitleId: Int? = null
|
private var initialSubtitleId: Int? = null
|
||||||
private var initialAudioId: Int? = null
|
private var initialAudioId: Int? = null
|
||||||
|
|
||||||
@@ -142,6 +150,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
*/
|
*/
|
||||||
private var voDriver: String = "gpu-next"
|
private var voDriver: String = "gpu-next"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on Android TV form-factor devices. Drives both the hwdec selection
|
||||||
|
* in [start] and the TV-only resume-recovery gating in [MpvPlayerView].
|
||||||
|
* Computed once from the system UI mode.
|
||||||
|
*/
|
||||||
|
val isTv: Boolean = isTvDevice()
|
||||||
|
|
||||||
fun start(voDriver: String = "gpu-next") {
|
fun start(voDriver: String = "gpu-next") {
|
||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
|
|
||||||
@@ -152,13 +167,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.mpv = mpv
|
this.mpv = mpv
|
||||||
mpv.addObserver(this)
|
mpv.addObserver(this)
|
||||||
|
|
||||||
// Resolved once — TV gets the memory-pressure customizations
|
|
||||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
|
||||||
// audio-buffer) that would be counterproductive on higher-RAM
|
|
||||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
|
||||||
// those come from user settings via load().
|
|
||||||
val isTV = isTvDevice()
|
|
||||||
|
|
||||||
// mpv config directory — used by the config-dir option below and
|
// mpv config directory — used by the config-dir option below and
|
||||||
// as XDG_CONFIG_HOME for fontconfig.
|
// as XDG_CONFIG_HOME for fontconfig.
|
||||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||||
@@ -212,7 +220,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||||
when {
|
when {
|
||||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||||
isTV -> {
|
isTv -> {
|
||||||
mpv?.setOptionString("hwdec", "mediacodec")
|
mpv?.setOptionString("hwdec", "mediacodec")
|
||||||
mpv?.setOptionString("profile", "fast")
|
mpv?.setOptionString("profile", "fast")
|
||||||
// Don't retain already-played content for backward
|
// Don't retain already-played content for backward
|
||||||
@@ -270,6 +278,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
currentUrl = null
|
currentUrl = null
|
||||||
currentHeaders = null
|
currentHeaders = null
|
||||||
pendingExternalSubtitles = emptyList()
|
pendingExternalSubtitles = emptyList()
|
||||||
|
activeExternalSubtitles = emptyList()
|
||||||
initialSubtitleId = null
|
initialSubtitleId = null
|
||||||
initialAudioId = null
|
initialAudioId = null
|
||||||
cachedPosition = 0.0
|
cachedPosition = 0.0
|
||||||
@@ -377,18 +386,67 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force mpv to render a frame to the current surface.
|
* Restore video after a system-initiated surface loss (Android TV
|
||||||
* Steps forward one frame then seeks back to the original position.
|
* screensaver / app background while paused). Triggered from the host
|
||||||
* Used after PiP entry to work around mpv stopping pixel output.
|
* activity's onResume via [MpvPlayerView.runResumeRecovery].
|
||||||
|
*
|
||||||
|
* On TV, `hwdec=mediacodec` (zero-copy) binds MediaCodec directly to the
|
||||||
|
* display surface. When the screensaver invalidates that surface, the
|
||||||
|
* decoder is left bound to dead buffers and mpv auto-disables the video
|
||||||
|
* track (vid=no). Re-attaching the surface + cycling hwdec + re-selecting
|
||||||
|
* vid + seeking rebuilds the pipeline but leaves the video chain at EOF
|
||||||
|
* ("video=eof" in playback-restart) — the recreated MediaCodec produces no
|
||||||
|
* frames. Only a fresh `loadfile` deterministically recreates the decoder
|
||||||
|
* against the live surface, so we reload at the cached position.
|
||||||
|
*
|
||||||
|
* This is the Android counterpart to iOS's `performDecoderReset()`, which
|
||||||
|
* can get away with a `hwdec` cycle because VideoToolbox reinitializes
|
||||||
|
* cleanly; zero-copy MediaCodec bound to a lost surface does not.
|
||||||
*/
|
*/
|
||||||
fun forceRedraw() {
|
fun recoverVideoOutput(surface: Surface?) {
|
||||||
if (!isRunning) return
|
if (!isRunning) {
|
||||||
val pos = cachedPosition
|
Log.w(TAG, "[Recover] recoverVideoOutput — renderer not running, skipping")
|
||||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
return
|
||||||
mpv?.command(arrayOf("frame-step"))
|
|
||||||
if (pos > 0) {
|
|
||||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
|
||||||
}
|
}
|
||||||
|
val url = currentUrl ?: run {
|
||||||
|
Log.w(TAG, "[Recover] recoverVideoOutput — no URL loaded, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"[Recover] reload recovery — pos=$cachedPosition, aid=${getCurrentAudioTrack()}, sid=${getCurrentSubtitleTrack()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Re-attach the surface first (covers the case where the surface
|
||||||
|
// survived and surfaceCreated never fired). The reload below fully
|
||||||
|
// rebuilds the pipeline anyway, but this keeps the VO target current
|
||||||
|
// while the load is in flight.
|
||||||
|
surface?.takeIf { it.isValid }?.let { mpv?.attachSurface(it) }
|
||||||
|
|
||||||
|
// Preserve the user's current audio/subtitle selection across the
|
||||||
|
// reload — pass them INTO load() (not via the field: load() overwrites
|
||||||
|
// the initial IDs from its parameters). They're re-applied when
|
||||||
|
// FILE_LOADED fires. (0 / "no" means "off", which setSubtitleTrack /
|
||||||
|
// setAudioTrack handle correctly.)
|
||||||
|
val savedAid = getCurrentAudioTrack()
|
||||||
|
val savedSid = getCurrentSubtitleTrack()
|
||||||
|
|
||||||
|
// Full reload at the paused position. With keep-open=always +
|
||||||
|
// cache-pause-initial=yes, mpv seeks to the position and holds on the
|
||||||
|
// first decoded frame, so the paused frame reappears.
|
||||||
|
load(
|
||||||
|
url = url,
|
||||||
|
headers = currentHeaders,
|
||||||
|
startPosition = cachedPosition,
|
||||||
|
initialAudioId = savedAid,
|
||||||
|
initialSubtitleId = savedSid,
|
||||||
|
externalSubtitles = activeExternalSubtitles
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hold the paused state explicitly — we only get here while paused,
|
||||||
|
// and load() doesn't touch the pause property.
|
||||||
|
pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(
|
fun load(
|
||||||
@@ -406,6 +464,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
currentUrl = url
|
currentUrl = url
|
||||||
currentHeaders = headers
|
currentHeaders = headers
|
||||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||||
|
activeExternalSubtitles = pendingExternalSubtitles
|
||||||
this.initialSubtitleId = initialSubtitleId
|
this.initialSubtitleId = initialSubtitleId
|
||||||
this.initialAudioId = initialAudioId
|
this.initialAudioId = initialAudioId
|
||||||
|
|
||||||
@@ -565,6 +624,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
val flag = if (select) "select" else "cached"
|
val flag = if (select) "select" else "cached"
|
||||||
mpv?.command(arrayOf("sub-add", url, flag))
|
mpv?.command(arrayOf("sub-add", url, flag))
|
||||||
|
// Track runtime side-loads too, so they survive a resume-recovery
|
||||||
|
// reload just like external subs passed to load().
|
||||||
|
if (url.isNotEmpty() && url !in activeExternalSubtitles) {
|
||||||
|
activeExternalSubtitles = activeExternalSubtitles + url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Positioning
|
// MARK: - Subtitle Positioning
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -51,6 +55,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
|
|
||||||
|
// Grace window after onActivityResumed before running the resume
|
||||||
|
// recovery, so surfaceCreated (surface-destroyed case) has fired and
|
||||||
|
// the holder has a valid surface. If the surface survived the
|
||||||
|
// screensaver and surfaceCreated never fires, this still runs.
|
||||||
|
private const val RESUME_RECOVERY_DELAY_MS = 300L
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event dispatchers
|
// Event dispatchers
|
||||||
@@ -77,6 +87,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// PiP state tracking
|
// PiP state tracking
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
// Resume-recovery state: recreate the decoder when returning from the
|
||||||
|
// screensaver / app background while paused. See
|
||||||
|
// MPVLayerRenderer.recoverVideoOutput for why zero-copy hwdec=mediacodec
|
||||||
|
// needs this.
|
||||||
|
private var hostActivity: Activity? = null
|
||||||
|
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||||
|
private var lifecycleRegistered = false
|
||||||
|
private val recoverResumeRunnable = Runnable { runResumeRecovery() }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
@@ -145,6 +164,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||||
renderer = MPVLayerRenderer(context)
|
renderer = MPVLayerRenderer(context)
|
||||||
renderer?.delegate = this
|
renderer?.delegate = this
|
||||||
|
|
||||||
|
// Watch the host activity's lifecycle to recover the video pipeline
|
||||||
|
// when returning from the screensaver while paused.
|
||||||
|
registerLifecycleCallbacks()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -527,6 +550,107 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
onError(mapOf("error" to message))
|
onError(mapOf("error" to message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Resume Recovery
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreate the decoder when returning from the Android TV screensaver (or
|
||||||
|
* app background) while paused. Triggered from the host activity's
|
||||||
|
* onResume; the work is in [MPVLayerRenderer.recoverVideoOutput]. The
|
||||||
|
* playing case is skipped — the render thread re-primes the VO on surface
|
||||||
|
* reattach by itself — as is PiP (it owns its surface lifecycle).
|
||||||
|
*/
|
||||||
|
private fun runResumeRecovery() {
|
||||||
|
if (!rendererStarted) return
|
||||||
|
if (pipController?.isPictureInPictureActive() == true) return
|
||||||
|
if (intendedPlayState) return // playing self-heals
|
||||||
|
val surface = surfaceView.holder.surface?.takeIf { it.isValid }
|
||||||
|
Log.i(TAG, "[Recover] onResume recovery — paused, surfaceValid=${surface != null}")
|
||||||
|
renderer?.recoverVideoOutput(surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerLifecycleCallbacks() {
|
||||||
|
if (lifecycleRegistered) return
|
||||||
|
// Resume-recovery is TV-only. Only TV's zero-copy hwdec=mediacodec
|
||||||
|
// binds MediaCodec directly to the display surface, so only TV needs
|
||||||
|
// decoder recreation after a system-initiated surface loss (screensaver
|
||||||
|
// / app background while paused). Phones (mediacodec-copy) and the
|
||||||
|
// emulator self-heal via the surfaceCreated → attachSurface path, so we
|
||||||
|
// don't even register there — no callback overhead, no spurious resets.
|
||||||
|
if (renderer?.isTv != true) {
|
||||||
|
Log.i(TAG, "[Recover] skipping lifecycle registration (isTv=${renderer?.isTv})")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "[Recover] registering lifecycle recovery (TV)")
|
||||||
|
val app = context.applicationContext as? Application ?: run {
|
||||||
|
Log.w(TAG, "Cannot register lifecycle callbacks: no Application")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||||
|
override fun onActivityStarted(activity: Activity) {}
|
||||||
|
override fun onActivityResumed(activity: Activity) {
|
||||||
|
val host = hostActivity ?: findActivity().also { hostActivity = it }
|
||||||
|
if (activity !== host) {
|
||||||
|
if (host == null) {
|
||||||
|
Log.i(TAG, "[Recover] onActivityResumed — host unresolved, activity=${activity.javaClass.simpleName}; skipping")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"[Recover] onActivityResumed — host resumed, hasMedia=${currentUrl != null}, pip=${pipController?.isPictureInPictureActive()}, paused=${!intendedPlayState}"
|
||||||
|
)
|
||||||
|
// Only recover when there's loaded media, we're paused, and not
|
||||||
|
// in PiP. Playing self-heals; nothing loaded yet = nothing to
|
||||||
|
// recover.
|
||||||
|
if (currentUrl == null) return
|
||||||
|
if (pipController?.isPictureInPictureActive() == true) return
|
||||||
|
if (intendedPlayState) return
|
||||||
|
// Post past the resume/surfaceCreated race so the holder has a
|
||||||
|
// valid surface, then recreate the decoder against it.
|
||||||
|
pipHandler.removeCallbacks(recoverResumeRunnable)
|
||||||
|
pipHandler.postDelayed(recoverResumeRunnable, RESUME_RECOVERY_DELAY_MS)
|
||||||
|
}
|
||||||
|
override fun onActivityPaused(activity: Activity) {
|
||||||
|
if (activity === hostActivity) {
|
||||||
|
Log.i(TAG, "[Recover] onActivityPaused — host paused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
if (activity === hostActivity) {
|
||||||
|
Log.i(TAG, "[Recover] onActivityStopped — host stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||||
|
override fun onActivityDestroyed(activity: Activity) {}
|
||||||
|
}
|
||||||
|
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||||
|
lifecycleRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterLifecycleCallbacks() {
|
||||||
|
pipHandler.removeCallbacks(recoverResumeRunnable)
|
||||||
|
if (!lifecycleRegistered) return
|
||||||
|
val app = context.applicationContext as? Application
|
||||||
|
lifecycleCallbacks?.let { app?.unregisterActivityLifecycleCallbacks(it) }
|
||||||
|
lifecycleCallbacks = null
|
||||||
|
lifecycleRegistered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findActivity(): Activity? {
|
||||||
|
// Prefer Expo's currentActivity. The view's Context is a ReactContext
|
||||||
|
// whose base is the Application, not the Activity, so walking the
|
||||||
|
// context chain does not reliably reach the Activity. Mirrors
|
||||||
|
// PiPController.getActivity().
|
||||||
|
appContext.currentActivity?.let { return it }
|
||||||
|
var ctx: Context = context
|
||||||
|
while (ctx is ContextWrapper) {
|
||||||
|
if (ctx is Activity) return ctx
|
||||||
|
ctx = ctx.baseContext
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -538,6 +662,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
*/
|
*/
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
unregisterLifecycleCallbacks()
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
renderer?.delegate = null
|
renderer?.delegate = null
|
||||||
|
|||||||
@@ -175,4 +175,28 @@ 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => {
|
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
|
||||||
// as video + trickplay, the total must match.
|
|
||||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
|
||||||
// Read the live file size on disk so the total reflects actual usage and
|
|
||||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
|
||||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
|
||||||
// value if the file can't be stat'd.
|
|
||||||
if (item.videoFilePath) {
|
|
||||||
try {
|
|
||||||
const file = new File(filePathToUri(item.videoFilePath));
|
|
||||||
if (file.exists) {
|
|
||||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to stat downloaded file for size:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
let totalSize = calculateTotalDownloadedSize();
|
const totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
// Also count in-progress downloads (they write straight to their final
|
|
||||||
// path) so the growing file shows up as app usage instead of drifting
|
|
||||||
// into the generic device share until completion.
|
|
||||||
for (const process of processes) {
|
|
||||||
try {
|
|
||||||
const file = new File(
|
|
||||||
Paths.document,
|
|
||||||
`${generateFilename(process.item)}.mp4`,
|
|
||||||
);
|
|
||||||
if (file.exists) {
|
|
||||||
totalSize += file.size ?? 0;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File not created yet — ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [processes]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { settingsAtom } from "@/utils/atoms/settings";
|
import { getActivePlayerType, 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,10 +78,11 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate device profile for MPV player
|
// Match the device profile to the actually-active player so the
|
||||||
|
// server picks codecs/containers the player can decode.
|
||||||
const native = generateDeviceProfile({
|
const native = generateDeviceProfile({
|
||||||
platform: Platform.OS as "ios" | "android",
|
platform: Platform.OS as "ios" | "android",
|
||||||
player: "mpv",
|
player: getActivePlayerType(settings),
|
||||||
audioMode: settings.audioTranscodeMode,
|
audioMode: settings.audioTranscodeMode,
|
||||||
});
|
});
|
||||||
const data = await getStreamUrl({
|
const data = await getStreamUrl({
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occurred": "An unexpected error occurred",
|
"an_unexpected_error_occured": "An unexpected error occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid username or password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||||
"there_is_a_server_error": "There is a server error",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_authorized": "Quick Connect authorized",
|
"quick_connect_autorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
@@ -199,6 +199,13 @@
|
|||||||
"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",
|
||||||
@@ -270,10 +277,6 @@
|
|||||||
"mpv_subtitle_margin_y": "Vertical margin",
|
"mpv_subtitle_margin_y": "Vertical margin",
|
||||||
"mpv_subtitle_align_x": "Horizontal align",
|
"mpv_subtitle_align_x": "Horizontal align",
|
||||||
"mpv_subtitle_align_y": "Vertical align",
|
"mpv_subtitle_align_y": "Vertical align",
|
||||||
"mpv_settings_title": "MPV Subtitle Settings",
|
|
||||||
"mpv_settings_description": "Advanced subtitle customization for MPV player",
|
|
||||||
"opaque_background": "Opaque Background",
|
|
||||||
"background_opacity": "Background Opacity",
|
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Left",
|
"left": "Left",
|
||||||
"center": "Center",
|
"center": "Center",
|
||||||
@@ -302,7 +305,7 @@
|
|||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Disable haptic feedback",
|
"disable_haptic_feedback": "Disable haptic feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default quality",
|
||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
@@ -388,8 +391,6 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
|
||||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -441,13 +442,10 @@
|
|||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"title": "Sessions",
|
"title": "Sessions",
|
||||||
"no_active_sessions": "No active sessions",
|
"no_active_sessions": "No active sessions"
|
||||||
"select_session": "Select Session",
|
|
||||||
"now_playing": "Now playing:"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"transcoding": "Transcoding",
|
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -503,8 +501,6 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
|
||||||
"open_menu": "Open Menu",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
@@ -606,34 +602,10 @@
|
|||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "LIVE",
|
"live": "LIVE",
|
||||||
"menu": {
|
|
||||||
"quality": "Quality",
|
|
||||||
"subtitles": "Subtitles",
|
|
||||||
"subtitle_scale": "Subtitle Scale",
|
|
||||||
"audio": "Audio",
|
|
||||||
"speed": "Speed",
|
|
||||||
"playback_options": "Playback Options",
|
|
||||||
"show_technical_info": "Show Technical Info",
|
|
||||||
"hide_technical_info": "Hide Technical Info"
|
|
||||||
},
|
|
||||||
"technical_info": {
|
|
||||||
"video": "Video:",
|
|
||||||
"audio": "Audio:",
|
|
||||||
"subtitle": "Subtitle:",
|
|
||||||
"bitrate": "Bitrate:",
|
|
||||||
"buffer_seconds": "Buffer: {{seconds}}s",
|
|
||||||
"vo": "VO:",
|
|
||||||
"dropped_frames": "Dropped: {{count}} frames",
|
|
||||||
"loading": "Loading..."
|
|
||||||
},
|
|
||||||
"mpv_player_title": "MPV player",
|
"mpv_player_title": "MPV player",
|
||||||
"aspect_ratio": "Aspect Ratio",
|
|
||||||
"aspect_ratio_original": "Original",
|
|
||||||
"hash_match": "Hash Match",
|
|
||||||
"still_watching": "Are you still watching?",
|
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
@@ -730,7 +702,6 @@
|
|||||||
"no_data_available": "No data available"
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"title": "Live TV",
|
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"coming_soon": "Coming soon",
|
"coming_soon": "Coming soon",
|
||||||
@@ -802,7 +773,7 @@
|
|||||||
"request_selected": "Request selected",
|
"request_selected": "Request selected",
|
||||||
"n_selected": "{{count}} selected",
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||||
"issue_submitted": "Issue submitted!",
|
"issue_submitted": "Issue submitted!",
|
||||||
@@ -815,16 +786,6 @@
|
|||||||
"failed_to_decline_request": "Failed to decline request"
|
"failed_to_decline_request": "Failed to decline request"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"accessibility": {
|
|
||||||
"play_button": "Play button",
|
|
||||||
"play_hint": "Tap to play the media",
|
|
||||||
"toggle_orientation": "Toggle screen orientation",
|
|
||||||
"toggle_orientation_hint": "Toggles the screen orientation between portrait and landscape"
|
|
||||||
},
|
|
||||||
"not_found": {
|
|
||||||
"title": "This screen doesn't exist.",
|
|
||||||
"go_home": "Go to home screen!"
|
|
||||||
},
|
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -835,12 +796,6 @@
|
|||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
"no_track_playing": "No track playing",
|
|
||||||
"queue_empty": "Queue is empty",
|
|
||||||
"playing_from_queue": "Playing from queue",
|
|
||||||
"up_next": "Up next",
|
|
||||||
"now_playing": "Now Playing",
|
|
||||||
"missing_library_id": "Missing music library id.",
|
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "Suggestions",
|
"suggestions": "Suggestions",
|
||||||
"albums": "Albums",
|
"albums": "Albums",
|
||||||
|
|||||||
@@ -171,11 +171,52 @@ export type HomeSectionLatestResolver = {
|
|||||||
includeItemTypes?: Array<BaseItemKind>;
|
includeItemTypes?: Array<BaseItemKind>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Video player enum - currently only MPV is supported
|
// Video player enum. MPV is the universal default; ExoPlayer is an
|
||||||
|
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
|
||||||
export enum VideoPlayer {
|
export enum VideoPlayer {
|
||||||
MPV = 0,
|
MPV = 0,
|
||||||
|
ExoPlayer = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether ExoPlayer's native module is available on the current platform.
|
||||||
|
* ExoPlayer only ships for Android TV; on any other platform a persisted
|
||||||
|
* `videoPlayer: ExoPlayer` preference (e.g. MMKV roaming) must fall back
|
||||||
|
* to MPV rather than crash on requireNativeView().
|
||||||
|
*/
|
||||||
|
export const isExoPlayerSupported =
|
||||||
|
Platform.OS === "android" && Platform.isTV === true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the actually-active video player for the current settings.
|
||||||
|
* MPV is the default on every platform; users can opt into ExoPlayer on
|
||||||
|
* Android TV via settings.videoPlayer. The Android-TV capability gate is
|
||||||
|
* folded in here so callers (VideoPlayerView, direct-player's device
|
||||||
|
* profile, PlaySettingsProvider) can never advertise ExoPlayer on a
|
||||||
|
* platform where MPV is actually rendering — that mismatch would let
|
||||||
|
* Jellyfin pick a stream for the wrong renderer.
|
||||||
|
*/
|
||||||
|
export const getActiveVideoPlayer = (
|
||||||
|
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
||||||
|
): VideoPlayer => {
|
||||||
|
if (isExoPlayerSupported && settings?.videoPlayer === VideoPlayer.ExoPlayer) {
|
||||||
|
return VideoPlayer.ExoPlayer;
|
||||||
|
}
|
||||||
|
return VideoPlayer.MPV;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same selection as getActiveVideoPlayer but returns the lowercase
|
||||||
|
* player-type identifier that `generateDeviceProfile` expects.
|
||||||
|
*/
|
||||||
|
export const getActivePlayerType = (
|
||||||
|
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
||||||
|
): "mpv" | "exoplayer" => {
|
||||||
|
return getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer
|
||||||
|
? "exoplayer"
|
||||||
|
: "mpv";
|
||||||
|
};
|
||||||
|
|
||||||
// TV Typography scale presets
|
// TV Typography scale presets
|
||||||
export enum TVTypographyScale {
|
export enum TVTypographyScale {
|
||||||
Small = "small",
|
Small = "small",
|
||||||
@@ -218,6 +259,8 @@ 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;
|
||||||
@@ -315,6 +358,8 @@ 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,
|
||||||
|
|||||||
@@ -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";
|
export type PlayerType = "mpv" | "exoplayer";
|
||||||
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
||||||
|
|
||||||
export interface ProfileOptions {
|
export interface ProfileOptions {
|
||||||
@@ -63,6 +63,26 @@ const getAudioCodecProfile = (platform: PlatformType) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the MaxAudioChannels string for a given audio transcoding mode.
|
||||||
|
* Used by both the MPV and ExoPlayer profile branches — the channel-cap
|
||||||
|
* rule is player-agnostic (the player decodes; the cap just tells the
|
||||||
|
* server when to transcode down).
|
||||||
|
*/
|
||||||
|
const maxChannelsForMode = (audioMode: AudioTranscodeModeType): string => {
|
||||||
|
switch (audioMode) {
|
||||||
|
case "stereo":
|
||||||
|
return "2";
|
||||||
|
case "5.1":
|
||||||
|
return "6";
|
||||||
|
case "passthrough":
|
||||||
|
return "8";
|
||||||
|
default:
|
||||||
|
// Auto: default to 5.1 (6 channels)
|
||||||
|
return "6";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the video audio codec configuration based on platform and audio mode.
|
* Gets the video audio codec configuration based on platform and audio mode.
|
||||||
*
|
*
|
||||||
@@ -89,35 +109,59 @@ const getVideoAudioCodecs = (
|
|||||||
// MPV can decode all codecs - only channel count varies by mode
|
// MPV can decode all codecs - only channel count varies by mode
|
||||||
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
||||||
|
|
||||||
switch (audioMode) {
|
|
||||||
case "stereo":
|
|
||||||
// Limit to 2 channels - MPV will decode and downmix
|
|
||||||
return {
|
return {
|
||||||
directPlayCodec: allCodecs,
|
directPlayCodec: allCodecs,
|
||||||
maxAudioChannels: "2",
|
maxAudioChannels: maxChannelsForMode(audioMode),
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
case "5.1":
|
/**
|
||||||
// Limit to 6 channels
|
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV.
|
||||||
return {
|
*
|
||||||
directPlayCodec: allCodecs,
|
* Codec set aligned with Media3's documented supported-formats list:
|
||||||
maxAudioChannels: "6",
|
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
|
||||||
};
|
* - Audio: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS,
|
||||||
|
* DTS-HD, TrueHD
|
||||||
|
*
|
||||||
|
* Hardware decode (MediaCodec) handles whatever the device ships with;
|
||||||
|
* the rest fall through to FFmpeg software decode via the Jellyfin-published
|
||||||
|
* `org.jellyfin.media3:media3-ffmpeg-decoder` extension wired up with
|
||||||
|
* `DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER` (see
|
||||||
|
* ExoPlayerView.kt:ensurePlayer).
|
||||||
|
*
|
||||||
|
* Cross-checked against the reference-device probe in
|
||||||
|
* docs/research/hdr-dv-atmos-tv-plan.md (Amlogic Android 14 TV; HDMI sink
|
||||||
|
* accepts AC3/EAC3 as bitstream and multichannel PCM up to 7.1 @ 192 kHz,
|
||||||
|
* so software-decoded DTS/DTS-HD/TrueHD reach the sink as PCM).
|
||||||
|
*
|
||||||
|
* Dolby Vision: the CodecProfile below uses `NotEquals VideoRangeType
|
||||||
|
* DOVI`, which in Jellyfin's semantics blocks ONLY pure Profile 5
|
||||||
|
* (IPTPQc2 — the stream that renders purple/green without a DV-aware
|
||||||
|
* decoder). DV Profiles 7/8 with HDR10 or SDR base layers (Jellyfin
|
||||||
|
* reports these as `DOVIWithHDR10`, `DOVIWithHDR10Plus`, `DOVIWithEL`)
|
||||||
|
* are NOT blocked — Media3 1.9.1+ correctly falls back to the AVC/HEVC
|
||||||
|
* base layer.
|
||||||
|
*
|
||||||
|
* Containers limited to Media3's bundled extractors. FLV is intentionally
|
||||||
|
* absent — Media3 has no FLV extractor (MPV claims it via FFmpeg).
|
||||||
|
*/
|
||||||
|
const getExoPlayerDirectPlayProfile = () => {
|
||||||
|
const audioCodecs =
|
||||||
|
"vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd";
|
||||||
|
|
||||||
case "passthrough":
|
|
||||||
// Allow up to 8 channels - for external DAC/receiver setups
|
|
||||||
return {
|
return {
|
||||||
directPlayCodec: allCodecs,
|
video: {
|
||||||
maxAudioChannels: "8",
|
Type: MediaTypes.Video,
|
||||||
|
Container: "mp4,mkv,webm,ts,mpegts,mov",
|
||||||
|
VideoCodec: "h263,h264,hevc,vp8,vp9,av1",
|
||||||
|
AudioCodec: audioCodecs,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
Type: MediaTypes.Audio,
|
||||||
|
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
||||||
|
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
default:
|
|
||||||
// Auto mode: default to 5.1 (6 channels)
|
|
||||||
return {
|
|
||||||
directPlayCodec: allCodecs,
|
|
||||||
maxAudioChannels: "6",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +170,63 @@ const getVideoAudioCodecs = (
|
|||||||
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,vp8,vp9,av1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
Codec: "hevc,h265",
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: "NotEquals",
|
||||||
|
Property: "VideoRangeType",
|
||||||
|
// Blocks ONLY pure DV Profile 5 (IPTPQc2). Profiles 7/8 with
|
||||||
|
// HDR10/SDR base layers fall through to Media3's HEVC fallback
|
||||||
|
// (1.9.1+). See getExoPlayerDirectPlayProfile doc above.
|
||||||
|
Value: "DOVI",
|
||||||
|
IsRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Audio,
|
||||||
|
Codec: "vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
DirectPlayProfiles: [exoDirect.video, exoDirect.audio],
|
||||||
|
TranscodingProfiles: [
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
Context: "Streaming",
|
||||||
|
Protocol: "hls",
|
||||||
|
Container: "ts",
|
||||||
|
VideoCodec: "h264,hevc",
|
||||||
|
AudioCodec: "aac,mp3,ac3",
|
||||||
|
MaxAudioChannels: maxChannelsForMode(audioMode),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Text-only subtitles for direct play. PGS delivered as Encode
|
||||||
|
// (burn-in) because Media3's PGS support is inconsistent.
|
||||||
|
SubtitleProfiles: [
|
||||||
|
{ Format: "srt", Method: "External" },
|
||||||
|
{ Format: "vtt", Method: "External" },
|
||||||
|
{ Format: "ttml", Method: "External" },
|
||||||
|
{ Format: "pgssub", Method: "Encode" },
|
||||||
|
],
|
||||||
|
} satisfies DeviceProfile;
|
||||||
|
}
|
||||||
|
|
||||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||||
platform,
|
platform,
|
||||||
@@ -198,6 +299,3 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
|||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default export for backward compatibility
|
|
||||||
export default generateDeviceProfile();
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const formatTimeString = (
|
|||||||
t: number | null | undefined,
|
t: number | null | undefined,
|
||||||
unit: "s" | "ms" | "tick" = "ms",
|
unit: "s" | "ms" | "tick" = "ms",
|
||||||
): string => {
|
): string => {
|
||||||
if (t === null || t === undefined) return "0:00";
|
if (t === null || t === undefined || !Number.isFinite(t)) return "0:00";
|
||||||
|
|
||||||
let seconds: number;
|
let seconds: number;
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
|
|||||||
Reference in New Issue
Block a user