feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
Fredrik Burmester
2026-01-10 19:35:27 +01:00
committed by GitHub
parent df2f44e086
commit f1575ca48b
98 changed files with 3257 additions and 7448 deletions

View File

@@ -1,7 +1,8 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
@@ -46,13 +47,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -65,13 +66,13 @@ export default function IndexLayout() {
headerShadowVisible: false,
title: t("home.downloads.tvseries"),
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -84,13 +85,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -102,13 +103,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -120,13 +121,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -138,13 +139,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -156,13 +157,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -174,13 +175,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -192,13 +193,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -210,13 +211,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -228,13 +229,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -246,13 +247,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -264,13 +265,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -282,13 +283,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -300,13 +301,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -318,13 +319,13 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
}}
/>
@@ -336,9 +337,9 @@ export default function IndexLayout() {
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Pressable onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
</Pressable>
),
headerShown: true,
headerBlurEffect: "prominent",
@@ -354,13 +355,13 @@ const SettingsButton = () => {
const router = useRouter();
return (
<TouchableOpacity
<Pressable
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name='settings' color={"white"} size={22} />
</TouchableOpacity>
</Pressable>
);
};
@@ -369,7 +370,7 @@ const SessionsButton = () => {
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
<TouchableOpacity
<Pressable
onPress={() => {
router.push("/(auth)/sessions");
}}
@@ -380,6 +381,6 @@ const SessionsButton = () => {
color={sessions.length === 0 ? "white" : "#9333ea"}
size={28}
/>
</TouchableOpacity>
</Pressable>
);
};

View File

@@ -3,13 +3,8 @@ import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
@@ -103,12 +98,12 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
<Pressable
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
</Pressable>
),
});
}, [downloadedFiles]);

View File

