Compare commits

..

4 Commits

Author SHA1 Message Date
Gauvain
039d7cfaec chore(i18n): localize hardcoded UI strings 2026-07-15 19:05:09 +02:00
Gauvain
2e79815d78 fix(downloads): delete-all confirmation, live storage usage, series poster cache (#1808)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 11:19:51 +02:00
Gauvain
43ce50fe70 fix(notifications): drop deprecated handler flags and payload logging (#1805)
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
2026-07-15 10:50:47 +02:00
Gauvain
bd0eef0407 fix(i18n): correct misspelled translation keys (#1804)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 08:36:11 +02:00
58 changed files with 387 additions and 2139 deletions

View File

@@ -5,7 +5,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
@@ -33,16 +33,13 @@ import {
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { import {
AudioTranscodeMode, AudioTranscodeMode,
getActiveVideoPlayer,
InactivityTimeout, InactivityTimeout,
type MpvCacheMode, type MpvCacheMode,
type MpvVoDriver, type MpvVoDriver,
TVTypographyScale, TVTypographyScale,
useSettings, useSettings,
VideoPlayer,
} from "@/utils/atoms/settings"; } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { scaleSize } from "@/utils/scaleSize";
import { import {
getPreviousServers, getPreviousServers,
type SavedServer, type SavedServer,
@@ -265,25 +262,6 @@ export default function SettingsTV() {
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next"; const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
const currentLanguage = settings.preferedLanguage; const currentLanguage = settings.preferedLanguage;
// Video player selection. MPV is the default; ExoPlayer is only offered
// as an opt-in alternative on Android TV. The selector is hidden on
// other platforms.
const isAndroidTv = Platform.OS === "android" && Platform.isTV;
const currentVideoPlayer = getActiveVideoPlayer(settings);
const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer;
// Shared style for the ExoPlayer / MPV limitation notes shown under the
// selector when the respective player is active. All pixel values scaled
// so the layout holds on 4K TVs (see utils/scaleSize.ts).
const playerNoteStyle = {
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginTop: scaleSize(4),
marginBottom: scaleSize(12),
marginLeft: scaleSize(8),
marginRight: scaleSize(8),
} as const;
// Audio transcoding options // Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo( const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
() => [ () => [
@@ -413,23 +391,6 @@ export default function SettingsTV() {
[t, currentVoDriver], [t, currentVoDriver],
); );
// Video player backend options (Android TV only)
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = useMemo(
() => [
{
label: t("home.settings.video_player.exoplayer"),
value: VideoPlayer.ExoPlayer,
selected: currentVideoPlayer === VideoPlayer.ExoPlayer,
},
{
label: t("home.settings.video_player.mpv"),
value: VideoPlayer.MPV,
selected: currentVideoPlayer === VideoPlayer.MPV,
},
],
[t, currentVideoPlayer],
);
// Typography scale options // Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo( const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [ () => [
@@ -561,11 +522,6 @@ export default function SettingsTV() {
return option?.label || t("home.settings.vo_driver.gpu_next"); return option?.label || t("home.settings.vo_driver.gpu_next");
}, [voDriverOptions, t]); }, [voDriverOptions, t]);
const videoPlayerLabel = useMemo(() => {
const option = videoPlayerOptions.find((o) => o.selected);
return option?.label || "MPV";
}, [videoPlayerOptions]);
const languageLabel = useMemo(() => { const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system"); if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage); const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
@@ -630,34 +586,6 @@ export default function SettingsTV() {
{/* Audio Section */} {/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} /> <TVSectionHeader title={t("home.settings.audio.audio_title")} />
{/* Video Player selector — Android TV only */}
{isAndroidTv && (
<>
<TVSettingsOptionButton
label={t("home.settings.video_player.title")}
value={videoPlayerLabel}
onPress={() =>
showOptions({
title: t("home.settings.video_player.title"),
options: videoPlayerOptions,
onSelect: (value) => updateSettings({ videoPlayer: value }),
})
}
/>
{!isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.exoplayer_note")}
</Text>
)}
{isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.mpv_note")}
</Text>
)}
</>
)}
<TVSettingsOptionButton <TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")} label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel} value={audioTranscodeLabel}
@@ -717,7 +645,7 @@ export default function SettingsTV() {
formatValue={(v) => `${v.toFixed(1)}x`} formatValue={(v) => `${v.toFixed(1)}x`}
/> />
<TVSettingsStepper <TVSettingsStepper
label='Vertical Margin' label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => { onDecrease={() => {
const newValue = Math.max( const newValue = Math.max(
@@ -734,11 +662,9 @@ 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"),
@@ -750,13 +676,12 @@ export default function SettingsTV() {
}) })
} }
/> />
)}
<TVSettingsOptionButton <TVSettingsOptionButton
label='Vertical Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_y")}
value={alignYLabel} value={alignYLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Vertical Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_y"),
options: alignYOptions, options: alignYOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -823,9 +748,7 @@ export default function SettingsTV() {
} }
/> />
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */} {/* Video Output Section */}
{isMpv && (
<>
<TVSectionHeader title={t("home.settings.vo_driver.title")} /> <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")}
@@ -838,9 +761,6 @@ 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}

View File

@@ -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_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>

View File

@@ -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_liraries_you_want_to_hide")} {t("home.settings.other.select_libraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -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'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -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'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -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'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -14,6 +14,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -72,6 +73,7 @@ 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();
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
paddingBottom: Platform.OS === "android" ? insets.bottom : 0, paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}} }}
> >
<Text className='text-neutral-500'>No track playing</Text> <Text className='text-neutral-500'>
{t("music.no_track_playing")}
</Text>
</View> </View>
</BottomSheetModalProvider> </BottomSheetModalProvider>
); );
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
: "text-neutral-500" : "text-neutral-500"
} }
> >
Now Playing {t("music.now_playing")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -718,6 +722,7 @@ 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;
@@ -831,13 +836,15 @@ 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 ? "Playing from queue" : "Up next"} {history.length > 0
? 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'>Queue is empty</Text> <Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
</View> </View>
} }
/> />

