This commit is contained in:
Alex Kim
2025-12-06 04:56:48 +11:00
parent 36655bba43
commit c76d7eb877
47 changed files with 458 additions and 3442 deletions

View File

@@ -22,35 +22,28 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import {
OUTLINE_THICKNESS,
OutlineThickness,
VLC_COLORS,
VLCColor,
} from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import type {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import {
MpvPlayerView,
type MpvPlayerViewRef,
type OnPlaybackStateChangePayload,
type OnProgressEventPayload,
} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
@@ -58,7 +51,7 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
const [isPipMode, _setIsPipMode] = useState(false);
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
@@ -325,7 +318,8 @@ export default function page() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
// MPV doesn't have a stop method, use pause instead
videoRef.current?.pause();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
@@ -380,10 +374,13 @@ export default function page() {
);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
async (data: { nativeEvent: OnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
const { position } = data.nativeEvent;
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
if (isBuffering) {
setIsBuffering(false);
}
@@ -509,10 +506,12 @@ export default function page() {
});
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
async (e: { nativeEvent: OnPlaybackStateChangePayload }) => {
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
if (playing) {
setIsPlaying(true);
setIsBuffering(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
@@ -522,7 +521,7 @@ export default function page() {
return;
}
if (state === "Paused") {
if (isPaused) {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
@@ -533,87 +532,18 @@ export default function page() {
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
if (isLoading) {
setIsBuffering(true);
}
},
[playbackManager, item?.Id, progress],
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio",
) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
const _allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
}));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */
const initOptions = [``];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
// Add VLC subtitle styling options from settings
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
const backgroundColor = (settings.vlcBackgroundColor ??
"Black") as VLCColor;
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
const outlineThickness = (settings.vlcOutlineThickness ??
"Normal") as OutlineThickness;
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
const isBold = settings.vlcIsBold ?? false;
// Add subtitle styling options
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
initOptions.push(
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
);
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
initOptions.push(
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
);
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
initOptions.push("--sub-margin=40");
if (isBold) {
initOptions.push("--freetype-bold");
}
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
@@ -626,6 +556,7 @@ export default function page() {
const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.();
}, []);
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
@@ -635,10 +566,8 @@ export default function page() {
}, []);
const seek = useCallback((position: number) => {
videoRef.current?.seekTo?.(position);
}, []);
const getAudioTracks = useCallback(async () => {
return videoRef.current?.getAudioTracks?.() || null;
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
}, []);
const getSubtitleTracks = useCallback(async () => {
@@ -650,54 +579,39 @@ export default function page() {
}, []);
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
// Note: VlcPlayer type only expects url parameter
videoRef.current?.setSubtitleURL?.(url);
videoRef.current?.addSubtitleFile?.(url);
}, []);
const setAudioTrack = useCallback((index: number) => {
videoRef.current?.setAudioTrack?.(index);
}, []);
// Apply MPV subtitle settings when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
const setVideoAspectRatio = useCallback(
async (aspectRatio: string | null) => {
return (
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
Promise.resolve()
);
},
[],
);
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
return (
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
);
}, []);
// Prepare metadata for iOS native media controls
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
const applySubtitleSettings = async () => {
if (settings.mpvSubtitleScale !== undefined) {
await videoRef.current?.setSubtitleScale(settings.mpvSubtitleScale);
}
if (settings.mpvSubtitleMarginY !== undefined) {
await videoRef.current?.setSubtitleMarginY(settings.mpvSubtitleMarginY);
}
if (settings.mpvSubtitleAlignX !== undefined) {
await videoRef.current?.setSubtitleAlignX(settings.mpvSubtitleAlignX);
}
if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await videoRef.current?.setSubtitleFontSize(
settings.mpvSubtitleFontSize,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await videoRef.current?.setSubtitleFontSize(settings.subtitleSize);
}
};
}, [item, api]);
applySubtitleSettings();
}, [isVideoLoaded, settings]);
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
@@ -708,7 +622,7 @@ export default function page() {
);
}
// Then show loader while either side is still fetching or data isnt present
// Then show loader while either side is still fetching or data isn't present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return (
@@ -744,25 +658,21 @@ export default function page() {
justifyContent: "center",
}}
>
<VlcPlayerView
<MpvPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
autoplay: true,
isNetwork: !offline,
startPosition,
externalSubtitles,
initOptions,
}}
url={stream?.url || ""}
autoplay={true}
style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadEnd={() => {
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => {
setIsVideoLoaded(true);
// Seek to start position after load
if (startPosition > 0) {
videoRef.current?.seekTo(startPosition);
}
}}
onVideoError={(e) => {
onError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
@@ -770,9 +680,6 @@ export default function page() {
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onPipStarted={(e) => {
setIsPipMode(e.nativeEvent.pipStarted);
}}
/>
</View>
{isMounted === true && item && !isPipMode && (
@@ -794,19 +701,14 @@ export default function page() {
pause={pause}
seek={seek}
enableTrickplay={true}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
offline={offline}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
api={api}
downloadedFiles={downloadedFiles}
/>

View File

@@ -1,5 +1,5 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
@@ -474,52 +474,6 @@ export const PlayButton: React.FC<Props> = ({
),
}));
// if (Platform.OS === "ios")
// return (
// <Host
// style={{
// height: 50,
// flex: 1,
// flexShrink: 0,
// }}
// >
// <Button
// variant='glassProminent'
// onPress={onPress}
// color={effectiveColors.primary}
// modifiers={[fixedSize()]}
// >
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
// {runtimeTicksToMinutes(
// (item?.RunTimeTicks || 0) -
// (item?.UserData?.PlaybackPositionTicks || 0),
// )}
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
// </Animated.Text>
// <Animated.Text style={animatedTextStyle}>
// <Ionicons name='play-circle' size={24} />
// </Animated.Text>
// {client && (
// <Animated.Text style={animatedTextStyle}>
// <Feather name='cast' size={22} />
// <CastButton tintColor='transparent' />
// </Animated.Text>
// )}
// {!client && settings?.openInVLC && (
// <Animated.Text style={animatedTextStyle}>
// <MaterialCommunityIcons
// name='vlc'
// size={18}
// color={animatedTextStyle.color}
// />
// </Animated.Text>
// )}
// </View>
// </Button>
// </Host>
// );
return (
<TouchableOpacity
disabled={!item}
@@ -569,15 +523,6 @@ export const PlayButton: React.FC<Props> = ({
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -1,4 +1,4 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -17,7 +17,6 @@ import Animated, {
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -207,15 +205,6 @@ export const PlayButton: React.FC<Props> = ({
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -5,12 +5,6 @@ import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { Stepper } from "@/components/inputs/Stepper";
import {
OUTLINE_THICKNESS,
type OutlineThickness,
VLC_COLORS,
type VLCColor,
} from "@/constants/SubtitleConstants";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
@@ -92,84 +86,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
];
}, [settings?.subtitleMode, t, updateSettings]);
const textColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcTextColor || "White") === color,
onPress: () => updateSettings({ vlcTextColor: color }),
}));
return [{ options }];
}, [settings?.vlcTextColor, t, updateSettings]);
const backgroundColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcBackgroundColor || "Black") === color,
onPress: () => updateSettings({ vlcBackgroundColor: color }),
}));
return [{ options }];
}, [settings?.vlcBackgroundColor, t, updateSettings]);
const outlineColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcOutlineColor || "Black") === color,
onPress: () => updateSettings({ vlcOutlineColor: color }),
}));
return [{ options }];
}, [settings?.vlcOutlineColor, t, updateSettings]);
const outlineThicknessOptionGroups = useMemo(() => {
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
const options = thicknesses.map((thickness) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.thickness.${thickness}`),
value: thickness,
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
}));
return [{ options }];
}, [settings?.vlcOutlineThickness, t, updateSettings]);
const backgroundOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcBackgroundOpacity, updateSettings]);
const outlineOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcOutlineOpacity, updateSettings]);
if (isTv) return null;
if (!settings) return null;
@@ -252,124 +168,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.text_color")}>
<PlatformDropdown
groups={textColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.text_color")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_color")}>
<PlatformDropdown
groups={backgroundColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.background_color")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_color")}>
<PlatformDropdown
groups={outlineColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.outline_color")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
<PlatformDropdown
groups={outlineThicknessOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.outline_thickness")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_opacity")}>
<PlatformDropdown
groups={backgroundOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.background_opacity")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
<PlatformDropdown
groups={outlineOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.subtitles.outline_opacity")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.bold_text")}>
<Switch
value={settings?.vlcIsBold ?? false}
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
/>
</ListItem>
</ListGroup>
</View>
);

View File

@@ -18,7 +18,6 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
isVlc: boolean;
showSkipButton: boolean;
showSkipCreditButton: boolean;
skipIntro: () => void;
@@ -66,7 +65,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble,
currentTime,
remainingTime,
isVlc,
showSkipButton,
showSkipCreditButton,
skipIntro,
@@ -145,13 +143,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
show={!nextItem ? false : remainingTime < 10000}
onFinish={handleNextEpisodeAutoPlay}
onPress={handleNextEpisodeManual}
/>
@@ -208,7 +200,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
<TimeDisplay
currentTime={currentTime}
remainingTime={remainingTime}
isVlc={isVlc}
/>
</View>
</View>

View File

@@ -28,7 +28,7 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import type { MpvPlayerViewRef, SubtitleTrack } from "@/modules";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -50,7 +50,7 @@ import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props {
item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
videoRef: MutableRefObject<MpvPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
@@ -68,18 +68,17 @@ interface Props {
startPictureInPicture?: () => Promise<void>;
play: () => void;
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
getSubtitleTracks?:
| (() => Promise<SubtitleTrack[] | null>)
| (() => SubtitleTrack[]);
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
aspectRatio?: AspectRatio;
scaleFactor?: ScaleFactor;
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
isVlc?: boolean;
api?: Api | null;
downloadedFiles?: DownloadedItem[];
}
@@ -100,11 +99,9 @@ export const Controls: FC<Props> = ({
setShowControls,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
aspectRatio = "default",
@@ -112,7 +109,6 @@ export const Controls: FC<Props> = ({
setAspectRatio,
setScaleFactor,
offline = false,
isVlc = false,
api = null,
downloadedFiles = undefined,
}) => {
@@ -194,17 +190,13 @@ export const Controls: FC<Props> = ({
zIndex: 10,
}));
// Initialize progress values
// Initialize progress values - MPV uses milliseconds
useEffect(() => {
if (item) {
progress.value = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
max.value = ticksToMs(item.RunTimeTicks || 0);
}
}, [item, isVlc, progress, max]);
}, [item, progress, max]);
// Navigation hooks
const {
@@ -215,7 +207,6 @@ export const Controls: FC<Props> = ({
} = useVideoNavigation({
progress,
isPlaying,
isVlc,
seek,
play,
});
@@ -225,7 +216,6 @@ export const Controls: FC<Props> = ({
progress,
max,
isSeeking,
isVlc,
});
const toggleControls = useCallback(() => {
@@ -248,7 +238,6 @@ export const Controls: FC<Props> = ({
progress,
min,
max,
isVlc,
showControls,
isPlaying,
seek,
@@ -273,7 +262,6 @@ export const Controls: FC<Props> = ({
progress,
isSeeking,
isPlaying,
isVlc,
seek,
play,
pause,
@@ -302,9 +290,8 @@ export const Controls: FC<Props> = ({
: current.actual;
} else {
// When not scrubbing, only update if progress changed significantly (1 second)
const progressUnit = isVlc
? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
: CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
// MPV uses milliseconds
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
if (progressDiff >= progressUnit) {
effectiveProgress.value = current.actual;
@@ -325,7 +312,6 @@ export const Controls: FC<Props> = ({
currentTime,
seek,
play,
isVlc,
offline,
api,
downloadedFiles,
@@ -336,7 +322,6 @@ export const Controls: FC<Props> = ({
currentTime,
seek,
play,
isVlc,
offline,
api,
downloadedFiles,
@@ -515,9 +500,7 @@ export const Controls: FC<Props> = ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
aspectRatio={aspectRatio}
@@ -554,7 +537,6 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
isVlc={isVlc}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
skipIntro={skipIntro}

View File

@@ -13,7 +13,7 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import { ICON_SIZES } from "./constants";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
@@ -34,9 +34,7 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
getAudioTracks?: (() => Promise<any[] | null>) | (() => any[]);
getSubtitleTracks?: (() => Promise<any[] | null>) | (() => any[]);
setAudioTrack?: (index: number) => void;
setSubtitleTrack?: (index: number) => void;
setSubtitleURL?: (url: string, customName: string) => void;
aspectRatio?: AspectRatio;
@@ -58,9 +56,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
goToNextItem,
previousItem,
nextItem,
getAudioTracks,
getSubtitleTracks,
setAudioTrack,
setSubtitleTrack,
setSubtitleURL,
aspectRatio = "default",
@@ -114,9 +110,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
<View className='mr-auto' pointerEvents='box-none'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
@@ -128,20 +122,18 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
</View>
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV &&
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
Platform.OS === "android") && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={ICON_SIZES.HEADER}
color='white'
/>
</TouchableOpacity>
)}
{!Platform.isTV && startPictureInPicture && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={ICON_SIZES.HEADER}
color='white'
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={switchOnEpisodeMode}

View File

@@ -6,18 +6,20 @@ import { formatTimeString } from "@/utils/time";
interface TimeDisplayProps {
currentTime: number;
remainingTime: number;
isVlc: boolean;
}
/**
* Displays current time and remaining time.
* MPV player uses milliseconds for time values.
*/
export const TimeDisplay: FC<TimeDisplayProps> = ({
currentTime,
remainingTime,
isVlc,
}) => {
const getFinishTime = () => {
const now = new Date();
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
const finishTime = new Date(now.getTime() + remainingMs);
// remainingTime is in ms
const finishTime = new Date(now.getTime() + remainingTime);
return finishTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@@ -28,11 +30,11 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
return (
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-[12px] text-neutral-400'>
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
{formatTimeString(currentTime, "ms")}
</Text>
<View className='flex flex-col items-end'>
<Text className='text-[12px] text-neutral-400'>
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text className='text-[10px] text-neutral-500 opacity-70'>
ends at {getFinishTime()}

View File

@@ -9,14 +9,12 @@ import {
useMemo,
useState,
} from "react";
import type { TrackInfo } from "@/modules/VlcPlayer.types";
import type { SubtitleTrack } from "@/modules";
import type { Track } from "../types";
import { useControlContext } from "./ControlContext";
interface VideoContextProps {
audioTracks: Track[] | null;
subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
}
@@ -25,28 +23,24 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps {
children: ReactNode;
getAudioTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| (() => Promise<SubtitleTrack[] | null>)
| (() => SubtitleTrack[])
| undefined;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
}
/**
* Video context provider for managing subtitle tracks.
* MPV player is used for all playback.
*/
export const VideoProvider: React.FC<VideoProviderProps> = ({
children,
getSubtitleTracks,
getAudioTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}) => {
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const ControlContext = useControlContext();
@@ -99,20 +93,15 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
};
const setTrackParams = (
type: "audio" | "subtitle",
_type: "subtitle",
index: number,
serverIndex: number,
) => {
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
// If we're transcoding and we're going from a image based subtitle
// to a text based subtitle, we need to change the player params.
const shouldChangePlayerParams =
type === "subtitle" &&
mediaSource?.TranscodingUrl &&
!onTextBasedSubtitle;
mediaSource?.TranscodingUrl && !onTextBasedSubtitle;
console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) {
@@ -121,47 +110,39 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
});
return;
}
setTrack?.(serverIndex);
setSubtitleTrack?.(serverIndex);
router.setParams({
[paramKey]: serverIndex.toString(),
subtitleIndex: serverIndex.toString(),
});
};
useEffect(() => {
const fetchTracks = async () => {
if (getSubtitleTracks) {
let subtitleData: TrackInfo[] | null = null;
let subtitleData: SubtitleTrack[] | null = null;
try {
subtitleData = await getSubtitleTracks();
} catch (error) {
console.log("[VideoContext] Failed to get subtitle tracks:", error);
return;
}
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
if (
mediaSource?.TranscodingUrl &&
subtitleData &&
subtitleData.length > 1
) {
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
}
let embedSubIndex = 1;
const processedSubs: Track[] = allSubs?.map((sub) => {
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
/** A boolean value determining if we should increment the embedSubIndex */
const shouldIncrement =
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
/** The index of subtitle inside VLC Player Itself */
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
/** The index of subtitle inside MPV Player itself */
const mpvIndex = subtitleData?.at(embedSubIndex)?.id ?? -1;
if (shouldIncrement) embedSubIndex++;
return {
name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1,
setTrack: () =>
shouldIncrement
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
? setTrackParams("subtitle", mpvIndex, sub.Index ?? -1)
: setPlayerParams({
chosenSubtitleIndex: sub.Index?.toString(),
}),
@@ -184,56 +165,16 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
});
setSubtitleTracks(subtitles);
}
if (getAudioTracks) {
let audioData: TrackInfo[] | null = null;
try {
audioData = await getAudioTracks();
} catch (error) {
console.log("[VideoContext] Failed to get audio tracks:", error);
return;
}
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () =>
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
};
}
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () =>
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
};
});
// Add a "Disable Audio" option if its not transcoding.
if (!mediaSource?.TranscodingUrl) {
audioTracks.unshift({
name: "Disable",
index: -1,
setTrack: () => setTrackParams("audio", -1, -1),
});
}
setAudioTracks(audioTracks);
}
};
fetchTracks();
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
}, [isVideoLoaded, getSubtitleTracks]);
return (
<VideoContext.Provider
value={{
audioTracks,
subtitleTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}}
>
{children}

View File

@@ -22,7 +22,6 @@ interface UseRemoteControlProps {
progress: SharedValue<number>;
min: SharedValue<number>;
max: SharedValue<number>;
isVlc: boolean;
showControls: boolean;
isPlaying: boolean;
seek: (value: number) => void;
@@ -34,11 +33,14 @@ interface UseRemoteControlProps {
handleSeekBackward: (seconds: number) => void;
}
/**
* Hook to manage TV remote control interactions.
* MPV player uses milliseconds for time values.
*/
export function useRemoteControl({
progress,
min,
max,
isVlc,
showControls,
isPlaying,
seek,
@@ -61,21 +63,18 @@ export function useRemoteControl({
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const SCRUB_INTERVAL = isVlc
? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS
: CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS;
// MPV uses ms
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
const updateTime = useCallback(
(progressValue: number) => {
const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue;
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
},
[isVlc],
);
const updateTime = useCallback((progressValue: number) => {
// Convert ms to ticks for calculation
const progressInTicks = msToTicks(progressValue);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, []);
// TV remote control handling (no-op on non-TV platforms)
useTVEventHandler((evt) => {
@@ -102,7 +101,8 @@ export function useRemoteControl({
Math.min(max.value, base + direction * SCRUB_INTERVAL),
);
remoteScrubProgress.value = updated;
const progressInTicks = isVlc ? msToTicks(updated) : updated;
// Convert ms to ticks for trickplay
const progressInTicks = msToTicks(updated);
calculateTrickplayUrl(progressInTicks);
updateTime(updated);
break;
@@ -111,9 +111,8 @@ export function useRemoteControl({
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
progress.value = remoteScrubProgress.value;
const seekTarget = isVlc
? Math.max(0, remoteScrubProgress.value)
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
// MPV uses ms, seek expects ms
const seekTarget = Math.max(0, remoteScrubProgress.value);
seek(seekTarget);
if (isPlaying) play();

View File

@@ -3,20 +3,22 @@ import type { SharedValue } from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import { secondsToMs, ticksToSeconds } from "@/utils/time";
import { secondsToMs } from "@/utils/time";
interface UseVideoNavigationProps {
progress: SharedValue<number>;
isPlaying: boolean;
isVlc: boolean;
seek: (value: number) => void;
play: () => void;
}
/**
* Hook to manage video navigation (seeking forward/backward).
* MPV player uses milliseconds for time values.
*/
export function useVideoNavigation({
progress,
isPlaying,
isVlc,
seek,
play,
}: UseVideoNavigationProps) {
@@ -30,16 +32,15 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(seconds))
: Math.max(0, ticksToSeconds(curr) - seconds);
// MPV uses ms
const newTime = Math.max(0, curr - secondsToMs(seconds));
seek(newTime);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
},
[isPlaying, isVlc, seek, progress],
[isPlaying, seek, progress],
);
const handleSeekForward = useCallback(
@@ -48,16 +49,15 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(seconds)
: ticksToSeconds(curr) + seconds;
// MPV uses ms
const newTime = curr + secondsToMs(seconds);
seek(Math.max(0, newTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
},
[isPlaying, isVlc, seek, progress],
[isPlaying, seek, progress],
);
const handleSkipBackward = useCallback(async () => {
@@ -69,9 +69,11 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
// MPV uses ms
const newTime = Math.max(
0,
curr - secondsToMs(settings.rewindSkipTime),
);
seek(newTime);
if (wasPlayingRef.current) {
play();
@@ -80,7 +82,7 @@ export function useVideoNavigation({
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) {
@@ -91,9 +93,8 @@ export function useVideoNavigation({
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
// MPV uses ms
const newTime = curr + secondsToMs(settings.forwardSkipTime);
seek(Math.max(0, newTime));
if (wasPlayingRef.current) {
play();
@@ -102,7 +103,7 @@ export function useVideoNavigation({
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
return {
handleSeekBackward,

View File

@@ -8,7 +8,6 @@ interface UseVideoSliderProps {
progress: SharedValue<number>;
isSeeking: SharedValue<boolean>;
isPlaying: boolean;
isVlc: boolean;
seek: (value: number) => void;
play: () => void;
pause: () => void;
@@ -16,11 +15,14 @@ interface UseVideoSliderProps {
showControls: boolean;
}
/**
* Hook to manage video slider interactions.
* MPV player uses milliseconds for time values.
*/
export function useVideoSlider({
progress,
isSeeking,
isPlaying,
isVlc,
seek,
play,
pause,
@@ -62,21 +64,20 @@ export function useVideoSlider({
setIsSliding(false);
isSeeking.value = false;
progress.value = value;
const seekValue = Math.max(
0,
Math.floor(isVlc ? value : ticksToSeconds(value)),
);
// MPV uses ms, seek expects ms
const seekValue = Math.max(0, Math.floor(value));
seek(seekValue);
if (wasPlayingRef.current) {
play();
}
},
[isVlc, seek, play, progress, isSeeking],
[seek, play, progress, isSeeking],
);
const handleSliderChange = useCallback(
debounce((value: number) => {
const progressInTicks = isVlc ? msToTicks(value) : value;
// Convert ms to ticks for trickplay
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
@@ -84,7 +85,7 @@ export function useVideoSlider({
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
[isVlc, calculateTrickplayUrl],
[calculateTrickplayUrl],
);
return {

View File

@@ -4,21 +4,18 @@ import {
type SharedValue,
useAnimatedReaction,
} from "react-native-reanimated";
import { ticksToSeconds } from "@/utils/time";
interface UseVideoTimeProps {
progress: SharedValue<number>;
max: SharedValue<number>;
isSeeking: SharedValue<boolean>;
isVlc: boolean;
}
export function useVideoTime({
progress,
max,
isSeeking,
isVlc,
}: UseVideoTimeProps) {
/**
* Hook to manage video time display.
* MPV player uses milliseconds for time values.
*/
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
@@ -27,19 +24,16 @@ export function useVideoTime({
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
const remaining = isVlc
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
// MPV uses milliseconds
const current = currentProgress;
const remaining = maxValue - currentProgress;
// Only update state if the displayed time actually changed (avoid sub-second updates)
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
const lastCurrentSeconds = Math.floor(
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
);
const currentSeconds = Math.floor(current / 1000);
const remainingSeconds = Math.floor(remaining / 1000);
const lastCurrentSeconds = Math.floor(lastCurrentTimeRef.current / 1000);
const lastRemainingSeconds = Math.floor(
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
lastRemainingTimeRef.current / 1000,
);
if (
@@ -52,7 +46,7 @@ export function useVideoTime({
lastRemainingTimeRef.current = remaining;
}
},
[isVlc],
[],
);
useAnimatedReaction(

View File

@@ -1,94 +0,0 @@
import type React from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import { Text } from "../common/Text";
interface Props extends ViewProps {
playerRef: React.RefObject<VlcPlayerViewRef>;
}
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null,
);
useEffect(() => {
const fetchTracks = async () => {
if (playerRef.current) {
try {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
} catch (error) {
console.log("[VideoDebugInfo] Failed to fetch tracks:", error);
}
}
};
fetchTracks();
}, [playerRef]);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
return (
<View
style={{
position: "absolute",
top: insets.top,
left: insets.left + 8,
zIndex: 100,
}}
{...props}
>
<Text className='font-bold'>{t("player.playback_state")}</Text>
<Text className='font-bold mt-2.5'>{t("player.audio_tracks")}</Text>
{audioTracks?.map((track, index) => (
<Text key={index}>
{track.name} ({t("player.index")} {track.index})
</Text>
))}
<Text className='font-bold mt-2.5'>{t("player.subtitles_tracks")}</Text>
{subtitleTracks?.map((track, index) => (
<Text key={index}>
{track.name} ({t("player.index")} {track.index})
</Text>
))}
<TouchableOpacity
className='mt-2.5 bg-blue-500 p-2 rounded'
onPress={() => {
if (playerRef.current) {
playerRef.current
.getAudioTracks()
.then(setAudioTracks)
.catch((err) => {
console.log(
"[VideoDebugInfo] Failed to get audio tracks:",
err,
);
});
playerRef.current
.getSubtitleTracks()
.then(setSubtitleTracks)
.catch((err) => {
console.log(
"[VideoDebugInfo] Failed to get subtitle tracks:",
err,
);
});
}
}}
>
<Text className='text-white text-center'>
{t("player.refresh_tracks")}
</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -1,45 +0,0 @@
export type VLCColor =
| "Black"
| "Gray"
| "Silver"
| "White"
| "Maroon"
| "Red"
| "Fuchsia"
| "Yellow"
| "Olive"
| "Green"
| "Teal"
| "Lime"
| "Purple"
| "Navy"
| "Blue"
| "Aqua";
export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick";
export const VLC_COLORS: Record<VLCColor, number> = {
Black: 0,
Gray: 8421504,
Silver: 12632256,
White: 16777215,
Maroon: 8388608,
Red: 16711680,
Fuchsia: 16711935,
Yellow: 16776960,
Olive: 8421376,
Green: 32768,
Teal: 32896,
Lime: 65280,
Purple: 8388736,
Navy: 128,
Blue: 255,
Aqua: 65535,
};
export const OUTLINE_THICKNESS: Record<OutlineThickness, number> = {
None: 0,
Thin: 2,
Normal: 4,
Thick: 6,
};

View File

@@ -5,12 +5,15 @@ import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping credits in a media player.
* MPV player uses milliseconds for time.
*/
export const useCreditSkipper = (
itemId: string,
currentTime: number,
seek: (time: number) => void,
seek: (ms: number) => void,
play: () => void,
isVlc = false,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
@@ -18,16 +21,11 @@ export const useCreditSkipper = (
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const wrappedSeek = (seconds: number) => {
if (isVlc) {
seek(secondsToMs(seconds));
return;
}
seek(seconds);
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
@@ -41,11 +39,11 @@ export const useCreditSkipper = (
useEffect(() => {
if (creditTimestamps) {
setShowSkipCreditButton(
currentTime > creditTimestamps.startTime &&
currentTime < creditTimestamps.endTime,
currentTimeSeconds > creditTimestamps.startTime &&
currentTimeSeconds < creditTimestamps.endTime,
);
}
}, [creditTimestamps, currentTime]);
}, [creditTimestamps, currentTimeSeconds]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;

View File

@@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping intros in a media player.
* MPV player uses milliseconds for time.
*
* @param {number} currentTime - The current playback time in seconds.
* @param {number} currentTime - The current playback time in milliseconds.
*/
export const useIntroSkipper = (
itemId: string,
currentTime: number,
seek: (ticks: number) => void,
seek: (ms: number) => void,
play: () => void,
isVlc = false,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
) => {
const [showSkipButton, setShowSkipButton] = useState(false);
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
if (isVlc) {
seek(secondsToMs(seconds));
return;
}
seek(seconds);
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
@@ -45,8 +40,8 @@ export const useIntroSkipper = (
useEffect(() => {
if (introTimestamps) {
const shouldShow =
currentTime > introTimestamps.startTime &&
currentTime < introTimestamps.endTime;
currentTimeSeconds > introTimestamps.startTime &&
currentTimeSeconds < introTimestamps.endTime;
setShowSkipButton(shouldShow);
} else {
@@ -54,7 +49,7 @@ export const useIntroSkipper = (
setShowSkipButton(false);
}
}
}, [introTimestamps, currentTime, showSkipButton]);
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
const skipIntro = useCallback(() => {
if (!introTimestamps) return;

View File

@@ -1,108 +0,0 @@
import { ViewStyle } from "react-native";
export type PlaybackStatePayload = {
nativeEvent: {
target: number;
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
currentTime: number;
duration: number;
isBuffering: boolean;
isPlaying: boolean;
};
};
export type ProgressUpdatePayload = {
nativeEvent: {
currentTime: number;
duration: number;
isPlaying: boolean;
isBuffering: boolean;
};
};
export type VideoLoadStartPayload = {
nativeEvent: {
target: number;
};
};
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
export type VlcPlayerSource = {
uri: string;
type?: string;
isNetwork?: boolean;
autoplay?: boolean;
startPosition?: number;
externalSubtitles?: { name: string; DeliveryUrl: string }[];
initOptions?: any[];
mediaOptions?: { [key: string]: any };
};
export type TrackInfo = {
name: string;
index: number;
language?: string;
};
export type ChapterInfo = {
name: string;
timeOffset: number;
duration: number;
};
export type NowPlayingMetadata = {
title?: string;
artist?: string;
albumTitle?: string;
artworkUri?: string;
};
export type VlcPlayerViewProps = {
source: VlcPlayerSource;
style?: ViewStyle | ViewStyle[];
progressUpdateInterval?: number;
paused?: boolean;
muted?: boolean;
volume?: number;
videoAspectRatio?: string;
nowPlayingMetadata?: NowPlayingMetadata;
onVideoProgress?: (event: ProgressUpdatePayload) => void;
onVideoStateChange?: (event: PlaybackStatePayload) => void;
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
};
export interface VlcPlayerViewRef {
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;
stop: () => Promise<void>;
seekTo: (time: number) => Promise<void>;
setAudioTrack: (trackIndex: number) => Promise<void>;
getAudioTracks: () => Promise<TrackInfo[] | null>;
setSubtitleTrack: (trackIndex: number) => Promise<void>;
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
setSubtitleDelay: (delay: number) => Promise<void>;
setAudioDelay: (delay: number) => Promise<void>;
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
setRate: (rate: number) => Promise<void>;
nextChapter: () => Promise<void>;
previousChapter: () => Promise<void>;
getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string) => Promise<void>;
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
}

View File

@@ -1,147 +0,0 @@
import { requireNativeViewManager } from "expo-modules-core";
import * as React from "react";
import { ViewStyle } from "react-native";
import type {
VlcPlayerSource,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./VlcPlayer.types";
interface NativeViewRef extends VlcPlayerViewRef {
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
}
const VLCViewManager = requireNativeViewManager("VlcPlayer");
// Create a forwarded ref version of the native view
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
(props, ref) => {
return <VLCViewManager {...props} ref={ref} />;
},
);
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
(props, ref) => {
const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture();
},
play: async () => {
await nativeRef.current?.play();
},
pause: async () => {
await nativeRef.current?.pause();
},
stop: async () => {
await nativeRef.current?.stop();
},
seekTo: async (time: number) => {
await nativeRef.current?.seekTo(time);
},
setAudioTrack: async (trackIndex: number) => {
await nativeRef.current?.setAudioTrack(trackIndex);
},
getAudioTracks: async () => {
const tracks = await nativeRef.current?.getAudioTracks();
return tracks ?? null;
},
setSubtitleTrack: async (trackIndex: number) => {
await nativeRef.current?.setSubtitleTrack(trackIndex);
},
getSubtitleTracks: async () => {
const tracks = await nativeRef.current?.getSubtitleTracks();
return tracks ?? null;
},
setSubtitleDelay: async (delay: number) => {
await nativeRef.current?.setSubtitleDelay(delay);
},
setAudioDelay: async (delay: number) => {
await nativeRef.current?.setAudioDelay(delay);
},
takeSnapshot: async (path: string, width: number, height: number) => {
await nativeRef.current?.takeSnapshot(path, width, height);
},
setRate: async (rate: number) => {
await nativeRef.current?.setRate(rate);
},
nextChapter: async () => {
await nativeRef.current?.nextChapter();
},
previousChapter: async () => {
await nativeRef.current?.previousChapter();
},
getChapters: async () => {
const chapters = await nativeRef.current?.getChapters();
return chapters ?? null;
},
setVideoCropGeometry: async (geometry: string | null) => {
await nativeRef.current?.setVideoCropGeometry(geometry);
},
getVideoCropGeometry: async () => {
const geometry = await nativeRef.current?.getVideoCropGeometry();
return geometry ?? null;
},
setSubtitleURL: async (url: string) => {
await nativeRef.current?.setSubtitleURL(url);
},
setVideoAspectRatio: async (aspectRatio: string | null) => {
await nativeRef.current?.setVideoAspectRatio(aspectRatio);
},
setVideoScaleFactor: async (scaleFactor: number) => {
await nativeRef.current?.setVideoScaleFactor(scaleFactor);
},
}));
const {
source,
style,
progressUpdateInterval = 500,
paused,
muted,
volume,
videoAspectRatio,
nowPlayingMetadata,
onVideoLoadStart,
onVideoStateChange,
onVideoProgress,
onVideoLoadEnd,
onVideoError,
onPipStarted,
...otherProps
} = props;
const processedSource: VlcPlayerSource =
typeof source === "string"
? ({ uri: source } as unknown as VlcPlayerSource)
: source;
if (processedSource.startPosition !== undefined) {
processedSource.startPosition = Math.floor(processedSource.startPosition);
}
return (
<NativeView
{...otherProps}
ref={nativeRef}
source={processedSource}
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
progressUpdateInterval={progressUpdateInterval}
paused={paused}
muted={muted}
volume={volume}
videoAspectRatio={videoAspectRatio}
nowPlayingMetadata={nowPlayingMetadata}
onVideoLoadStart={onVideoLoadStart}
onVideoLoadEnd={onVideoLoadEnd}
onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
/>
);
},
);
export default VlcPlayerView;

View File

@@ -1,17 +1,4 @@
import type {
ChapterInfo,
PlaybackStatePayload,
ProgressUpdatePayload,
TrackInfo,
VideoLoadStartPayload,
VideoProgressPayload,
VideoStateChangePayload,
VlcPlayerSource,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./VlcPlayer.types";
import VlcPlayerView from "./VlcPlayerView";
// Background Downloader
export type {
ActiveDownload,
DownloadCompleteEvent,
@@ -19,23 +6,18 @@ export type {
DownloadProgressEvent,
DownloadStartedEvent,
} from "./background-downloader";
// Background Downloader
export { default as BackgroundDownloader } from "./background-downloader";
// Component
export { VlcPlayerView };
// Component Types
export type { VlcPlayerViewProps, VlcPlayerViewRef };
// Media Types
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
// Playback Events (alphabetically sorted)
// Type aliases for backward compatibility during migration
// These map old VLC type names to new MPV equivalents
export type {
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoProgressPayload,
VideoStateChangePayload,
};
MpvPlayerViewProps,
MpvPlayerViewRef,
OnErrorEventPayload,
OnLoadEventPayload,
OnPlaybackStateChangePayload,
OnProgressEventPayload,
SubtitleTrack,
SubtitleTrack as TrackInfo,
} from "./mpv-player";
// MPV Player - Main exports
export { MpvPlayerView } from "./mpv-player";

View File

@@ -1,10 +1,3 @@
//
// Logging.swift
// Sora
//
// Created by seiike on 16/01/2025.
//
import Foundation
class Logger {

View File

@@ -5,6 +5,7 @@
// Created by Francesco on 28/09/25.
//
import UIKit
import Libmpv
import CoreMedia
import CoreVideo
@@ -44,7 +45,7 @@ final class MPVSoftwareRenderer {
private var poolWidth: Int = 0
private var poolHeight: Int = 0
private var preAllocatedBuffers: [CVPixelBuffer] = []
private let maxPreAllocatedBuffers = 6
private let maxPreAllocatedBuffers = 12
private var currentPreset: PlayerPreset?
private var currentURL: URL?
@@ -64,15 +65,26 @@ final class MPVSoftwareRenderer {
private var isLoading: Bool = false
private var isRenderScheduled = false
private var lastRenderTime: CFTimeInterval = 0
private let minRenderInterval: CFTimeInterval = 1.0 / 120.0
private var minRenderInterval: CFTimeInterval
private var isReadyToSeek: Bool = false
private var lastRenderDimensions: CGSize = .zero
var isPausedState: Bool {
return isPaused
}
init(displayLayer: AVSampleBufferDisplayLayer) {
guard
let screen = UIApplication.shared.connectedScenes
.compactMap({ ($0 as? UIWindowScene)?.screen })
.first
else {
fatalError("⚠️ No active screen found — app may not have a visible window yet.")
}
self.displayLayer = displayLayer
let maxFPS = screen.maximumFramesPerSecond
let cappedFPS = min(maxFPS, 60)
self.minRenderInterval = 1.0 / CFTimeInterval(cappedFPS)
renderQueue.setSpecific(key: renderQueueKey, value: ())
}
@@ -96,11 +108,16 @@ final class MPVSoftwareRenderer {
setOption(name: "gpu-context", value: "metal")
setOption(name: "demuxer-thread", value: "yes")
setOption(name: "ytdl", value: "yes")
setOption(name: "profile", value: "fast")
setOption(name: "vd-lavc-threads", value: "8")
setOption(name: "cache", value: "yes")
setOption(name: "demuxer-max-bytes", value: "150M")
setOption(name: "demuxer-readahead-secs", value: "20")
// Subtitle rendering options
setOption(name: "subs-match-os-language", value: "yes")
setOption(name: "subs-fallback", value: "yes")
setOption(name: "sub-auto", value: "no")
// Subtitle options - blend into video for software renderer
setOption(name: "blend-subtitles", value: "video")
setOption(name: "sub-visibility", value: "yes")
setOption(name: "osd-level", value: "0")
let initStatus = mpv_initialize(handle)
guard initStatus >= 0 else {
@@ -144,6 +161,7 @@ final class MPVSoftwareRenderer {
self.pixelBufferPool = nil
self.poolWidth = 0
self.poolHeight = 0
self.lastRenderDimensions = .zero
}
eventQueueGroup.wait()
@@ -162,6 +180,7 @@ final class MPVSoftwareRenderer {
self.formatDescription = nil
self.poolWidth = 0
self.poolHeight = 0
self.lastRenderDimensions = .zero
self.disposeBag.forEach { $0() }
self.disposeBag.removeAll()
@@ -169,7 +188,11 @@ final class MPVSoftwareRenderer {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.displayLayer.flushAndRemoveImage()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
}
isStopping = false
@@ -198,18 +221,12 @@ final class MPVSoftwareRenderer {
self.command(handle, ["stop"])
self.updateHTTPHeaders(headers)
// Handle file URLs - use path, otherwise use absolute string
let target: String
if url.isFileURL {
// For file URLs, use the path (removes file:// prefix)
target = url.path
Logger.shared.log("Loading file: \(target)", type: "Info")
} else {
// For network URLs, use absolute string
target = url.absoluteString
Logger.shared.log("Loading URL: \(target)", type: "Info")
var finalURL = url
if !url.isFileURL {
finalURL = url
}
let target = finalURL.isFileURL ? finalURL.path : finalURL.absoluteString
self.command(handle, ["loadfile", target, "replace"])
}
}
@@ -339,7 +356,18 @@ final class MPVSoftwareRenderer {
guard let self, self.isRunning, !self.isStopping else { return }
let currentTime = CACurrentMediaTime()
if self.isRenderScheduled && (currentTime - self.lastRenderTime) < self.minRenderInterval {
let timeSinceLastRender = currentTime - self.lastRenderTime
if timeSinceLastRender < self.minRenderInterval {
let remaining = self.minRenderInterval - timeSinceLastRender
if self.isRenderScheduled { return }
self.isRenderScheduled = true
self.renderQueue.asyncAfter(deadline: .now() + remaining) { [weak self] in
guard let self else { return }
self.lastRenderTime = CACurrentMediaTime()
self.performRenderUpdate()
self.isRenderScheduled = false
}
return
}
@@ -367,11 +395,21 @@ final class MPVSoftwareRenderer {
private func renderFrame() {
guard let context = renderContext else { return }
let size = currentVideoSize()
guard size.width > 0, size.height > 0 else { return }
let videoSize = currentVideoSize()
guard videoSize.width > 0, videoSize.height > 0 else { return }
let width = Int(size.width)
let height = Int(size.height)
let targetSize = targetRenderSize(for: videoSize)
let width = Int(targetSize.width)
let height = Int(targetSize.height)
guard width > 0, height > 0 else { return }
if lastRenderDimensions != targetSize {
lastRenderDimensions = targetSize
if targetSize != videoSize {
Logger.shared.log("Rendering scaled output at \(width)x\(height) (source \(Int(videoSize.width))x\(Int(videoSize.height)))", type: "Info")
} else {
Logger.shared.log("Rendering output at native size \(width)x\(height)", type: "Info")
}
}
if poolWidth != width || poolHeight != height {
recreatePixelBufferPool(width: width, height: height)
@@ -451,15 +489,40 @@ final class MPVSoftwareRenderer {
}
CVPixelBufferUnlockBaseAddress(buffer, [])
enqueue(buffer: buffer)
if preAllocatedBuffers.count < 2 {
if preAllocatedBuffers.count < 4 {
renderQueue.async { [weak self] in
self?.preAllocateBuffers()
}
}
}
private func targetRenderSize(for videoSize: CGSize) -> CGSize {
guard videoSize.width > 0, videoSize.height > 0 else { return videoSize }
guard
let screen = UIApplication.shared.connectedScenes
.compactMap({ ($0 as? UIWindowScene)?.screen })
.first
else {
fatalError("⚠️ No active screen found — app may not have a visible window yet.")
}
var scale = screen.scale
if scale <= 0 { scale = 1 }
let maxWidth = max(screen.bounds.width * scale, 1.0)
let maxHeight = max(screen.bounds.height * scale, 1.0)
if maxWidth <= 0 || maxHeight <= 0 {
return videoSize
}
let widthRatio = videoSize.width / maxWidth
let heightRatio = videoSize.height / maxHeight
let ratio = max(widthRatio, heightRatio, 1)
let targetWidth = max(1, Int(videoSize.width / ratio))
let targetHeight = max(1, Int(videoSize.height / ratio))
return CGSize(width: CGFloat(targetWidth), height: CGFloat(targetHeight))
}
private func createPixelBufferPool(width: Int, height: Int) {
let pixelFormat = kCVPixelFormatType_32BGRA
@@ -479,7 +542,7 @@ final class MPVSoftwareRenderer {
]
let auxAttrs: [CFString: Any] = [
kCVPixelBufferPoolAllocationThresholdKey: 4
kCVPixelBufferPoolAllocationThresholdKey: 8
]
var pool: CVPixelBufferPool?
@@ -522,7 +585,7 @@ final class MPVSoftwareRenderer {
guard let pool = pixelBufferPool else { return }
let targetCount = min(maxPreAllocatedBuffers, 4)
let targetCount = min(maxPreAllocatedBuffers, 8)
let currentCount = preAllocatedBuffers.count
guard currentCount < targetCount else { return }
@@ -597,19 +660,43 @@ final class MPVSoftwareRenderer {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if self.displayLayer.status == .failed {
if let error = self.displayLayer.error {
let (status, error): (AVQueuedSampleBufferRenderingStatus?, Error?) = {
if #available(iOS 18.0, *) {
return (
self.displayLayer.sampleBufferRenderer.status,
self.displayLayer.sampleBufferRenderer.error
)
} else {
return (
self.displayLayer.status,
self.displayLayer.error
)
}
}()
if status == .failed {
if let error = error {
Logger.shared.log("Display layer in failed state: \(error.localizedDescription)", type: "Error")
}
self.displayLayer.flushAndRemoveImage()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
}
if needsFlush {
self.displayLayer.flushAndRemoveImage()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
self.didFlushForFormatChange = true
} else if self.didFlushForFormatChange {
self.displayLayer.flush()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: false, completionHandler: nil)
} else {
self.displayLayer.flush()
}
self.didFlushForFormatChange = false
}
@@ -624,14 +711,14 @@ final class MPVSoftwareRenderer {
}
}
if !self.displayLayer.isReadyForMoreMediaData {
Logger.shared.log("Display layer not ready for more media data", type: "Warn")
}
if shouldNotifyLoadingEnd {
self.delegate?.renderer(self, didChangeLoading: false)
}
self.displayLayer.enqueue(sample)
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.enqueue(sample)
} else {
self.displayLayer.enqueue(sample)
}
}
}
@@ -890,70 +977,40 @@ final class MPVSoftwareRenderer {
}
// MARK: - Subtitle Controls
func getSubtitleTracks() -> [[String: Any]] {
guard let handle = mpv else { return [] }
var node = mpv_node()
let status = "track-list".withCString { pointer in
mpv_get_property(handle, pointer, MPV_FORMAT_NODE, &node)
}
guard status >= 0 else { return [] }
defer { mpv_free_node_contents(&node) }
var tracks: [[String: Any]] = []
if node.format == MPV_FORMAT_NODE_ARRAY {
let array = node.u.list.pointee
for i in 0..<Int(array.num) {
guard let values = array.values else { continue }
let trackNode = values[i]
if trackNode.format == MPV_FORMAT_NODE_MAP {
let map = trackNode.u.list.pointee
var trackInfo: [String: Any] = [:]
for j in 0..<Int(map.num) {
guard let keys = map.keys, let vals = map.values else { continue }
let key = String(cString: keys[j]!)
let val = vals[j]
switch key {
case "id":
if val.format == MPV_FORMAT_INT64 {
trackInfo["id"] = Int(val.u.int64)
}
case "type":
if val.format == MPV_FORMAT_STRING, let str = val.u.string {
trackInfo["type"] = String(cString: str)
}
case "title":
if val.format == MPV_FORMAT_STRING, let str = val.u.string {
trackInfo["title"] = String(cString: str)
}
case "lang":
if val.format == MPV_FORMAT_STRING, let str = val.u.string {
trackInfo["lang"] = String(cString: str)
}
case "selected":
if val.format == MPV_FORMAT_FLAG {
trackInfo["selected"] = val.u.flag == 1
}
default:
break
}
}
if trackInfo["type"] as? String == "sub" {
tracks.append(trackInfo)
}
}
}
}
var trackCount: Int64 = 0
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
print("MPV: Found \(tracks.count) subtitle tracks")
for track in tracks {
print("MPV: Subtitle track - ID: \(track["id"] ?? "?"), Title: \(track["title"] ?? "?"), Lang: \(track["lang"] ?? "?"), Selected: \(track["selected"] ?? false)")
for i in 0..<trackCount {
var trackType: String?
if let typeStr = getStringProperty(handle: handle, name: "track-list/\(i)/type") {
trackType = typeStr
}
guard trackType == "sub" else { continue }
var trackId: Int64 = 0
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
var track: [String: Any] = ["id": Int(trackId)]
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
track["title"] = title
}
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
var selected: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
track["selected"] = selected != 0
tracks.append(track)
}
return tracks
@@ -961,20 +1018,17 @@ final class MPVSoftwareRenderer {
func setSubtitleTrack(_ trackId: Int) {
setProperty(name: "sid", value: String(trackId))
print("MPV: Set subtitle track to \(trackId)")
}
func disableSubtitles() {
setProperty(name: "sid", value: "no")
print("MPV: Disabled subtitles")
}
func getCurrentSubtitleTrack() -> Int {
guard let handle = mpv else { return 0 }
var trackId: Int64 = 0
let status = getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &trackId)
print("MPV: Current subtitle track is \(trackId), status: \(status)")
return status >= 0 ? Int(trackId) : 0
var sid: Int64 = 0
getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid)
return Int(sid)
}
func addSubtitleFile(url: String) {
@@ -984,36 +1038,27 @@ final class MPVSoftwareRenderer {
// MARK: - Subtitle Positioning
/// Set subtitle vertical position (0-100, where 100 is bottom)
func setSubtitlePosition(_ position: Int) {
let clampedPosition = max(0, min(100, position))
setProperty(name: "sub-pos", value: String(clampedPosition))
setProperty(name: "sub-pos", value: String(position))
}
/// Set subtitle scale (1.0 is normal size)
func setSubtitleScale(_ scale: Double) {
let clampedScale = max(0.1, min(10.0, scale))
setProperty(name: "sub-scale", value: String(clampedScale))
setProperty(name: "sub-scale", value: String(scale))
}
/// Set subtitle vertical margin in pixels
func setSubtitleMarginY(_ margin: Int) {
setProperty(name: "sub-margin-y", value: String(margin))
}
/// Set subtitle horizontal alignment: "left", "center", "right"
func setSubtitleAlignX(_ alignment: String) {
setProperty(name: "sub-align-x", value: alignment)
}
/// Set subtitle vertical alignment: "top", "center", "bottom"
func setSubtitleAlignY(_ alignment: String) {
setProperty(name: "sub-align-y", value: alignment)
}
/// Set subtitle font size
func setSubtitleFontSize(_ size: Int) {
let clampedSize = max(10, min(200, size))
setProperty(name: "sub-font-size", value: String(clampedSize))
setProperty(name: "sub-font-size", value: String(size))
}
}

View File

@@ -18,6 +18,10 @@ Pod::Spec.new do |s|
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
# Strip debug symbols to avoid DWARF errors from MPVKit
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"

View File

@@ -208,37 +208,49 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate {
cachedPosition = position
cachedDuration = duration
// Only update PiP state when PiP is active (like the working code does)
if pipController?.isPictureInPictureActive == true {
pipController?.updatePlaybackState()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Only update PiP state when PiP is active
if self.pipController?.isPictureInPictureActive == true {
self.pipController?.updatePlaybackState()
}
self.onProgress([
"position": position,
"duration": duration,
"progress": duration > 0 ? position / duration : 0,
])
}
onProgress([
"position": position,
"duration": duration,
"progress": duration > 0 ? position / duration : 0,
])
}
func renderer(_: MPVSoftwareRenderer, didChangePause isPaused: Bool) {
onPlaybackStateChange([
"isPaused": isPaused,
"isPlaying": !isPaused,
])
// Update PiP state when playback changes (direct call, like working code)
pipController?.updatePlaybackState()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onPlaybackStateChange([
"isPaused": isPaused,
"isPlaying": !isPaused,
])
// Update PiP state when playback changes
self.pipController?.updatePlaybackState()
}
}
func renderer(_: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) {
onPlaybackStateChange([
"isLoading": isLoading,
])
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onPlaybackStateChange([
"isLoading": isLoading,
])
}
}
func renderer(_: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) {
onPlaybackStateChange([
"isReadyToSeek": didBecomeReadyToSeek,
])
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onPlaybackStateChange([
"isReadyToSeek": didBecomeReadyToSeek,
])
}
}
}

View File

@@ -1,10 +1,3 @@
//
// PiPController.swift
// test
//
// Created by Francesco on 30/09/25.
//
import AVKit
import AVFoundation
@@ -46,7 +39,8 @@ final class PiPController: NSObject {
}
private func setupPictureInPicture() {
guard isPictureInPictureSupported, let displayLayer = sampleBufferDisplayLayer else {
guard isPictureInPictureSupported,
let displayLayer = sampleBufferDisplayLayer else {
return
}
@@ -58,7 +52,9 @@ final class PiPController: NSObject {
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = self
pipController?.requiresLinearPlayback = false
#if !os(tvOS)
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
#endif
}
func startPictureInPicture() {
@@ -75,11 +71,23 @@ final class PiPController: NSObject {
}
func invalidate() {
pipController?.invalidatePlaybackState()
if Thread.isMainThread {
pipController?.invalidatePlaybackState()
} else {
DispatchQueue.main.async { [weak self] in
self?.pipController?.invalidatePlaybackState()
}
}
}
func updatePlaybackState() {
pipController?.invalidatePlaybackState()
if Thread.isMainThread {
pipController?.invalidatePlaybackState()
} else {
DispatchQueue.main.async { [weak self] in
self?.pipController?.invalidatePlaybackState()
}
}
}
}
@@ -161,4 +169,4 @@ extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate {
}
completion()
}
}
}

View File

@@ -1,10 +1,3 @@
//
// PlayerPreset.swift
// test
//
// Created by Francesco on 28/09/25.
//
import Foundation
struct PlayerPreset: Identifiable, Hashable {
@@ -41,7 +34,7 @@ struct PlayerPreset: Identifiable, Hashable {
let commands: [[String]]
static var presets: [PlayerPreset] {
var list: [PlayerPreset] = []
let list: [PlayerPreset] = []
return list
}
}
}

View File

@@ -1,10 +1,3 @@
//
// SampleBufferDisplayView.swift
// test
//
// Created by Francesco on 28/09/25.
//
import UIKit
import AVFoundation
@@ -36,9 +29,18 @@ final class SampleBufferDisplayView: UIView {
private func commonInit() {
backgroundColor = .black
displayLayer.videoGravity = .resizeAspect
if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#if !os(tvOS)
#if compiler(>=6.0)
if #available(iOS 26.0, *) {
displayLayer.preferredDynamicRange = .automatic
} else if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
setupPictureInPicture()
}

View File

@@ -69,7 +69,6 @@ export interface MpvPlayerViewRef {
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise<void>;
setSubtitleFontSize: (size: number) => Promise<void>;
}
export type SubtitleTrack = {
id: number;
title?: string;

View File

@@ -1,7 +0,0 @@
{
"platforms": ["ios", "tvos"],
"ios": {
"modules": ["VlcPlayer4Module"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
}
}

View File

@@ -1,32 +0,0 @@
import ExpoModulesCore
protocol SimpleAppLifecycleListener {
func applicationDidEnterBackground() -> Void
func applicationDidEnterForeground() -> Void
}
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
public func applicationDidBecomeActive(_ application: UIApplication) {
// The app has become active.
}
public func applicationWillResignActive(_ application: UIApplication) {
// The app is about to become inactive.
}
public func applicationDidEnterBackground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterBackground()
}
}
public func applicationWillEnterForeground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterForeground()
}
}
public func applicationWillTerminate(_ application: UIApplication) {
// The app is about to terminate.
}
}

View File

@@ -1,4 +0,0 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -1,22 +0,0 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer4'
s.version = '4.0.0a10'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '16' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -1,71 +0,0 @@
import ExpoModulesCore
public class VlcPlayer4Module: Module {
public func definition() -> ModuleDefinition {
Name("VlcPlayer4")
View(VlcPlayer4View.self) {
Prop("source") { (view: VlcPlayer4View, source: [String: Any]) in
view.setSource(source)
}
Prop("paused") { (view: VlcPlayer4View, paused: Bool) in
if paused {
view.pause()
} else {
view.play()
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayer4View) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayer4View) in
view.play()
}
AsyncFunction("pause") { (view: VlcPlayer4View) in
view.pause()
}
AsyncFunction("stop") { (view: VlcPlayer4View) in
view.stop()
}
AsyncFunction("seekTo") { (view: VlcPlayer4View, time: Int32) in
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { (view: VlcPlayer4View, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
return view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer4View, trackIndex: Int) in
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
return view.getSubtitleTracks()
}
AsyncFunction("setSubtitleURL") {
(view: VlcPlayer4View, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
}
}
}

View File

@@ -1,507 +0,0 @@
import ExpoModulesCore
import UIKit
import VLCKit
import os
public class VLCPlayerView: UIView {
func setupView(parent: UIView) {
self.backgroundColor = .black
self.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
self.topAnchor.constraint(equalTo: parent.topAnchor),
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
])
}
public override func layoutSubviews() {
super.layoutSubviews()
for subview in subviews {
subview.frame = bounds
}
}
}
class VLCPlayerWrapper: NSObject {
private var lastProgressCall = Date().timeIntervalSince1970
public var player: VLCMediaPlayer = VLCMediaPlayer()
private var updatePlayerState: (() -> Void)?
private var updateVideoProgress: (() -> Void)?
private var playerView: VLCPlayerView = VLCPlayerView()
public weak var pipController: VLCPictureInPictureWindowControlling?
override public init() {
super.init()
player.delegate = self
player.drawable = self
player.scaleFactor = 0
}
public func setup(
parent: UIView,
updatePlayerState: (() -> Void)?,
updateVideoProgress: (() -> Void)?
) {
self.updatePlayerState = updatePlayerState
self.updateVideoProgress = updateVideoProgress
player.delegate = self
parent.addSubview(playerView)
playerView.setupView(parent: parent)
}
public func getPlayerView() -> UIView {
return playerView
}
}
// MARK: - VLCPictureInPictureDrawable
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
return self
}
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
{
return { [weak self] controller in
self?.pipController = controller
}
}
}
// MARK: - VLCPictureInPictureMediaControlling
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
func mediaTime() -> Int64 {
return player.time.value?.int64Value ?? 0
}
func mediaLength() -> Int64 {
return player.media?.length.value?.int64Value ?? 0
}
func play() {
player.play()
}
func pause() {
player.pause()
}
func seek(by offset: Int64, completion: @escaping () -> Void) {
player.jump(withOffset: Int32(offset), completion: completion)
}
func isMediaSeekable() -> Bool {
return player.isSeekable
}
func isMediaPlaying() -> Bool {
return player.isPlaying
}
}
// MARK: - VLCDrawable
extension VLCPlayerWrapper: VLCDrawable {
public func addSubview(_ view: UIView) {
playerView.addSubview(view)
}
public func bounds() -> CGRect {
return playerView.bounds
}
}
// MARK: - VLCMediaPlayerDelegate
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let timeNow = Date().timeIntervalSince1970
if timeNow - self.lastProgressCall >= 1 {
self.lastProgressCall = timeNow
self.updateVideoProgress?()
}
}
}
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.updatePlayerState?()
guard let pipController = self.pipController else { return }
pipController.invalidatePlaybackState()
}
}
}
class VlcPlayer4View: ExpoView {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayer4View")
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var externalTrack: [String: String]?
private var isStopping: Bool = false // Define isStopping here
private var externalSubtitles: [[String: String]]?
var hasSource = false
var initialSeekPerformed = false
// A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes
var shouldPerformInitialSeek: Bool = false
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupVLC()
setupNotifications()
VLCManager.shared.listeners.append(self)
}
// MARK: - Setup
private func setupVLC() {
vlc.setup(
parent: self,
updatePlayerState: updatePlayerState,
updateVideoProgress: updateVideoProgress
)
}
// Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash.
// To avoid this, we wait until the video has started playing before performing the initial seek.
func performInitialSeek() {
guard !initialSeekPerformed,
startPosition > 0,
shouldPerformInitialSeek,
vlc.player.isSeekable else { return }
initialSeekPerformed = true
logger.debug("First time update, performing initial seek to \(self.startPosition) seconds")
vlc.player.time = VLCTime(int: startPosition * 1000)
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil)
}
// MARK: - Public Methods
func startPictureInPicture() {
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
self.onPipStarted?(["pipStarted": isStarted])
}
self.vlc.pipController?.startPictureInPicture()
}
@objc func play() {
self.vlc.player.play()
self.isPaused = false
logger.debug("Play")
}
@objc func pause() {
self.vlc.player.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
let wasPlaying = vlc.player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = vlc.player.media?.length.intValue {
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
vlc.player.time = VLCTime(int: seekTime)
self.updatePlayerState()
// Let mediaPlayerStateChanged handle play state change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if wasPlaying {
self.play()
}
}
} else {
logger.error("Unable to retrieve video duration")
}
}
@objc func setSource(_ source: [String: Any]) {
logger.debug("Setting source...")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
return
}
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
let initOptions: [String] = source["initOptions"] as? [String] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
for item in initOptions {
let option = item.components(separatedBy: "=")
mediaOptions.updateValue(
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
}
guard let uri = source["uri"] as? String, !uri.isEmpty else {
logger.error("Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
// Set shouldPeformIntial based on isTranscoding and is not a network stream
self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
let media: VLCMedia!
if isNetwork {
logger.debug("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
logger.debug("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
media = VLCMedia(path: uri)
}
}
logger.debug("Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.vlc.player.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
logger.info("Playing...")
// The Video is not transcoding so it its safe to seek to the start position.
if !self.shouldPerformInitialSeek {
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
}
self.play()
}
}
}
@objc func setAudioTrack(_ trackIndex: Int) {
print("Setting audio track: \(trackIndex)")
let track = self.vlc.player.audioTracks[trackIndex]
track.isSelectedExclusively = true
}
@objc func getAudioTracks() -> [[String: Any]]? {
return vlc.player.audioTracks.enumerated().map {
return ["name": $1.trackName, "index": $0]
}
}
@objc func setSubtitleTrack(_ trackIndex: Int) {
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
if trackIndex == -1 {
logger.debug("Disabling all subtitles")
for track in self.vlc.player.textTracks {
track.isSelected = false
}
return
}
let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true;
logger.debug("Current subtitle track index after setting: \(track.trackName)")
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else {
logger.error("Invalid subtitle URL")
return
}
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
if result == 0 {
let internalName = "Track \(self.customSubtitles.count)"
self.customSubtitles.append((internalName: internalName, originalName: name))
logger.debug("Subtitle added with result: \(result) \(internalName)")
} else {
logger.debug("Failed to add subtitle")
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
if self.vlc.player.textTracks.count == 0 {
return nil
}
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
if let customSubtitle = customSubtitles.first(where: {
$0.internalName == track.trackName
}) {
return ["name": customSubtitle.originalName, "index": index]
} else {
return ["name": track.trackName, "index": index]
}
}
logger.debug("Subtitle tracks: \(tracks)")
return tracks
}
@objc func stop(completion: (() -> Void)? = nil) {
logger.debug("Stopping media...")
guard !isStopping else {
completion?()
return
}
isStopping = true
// If we're not on the main thread, dispatch to main thread
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.performStop(completion: completion)
}
} else {
performStop(completion: completion)
}
}
// MARK: - Private Methods
@objc private func applicationWillResignActive() {
}
@objc private func applicationDidBecomeActive() {
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
self.setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player
vlc.player.stop()
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
vlc.getPlayerView().removeFromSuperview()
isStopping = false
completion?()
}
private func updateVideoProgress() {
guard self.vlc.player.media != nil else { return }
let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0
logger.debug("Current time: \(currentTimeMs)")
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
private func updatePlayerState() {
let player = self.vlc.player
if player.isPlaying {
performInitialSeek()
}
self.onVideoStateChange?([
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
"isPlaying": player.isPlaying,
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
"state": player.state.description,
])
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization
deinit {
logger.debug("Deinitialization")
performStop()
VLCManager.shared.listeners.removeAll()
}
}
// MARK: - SimpleAppLifecycleListener
extension VlcPlayer4View: SimpleAppLifecycleListener {
func applicationDidEnterBackground() {
logger.debug("Entering background")
}
func applicationDidEnterForeground() {
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
if !self.vlc.getPlayerView().isDescendant(of: self) {
logger.debug("Player view is missing. Adding back as subview")
self.addSubview(self.vlc.getPlayerView())
}
// Current solution to fixing black screen when re-entering application
if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }),
!self.vlc.isMediaPlaying()
{
videoTrack.isSelected = false
videoTrack.isSelectedExclusively = true
self.vlc.player.play()
self.vlc.player.pause()
}
}
}
extension VLCMediaPlayerState {
var description: String {
switch self {
case .opening: return "Opening"
case .buffering: return "Buffering"
case .playing: return "Playing"
case .paused: return "Paused"
case .stopped: return "Stopped"
case .error: return "Error"
case .stopping: return "Stopping"
@unknown default: return "Unknown"
}
}
}

View File

@@ -1,5 +0,0 @@
import { requireNativeModule } from "expo-modules-core";
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule("VlcPlayer4");

View File

@@ -1,47 +0,0 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
}
group = 'expo.modules.vlcplayer'
version = '0.6.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android {
namespace "expo.modules.vlcplayer"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation 'org.videolan.android:libvlc-all:3.6.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
jvmTarget = "17"
}
}

View File

@@ -1,2 +0,0 @@
<manifest>
</manifest>

View File

@@ -1,38 +0,0 @@
package expo.modules.vlcplayer
import expo.modules.core.interfaces.ReactActivityLifecycleListener
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
// https://docs.expo.dev/modules/android-lifecycle-listeners/
object VLCManager: ReactActivityLifecycleListener {
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
// listeners.forEach {
// it.onCreate(activity, savedInstanceState)
// }
// }
//
// override fun onResume(activity: Activity?) {
// listeners.forEach {
// it.onResume(activity)
// }
// }
//
// override fun onPause(activity: Activity?) {
// listeners.forEach {
// it.onPause(activity)
// }
// }
//
// override fun onUserLeaveHint(activity: Activity?) {
// listeners.forEach {
// it.onUserLeaveHint(activity)
// }
// }
//
// override fun onDestroy(activity: Activity?) {
// listeners.forEach {
// it.onDestroy(activity)
// }
// }
}

View File

@@ -1,95 +0,0 @@
package expo.modules.vlcplayer
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source)
}
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
if (paused) {
view.pause()
} else {
view.play()
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView ->
view.play()
}
AsyncFunction("pause") { view: VlcPlayerView ->
view.pause()
}
AsyncFunction("stop") { view: VlcPlayerView ->
view.stop()
}
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
view.getSubtitleTracks()
}
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
view.setSubtitleURL(url, name)
}
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
view.setVideoAspectRatio(aspectRatio)
}
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
view.setVideoScaleFactor(scaleFactor)
}
}
}
}

View File

@@ -1,482 +0,0 @@
package expo.modules.vlcplayer
import android.R
import android.app.Activity
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import androidx.annotation.RequiresApi
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import expo.modules.core.interfaces.ReactActivityLifecycleListener
import expo.modules.core.logging.LogHandlers
import expo.modules.core.logging.Logger
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
private var libVLC: LibVLC? = null
private var mediaPlayer: MediaPlayer? = null
private lateinit var videoLayout: VLCVideoLayout
private var isPaused: Boolean = false
private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null
private var timeLeft: Long? = null
private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher()
private val onVideoLoadEnd by EventDispatcher()
private val onPipStarted by EventDispatcher()
private var startPosition: Int? = 0
private var isMediaReady: Boolean = false
private var externalTrack: Map<String, String>? = null
private var externalSubtitles: List<Map<String, String>>? = null
var hasSource: Boolean = false
private val handler = Handler(Looper.getMainLooper())
private val updateInterval = 1000L // 1 second
private val updateProgressRunnable = object : Runnable {
override fun run() {
updateVideoProgress()
handler.postDelayed(this, updateInterval)
}
}
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
private val remoteActionFilter = IntentFilter()
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> {
if (isPaused) play() else pause()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.setPictureInPictureParams(getPipParams()!!)
}
}
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
}
}
}
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
log.debug("Exiting PiP")
timeLeft = mediaPlayer?.time
pause()
// Setting the media after reattaching the view allows for a fast video view render
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.media = media
mediaPlayer?.play()
timeLeft?.let { mediaPlayer?.time = it }
mediaPlayer?.pause()
}
}
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
init {
VLCManager.listeners.add(this)
setupView()
setupPiP()
}
private fun setupView() {
log.debug("Setting up view")
setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
videoLayout.keepScreenOn = true
addView(videoLayout)
log.debug("View setup complete")
}
private fun setupPiP() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentActivity.registerReceiver(
actionReceiver,
remoteActionFilter,
Context.RECEIVER_NOT_EXPORTED
)
}
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener(pipChangeListener)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() {
actions.clear()
actions.addAll(
listOf(
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_rew),
"Rewind",
"Rewind Video",
PendingIntent.getBroadcast(
context,
0,
rewindIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
else Icon.createWithResource(context, R.drawable.ic_media_pause),
"Play",
"Play Video",
PendingIntent.getBroadcast(
context,
if (isPaused) 0 else 1,
playPauseIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_ff),
"Skip",
"Skip Forward",
PendingIntent.getBroadcast(
context,
0,
forwardIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
)
)
)
}
private fun getPipParams(): PictureInPictureParams? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
var builder = PictureInPictureParams.Builder()
.setActions(actions)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder = builder.setAutoEnterEnabled(true)
}
return builder.build()
}
return null
}
fun setSource(source: Map<String, Any>) {
log.debug("setting source $source")
if (hasSource) {
log.debug("Source already set. Ignoring.")
return
}
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
val autoplay = source["autoplay"] as? Boolean ?: false
val isNetwork = source["isNetwork"] as? Boolean ?: false
externalTrack = source["externalTrack"] as? Map<String, String>
externalSubtitles = source["externalSubtitles"] as? List<Map<String, String>>
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
initOptions.add("--start-time=$startPosition")
val uri = source["uri"] as? String
// Handle video load start event
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
libVLC = LibVLC(context, initOptions)
mediaPlayer = MediaPlayer(libVLC)
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this)
log.debug("Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media
log.debug("Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions)
// Set initial external subtitles immediately like iOS
setInitialExternalSubtitles()
hasSource = true
if (autoplay) {
log.debug("Playing...")
play()
}
}
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentActivity.enterPictureInPictureMode(getPipParams()!!)
}
}
fun play() {
mediaPlayer?.play()
isPaused = false
handler.post(updateProgressRunnable) // Start updating progress
}
fun pause() {
mediaPlayer?.pause()
isPaused = true
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
}
fun stop() {
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
}
fun seekTo(time: Int) {
mediaPlayer?.let { player ->
val wasPlaying = player.isPlaying
if (wasPlaying) {
player.pause()
}
val duration = player.length.toInt()
val seekTime = if (time > duration) duration - 1000 else time
player.time = seekTime.toLong()
if (wasPlaying) {
player.play()
}
}
}
fun setAudioTrack(trackIndex: Int) {
mediaPlayer?.setAudioTrack(trackIndex)
}
fun getAudioTracks(): List<Map<String, Any>>? {
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription ->
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
}
}
fun setSubtitleTrack(trackIndex: Int) {
mediaPlayer?.setSpuTrack(trackIndex)
}
// fun getSubtitleTracks(): List<Map<String, Any>>? {
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
// }
// }
fun getSubtitleTracks(): List<Map<String, Any>>? {
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
}
// Debug statement to print the result
log.debug("Subtitle Tracks: $subtitleTracks")
return subtitleTracks
}
fun setSubtitleURL(subtitleURL: String, name: String) {
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
fun setVideoAspectRatio(aspectRatio: String?) {
log.debug("Setting video aspect ratio: $aspectRatio")
mediaPlayer?.aspectRatio = aspectRatio
}
fun setVideoScaleFactor(scaleFactor: Float) {
log.debug("Setting video scale factor: $scaleFactor")
mediaPlayer?.scale = scaleFactor
}
private fun setInitialExternalSubtitles() {
externalSubtitles?.let { subtitles ->
for (subtitle in subtitles) {
val subtitleName = subtitle["name"]
val subtitleURL = subtitle["DeliveryUrl"]
if (!subtitleName.isNullOrEmpty() && !subtitleURL.isNullOrEmpty()) {
log.debug("Setting external subtitle: $subtitleName $subtitleURL")
setSubtitleURL(subtitleURL, subtitleName)
}
}
}
}
override fun onDetachedFromWindow() {
log.debug("onDetachedFromWindow")
super.onDetachedFromWindow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
currentActivity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentActivity.unregisterReceiver(actionReceiver)
}
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
VLCManager.listeners.clear()
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
media?.release()
mediaPlayer?.release()
libVLC?.release()
mediaPlayer = null
media = null
libVLC = null
}
override fun onEvent(event: MediaPlayer.Event) {
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
when (event.type) {
MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused,
MediaPlayer.Event.Stopped,
MediaPlayer.Event.Buffering,
MediaPlayer.Event.EndReached,
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
MediaPlayer.Event.TimeChanged -> {
// Do nothing here, as we are updating progress every 1 second
}
}
}
private fun updatePlayerState(event: MediaPlayer.Event) {
val player = mediaPlayer ?: return
val currentState = event.type
val stateInfo = mutableMapOf<String, Any>(
"target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false,
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
)
// Todo: make enum - string to prevent this when statement from becoming exhaustive
when (currentState) {
MediaPlayer.Event.Playing ->
stateInfo["state"] = "Playing"
MediaPlayer.Event.Paused ->
stateInfo["state"] = "Paused"
MediaPlayer.Event.Buffering ->
stateInfo["state"] = "Buffering"
MediaPlayer.Event.EncounteredError -> {
stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo);
}
MediaPlayer.Event.Opening ->
stateInfo["state"] = "Opening"
}
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying
onVideoStateChange(stateInfo)
}
}
private fun updateVideoProgress() {
val player = mediaPlayer ?: return
val currentTimeMs = player.time.toInt()
val durationMs = player.media?.duration?.toInt() ?: 0
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
// Set subtitle URL if available
if (player.isPlaying && !isMediaReady) {
isMediaReady = true
externalTrack?.let {
val name = it["name"]
val deliveryUrl = it["DeliveryUrl"] ?: ""
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
setSubtitleURL(deliveryUrl, name)
}
}
}
onVideoProgress(mapOf(
"currentTime" to currentTimeMs,
"duration" to durationMs
));
}
}
override fun onPause(activity: Activity?) {
log.debug("Pausing activity...")
}
override fun onResume(activity: Activity?) {
log.debug("Resuming activity...")
if (isPaused) play()
}
}
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
var context = this
while (context is ContextWrapper) {
if (context is androidx.activity.ComponentActivity) return context
context = context.baseContext
}
throw IllegalStateException("Failed to find ComponentActivity")
}

View File

@@ -1,9 +0,0 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
}
}

View File

@@ -1,23 +0,0 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer'
s.version = '3.6.1b1'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.ios.dependency 'MobileVLCKit', s.version
s.tvos.dependency 'TVVLCKit', s.version
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -1,84 +0,0 @@
import ExpoModulesCore
public class VlcPlayerModule: Module {
public func definition() -> ModuleDefinition {
Name("VlcPlayer")
View(VlcPlayerView.self) {
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
view.setSource(source)
}
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
if paused {
view.pause()
} else {
view.play()
}
}
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
if let metadata = metadata {
view.setNowPlayingMetadata(metadata)
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayerView) in
view.play()
}
AsyncFunction("pause") { (view: VlcPlayerView) in
view.pause()
}
AsyncFunction("stop") { (view: VlcPlayerView) in
view.stop()
}
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
return view.getAudioTracks()
}
AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("setVideoAspectRatio") { (view: VlcPlayerView, aspectRatio: String?) in
view.setVideoAspectRatio(aspectRatio)
}
AsyncFunction("setVideoScaleFactor") { (view: VlcPlayerView, scaleFactor: Float) in
view.setVideoScaleFactor(scaleFactor)
}
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
return view.getSubtitleTracks()
}
}
}
}

View File

@@ -1,718 +0,0 @@
import ExpoModulesCore
import MediaPlayer
import AVFoundation
#if os(tvOS)
import TVVLCKit
#else
import MobileVLCKit
#endif
class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState?
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var externalSubtitles: [[String: String]]?
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
var hasSource = false
var isTranscoding = false
private var initialSeekPerformed: Bool = false
private var nowPlayingMetadata: [String: String]?
private var artworkImage: UIImage?
private var artworkDownloadTask: URLSessionDataTask?
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
setupNotifications()
setupRemoteCommandCenter()
setupAudioSession()
}
// MARK: - Setup
private func setupView() {
DispatchQueue.main.async {
self.backgroundColor = .black
self.videoView = UIView()
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
if let videoView = self.videoView {
self.addSubview(videoView)
NSLayoutConstraint.activate([
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
videoView.topAnchor.constraint(equalTo: self.topAnchor),
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
}
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil)
#if !os(tvOS)
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
#endif
}
private func setupAudioSession() {
#if !os(tvOS)
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
try audioSession.setActive(true)
print("Audio session configured for media controls")
} catch {
print("Failed to setup audio session: \(error)")
}
#endif
}
private func setupRemoteCommandCenter() {
#if !os(tvOS)
let commandCenter = MPRemoteCommandCenter.shared()
// Play command
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
// Pause command
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
// Toggle play/pause command
commandCenter.togglePlayPauseCommand.isEnabled = true
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
if player.isPlaying {
self.pause()
} else {
self.play()
}
return .success
}
// Seek forward command
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [15]
commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
let currentTime = player.time.intValue
self.seekTo(currentTime + Int32(skipInterval * 1000))
return .success
}
// Seek backward command
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [15]
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
let currentTime = player.time.intValue
self.seekTo(max(0, currentTime - Int32(skipInterval * 1000)))
return .success
}
// Change playback position command (scrubbing)
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self = self,
let event = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
let positionTime = event.positionTime
self.seekTo(Int32(positionTime * 1000))
return .success
}
print("Remote command center configured")
#endif
}
private func cleanupRemoteCommandCenter() {
#if !os(tvOS)
let commandCenter = MPRemoteCommandCenter.shared()
// Remove all command targets to prevent memory leaks
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.togglePlayPauseCommand.removeTarget(nil)
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.removeTarget(nil)
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
// Disable commands
commandCenter.playCommand.isEnabled = false
commandCenter.pauseCommand.isEnabled = false
commandCenter.togglePlayPauseCommand.isEnabled = false
commandCenter.skipForwardCommand.isEnabled = false
commandCenter.skipBackwardCommand.isEnabled = false
commandCenter.changePlaybackPositionCommand.isEnabled = false
print("Remote command center cleaned up")
#endif
}
// MARK: - Public Methods
func startPictureInPicture() {}
@objc func play() {
DispatchQueue.main.async {
self.mediaPlayer?.play()
self.isPaused = false
self.updateNowPlayingInfo()
print("Play")
}
}
@objc func pause() {
DispatchQueue.main.async {
self.mediaPlayer?.pause()
self.isPaused = true
self.updateNowPlayingInfo()
}
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
#if !os(tvOS)
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began - pause the video
print("Audio session interrupted - pausing video")
self.pause()
case .ended:
// Interruption ended - check if we should resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("Audio session interruption ended - can resume")
// Don't auto-resume - let user manually resume playback
} else {
print("Audio session interruption ended - should not resume")
}
}
@unknown default:
break
}
#endif
}
@objc func seekTo(_ time: Int32) {
DispatchQueue.main.async {
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
if wasPlaying {
player.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
player.play()
}
self.updatePlayerState()
self.updateNowPlayingInfo()
} else {
print("Error: Unable to retrieve video duration")
}
}
}
@objc func setSource(_ source: [String: Any]) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
self.isTranscoding = uri.contains("m3u8")
if !self.isTranscoding, self.startPosition > 0 {
initOptions.append("--start-time=\(self.startPosition)")
}
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0
self.initialSeekPerformed = false
let media: VLCMedia
if isNetwork {
print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
print("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
media = VLCMedia(path: uri)
}
}
print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
print("Playing...")
self.play()
}
}
}
@objc func setAudioTrack(_ trackIndex: Int) {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
}
@objc func getAudioTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.audioTrackNames,
let trackIndexes = mediaPlayer?.audioTrackIndexes
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
}
}
@objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
print(
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
)
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
if let result = result {
let internalName = "Track \(self.customSubtitles.count)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
print("Failed to add subtitle")
}
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
self.setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
return nil
}
let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
guard count > 0 else {
return nil
}
var tracks: [[String: Any]] = []
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else {
tracks.append(["name": name, "index": index.intValue])
}
}
}
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
@objc func setVideoAspectRatio(_ aspectRatio: String?) {
DispatchQueue.main.async {
if let aspectRatio = aspectRatio {
// Convert String to C string for VLC
let cString = strdup(aspectRatio)
self.mediaPlayer?.videoAspectRatio = cString
} else {
// Reset to default (let VLC determine aspect ratio)
self.mediaPlayer?.videoAspectRatio = nil
}
}
}
@objc func setVideoScaleFactor(_ scaleFactor: Float) {
DispatchQueue.main.async {
self.mediaPlayer?.scaleFactor = scaleFactor
print("Set video scale factor: \(scaleFactor)")
}
}
@objc func setNowPlayingMetadata(_ metadata: [String: String]) {
// Cancel any existing artwork download to prevent race conditions
artworkDownloadTask?.cancel()
artworkDownloadTask = nil
self.nowPlayingMetadata = metadata
print("[NowPlaying] Metadata received: \(metadata)")
// Load artwork asynchronously if provided
if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) {
print("[NowPlaying] Loading artwork from: \(artworkUri)")
artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error as NSError?, error.code == NSURLErrorCancelled {
print("[NowPlaying] Artwork download cancelled")
return
}
if let error = error {
print("[NowPlaying] Artwork loading error: \(error)")
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
} else if let data = data, let image = UIImage(data: data) {
print("[NowPlaying] Artwork loaded successfully, size: \(image.size)")
self.artworkImage = image
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
} else {
print("[NowPlaying] Failed to create image from data")
// Update Now Playing info without artwork on failure
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
}
artworkDownloadTask?.resume()
} else {
// No artwork URI provided - update immediately
print("[NowPlaying] No artwork URI provided")
artworkImage = nil
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
}
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()
return
}
isStopping = true
// If we're not on the main thread, dispatch to main thread
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.performStop(completion: completion)
}
} else {
performStop(completion: completion)
}
}
// MARK: - Private Methods
@objc private func applicationWillResignActive() {
}
@objc private func applicationDidBecomeActive() {
}
private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player
mediaPlayer?.stop()
// Cancel any in-flight artwork downloads
artworkDownloadTask?.cancel()
artworkDownloadTask = nil
artworkImage = nil
// Cleanup remote command center targets
cleanupRemoteCommandCenter()
#if !os(tvOS)
// Deactivate audio session to allow other apps to use audio
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
print("Audio session deactivated")
} catch {
print("Failed to deactivate audio session: \(error)")
}
// Clear Now Playing info
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
#endif
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
videoView?.removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
isStopping = false
completion?()
}
private func updateVideoProgress() {
guard let player = self.mediaPlayer else { return }
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 {
player.time = VLCTime(int: self.startPosition * 1000)
self.initialSeekPerformed = true
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
// Update Now Playing info to sync elapsed playback time
// iOS needs periodic updates to keep progress indicator in sync
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
private func updateNowPlayingInfo() {
#if !os(tvOS)
guard let player = self.mediaPlayer else { return }
var nowPlayingInfo = [String: Any]()
// Playback rate (0.0 = paused, 1.0 = playing at normal speed)
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0
// Current playback time in seconds
let currentTimeSeconds = Double(player.time.intValue) / 1000.0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds
// Total duration in seconds
if let duration = player.media?.length.intValue {
let durationSeconds = Double(duration) / 1000.0
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds
}
// Add metadata if available
if let metadata = self.nowPlayingMetadata {
if let title = metadata["title"] {
nowPlayingInfo[MPMediaItemPropertyTitle] = title
print("[NowPlaying] Setting title: \(title)")
}
if let artist = metadata["artist"] {
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
print("[NowPlaying] Setting artist: \(artist)")
}
if let albumTitle = metadata["albumTitle"] {
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
print("[NowPlaying] Setting album: \(albumTitle)")
}
}
// Add artwork if available
if let artwork = self.artworkImage {
print("[NowPlaying] Setting artwork with size: \(artwork.size)")
let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in
return artwork
}
nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
#endif
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization
deinit {
performStop()
}
}
extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
// self?.updateVideoProgress()
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
}
}
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
}
private func updatePlayerState() {
guard let player = self.mediaPlayer else { return }
let currentState = player.state
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
}
}
extension VlcPlayerView: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
extension VLCMediaPlayerState {
var description: String {
switch self {
case .opening: return "Opening"
case .buffering: return "Buffering"
case .playing: return "Playing"
case .paused: return "Paused"
case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown"
}
}
}

View File

@@ -1,5 +0,0 @@
import { requireNativeModule } from "expo-modules-core";
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule("VlcPlayer");

View File

@@ -130,10 +130,9 @@ export type HomeSectionLatestResolver = {
includeItemTypes?: Array<BaseItemKind>;
};
// Video player enum - currently only MPV is supported
export enum VideoPlayer {
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
VLC_3 = 0,
VLC_4 = 1,
MPV = 0,
}
export type Settings = {
@@ -143,7 +142,6 @@ export type Settings = {
preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
defaultBitrate?: Bitrate;
libraryOptions: LibraryOptions;
@@ -164,16 +162,14 @@ export type Settings = {
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
enableH265ForChromecast: boolean;
defaultPlayer: VideoPlayer;
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
vlcTextColor?: string;
vlcBackgroundColor?: string;
vlcOutlineColor?: string;
vlcOutlineThickness?: string;
vlcBackgroundOpacity?: number;
vlcOutlineOpacity?: number;
vlcIsBold?: boolean;
// MPV subtitle settings
mpvSubtitleScale?: number;
mpvSubtitleMarginY?: number;
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
@@ -201,7 +197,6 @@ export const defaultValues: Settings = {
preferedLanguage: undefined,
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
defaultBitrate: BITRATES[0],
libraryOptions: {
@@ -228,16 +223,14 @@ export const defaultValues: Settings = {
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
enableH265ForChromecast: false,
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0,
vlcTextColor: undefined,
vlcBackgroundColor: undefined,
vlcOutlineColor: undefined,
vlcOutlineThickness: undefined,
vlcBackgroundOpacity: undefined,
vlcOutlineOpacity: undefined,
vlcIsBold: undefined,
// MPV subtitle defaults
mpvSubtitleScale: undefined,
mpvSubtitleMarginY: undefined,
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,