@@ -2,8 +2,8 @@ import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { VlcSubtitleSettings } from "@/components/settings/VlcSubtitleSettings";
export default function AudioSubtitlesPage() {
const insets = useSafeAreaInsets();
@@ -23,7 +23,7 @@ export default function AudioSubtitlesPage() {
<MediaProvider>
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
<VlcSubtitleSettings className='mb-4' />
<MpvSubtitleSettings className='mb-4' />
</MediaProvider>
</View>
</ScrollView>

View File

@@ -1,7 +1,8 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native";
import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
@@ -22,14 +23,14 @@ export default function WatchlistsLayout() {
headerShadowVisible: false,
headerRight: streamystatsEnabled
? () => (
<TouchableOpacity
<Pressable
onPress={() =>
router.push("/(auth)/(tabs)/(watchlists)/create")
}
className='p-1.5'
>
<Ionicons name='add' size={24} color='white' />
</TouchableOpacity>
</Pressable>
)
: undefined,
}}

View File

@@ -14,7 +14,7 @@ import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { Alert, Platform, useWindowDimensions, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector";
@@ -27,7 +27,6 @@ import {
PlaybackSpeedScope,
updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings";
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
@@ -35,24 +34,17 @@ import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import {
type PlaybackStatePayload,
type ProgressUpdatePayload,
type SfOnErrorEventPayload,
type SfOnPictureInPictureChangePayload,
type SfOnPlaybackStateChangePayload,
type SfOnProgressEventPayload,
SfPlayerView,
type SfPlayerViewRef,
type SfVideoSource,
setHardwareDecode,
type VlcPlayerSource,
VlcPlayerView,
type VlcPlayerViewRef,
type MpvOnErrorEventPayload,
type MpvOnPlaybackStateChangePayload,
type MpvOnProgressEventPayload,
MpvPlayerView,
type MpvPlayerViewRef,
type MpvVideoSource,
} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
@@ -63,29 +55,21 @@ import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() {
const videoRef = useRef<SfPlayerViewRef | VlcPlayerViewRef>(null);
const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
const { settings, updateSettings } = useSettings();
// Determine which player to use:
// - Android always uses VLC
// - iOS uses user setting (KSPlayer by default, VLC optional)
const useVlcPlayer =
Platform.OS === "android" ||
(Platform.OS === "ios" && settings.videoPlayerIOS === VideoPlayerIOS.VLC);
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
const [scaleFactor, setScaleFactor] = useState<
0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0
>(0);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default",
);
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
@@ -190,15 +174,11 @@ export default function page() {
updateSettings,
);
// Apply speed to the current player
// Apply speed to the current player (MPV)
setCurrentPlaybackSpeed(speed);
if (useVlcPlayer) {
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(speed);
} else {
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(speed);
}
await videoRef.current?.setSpeed?.(speed);
},
[item, settings, updateSettings, useVlcPlayer],
[item, settings, updateSettings],
);
/** Gets the initial playback position from the URL. */
@@ -311,11 +291,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile({
platform: Platform.OS as "ios" | "android",
player: useVlcPlayer ? "vlc" : "ksplayer",
audioMode: settings.audioTranscodeMode,
}),
deviceProfile: generateDeviceProfile(),
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
@@ -407,7 +383,6 @@ export default function page() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
// KSPlayer doesn't have a stop method, use pause instead
videoRef.current?.pause();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
@@ -465,13 +440,13 @@ export default function page() {
[],
);
/** Progress handler for iOS (SfPlayer) - position in seconds */
const onProgressSf = useCallback(
async (data: { nativeEvent: SfOnProgressEventPayload }) => {
/** Progress handler for MPV - position in seconds */
const onProgress = useCallback(
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent;
// KSPlayer reports position in seconds, convert to ms
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
if (isBuffering) {
@@ -514,63 +489,14 @@ export default function page() {
],
);
/** Progress handler for Android (VLC) - currentTime in milliseconds */
const onProgressVlc = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
// VLC reports currentTime in milliseconds
if (isBuffering) {
setIsBuffering(false);
}
progress.set(currentTime);
// Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();
wasJustSeeking.value = false;
if (
shouldUpdateUrl ||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
) {
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
lastUrlUpdateTime.value = now;
}
if (!item?.Id) return;
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
},
[
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
);
/** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => {
const _startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
/** Build video source config for iOS (SfPlayer/KSPlayer) */
const sfVideoSource = useMemo<SfVideoSource | undefined>(() => {
if (!stream?.url || useVlcPlayer) return undefined;
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined;
const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
@@ -609,15 +535,10 @@ export default function page() {
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const startPos = ticksToSeconds(startTicks);
// For transcoded streams, the server already handles seeking via startTimeTicks,
// so we should NOT also tell the player to seek (would cause double-seeking).
// For direct play/stream, the player needs to seek itself.
const playerStartPos = isTranscoding ? 0 : startPos;
// Build source config - headers only needed for online streaming
const source: SfVideoSource = {
const source: MpvVideoSource = {
url: stream.url,
startPosition: playerStartPos,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
initialAudioId,
@@ -646,167 +567,6 @@ export default function page() {
subtitleIndex,
audioIndex,
offline,
useVlcPlayer,
]);
/** Build video source config for Android (VLC) */
const vlcVideoSource = useMemo<VlcPlayerSource | undefined>(() => {
if (!stream?.url || !useVlcPlayer) return undefined;
const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// Get external subtitle URLs for VLC (need name and DeliveryUrl)
// - Online: prepend API base path to server URLs
// - Offline: use local file paths (stored in DeliveryUrl during download)
let externalSubs: { name: string; DeliveryUrl: string }[] | undefined;
if (!offline && api?.basePath) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => ({
name: s.DisplayTitle || s.Title || `Subtitle ${s.Index}`,
DeliveryUrl: `${api.basePath}${s.DeliveryUrl}`,
}));
} else if (offline) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => ({
name: s.DisplayTitle || s.Title || `Subtitle ${s.Index}`,
DeliveryUrl: s.DeliveryUrl!,
}));
}
// Build VLC init options (required for VLC to work properly)
const initOptions: string[] = [""];
// Get all subtitle and audio streams
const allSubs =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [];
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
// Find chosen tracks
const chosenSubtitleTrack = allSubs.find((s) => s.Index === subtitleIndex);
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
// Set subtitle track
if (
chosenSubtitleTrack &&
(!isTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = !isTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
if (finalIndex >= 0) {
initOptions.push(`--sub-track=${finalIndex}`);
}
}
// Set audio track
if (!isTranscoding && chosenAudioTrack) {
const audioTrackIndex = allAudio.indexOf(chosenAudioTrack);
if (audioTrackIndex >= 0) {
initOptions.push(`--audio-track=${audioTrackIndex}`);
}
}
// Add VLC subtitle styling from settings
if (settings.subtitleSize) {
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
}
initOptions.push(`--sub-margin=${settings.vlcSubtitleMargin ?? 40}`);
// Text color
if (
settings.vlcTextColor &&
VLC_COLORS[settings.vlcTextColor] !== undefined
) {
initOptions.push(`--freetype-color=${VLC_COLORS[settings.vlcTextColor]}`);
}
// Background styling
if (
settings.vlcBackgroundColor &&
VLC_COLORS[settings.vlcBackgroundColor] !== undefined
) {
initOptions.push(
`--freetype-background-color=${VLC_COLORS[settings.vlcBackgroundColor]}`,
);
}
if (settings.vlcBackgroundOpacity !== undefined) {
initOptions.push(
`--freetype-background-opacity=${settings.vlcBackgroundOpacity}`,
);
}
// Outline styling
if (
settings.vlcOutlineColor &&
VLC_COLORS[settings.vlcOutlineColor] !== undefined
) {
initOptions.push(
`--freetype-outline-color=${VLC_COLORS[settings.vlcOutlineColor]}`,
);
}
if (settings.vlcOutlineOpacity !== undefined) {
initOptions.push(
`--freetype-outline-opacity=${settings.vlcOutlineOpacity}`,
);
}
if (
settings.vlcOutlineThickness &&
OUTLINE_THICKNESS[settings.vlcOutlineThickness] !== undefined
) {
initOptions.push(
`--freetype-outline-thickness=${OUTLINE_THICKNESS[settings.vlcOutlineThickness]}`,
);
}
// Bold text
if (settings.vlcIsBold) {
initOptions.push("--freetype-bold");
}
// For transcoded streams, the server already handles seeking via startTimeTicks,
// so we should NOT also tell the player to seek (would cause double-seeking).
// For direct play/stream, the player needs to seek itself.
const playerStartPos = isTranscoding ? 0 : startPosition;
const source: VlcPlayerSource = {
uri: stream.url,
startPosition: playerStartPos,
autoplay: true,
isNetwork: !offline,
externalSubtitles: externalSubs,
initOptions,
};
return source;
}, [
stream?.url,
stream?.mediaSource,
startPosition,
useVlcPlayer,
api?.basePath,
offline,
subtitleIndex,
audioIndex,
settings.subtitleSize,
settings.vlcTextColor,
settings.vlcBackgroundColor,
settings.vlcBackgroundOpacity,
settings.vlcOutlineColor,
settings.vlcOutlineOpacity,
settings.vlcOutlineThickness,
settings.vlcIsBold,
settings.vlcSubtitleMargin,
]);
const volumeUpCb = useCallback(async () => {
@@ -888,9 +648,9 @@ export default function page() {
setVolume: setVolumeCb,
});
/** Playback state handler for iOS (SfPlayer) */
const onPlaybackStateChangedSf = useCallback(
async (e: { nativeEvent: SfOnPlaybackStateChangePayload }) => {
/** Playback state handler for MPV */
const onPlaybackStateChanged = useCallback(
async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
if (playing) {
@@ -924,52 +684,9 @@ export default function page() {
[playbackManager, item?.Id, progress],
);
/** Playback state handler for Android (VLC) */
const onPlaybackStateChangedVlc = useCallback(
async (e: PlaybackStatePayload) => {
const {
state,
isBuffering: buffering,
isPlaying: playing,
} = e.nativeEvent;
if (state === "Playing" || playing) {
setIsPlaying(true);
setIsBuffering(false);
setHasPlaybackStarted(true);
setTracksReady(true); // VLC tracks are ready when playback starts
if (item?.Id) {
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
}
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") {
setIsPlaying(false);
if (item?.Id) {
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
}
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (state === "Buffering" || buffering) {
setIsBuffering(true);
}
},
[playbackManager, item?.Id, progress],
);
/** PiP handler for iOS (SfPlayer) */
const onPictureInPictureChangeSf = useCallback(
(e: { nativeEvent: SfOnPictureInPictureChangePayload }) => {
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent;
setIsPipMode(isActive);
// Hide controls when entering PiP
@@ -980,19 +697,6 @@ export default function page() {
[],
);
/** PiP handler for Android (VLC) */
const onPipStartedVlc = useCallback(
(e: { nativeEvent: { pipStarted: boolean } }) => {
const { pipStarted } = e.nativeEvent;
setIsPipMode(pipStarted);
// Hide controls when entering PiP
if (pipStarted) {
_setShowControls(false);
}
},
[],
);
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
@@ -1014,96 +718,79 @@ export default function page() {
videoRef.current?.pause?.();
}, []);
const seek = useCallback(
(position: number) => {
if (useVlcPlayer) {
// VLC expects milliseconds
videoRef.current?.seekTo?.(position);
} else {
// KSPlayer expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
}
},
[useVlcPlayer],
);
const seek = useCallback((position: number) => {
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
}, []);
const handleZoomToggle = useCallback(async () => {
// Zoom toggle only supported when using SfPlayer (KSPlayer)
if (useVlcPlayer) return;
const newZoomState = !isZoomedToFill;
await videoRef.current?.setZoomedToFill?.(newZoomState);
setIsZoomedToFill(newZoomState);
await (videoRef.current as SfPlayerViewRef)?.setVideoZoomToFill?.(
newZoomState,
);
}, [isZoomedToFill, useVlcPlayer]);
// VLC-specific handlers for aspect ratio and scale factor
const handleSetVideoAspectRatio = useCallback(
async (newAspectRatio: string | null) => {
if (!useVlcPlayer) return;
const ratio = (newAspectRatio ?? "default") as
| "default"
| "16:9"
| "4:3"
| "1:1"
| "21:9";
setAspectRatio(ratio);
await (videoRef.current as VlcPlayerViewRef)?.setVideoAspectRatio?.(
newAspectRatio,
// Adjust subtitle position to compensate for video cropping when zoomed
if (newZoomState) {
// Get video dimensions from mediaSource
const videoStream = stream?.mediaSource?.MediaStreams?.find(
(s) => s.Type === "Video",
);
},
[useVlcPlayer],
);
const videoWidth = videoStream?.Width ?? 1920;
const videoHeight = videoStream?.Height ?? 1080;
const handleSetVideoScaleFactor = useCallback(
async (newScaleFactor: number) => {
if (!useVlcPlayer) return;
setScaleFactor(
newScaleFactor as 0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0,
);
await (videoRef.current as VlcPlayerViewRef)?.setVideoScaleFactor?.(
newScaleFactor,
);
},
[useVlcPlayer],
);
const videoAR = videoWidth / videoHeight;
const screenAR = screenWidth / screenHeight;
// Apply KSPlayer global settings before video loads (only when using KSPlayer)
useEffect(() => {
if (Platform.OS === "ios" && !useVlcPlayer) {
setHardwareDecode(settings.ksHardwareDecode);
if (screenAR > videoAR) {
// Screen is wider than video - video height extends beyond screen
// Calculate how much of the video is cropped at the bottom (as % of video height)
const bottomCropPercent = 50 * (1 - videoAR / screenAR);
// Only adjust by 70% of the crop to keep a comfortable margin from the edge
// (subtitles already have some built-in padding from the bottom)
const adjustmentFactor = 0.7;
const newSubPos = Math.round(
100 - bottomCropPercent * adjustmentFactor,
);
await videoRef.current?.setSubtitlePosition?.(newSubPos);
}
// If videoAR >= screenAR, sides are cropped but bottom is visible, no adjustment needed
} else {
// Restore to default position (bottom of video frame)
await videoRef.current?.setSubtitlePosition?.(100);
}
}, [settings.ksHardwareDecode, useVlcPlayer]);
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// Apply subtitle settings when video loads (SfPlayer-specific)
// Apply subtitle settings when video loads
useEffect(() => {
if (useVlcPlayer || !isVideoLoaded || !videoRef.current) return;
if (!isVideoLoaded || !videoRef.current) return;
const sfRef = videoRef.current as SfPlayerViewRef;
const applySubtitleSettings = async () => {
if (settings.mpvSubtitleScale !== undefined) {
await sfRef?.setSubtitleScale?.(settings.mpvSubtitleScale);
await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale);
}
if (settings.mpvSubtitleMarginY !== undefined) {
await sfRef?.setSubtitleMarginY?.(settings.mpvSubtitleMarginY);
await videoRef.current?.setSubtitleMarginY?.(
settings.mpvSubtitleMarginY,
);
}
if (settings.mpvSubtitleAlignX !== undefined) {
await sfRef?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
}
if (settings.mpvSubtitleAlignY !== undefined) {
await sfRef?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await sfRef?.setSubtitleFontSize?.(settings.mpvSubtitleFontSize);
await videoRef.current?.setSubtitleFontSize?.(
settings.mpvSubtitleFontSize,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await sfRef?.setSubtitleFontSize?.(settings.subtitleSize);
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
}
};
applySubtitleSettings();
}, [isVideoLoaded, settings, useVlcPlayer]);
}, [isVideoLoaded, settings]);
// Apply initial playback speed when video loads
useEffect(() => {
@@ -1112,20 +799,12 @@ export default function page() {
const applyInitialPlaybackSpeed = async () => {
if (initialPlaybackSpeed !== 1.0) {
setCurrentPlaybackSpeed(initialPlaybackSpeed);
if (useVlcPlayer) {
await (videoRef.current as VlcPlayerViewRef)?.setRate?.(
initialPlaybackSpeed,
);
} else {
await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(
initialPlaybackSpeed,
);
}
await videoRef.current?.setSpeed?.(initialPlaybackSpeed);
}
};
applyInitialPlaybackSpeed();
}, [isVideoLoaded, initialPlaybackSpeed, useVlcPlayer]);
}, [isVideoLoaded, initialPlaybackSpeed]);
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
@@ -1160,7 +839,6 @@ export default function page() {
mediaSource={stream?.mediaSource}
isVideoLoaded={isVideoLoaded}
tracksReady={tracksReady}
useVlcPlayer={useVlcPlayer}
offline={offline}
downloadedItem={downloadedItem}
>
@@ -1183,51 +861,25 @@ export default function page() {
justifyContent: "center",
}}
>
{useVlcPlayer ? (
<VlcPlayerView
ref={videoRef as React.RefObject<VlcPlayerViewRef>}
source={vlcVideoSource!}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgressVlc}
onVideoStateChange={onPlaybackStateChangedVlc}
onPipStarted={onPipStartedVlc}
onVideoLoadEnd={() => {
// Note: VLC only fires this on error, not on successful load
// tracksReady is set in onPlaybackStateChangedVlc when state is "Playing"
setIsVideoLoaded(true);
}}
onVideoError={(e: PlaybackStatePayload) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
progressUpdateInterval={1000}
/>
) : (
<SfPlayerView
ref={videoRef as React.RefObject<SfPlayerViewRef>}
source={sfVideoSource}
style={{ width: "100%", height: "100%" }}
onProgress={onProgressSf}
onPlaybackStateChange={onPlaybackStateChangedSf}
onPictureInPictureChange={onPictureInPictureChangeSf}
onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: SfOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onTracksReady={() => {
setTracksReady(true);
}}
/>
)}
<MpvPlayerView
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onTracksReady={() => {
setTracksReady(true);
}}
/>
{!hasPlaybackStarted && (
<View
style={{
@@ -1263,11 +915,7 @@ export default function page() {
seek={seek}
enableTrickplay={true}
offline={offline}
useVlcPlayer={useVlcPlayer}
aspectRatio={aspectRatio}
setVideoAspectRatio={handleSetVideoAspectRatio}
scaleFactor={scaleFactor}
setVideoScaleFactor={handleSetVideoScaleFactor}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}