View File

@@ -29,7 +29,6 @@ import {
PlaybackSpeedScope, PlaybackSpeedScope,
updatePlaybackSpeedSettings, updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
@@ -41,6 +40,7 @@ import {
type MpvOnErrorEventPayload, type MpvOnErrorEventPayload,
type MpvOnPlaybackStateChangePayload, type MpvOnPlaybackStateChangePayload,
type MpvOnProgressEventPayload, type MpvOnProgressEventPayload,
MpvPlayerView,
type MpvPlayerViewRef, type MpvPlayerViewRef,
type MpvVideoSource, type MpvVideoSource,
} from "@/modules"; } from "@/modules";
@@ -51,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 { getActivePlayerType, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -364,13 +364,7 @@ export default function DirectPlayerPage() {
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
// Match the device profile to the player that will render the deviceProfile: generateDeviceProfile(),
// stream so the server picks a codec/container the player can
// actually decode.
deviceProfile: generateDeviceProfile({
player: getActivePlayerType(settings),
audioMode: settings.audioTranscodeMode,
}),
}); });
if (!res) return null; if (!res) return null;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res; const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
@@ -1311,7 +1305,7 @@ export default function DirectPlayerPage() {
justifyContent: "center", justifyContent: "center",
}} }}
> >
<VideoPlayerView <MpvPlayerView
ref={videoRef} ref={videoRef}
source={videoSource} source={videoSource}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
@@ -1324,7 +1318,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_occured_while_playing_the_video"), t("player.an_error_occurred_while_playing_the_video"),
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}

View File

@@ -192,6 +192,7 @@ 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
@@ -328,7 +329,7 @@ const SubtitleResultCard = React.forwardRef<
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
Hash Match {t("player.hash_match")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,17 +1,20 @@
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: "Oops!" }} /> <Stack.Screen options={{ title: t("home.oops") }} />
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type='title'>This screen doesn't exist.</ThemedText> <ThemedText type='title'>{t("not_found.title")}</ThemedText>
<Link href={"/home"} style={styles.link}> <Link href={"/home"} style={styles.link}>
<ThemedText type='link'>Go to home screen!</ThemedText> <ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </>

View File

@@ -85,7 +85,8 @@ configureReanimatedLogger({
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
shouldShowAlert: true, shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true, shouldPlaySound: true,
shouldSetBadge: false, shouldSetBadge: false,
}), }),
@@ -350,9 +351,12 @@ 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, notification.request.content.title,
); );
}, },
); );

View File

@@ -1,6 +1,7 @@
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";
@@ -209,6 +210,7 @@ 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
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
return ( return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>} {trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative flex-1"} className={"relative flex-1"}
> >

View File

@@ -2,6 +2,7 @@ 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,
@@ -36,6 +37,7 @@ 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
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props} {...props}

View File

