From c76d7eb8777127da18cd71abf9ac9014a1793277 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Sat, 6 Dec 2025 04:56:48 +1100 Subject: [PATCH] Working --- app/(auth)/player/direct-player.tsx | 232 ++---- components/PlayButton.tsx | 57 +- components/PlayButton.tv.tsx | 13 +- components/settings/SubtitleToggles.tsx | 202 ----- .../video-player/controls/BottomControls.tsx | 11 +- components/video-player/controls/Controls.tsx | 40 +- .../video-player/controls/HeaderControls.tsx | 34 +- .../video-player/controls/TimeDisplay.tsx | 14 +- .../controls/contexts/VideoContext.tsx | 93 +-- .../controls/hooks/useRemoteControl.ts | 39 +- .../controls/hooks/useVideoNavigation.ts | 39 +- .../controls/hooks/useVideoSlider.ts | 19 +- .../controls/hooks/useVideoTime.ts | 32 +- components/vlc/VideoDebugInfo.tsx | 94 --- constants/SubtitleConstants.ts | 45 -- hooks/useCreditSkipper.ts | 24 +- hooks/useIntroSkipper.ts | 23 +- modules/VlcPlayer.types.ts | 108 --- modules/VlcPlayerView.tsx | 147 ---- modules/index.ts | 46 +- modules/mpv-player/ios/Logger.swift | 7 - .../mpv-player/ios/MPVSoftwareRenderer.swift | 271 ++++--- modules/mpv-player/ios/MpvPlayer.podspec | 4 + modules/mpv-player/ios/MpvPlayerView.swift | 54 +- modules/mpv-player/ios/PiPController.swift | 30 +- modules/mpv-player/ios/PlayerPreset.swift | 11 +- .../ios/SampleBufferDisplayView.swift | 22 +- modules/mpv-player/src/MpvPlayer.types.ts | 1 - modules/vlc-player-4/expo-module.config.json | 7 - .../ios/AppLifecycleDelegate.swift | 32 - modules/vlc-player-4/ios/VLCManager.swift | 4 - modules/vlc-player-4/ios/VlcPlayer4.podspec | 22 - .../vlc-player-4/ios/VlcPlayer4Module.swift | 71 -- modules/vlc-player-4/ios/VlcPlayer4View.swift | 507 ------------- modules/vlc-player-4/src/VlcPlayer4Module.ts | 5 - modules/vlc-player/android/build.gradle | 47 -- .../android/src/main/AndroidManifest.xml | 2 - .../java/expo/modules/vlcplayer/VLCManager.kt | 38 - .../expo/modules/vlcplayer/VlcPlayerModule.kt | 95 --- .../expo/modules/vlcplayer/VlcPlayerView.kt | 482 ------------ modules/vlc-player/expo-module.config.json | 9 - modules/vlc-player/ios/VlcPlayer.podspec | 23 - modules/vlc-player/ios/VlcPlayerModule.swift | 84 -- modules/vlc-player/ios/VlcPlayerView.swift | 718 ------------------ modules/vlc-player/src/VlcPlayerModule.ts | 5 - utils/atoms/settings.ts | 35 +- utils/jellyseerr | 2 +- 47 files changed, 458 insertions(+), 3442 deletions(-) delete mode 100644 components/vlc/VideoDebugInfo.tsx delete mode 100644 constants/SubtitleConstants.ts delete mode 100644 modules/VlcPlayer.types.ts delete mode 100644 modules/VlcPlayerView.tsx delete mode 100644 modules/vlc-player-4/expo-module.config.json delete mode 100644 modules/vlc-player-4/ios/AppLifecycleDelegate.swift delete mode 100644 modules/vlc-player-4/ios/VLCManager.swift delete mode 100644 modules/vlc-player-4/ios/VlcPlayer4.podspec delete mode 100644 modules/vlc-player-4/ios/VlcPlayer4Module.swift delete mode 100644 modules/vlc-player-4/ios/VlcPlayer4View.swift delete mode 100644 modules/vlc-player-4/src/VlcPlayer4Module.ts delete mode 100644 modules/vlc-player/android/build.gradle delete mode 100644 modules/vlc-player/android/src/main/AndroidManifest.xml delete mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VLCManager.kt delete mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt delete mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt delete mode 100644 modules/vlc-player/expo-module.config.json delete mode 100644 modules/vlc-player/ios/VlcPlayer.podspec delete mode 100644 modules/vlc-player/ios/VlcPlayerModule.swift delete mode 100644 modules/vlc-player/ios/VlcPlayerView.swift delete mode 100644 modules/vlc-player/src/VlcPlayerModule.ts diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 0d93f57f..efe6ac45 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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(null); + const videoRef = useRef(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/missing‐data if (itemStatus.isError || streamStatus.isError) { @@ -708,7 +622,7 @@ export default function page() { ); } - // Then show loader while either side is still fetching or data isn’t 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", }} > - { + 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); - }} /> {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} /> diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 6dee64c2..a873698c 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -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 = ({ ), })); - // if (Platform.OS === "ios") - // return ( - // - // - // - // ); - return ( = ({ )} - {!client && settings?.openInVLC && ( - - - - )} diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index 8e3b9811..b201b106 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -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 = ({ 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 = ({ - {settings?.openInVLC && ( - - - - )} diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 5a6bfee3..dc695a5d 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -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 }) => { ]; }, [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 }) => { onUpdate={(subtitleSize) => updateSettings({ subtitleSize })} /> - - - - {t( - `home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`, - )} - - - - } - title={t("home.settings.subtitles.text_color")} - /> - - - - - {t( - `home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`, - )} - - - - } - title={t("home.settings.subtitles.background_color")} - /> - - - - - {t( - `home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`, - )} - - - - } - title={t("home.settings.subtitles.outline_color")} - /> - - - - - {t( - `home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`, - )} - - - - } - title={t("home.settings.subtitles.outline_thickness")} - /> - - - - {`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`} - - - } - title={t("home.settings.subtitles.background_opacity")} - /> - - - - {`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`} - - - } - title={t("home.settings.subtitles.outline_opacity")} - /> - - - updateSettings({ vlcIsBold: value })} - /> - ); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index a2652d70..0ccd616d 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -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 = ({ showRemoteBubble, currentTime, remainingTime, - isVlc, showSkipButton, showSkipCreditButton, skipIntro, @@ -145,13 +143,7 @@ export const BottomControls: FC = ({ settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value) && ( @@ -208,7 +200,6 @@ export const BottomControls: FC = ({ diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index fb62fcef..7037fb11 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -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; + videoRef: MutableRefObject; isPlaying: boolean; isSeeking: SharedValue; cacheProgress: SharedValue; @@ -68,18 +68,17 @@ interface Props { startPictureInPicture?: () => Promise; play: () => void; pause: () => void; - getAudioTracks?: (() => Promise) | (() => TrackInfo[]); - getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]); + getSubtitleTracks?: + | (() => Promise) + | (() => SubtitleTrack[]); setSubtitleURL?: (url: string, customName: string) => void; setSubtitleTrack?: (index: number) => void; - setAudioTrack?: (index: number) => void; setVideoAspectRatio?: (aspectRatio: string | null) => Promise; setVideoScaleFactor?: (scaleFactor: number) => Promise; aspectRatio?: AspectRatio; scaleFactor?: ScaleFactor; setAspectRatio?: Dispatch>; setScaleFactor?: Dispatch>; - isVlc?: boolean; api?: Api | null; downloadedFiles?: DownloadedItem[]; } @@ -100,11 +99,9 @@ export const Controls: FC = ({ setShowControls, mediaSource, isVideoLoaded, - getAudioTracks, getSubtitleTracks, setSubtitleURL, setSubtitleTrack, - setAudioTrack, setVideoAspectRatio, setVideoScaleFactor, aspectRatio = "default", @@ -112,7 +109,6 @@ export const Controls: FC = ({ setAspectRatio, setScaleFactor, offline = false, - isVlc = false, api = null, downloadedFiles = undefined, }) => { @@ -194,17 +190,13 @@ export const Controls: FC = ({ 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 = ({ } = useVideoNavigation({ progress, isPlaying, - isVlc, seek, play, }); @@ -225,7 +216,6 @@ export const Controls: FC = ({ progress, max, isSeeking, - isVlc, }); const toggleControls = useCallback(() => { @@ -248,7 +238,6 @@ export const Controls: FC = ({ progress, min, max, - isVlc, showControls, isPlaying, seek, @@ -273,7 +262,6 @@ export const Controls: FC = ({ progress, isSeeking, isPlaying, - isVlc, seek, play, pause, @@ -302,9 +290,8 @@ export const Controls: FC = ({ : 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 = ({ currentTime, seek, play, - isVlc, offline, api, downloadedFiles, @@ -336,7 +322,6 @@ export const Controls: FC = ({ currentTime, seek, play, - isVlc, offline, api, downloadedFiles, @@ -515,9 +500,7 @@ export const Controls: FC = ({ 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 = ({ showRemoteBubble={showRemoteBubble} currentTime={currentTime} remainingTime={remainingTime} - isVlc={isVlc} showSkipButton={showSkipButton} showSkipCreditButton={showSkipCreditButton} skipIntro={skipIntro} diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index d8e4dcfe..bb0296f4 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -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[]); getSubtitleTracks?: (() => Promise) | (() => 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 = ({ goToNextItem, previousItem, nextItem, - getAudioTracks, getSubtitleTracks, - setAudioTrack, setSubtitleTrack, setSubtitleURL, aspectRatio = "default", @@ -114,9 +110,7 @@ export const HeaderControls: FC = ({ {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( @@ -128,20 +122,18 @@ export const HeaderControls: FC = ({ - {!Platform.isTV && - (settings.defaultPlayer === VideoPlayer.VLC_4 || - Platform.OS === "android") && ( - - - - )} + {!Platform.isTV && startPictureInPicture && ( + + + + )} {item?.Type === "Episode" && ( = ({ 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 = ({ return ( - {formatTimeString(currentTime, isVlc ? "ms" : "s")} + {formatTimeString(currentTime, "ms")} - -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} + -{formatTimeString(remainingTime, "ms")} ends at {getFinishTime()} diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index ed7fa1e0..c142f22d 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -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(undefined); interface VideoProviderProps { children: ReactNode; - getAudioTracks: - | (() => Promise) - | (() => TrackInfo[]) - | undefined; getSubtitleTracks: - | (() => Promise) - | (() => TrackInfo[]) + | (() => Promise) + | (() => 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 = ({ children, getSubtitleTracks, - getAudioTracks, setSubtitleTrack, setSubtitleURL, - setAudioTrack, }) => { - const [audioTracks, setAudioTracks] = useState(null); const [subtitleTracks, setSubtitleTracks] = useState(null); const ControlContext = useControlContext(); @@ -99,20 +93,15 @@ export const VideoProvider: React.FC = ({ }; 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 = ({ }); 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 = ({ }); 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 ( {children} diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index 8eba2c45..0a71b8b0 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -22,7 +22,6 @@ interface UseRemoteControlProps { progress: SharedValue; min: SharedValue; max: SharedValue; - 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 | 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(); diff --git a/components/video-player/controls/hooks/useVideoNavigation.ts b/components/video-player/controls/hooks/useVideoNavigation.ts index 0573d6e4..5468c790 100644 --- a/components/video-player/controls/hooks/useVideoNavigation.ts +++ b/components/video-player/controls/hooks/useVideoNavigation.ts @@ -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; 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, diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts index 85072954..dfc1164b 100644 --- a/components/video-player/controls/hooks/useVideoSlider.ts +++ b/components/video-player/controls/hooks/useVideoSlider.ts @@ -8,7 +8,6 @@ interface UseVideoSliderProps { progress: SharedValue; isSeeking: SharedValue; 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 { diff --git a/components/video-player/controls/hooks/useVideoTime.ts b/components/video-player/controls/hooks/useVideoTime.ts index bb0fa77d..ad680081 100644 --- a/components/video-player/controls/hooks/useVideoTime.ts +++ b/components/video-player/controls/hooks/useVideoTime.ts @@ -4,21 +4,18 @@ import { type SharedValue, useAnimatedReaction, } from "react-native-reanimated"; -import { ticksToSeconds } from "@/utils/time"; interface UseVideoTimeProps { progress: SharedValue; max: SharedValue; isSeeking: SharedValue; - 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( diff --git a/components/vlc/VideoDebugInfo.tsx b/components/vlc/VideoDebugInfo.tsx deleted file mode 100644 index 40b74b6d..00000000 --- a/components/vlc/VideoDebugInfo.tsx +++ /dev/null @@ -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; -} - -export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => { - const [audioTracks, setAudioTracks] = useState(null); - const [subtitleTracks, setSubtitleTracks] = useState( - 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 ( - - {t("player.playback_state")} - {t("player.audio_tracks")} - {audioTracks?.map((track, index) => ( - - {track.name} ({t("player.index")} {track.index}) - - ))} - {t("player.subtitles_tracks")} - {subtitleTracks?.map((track, index) => ( - - {track.name} ({t("player.index")} {track.index}) - - ))} - { - 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, - ); - }); - } - }} - > - - {t("player.refresh_tracks")} - - - - ); -}; diff --git a/constants/SubtitleConstants.ts b/constants/SubtitleConstants.ts deleted file mode 100644 index 7fc7a8e6..00000000 --- a/constants/SubtitleConstants.ts +++ /dev/null @@ -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 = { - 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 = { - None: 0, - Thin: 2, - Normal: 4, - Thick: 6, -}; diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index d023e7be..f3362078 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -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; diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index d11ed511..eeed9833 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -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; diff --git a/modules/VlcPlayer.types.ts b/modules/VlcPlayer.types.ts deleted file mode 100644 index 93c0923d..00000000 --- a/modules/VlcPlayer.types.ts +++ /dev/null @@ -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; - play: () => Promise; - pause: () => Promise; - stop: () => Promise; - seekTo: (time: number) => Promise; - setAudioTrack: (trackIndex: number) => Promise; - getAudioTracks: () => Promise; - setSubtitleTrack: (trackIndex: number) => Promise; - getSubtitleTracks: () => Promise; - setSubtitleDelay: (delay: number) => Promise; - setAudioDelay: (delay: number) => Promise; - takeSnapshot: (path: string, width: number, height: number) => Promise; - setRate: (rate: number) => Promise; - nextChapter: () => Promise; - previousChapter: () => Promise; - getChapters: () => Promise; - setVideoCropGeometry: (cropGeometry: string | null) => Promise; - getVideoCropGeometry: () => Promise; - setSubtitleURL: (url: string) => Promise; - setVideoAspectRatio: (aspectRatio: string | null) => Promise; - setVideoScaleFactor: (scaleFactor: number) => Promise; -} diff --git a/modules/VlcPlayerView.tsx b/modules/VlcPlayerView.tsx deleted file mode 100644 index a5cac3af..00000000 --- a/modules/VlcPlayerView.tsx +++ /dev/null @@ -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) => void; -} - -const VLCViewManager = requireNativeViewManager("VlcPlayer"); - -// Create a forwarded ref version of the native view -const NativeView = React.forwardRef( - (props, ref) => { - return ; - }, -); - -const VlcPlayerView = React.forwardRef( - (props, ref) => { - const nativeRef = React.useRef(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 ( - - ); - }, -); - -export default VlcPlayerView; diff --git a/modules/index.ts b/modules/index.ts index d0ea5cd2..b83da5f4 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -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"; diff --git a/modules/mpv-player/ios/Logger.swift b/modules/mpv-player/ios/Logger.swift index 61857489..0d79f459 100644 --- a/modules/mpv-player/ios/Logger.swift +++ b/modules/mpv-player/ios/Logger.swift @@ -1,10 +1,3 @@ -// -// Logging.swift -// Sora -// -// Created by seiike on 16/01/2025. -// - import Foundation class Logger { diff --git a/modules/mpv-player/ios/MPVSoftwareRenderer.swift b/modules/mpv-player/ios/MPVSoftwareRenderer.swift index ae839d1f..c6274fac 100644 --- a/modules/mpv-player/ios/MPVSoftwareRenderer.swift +++ b/modules/mpv-player/ios/MPVSoftwareRenderer.swift @@ -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 { 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)) } } diff --git a/modules/mpv-player/ios/MpvPlayer.podspec b/modules/mpv-player/ios/MpvPlayer.podspec index aee9c799..e0960aa6 100644 --- a/modules/mpv-player/ios/MpvPlayer.podspec +++ b/modules/mpv-player/ios/MpvPlayer.podspec @@ -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}" diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index cdf22d13..3e2407f4 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -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, + ]) + } } } diff --git a/modules/mpv-player/ios/PiPController.swift b/modules/mpv-player/ios/PiPController.swift index aff2cc3a..80680896 100644 --- a/modules/mpv-player/ios/PiPController.swift +++ b/modules/mpv-player/ios/PiPController.swift @@ -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() } -} +} \ No newline at end of file diff --git a/modules/mpv-player/ios/PlayerPreset.swift b/modules/mpv-player/ios/PlayerPreset.swift index b21ced0a..38112f05 100644 --- a/modules/mpv-player/ios/PlayerPreset.swift +++ b/modules/mpv-player/ios/PlayerPreset.swift @@ -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 } -} +} \ No newline at end of file diff --git a/modules/mpv-player/ios/SampleBufferDisplayView.swift b/modules/mpv-player/ios/SampleBufferDisplayView.swift index 94a17c4c..8e432c33 100644 --- a/modules/mpv-player/ios/SampleBufferDisplayView.swift +++ b/modules/mpv-player/ios/SampleBufferDisplayView.swift @@ -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() } diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 76e4a36d..70ff4896 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -69,7 +69,6 @@ export interface MpvPlayerViewRef { setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise; setSubtitleFontSize: (size: number) => Promise; } - export type SubtitleTrack = { id: number; title?: string; diff --git a/modules/vlc-player-4/expo-module.config.json b/modules/vlc-player-4/expo-module.config.json deleted file mode 100644 index 494e40db..00000000 --- a/modules/vlc-player-4/expo-module.config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "platforms": ["ios", "tvos"], - "ios": { - "modules": ["VlcPlayer4Module"], - "appDelegateSubscribers": ["AppLifecycleDelegate"] - } -} diff --git a/modules/vlc-player-4/ios/AppLifecycleDelegate.swift b/modules/vlc-player-4/ios/AppLifecycleDelegate.swift deleted file mode 100644 index 916de305..00000000 --- a/modules/vlc-player-4/ios/AppLifecycleDelegate.swift +++ /dev/null @@ -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. - } -} \ No newline at end of file diff --git a/modules/vlc-player-4/ios/VLCManager.swift b/modules/vlc-player-4/ios/VLCManager.swift deleted file mode 100644 index 8f2b84b7..00000000 --- a/modules/vlc-player-4/ios/VLCManager.swift +++ /dev/null @@ -1,4 +0,0 @@ -class VLCManager { - static let shared = VLCManager() - var listeners: [SimpleAppLifecycleListener] = [] -} \ No newline at end of file diff --git a/modules/vlc-player-4/ios/VlcPlayer4.podspec b/modules/vlc-player-4/ios/VlcPlayer4.podspec deleted file mode 100644 index fe47f77f..00000000 --- a/modules/vlc-player-4/ios/VlcPlayer4.podspec +++ /dev/null @@ -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 diff --git a/modules/vlc-player-4/ios/VlcPlayer4Module.swift b/modules/vlc-player-4/ios/VlcPlayer4Module.swift deleted file mode 100644 index 6010b156..00000000 --- a/modules/vlc-player-4/ios/VlcPlayer4Module.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/modules/vlc-player-4/ios/VlcPlayer4View.swift b/modules/vlc-player-4/ios/VlcPlayer4View.swift deleted file mode 100644 index 66c9a071..00000000 --- a/modules/vlc-player-4/ios/VlcPlayer4View.swift +++ /dev/null @@ -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" - } - } -} diff --git a/modules/vlc-player-4/src/VlcPlayer4Module.ts b/modules/vlc-player-4/src/VlcPlayer4Module.ts deleted file mode 100644 index 9e489bd2..00000000 --- a/modules/vlc-player-4/src/VlcPlayer4Module.ts +++ /dev/null @@ -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"); diff --git a/modules/vlc-player/android/build.gradle b/modules/vlc-player/android/build.gradle deleted file mode 100644 index b372dded..00000000 --- a/modules/vlc-player/android/build.gradle +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/modules/vlc-player/android/src/main/AndroidManifest.xml b/modules/vlc-player/android/src/main/AndroidManifest.xml deleted file mode 100644 index bdae66c8..00000000 --- a/modules/vlc-player/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VLCManager.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VLCManager.kt deleted file mode 100644 index e65d98e4..00000000 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VLCManager.kt +++ /dev/null @@ -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 = 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) -// } -// } -} \ No newline at end of file diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt deleted file mode 100644 index 21c953e1..00000000 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt +++ /dev/null @@ -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 -> - 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) - } - } - } -} \ No newline at end of file diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt deleted file mode 100644 index e164efb9..00000000 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt +++ /dev/null @@ -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? = null - private var externalSubtitles: List>? = 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 = 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) { - log.debug("setting source $source") - if (hasSource) { - log.debug("Source already set. Ignoring.") - return - } - val mediaOptions = source["mediaOptions"] as? Map ?: emptyMap() - val autoplay = source["autoplay"] as? Boolean ?: false - val isNetwork = source["isNetwork"] as? Boolean ?: false - externalTrack = source["externalTrack"] as? Map - externalSubtitles = source["externalSubtitles"] as? List> - startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0 - - val initOptions = source["initOptions"] as? MutableList ?: 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>? { - 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>? { - // return mediaPlayer?.getSpuTracks()?.map { trackDescription -> - // mapOf("name" to trackDescription.name, "index" to trackDescription.id) - // } - // } - - fun getSubtitleTracks(): List>? { - 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( - "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") -} \ No newline at end of file diff --git a/modules/vlc-player/expo-module.config.json b/modules/vlc-player/expo-module.config.json deleted file mode 100644 index 2fbd3167..00000000 --- a/modules/vlc-player/expo-module.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "platforms": ["ios", "tvos", "android", "web"], - "ios": { - "modules": ["VlcPlayerModule"] - }, - "android": { - "modules": ["expo.modules.vlcplayer.VlcPlayerModule"] - } -} diff --git a/modules/vlc-player/ios/VlcPlayer.podspec b/modules/vlc-player/ios/VlcPlayer.podspec deleted file mode 100644 index af95170d..00000000 --- a/modules/vlc-player/ios/VlcPlayer.podspec +++ /dev/null @@ -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 diff --git a/modules/vlc-player/ios/VlcPlayerModule.swift b/modules/vlc-player/ios/VlcPlayerModule.swift deleted file mode 100644 index 5685ac8c..00000000 --- a/modules/vlc-player/ios/VlcPlayerModule.swift +++ /dev/null @@ -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() - } - } - } -} diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift deleted file mode 100644 index 86607d14..00000000 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ /dev/null @@ -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" - } - } -} \ No newline at end of file diff --git a/modules/vlc-player/src/VlcPlayerModule.ts b/modules/vlc-player/src/VlcPlayerModule.ts deleted file mode 100644 index be5b1b65..00000000 --- a/modules/vlc-player/src/VlcPlayerModule.ts +++ /dev/null @@ -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"); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index ee0e8625..08cf745d 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -130,10 +130,9 @@ export type HomeSectionLatestResolver = { includeItemTypes?: Array; }; +// 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, diff --git a/utils/jellyseerr b/utils/jellyseerr index 4401b164..fc6a9e95 160000 --- a/utils/jellyseerr +++ b/utils/jellyseerr @@ -1 +1 @@ -Subproject commit 4401b16414af604a7372dacac326c38b18ad8555 +Subproject commit fc6a9e952ca524fcc2252d4a6eb4f08bb767a9a3