@@ -6,6 +6,7 @@ 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,
@@ -31,6 +32,7 @@ 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;
@@ -65,7 +67,9 @@ 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}>Select Session</Text> <Text style={styles.modalTitle}>
{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>
@@ -78,7 +82,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}>
No active sessions found {t("home.sessions.no_active_sessions")}
</Text> </Text>
) : ( ) : (
<FlatList <FlatList
@@ -98,7 +102,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}>
Now playing:{" "} {t("home.sessions.now_playing")}{" "}
{session.NowPlayingItem.SeriesName {session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :` ? `${session.NowPlayingItem.SeriesName} :`
: ""} : ""}

View File

@@ -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,6 +35,7 @@ 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();
@@ -173,7 +174,9 @@ 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'>Transcoding</Text> <Text className='text-xs text-purple-400'>
{t("home.downloads.transcoding")}
</Text>
</View> </View>
)} )}

View File

@@ -16,9 +16,12 @@ 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(() => {
return storage.getString(items[0].SeriesId!); const seriesId = items[0]?.SeriesId;
}, []); return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
const deleteSeries = useCallback( const deleteSeries = useCallback(
async () => async () =>

View File

@@ -1,5 +1,6 @@
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";
@@ -22,6 +23,7 @@ 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,
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
<Text <Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]} style={[styles.liveBadgeText, { fontSize: typography.callout }]}
> >
LIVE {t("player.live")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
marginBottom: 24, marginBottom: 24,
}} }}
> >
Live TV {t("live_tv.title")}
</Text> </Text>
{/* Tab Bar */} {/* Tab Bar */}

View File

@@ -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_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} finally { } finally {

View File

@@ -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_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} 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_occured"); : t("login.an_unexpected_error_occurred");
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_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} 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_occured"); : t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message); Alert.alert(t("login.connection_failed"), message);
goToQRScreen(); goToQRScreen();
}); });

View File

@@ -1,4 +1,5 @@
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";
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
showDiscover, showDiscover,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
if (!showDiscover) { if (!showDiscover) {
return null; return null;
} }
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
}} }}
> >
<TVSearchTabBadge <TVSearchTabBadge
label='Library' label={t("search.library")}
isSelected={searchType === "Library"} isSelected={searchType === "Library"}
onPress={() => setSearchType("Library")} onPress={() => setSearchType("Library")}
disabled={disabled} disabled={disabled}
/> />
<TVSearchTabBadge <TVSearchTabBadge
label='Discover' label={t("search.discover")}
isSelected={searchType === "Discover"} isSelected={searchType === "Discover"}
onPress={() => setSearchType("Discover")} onPress={() => setSearchType("Discover")}
disabled={disabled} disabled={disabled}

View File

@@ -1,5 +1,6 @@
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";
@@ -17,20 +18,21 @@ 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: "Left", left: t("home.settings.subtitles.align.left"),
center: "Center", center: t("home.settings.subtitles.align.center"),
right: "Right", right: t("home.settings.subtitles.align.right"),
}; };
const alignYLabels: Record<AlignY, string> = { const alignYLabels: Record<AlignY, string> = {
top: "Top", top: t("home.settings.subtitles.align.top"),
center: "Center", center: t("home.settings.subtitles.align.center"),
bottom: "Bottom", bottom: t("home.settings.subtitles.align.bottom"),
}; };
const alignXOptionGroups = useMemo(() => { const alignXOptionGroups = useMemo(() => {
@@ -60,16 +62,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
title='MPV Subtitle Settings' title={t("home.settings.subtitles.mpv_settings_title")}
description={ description={
<Text className='text-[#8E8D91] text-xs'> <Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player {t("home.settings.subtitles.mpv_settings_description")}
</Text> </Text>
} }
> >
{!isTv && ( {!isTv && (
<> <>
<ListItem title='Vertical Margin'> <ListItem
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
>
<Stepper <Stepper
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
step={5} step={5}
@@ -81,7 +85,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</ListItem> </ListItem>
<ListItem title='Horizontal Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
<PlatformDropdown <PlatformDropdown
groups={alignXOptionGroups} groups={alignXOptionGroups}
trigger={ trigger={
@@ -96,11 +100,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Horizontal Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_x")}
/> />
</ListItem> </ListItem>
<ListItem title='Vertical Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
<PlatformDropdown <PlatformDropdown
groups={alignYOptionGroups} groups={alignYOptionGroups}
trigger={ trigger={
@@ -115,13 +119,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Vertical Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_y")}
/> />
</ListItem> </ListItem>
</> </>
)} )}
<ListItem title='Opaque Background'> <ListItem title={t("home.settings.subtitles.opaque_background")}>
<Switch <Switch
value={settings.mpvSubtitleBackgroundEnabled ?? false} value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) => onValueChange={(value) =>
@@ -131,7 +135,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
{settings.mpvSubtitleBackgroundEnabled && ( {settings.mpvSubtitleBackgroundEnabled && (
<ListItem title='Background Opacity'> <ListItem title={t("home.settings.subtitles.background_opacity")}>
<Stepper <Stepper
value={settings.mpvSubtitleBackgroundOpacity ?? 75} value={settings.mpvSubtitleBackgroundOpacity ?? 75}
step={5} step={5}

View File

@@ -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_autorized"), t("home.settings.quick_connect.quick_connect_authorized"),
); );
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Alert, 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,6 +12,7 @@ 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");
@@ -27,16 +28,38 @@ 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 = async () => { const onDeleteClicked = () => {
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) => {

View File

@@ -44,10 +44,8 @@ export interface TVNextEpisodeCountdownProps {
playButtonRef?: RNView | null; playButtonRef?: RNView | null;
} }
// Position constants — kept in sync with TVSkipSegmentCard (the two are // Position constants
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS const BOTTOM_WITH_CONTROLS = scaleSize(300);
// rationale (220 sits just above the controls bar; 300 floated too high).
const BOTTOM_WITH_CONTROLS = scaleSize(220);
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120); const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({

View File

@@ -33,15 +33,9 @@ export interface TVSkipSegmentCardProps {
playButtonRef?: View | null; playButtonRef?: View | null;
} }
// Position constants — kept in sync with TVNextEpisodeCountdown (the two // Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a const BOTTOM_WITH_CONTROLS = 300;
// card that floats far above the controls. const BOTTOM_WITHOUT_CONTROLS = 120;
//
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
// left the card hovering ~100px above the controls.
const BOTTOM_WITH_CONTROLS = scaleSize(220);
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
show, show,

View File

@@ -1,5 +1,6 @@
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,
@@ -28,6 +29,7 @@ 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
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
}, },
]} ]}
> >
<Text style={styles.flagText}>Hash Match</Text> <Text style={styles.flagText}>{t("player.hash_match")}</Text>
</View> </View>
)} )}
{result.hearingImpaired && ( {result.hearingImpaired && (

View File

@@ -1,30 +0,0 @@
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} />;
});

View File

@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
buttonText='Skip Intro' buttonText={t("player.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='Skip Credits' buttonText={t("player.skip_credits")}
/> />
{settings.autoPlayNextEpisode !== false && {settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 || (settings.maxAutoPlayEpisodeCount.value === -1 ||

View File

@@ -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 '>
Are you still watching ? {t("player.still_watching")}
</Text> </Text>
<Button <Button
onPress={() => { onPress={() => {

View File

@@ -1132,16 +1132,7 @@ export const Controls: FC<Props> = ({
{/* Skip intro card */} {/* Skip intro card */}
<TVSkipSegmentCard <TVSkipSegmentCard
show={showSkipButton && !isCountdownActive} show={showSkipButton && !isCountdownActive}
onPress={() => { onPress={skipIntro}
// After the seek lands, showSkipButton flips false and this card
// unmounts. With controls visible the focus-stealing overlay is
// disabled, so without an explicit handoff the focus engine is
// stranded. Prime the play button to receive focus on the next
// render — when controls are hidden the focus overlay takes over
// naturally and this is a harmless no-op.
if (showControls) setFocusPlayButton(true);
skipIntro();
}}
type='intro' type='intro'
controlsVisible={showControls} controlsVisible={showControls}
refSetter={setSkipSegmentRef} refSetter={setSkipSegmentRef}
@@ -1156,11 +1147,7 @@ export const Controls: FC<Props> = ({
(hasContentAfterCredits || !nextItem) && (hasContentAfterCredits || !nextItem) &&
!isCountdownActive !isCountdownActive
} }
onPress={() => { onPress={skipCredit}
// See the intro card above for the focus-handoff rationale.
if (showControls) setFocusPlayButton(true);
skipCredit();
}}
type='credits' type='credits'
controlsVisible={showControls} controlsVisible={showControls}
refSetter={setSkipSegmentRef} refSetter={setSkipSegmentRef}

View File

@@ -4,6 +4,7 @@ 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";
@@ -57,6 +58,7 @@ 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");
@@ -127,8 +129,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='Toggle screen orientation' accessibilityLabel={t("accessibility.toggle_orientation")}
accessibilityHint='Toggles the screen orientation between portrait and landscape' accessibilityHint={t("accessibility.toggle_orientation_hint")}
> >
<MaterialIcons <MaterialIcons
name='screen-rotation' name='screen-rotation'

View File

@@ -7,6 +7,7 @@ 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,
@@ -184,6 +185,7 @@ 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);
@@ -213,10 +215,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
); );
return { return {
container: mediaSource.Container,
videoRange: videoStream?.VideoRangeType, videoRange: videoStream?.VideoRangeType,
bitDepth: videoStream?.BitDepth, bitDepth: videoStream?.BitDepth,
audioChannels: audioStream?.Channels, audioChannels: audioStream?.Channels,
audioCodecFromSource: audioStream?.Codec,
subtitleCodec: subtitleStream?.Codec, subtitleCodec: subtitleStream?.Codec,
subtitleTitle: subtitleStream?.DisplayTitle,
}; };
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]); }, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
@@ -302,47 +307,34 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
<Text style={textStyle}> <Text style={textStyle}>
{info.videoWidth}x{info.videoHeight} {info.videoWidth}x{info.videoHeight}
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""} {streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
{/* Prefer the player-reported HDR format (authoritative — {formatVideoRange(streamInfo?.videoRange)
what's actually being decoded) over Jellyfin metadata. */} ? ` ${formatVideoRange(streamInfo?.videoRange)}`
{info?.hdrFormat : ""}
? ` ${info.hdrFormat}`
: (() => {
const videoRange = formatVideoRange(streamInfo?.videoRange);
return videoRange ? ` ${videoRange}` : "";
})()}
</Text> </Text>
)} )}
{info?.videoCodec && ( {info?.videoCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Video: {formatCodec(info.videoCodec)} {t("player.technical_info.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}>
Audio: {formatCodec(info.audioCodec)} {t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
{/* Prefer player-reported channel count; fall back to {streamInfo?.audioChannels
Jellyfin metadata for MPV which doesn't populate it. */} ? ` ${formatAudioChannels(streamInfo.audioChannels)}`
{(() => {
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}>
Subtitle: {formatCodec(streamInfo.subtitleCodec)} {t("player.technical_info.subtitle")}{" "}
{formatCodec(streamInfo.subtitleCodec)}
</Text> </Text>
)} )}
{(info?.videoBitrate || info?.audioBitrate) && ( {(info?.videoBitrate || info?.audioBitrate) && (
<Text style={textStyle}> <Text style={textStyle}>
Bitrate:{" "} {t("player.technical_info.bitrate")}{" "}
{info.videoBitrate {info.videoBitrate
? formatBitrate(info.videoBitrate) ? formatBitrate(info.videoBitrate)
: info.audioBitrate : info.audioBitrate
@@ -350,20 +342,11 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
: "N/A"} : "N/A"}
</Text> </Text>
)} )}
{(info?.colorSpace || info?.colorRange || info?.colorTransfer) && (
<Text style={textStyle}>
Color:{" "}
{[info.colorSpace, info.colorRange, info.colorTransfer]
.filter(Boolean)
.join(" / ")}
</Text>
)}
{info?.videoCodecs && (
<Text style={textStyle}>Codec tag: {info.videoCodecs}</Text>
)}
{info?.cacheSeconds !== undefined && ( {info?.cacheSeconds !== undefined && (
<Text style={textStyle}> <Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s {t("player.technical_info.buffer_seconds", {
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` : ""}` +
@@ -374,16 +357,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.voDriver && ( {info?.voDriver && (
<Text style={textStyle}> <Text style={textStyle}>
VO: {info.voDriver} {t("player.technical_info.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)}
@@ -392,10 +369,14 @@ 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]}>
Dropped: {info.droppedFrames} frames {t("player.technical_info.dropped_frames", {
count: info.droppedFrames,
})}
</Text> </Text>
)} )}
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>} {!info && !playMethod && (
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
)}
</View> </View>
</Animated.View> </Animated.View>
); );

View File

@@ -20,7 +20,6 @@ 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);

View File

@@ -1,5 +1,6 @@
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,
@@ -54,6 +55,7 @@ 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) => {
@@ -66,7 +68,10 @@ 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: option.label, 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),
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Aspect Ratio' title={t("player.aspect_ratio")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
bottomSheetConfig={{ bottomSheetConfig={{

View File

@@ -1,6 +1,7 @@
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 {
@@ -47,6 +48,7 @@ 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<{
@@ -101,7 +103,7 @@ const DropdownView = ({
// Quality Section // Quality Section
if (!isOffline) { if (!isOffline) {
groups.push({ groups.push({
title: "Quality", title: t("player.menu.quality"),
options: options:
BITRATES?.map((bitrate) => ({ BITRATES?.map((bitrate) => ({
type: "radio" as const, type: "radio" as const,
@@ -116,7 +118,7 @@ const DropdownView = ({
// Subtitle Section // Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) { if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({ groups.push({
title: "Subtitles", title: t("player.menu.subtitles"),
options: subtitleTracks.map((sub) => ({ options: subtitleTracks.map((sub) => ({
type: "radio" as const, type: "radio" as const,
label: sub.name, label: sub.name,
@@ -128,7 +130,7 @@ const DropdownView = ({
// Subtitle Scale Section // Subtitle Scale Section
groups.push({ groups.push({
title: "Subtitle Scale", title: t("player.menu.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,
@@ -142,7 +144,7 @@ const DropdownView = ({
// Audio Section // Audio Section
if (audioTracks && audioTracks.length > 0) { if (audioTracks && audioTracks.length > 0) {
groups.push({ groups.push({
title: "Audio", title: t("player.menu.audio"),
options: audioTracks.map((track) => ({ options: audioTracks.map((track) => ({
type: "radio" as const, type: "radio" as const,
label: track.name, label: track.name,
@@ -156,7 +158,7 @@ const DropdownView = ({
// Speed Section // Speed Section
if (setPlaybackSpeed) { if (setPlaybackSpeed) {
groups.push({ groups.push({
title: "Speed", title: t("player.menu.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,
@@ -174,8 +176,8 @@ const DropdownView = ({
{ {
type: "action" as const, type: "action" as const,
label: showTechnicalInfo label: showTechnicalInfo
? "Hide Technical Info" ? t("player.menu.hide_technical_info")
: "Show Technical Info", : t("player.menu.show_technical_info"),
onPress: onToggleTechnicalInfo, onPress: onToggleTechnicalInfo,
}, },
], ],
@@ -185,6 +187,7 @@ 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,
@@ -217,7 +220,7 @@ const DropdownView = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Playback Options' title={t("player.menu.playback_options")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
expoUIConfig={{}} expoUIConfig={{}}

View File

@@ -3,6 +3,7 @@ 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;
@@ -124,17 +125,23 @@ export function useRemoteControl({
// Controls are hidden, so confirm before leaving playback. // Controls are hidden, so confirm before leaving playback.
Alert.alert( Alert.alert(
"Stop Playback", i18n.t("player.stopPlayback"),
videoTitleRef.current videoTitleRef.current
? `Stop playing "${videoTitleRef.current}"?` ? i18n.t("player.stopPlayingTitle", {
: "Are you sure you want to stop playback?", title: videoTitleRef.current,
})
: i18n.t("player.stopPlayingConfirm"),
[ [
{ {
text: "Cancel", text: i18n.t("common.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;

View File

@@ -17,10 +17,7 @@ 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);
// Start at 0 (not Infinity) so the controls' first paint — before the first const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
// 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);

View File

@@ -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.jellyseer_does_not_meet_requirements", "jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
); );
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);

View File

@@ -1,68 +0,0 @@
apply plugin: 'com.android.library'
group = 'expo.modules.exoplayerplayer'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
def useManagedAndroidSdkVersions = false
if (useManagedAndroidSdkVersions) {
useDefaultAndroidSdkVersions()
} else {
buildscript {
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}
project.android {
compileSdkVersion safeExtGet("compileSdkVersion", 36)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 26)
targetSdkVersion safeExtGet("targetSdkVersion", 36)
}
}
}
android {
namespace "expo.modules.exoplayerplayer"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
// Media3 (ExoPlayer). The default tracks react-native-track-player's
// pinned version (currently 1.10.1) so we don't end up with two media3
// versions on the classpath and duplicate-class errors. The
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
// globally via plugins/withExcludeMedia3Dash.js.
def media3Version = safeExtGet('media3Version', '1.10.1')
implementation "androidx.media3:media3-exoplayer:${media3Version}"
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
implementation "androidx.media3:media3-ui:${media3Version}"
implementation "androidx.media3:media3-common:${media3Version}"
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
// codecs that MediaCodec doesn't ship with on most Android TVs.
//
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
// with prebuilt native libraries (the upstream androidx artifact is a
// stub that requires building FFmpeg yourself). RNTP already pulls
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
// it guarantees we still get it if RNTP ever drops the dep.
//
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
// resolve media3 core to 1.10.1. RNTP ships the same combination in
// production, and Media3 maintains binary compat for Renderer /
// RenderersFactory APIs across minor versions, so this works in
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
}

View File

@@ -1,193 +0,0 @@
package expo.modules.exoplayerplayer
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExoPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExoPlayer")
// Enables the module to be used as a native view.
View(ExoPlayerView::class) {
// All video load options are passed via a single "source" prop,
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
// cacheConfig fields) are silently ignored.
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop
@Suppress("UNCHECKED_CAST")
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
val config = VideoLoadConfig(
url = urlString,
headers = source["headers"] as? Map<String, String>,
externalSubtitles = source["externalSubtitles"] as? List<String>,
startPosition = (source["startPosition"] as? Number)?.toDouble(),
autoplay = (source["autoplay"] as? Boolean) ?: true,
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
cacheEnabled = cacheConfig?.get("enabled") as? String,
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
)
view.loadVideo(config)
}
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
// no Control Center equivalent — Android handles media sessions
// via MediaSessionCompat which we don't wire up for TV).
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
// No-op
}
AsyncFunction("play") { view: ExoPlayerView ->
view.play()
}
AsyncFunction("pause") { view: ExoPlayerView ->
view.pause()
}
AsyncFunction("destroy") { view: ExoPlayerView ->
view.destroy()
}
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
view.seekTo(position)
}
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
view.seekBy(offset)
}
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
view.setSpeed(speed)
}
AsyncFunction("getSpeed") { view: ExoPlayerView ->
view.getSpeed()
}
AsyncFunction("isPaused") { view: ExoPlayerView ->
view.isPaused()
}
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
view.getCurrentPosition()
}
AsyncFunction("getDuration") { view: ExoPlayerView ->
view.getDuration()
}
// Picture in Picture — TV does not use PiP; safe no-ops.
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
// No-op
}
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
// No-op
}
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
false
}
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
false
}
// Subtitle functions
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
view.getSubtitleTracks()
}
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
view.setSubtitleTrack(trackId)
}
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
view.disableSubtitles()
}
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
view.getCurrentSubtitleTrack()
}
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
view.addSubtitleFile(url, select)
}
// Subtitle positioning / styling
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
view.setSubtitlePosition(position)
}
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
view.setSubtitleScale(scale)
}
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
view.setSubtitleMarginY(margin)
}
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
// No-op — SubtitleView follows authored cue alignment.
}
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
view.setSubtitleAlignY(alignment)
}
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
view.setSubtitleFontSize(size)
}
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
view.setSubtitleBorderStyle(style)
}
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
view.setSubtitleBackgroundColor(color)
}
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
// No-op — libass-specific, no Media3 equivalent.
}
// Audio track functions
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
view.getAudioTracks()
}
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
view.setAudioTrack(trackId)
}
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
view.getCurrentAudioTrack()
}
// Video scaling
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
view.setZoomedToFill(zoomed)
}
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
view.isZoomedToFill()
}
// Technical info
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
view.getTechnicalInfo()
}
// Events that the view can send to JavaScript — same set as MPV.
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}

View File

@@ -1,976 +0,0 @@
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
package expo.modules.exoplayerplayer
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ColorInfo
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
/**
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
* MPV-only fields are accepted and ignored.
*/
data class VideoLoadConfig(
val url: String,
val headers: Map<String, String>? = null,
val externalSubtitles: List<String>? = null,
val startPosition: Double? = null,
val autoplay: Boolean = true,
val initialSubtitleId: Int? = null,
val initialAudioId: Int? = null,
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null,
)
/**
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
*
* Implements the same JS contract (events, ref methods, 1-based track IDs)
* as MpvPlayerView so the React layer can swap between the two without
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
*/
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
companion object {
private const val TAG = "ExoPlayerView"
private const val PROGRESS_INTERVAL_MS = 1000L
}
// Event dispatchers — names must match the Events() declaration in the module.
val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher()
val onError by EventDispatcher()
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private val mainHandler = Handler(Looper.getMainLooper())
private var player: ExoPlayer? = null
private val playerView: PlayerView
private val subtitleView: SubtitleView?
private var currentUrl: String? = null
private var pendingConfig: VideoLoadConfig? = null
private var tracksReadyFired: Boolean = false
// 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()
}
}

View File

@@ -1,6 +0,0 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
}
}

View File

@@ -1,19 +0,0 @@
// Re-export the shared player contract from mpv-player so ExoPlayer
// and MPV present identical surfaces to React. The MPV-prefixed setting
// keys keep their names to avoid migrating existing installs.
export type {
AudioTrack,
MpvPlayerViewProps,
MpvPlayerViewRef,
NowPlayingMetadata,
OnErrorEventPayload,
OnLoadEventPayload,
OnPictureInPictureChangePayload,
OnPlaybackStateChangePayload,
OnProgressEventPayload,
OnTracksReadyEventPayload,
SubtitleTrack,
TechnicalInfo,
VideoSource,
} from "../mpv-player/src/MpvPlayer.types";
export { default as ExoPlayerView } from "./src/ExoPlayerView";

View File

@@ -1,132 +0,0 @@
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} />;
},
);

View File

@@ -7,8 +7,6 @@ export type {
DownloadStartedEvent, DownloadStartedEvent,
} from "./background-downloader"; } from "./background-downloader";
export { default as BackgroundDownloader } from "./background-downloader"; export { default as BackgroundDownloader } from "./background-downloader";
// ExoPlayer (Android TV)
export { ExoPlayerView } from "./exoplayer-player";
// Glass Poster (tvOS 26+) // Glass Poster (tvOS 26+)
export type { GlassPosterViewProps } from "./glass-poster"; export type { GlassPosterViewProps } from "./glass-poster";
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster"; export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";

View File

@@ -125,14 +125,6 @@ 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
@@ -150,13 +142,6 @@ 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
@@ -167,6 +152,13 @@ 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")
@@ -220,7 +212,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
@@ -278,7 +270,6 @@ 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
@@ -386,67 +377,18 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
/** /**
* Restore video after a system-initiated surface loss (Android TV * Force mpv to render a frame to the current surface.
* screensaver / app background while paused). Triggered from the host * Steps forward one frame then seeks back to the original position.
* activity's onResume via [MpvPlayerView.runResumeRecovery]. * Used after PiP entry to work around mpv stopping pixel output.
*
* 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 recoverVideoOutput(surface: Surface?) { fun forceRedraw() {
if (!isRunning) { if (!isRunning) return
Log.w(TAG, "[Recover] recoverVideoOutput — renderer not running, skipping") val pos = cachedPosition
return Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
mpv?.command(arrayOf("frame-step"))
if (pos > 0) {
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
} }
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(
@@ -464,7 +406,6 @@ 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
@@ -624,11 +565,6 @@ 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

View File

@@ -1,11 +1,7 @@
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
@@ -55,12 +51,6 @@ 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
@@ -87,15 +77,6 @@ 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)
@@ -164,10 +145,6 @@ 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()
} }
/** /**
@@ -550,107 +527,6 @@ 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
/** /**
@@ -662,7 +538,6 @@ 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

View File

@@ -175,28 +175,4 @@ export type TechnicalInfo = {
hwdec?: string; hwdec?: string;
/** Estimated video output fps (mpv "estimated-vf-fps") */ /** Estimated video output fps (mpv "estimated-vf-fps") */
estimatedVfFps?: number; estimatedVfFps?: number;
// ---- Extended fields (primarily ExoPlayer-backed; MPV may fill some) ----
/** Derived HDR format: "SDR" | "HDR10" | "HDR10+" | "HLG" | null */
hdrFormat?: string;
/** Color space, e.g. "BT.709" / "BT.2020" */
colorSpace?: string;
/** Color range: "Limited" / "Full" */
colorRange?: string;
/** Color transfer: "SDR" / "ST2084 (PQ)" / "HLG" */
colorTransfer?: string;
/** Decoder path: "hardware" (MediaCodec) or "software" (FFmpeg extension) */
decoderType?: string;
/** Instantiated decoder name, e.g. "c2.amlogic.hevc.decoder" */
decoderName?: string;
/** Active audio channel count (2 = stereo, 6 = 5.1, 8 = 7.1) */
audioChannels?: number;
/** Active audio sample rate in Hz */
audioSampleRate?: number;
/**
* Raw codec tag from the container, e.g. "hev1.2.4.L153.B0". Encodes
* profile / tier / level / constraint bytes per ISO/IEC 14496-15. Power
* users can decode this manually; it's how Jellyfin's HEVC level cap
* (153 = Level 5.1) is checked against the file.
*/
videoCodecs?: string;
}; };

View File

@@ -96,5 +96,24 @@ 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) => sum + (item.videoFileSize || 0), 0); return items.reduce((sum, item) => {
// 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);
} }

View File

@@ -289,7 +289,24 @@ export function useDownloadOperations({
); );
const appSizeUsage = useCallback(async () => { const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize(); let 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([
@@ -310,7 +327,7 @@ export function useDownloadOperations({
appSize: totalSize, appSize: totalSize,
}; };
} }
}, []); }, [processes]);
return { return {
startBackgroundDownload, startBackgroundDownload,

View File

@@ -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_occured_did_you_enter_the_correct_url", "login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
), ),
); );
} }

View File

@@ -7,7 +7,7 @@ import type React from "react";
import { createContext, useCallback, useContext, useState } from "react"; import { createContext, useCallback, useContext, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import type { Bitrate } from "@/components/BitrateSelector"; import type { Bitrate } from "@/components/BitrateSelector";
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings"; import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { generateDeviceProfile } from "../utils/profiles/native"; import { generateDeviceProfile } from "../utils/profiles/native";
import { apiAtom, userAtom } from "./JellyfinProvider"; import { apiAtom, userAtom } from "./JellyfinProvider";
@@ -78,11 +78,10 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
} }
try { try {
// Match the device profile to the actually-active player so the // Generate device profile for MPV player
// server picks codecs/containers the player can decode.
const native = generateDeviceProfile({ const native = generateDeviceProfile({
platform: Platform.OS as "ios" | "android", platform: Platform.OS as "ios" | "android",
player: getActivePlayerType(settings), player: "mpv",
audioMode: settings.audioTranscodeMode, audioMode: settings.audioTranscodeMode,
}); });
const data = await getStreamUrl({ const data = await getStreamUrl({

View File

@@ -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_occured": "An unexpected error occurred", "an_unexpected_error_occurred": "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_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?", "an_unexpected_error_occurred_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_autorized": "Quick Connect authorized", "quick_connect_authorized": "Quick Connect authorized",
"error": "Error", "error": "Error",
"invalid_code": "Invalid code", "invalid_code": "Invalid code",
"authorize": "Authorize" "authorize": "Authorize"
@@ -199,13 +199,6 @@
"rewind_length": "Rewind length", "rewind_length": "Rewind length",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"video_player": {
"title": "Video Player",
"exoplayer": "ExoPlayer",
"mpv": "MPV",
"exoplayer_note": "ExoPlayer does not support advanced ASS/SSA subtitle styling or horizontal subtitle alignment. Switch to MPV if you need those.",
"mpv_note": "MPV on TV does not currently pass HDR metadata to the display — HDR10/HDR10+ content is tone-mapped to SDR. Switch to ExoPlayer for HDR output."
},
"buffer": { "buffer": {
"title": "Buffer settings", "title": "Buffer settings",
"cache_mode": "Cache mode", "cache_mode": "Cache mode",
@@ -277,6 +270,10 @@
"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",
@@ -305,7 +302,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_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "select_libraries_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",
@@ -391,6 +388,8 @@
"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",
@@ -442,10 +441,13 @@
}, },
"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",
@@ -501,6 +503,8 @@
"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",
@@ -602,10 +606,34 @@
}, },
"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_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occurred_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}}",
@@ -702,6 +730,7 @@
"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",
@@ -773,7 +802,7 @@
"request_selected": "Request selected", "request_selected": "Request selected",
"n_selected": "{{count}} selected", "n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_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!",
@@ -786,6 +815,16 @@
"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",
@@ -796,6 +835,12 @@
}, },
"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",

View File

@@ -171,52 +171,11 @@ export type HomeSectionLatestResolver = {
includeItemTypes?: Array<BaseItemKind>; includeItemTypes?: Array<BaseItemKind>;
}; };
// Video player enum. MPV is the universal default; ExoPlayer is an // Video player enum - currently only MPV is supported
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
export enum VideoPlayer { export enum VideoPlayer {
MPV = 0, MPV = 0,
ExoPlayer = 1,
} }
/**
* 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",
@@ -259,8 +218,6 @@ export type Settings = {
mediaListCollectionIds?: string[]; mediaListCollectionIds?: string[];
preferedLanguage?: string; preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin" | "Streamystats"; searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
videoPlayer?: VideoPlayer;
marlinServerUrl?: string; marlinServerUrl?: string;
streamyStatsServerUrl?: string; streamyStatsServerUrl?: string;
streamyStatsMovieRecommendations?: boolean; streamyStatsMovieRecommendations?: boolean;
@@ -358,8 +315,6 @@ export const defaultValues: Settings = {
mediaListCollectionIds: [], mediaListCollectionIds: [],
preferedLanguage: undefined, preferedLanguage: undefined,
searchEngine: "Jellyfin", searchEngine: "Jellyfin",
// videoPlayer intentionally undefined — resolved at runtime via
// getActiveVideoPlayer() so existing installs are unaffected.
marlinServerUrl: "", marlinServerUrl: "",
streamyStatsServerUrl: "", streamyStatsServerUrl: "",
streamyStatsMovieRecommendations: false, streamyStatsMovieRecommendations: false,

View File

@@ -9,7 +9,7 @@ import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles"; import { getSubtitleProfiles } from "./subtitles";
export type PlatformType = "ios" | "android"; export type PlatformType = "ios" | "android";
export type PlayerType = "mpv" | "exoplayer"; export type PlayerType = "mpv";
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
export interface ProfileOptions { export interface ProfileOptions {
@@ -63,26 +63,6 @@ const getAudioCodecProfile = (platform: PlatformType) => {
}; };
}; };
/**
* Resolves the MaxAudioChannels string for a given audio transcoding mode.
* Used by both the MPV and ExoPlayer profile branches — the channel-cap
* rule is player-agnostic (the player decodes; the cap just tells the
* server when to transcode down).
*/
const maxChannelsForMode = (audioMode: AudioTranscodeModeType): string => {
switch (audioMode) {
case "stereo":
return "2";
case "5.1":
return "6";
case "passthrough":
return "8";
default:
// Auto: default to 5.1 (6 channels)
return "6";
}
};
/** /**
* Gets the video audio codec configuration based on platform and audio mode. * Gets the video audio codec configuration based on platform and audio mode.
* *
@@ -109,59 +89,35 @@ const getVideoAudioCodecs = (
// MPV can decode all codecs - only channel count varies by mode // MPV can decode all codecs - only channel count varies by mode
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`; const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
switch (audioMode) {
case "stereo":
// Limit to 2 channels - MPV will decode and downmix
return { return {
directPlayCodec: allCodecs, directPlayCodec: allCodecs,
maxAudioChannels: maxChannelsForMode(audioMode), maxAudioChannels: "2",
};
}; };
/** case "5.1":
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV. // Limit to 6 channels
*
* Codec set aligned with Media3's documented supported-formats list:
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
* - Audio: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS,
* DTS-HD, TrueHD
*
* Hardware decode (MediaCodec) handles whatever the device ships with;
* the rest fall through to FFmpeg software decode via the Jellyfin-published
* `org.jellyfin.media3:media3-ffmpeg-decoder` extension wired up with
* `DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER` (see
* ExoPlayerView.kt:ensurePlayer).
*
* Cross-checked against the reference-device probe in
* docs/research/hdr-dv-atmos-tv-plan.md (Amlogic Android 14 TV; HDMI sink
* accepts AC3/EAC3 as bitstream and multichannel PCM up to 7.1 @ 192 kHz,
* so software-decoded DTS/DTS-HD/TrueHD reach the sink as PCM).
*
* Dolby Vision: the CodecProfile below uses `NotEquals VideoRangeType
* DOVI`, which in Jellyfin's semantics blocks ONLY pure Profile 5
* (IPTPQc2 — the stream that renders purple/green without a DV-aware
* decoder). DV Profiles 7/8 with HDR10 or SDR base layers (Jellyfin
* reports these as `DOVIWithHDR10`, `DOVIWithHDR10Plus`, `DOVIWithEL`)
* are NOT blocked — Media3 1.9.1+ correctly falls back to the AVC/HEVC
* base layer.
*
* Containers limited to Media3's bundled extractors. FLV is intentionally
* absent — Media3 has no FLV extractor (MPV claims it via FFmpeg).
*/
const getExoPlayerDirectPlayProfile = () => {
const audioCodecs =
"vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd";
return { return {
video: { directPlayCodec: allCodecs,
Type: MediaTypes.Video, maxAudioChannels: "6",
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",
},
}; };
case "passthrough":
// Allow up to 8 channels - for external DAC/receiver setups
return {
directPlayCodec: allCodecs,
maxAudioChannels: "8",
};
default:
// Auto mode: default to 5.1 (6 channels)
return {
directPlayCodec: allCodecs,
maxAudioChannels: "6",
};
}
}; };
/** /**
@@ -170,63 +126,6 @@ const getExoPlayerDirectPlayProfile = () => {
export const generateDeviceProfile = (options: ProfileOptions = {}) => { export const generateDeviceProfile = (options: ProfileOptions = {}) => {
const platform = (options.platform || Platform.OS) as PlatformType; const platform = (options.platform || Platform.OS) as PlatformType;
const audioMode = options.audioMode || "auto"; const audioMode = options.audioMode || "auto";
const player = options.player || "mpv";
// ExoPlayer branch — Media3 capabilities on Android TV.
if (player === "exoplayer" && platform === "android") {
const exoDirect = getExoPlayerDirectPlayProfile();
return {
Name: "1. ExoPlayer",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h263,h264,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,
@@ -299,3 +198,6 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
return profile; return profile;
}; };
// Default export for backward compatibility
export default generateDeviceProfile();

View File

@@ -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 || !Number.isFinite(t)) return "0:00"; if (t === null || t === undefined) return "0:00";
let seconds: number; let seconds: number;
switch (unit) { switch (unit) {