From f1575ca48b51103a46cf9b88ca533938fcd6fa79 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 10 Jan 2026 19:35:27 +0100 Subject: [PATCH] feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332) Co-authored-by: Alex Kim Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com> Co-authored-by: Simon-Eklundh --- .claude/learned-facts.md | 8 + .gitignore | 2 +- README.md | 6 + app.config.js | 3 - app.json | 20 +- app/(auth)/(tabs)/(home)/_layout.tsx | 79 +- app/(auth)/(tabs)/(home)/downloads/index.tsx | 13 +- .../(home)/settings/audio-subtitles/page.tsx | 4 +- app/(auth)/(tabs)/(watchlists)/_layout.tsx | 7 +- app/(auth)/player/direct-player.tsx | 548 ++----- bun.lock | 3 - components/Chromecast.tsx | 7 +- components/DownloadItem.tsx | 1 + components/RoundButton.tsx | 23 +- components/common/HeaderBackButton.tsx | 30 +- components/home/Home.tsx | 6 +- components/settings/KSPlayerSettings.tsx | 40 - components/settings/MpvSubtitleSettings.tsx | 133 ++ components/settings/OtherSettings.tsx | 30 - .../settings/PlaybackControlsSettings.tsx | 3 - components/settings/VideoPlayerSettings.tsx | 93 -- components/settings/VlcSubtitleSettings.tsx | 245 --- components/stacks/NestedTabPageStack.tsx | 9 +- .../video-player/controls/BottomControls.tsx | 12 +- .../controls/BrightnessSlider.tsx | 20 +- .../video-player/controls/CenterControls.tsx | 4 +- components/video-player/controls/Controls.tsx | 17 +- .../video-player/controls/HeaderControls.tsx | 106 +- .../video-player/controls/VlcZoomControl.tsx | 121 -- .../controls/contexts/PlayerContext.tsx | 51 +- .../controls/contexts/VideoContext.tsx | 119 +- .../controls/dropdown/DropdownView.tsx | 55 +- .../controls/hooks/useVolumeAndBrightness.ts | 16 +- constants/SubtitleConstants.ts | 40 - hooks/useMarkAsPlayed.ts | 4 +- modules/VlcPlayer.types.ts | 108 -- modules/VlcPlayerView.tsx | 152 -- modules/index.ts | 48 +- modules/mpv-player/android/build.gradle | 16 +- .../android/src/main/AndroidManifest.xml | 9 +- .../modules/mpvplayer/MPVLayerRenderer.kt | 552 +++++++ .../java/expo/modules/mpvplayer/MPVLib.kt | 220 +++ .../expo/modules/mpvplayer/MpvPlayerModule.kt | 212 ++- .../expo/modules/mpvplayer/MpvPlayerView.kt | 406 ++++- .../expo/modules/mpvplayer/PiPController.kt | 263 ++++ modules/mpv-player/expo-module.config.json | 5 +- modules/mpv-player/ios/MPVLayerRenderer.swift | 726 +++++++++ .../mpv-player/ios/MPVSoftwareRenderer.swift | 1389 ----------------- modules/mpv-player/ios/MpvPlayer.podspec | 11 +- modules/mpv-player/ios/MpvPlayerModule.swift | 9 + modules/mpv-player/ios/MpvPlayerView.swift | 60 +- modules/mpv-player/ios/PiPController.swift | 61 + modules/mpv-player/src/MpvPlayer.types.ts | 3 + modules/mpv-player/src/MpvPlayerView.tsx | 7 + modules/mpv-player/src/MpvPlayerView.web.tsx | 4 +- modules/sf-player/android/build.gradle | 71 - .../android/src/main/AndroidManifest.xml | 2 - .../expo/modules/sfplayer/SfPlayerModule.kt | 120 -- .../expo/modules/sfplayer/SfPlayerView.kt | 29 - modules/sf-player/expo-module.config.json | 9 - modules/sf-player/index.ts | 1 - modules/sf-player/ios/SfPlayer.podspec | 32 - modules/sf-player/ios/SfPlayerModule.swift | 179 --- modules/sf-player/ios/SfPlayerView.swift | 317 ---- modules/sf-player/ios/SfPlayerWrapper.swift | 869 ----------- modules/sf-player/src/SfPlayer.types.ts | 111 -- modules/sf-player/src/SfPlayerView.tsx | 120 -- modules/sf-player/src/index.ts | 15 - 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 | 99 -- .../expo/modules/vlcplayer/VlcPlayerView.kt | 487 ------ modules/vlc-player/expo-module.config.json | 9 - modules/vlc-player/ios/VlcPlayer.podspec | 23 - modules/vlc-player/ios/VlcPlayerModule.swift | 88 -- modules/vlc-player/ios/VlcPlayerView.swift | 725 --------- modules/vlc-player/src/VlcPlayerModule.ts | 5 - package.json | 1 - plugins/withGitPod.js | 24 + plugins/withKSPlayer.js | 38 - providers/PlaySettingsProvider.tsx | 13 +- utils/atoms/settings.ts | 46 +- utils/jellyfin/audio/getAudioStreamUrl.ts | 4 +- utils/jellyfin/media/getDownloadUrl.ts | 10 +- utils/jellyfin/media/getStreamUrl.ts | 7 +- utils/profiles/download.js | 170 +- utils/profiles/native.d.ts | 5 +- utils/profiles/native.js | 88 +- utils/profiles/trackplayer.d.ts | 19 + utils/profiles/trackplayer.js | 95 ++ 98 files changed, 3257 insertions(+), 7448 deletions(-) delete mode 100644 components/settings/KSPlayerSettings.tsx create mode 100644 components/settings/MpvSubtitleSettings.tsx delete mode 100644 components/settings/VideoPlayerSettings.tsx delete mode 100644 components/settings/VlcSubtitleSettings.tsx delete mode 100644 components/video-player/controls/VlcZoomControl.tsx delete mode 100644 constants/SubtitleConstants.ts delete mode 100644 modules/VlcPlayer.types.ts delete mode 100644 modules/VlcPlayerView.tsx create mode 100644 modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt create mode 100644 modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt create mode 100644 modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt create mode 100644 modules/mpv-player/ios/MPVLayerRenderer.swift delete mode 100644 modules/mpv-player/ios/MPVSoftwareRenderer.swift delete mode 100644 modules/sf-player/android/build.gradle delete mode 100644 modules/sf-player/android/src/main/AndroidManifest.xml delete mode 100644 modules/sf-player/android/src/main/java/expo/modules/sfplayer/SfPlayerModule.kt delete mode 100644 modules/sf-player/android/src/main/java/expo/modules/sfplayer/SfPlayerView.kt delete mode 100644 modules/sf-player/expo-module.config.json delete mode 100644 modules/sf-player/index.ts delete mode 100644 modules/sf-player/ios/SfPlayer.podspec delete mode 100644 modules/sf-player/ios/SfPlayerModule.swift delete mode 100644 modules/sf-player/ios/SfPlayerView.swift delete mode 100644 modules/sf-player/ios/SfPlayerWrapper.swift delete mode 100644 modules/sf-player/src/SfPlayer.types.ts delete mode 100644 modules/sf-player/src/SfPlayerView.tsx delete mode 100644 modules/sf-player/src/index.ts 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 create mode 100644 plugins/withGitPod.js delete mode 100644 plugins/withKSPlayer.js create mode 100644 utils/profiles/trackplayer.d.ts create mode 100644 utils/profiles/trackplayer.js diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index b28716b4..62f9f3f3 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -15,3 +15,11 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **Intro modal trigger location**: The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. _(2025-01-09)_ - **Tab folder naming**: The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. _(2025-01-09)_ + +- **macOS header buttons fix**: Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. _(2026-01-10)_ + +- **Header button locations**: Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. _(2026-01-10)_ + +- **useNetworkAwareQueryClient limitations**: The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. _(2026-01-10)_ + +- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_ diff --git a/.gitignore b/.gitignore index facf4574..c022785c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ web-build/ /androidtv # Module-specific Builds -modules/vlc-player/android/build +modules/mpv-player/android/build modules/player/android modules/hls-downloader/android/build diff --git a/README.md b/README.md index ef4bc974..7bb0c9bc 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,11 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features. +### 🎬 MPV Player + +Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback. +Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin. + ### 🔍 Jellysearch [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin @@ -230,6 +235,7 @@ We also thank all other developers who have contributed to Streamyfin, your effo A special mention to the following people and projects for their contributions: +- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native - [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK - [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project diff --git a/app.config.js b/app.config.js index 527e0cab..2e37927b 100644 --- a/app.config.js +++ b/app.config.js @@ -6,9 +6,6 @@ module.exports = ({ config }) => { "react-native-google-cast", { useDefaultExpandedMediaControls: true }, ]); - - // KSPlayer for iOS (GPU acceleration + native PiP) - config.plugins.push("./plugins/withKSPlayer.js"); } // Only override googleServicesFile if env var is set diff --git a/app.json b/app.json index 59d5e5e6..0261dd8a 100644 --- a/app.json +++ b/app.json @@ -58,7 +58,8 @@ "expo-build-properties", { "ios": { - "deploymentTarget": "15.6" + "deploymentTarget": "15.6", + "useFrameworks": "static" }, "android": { "buildArchs": ["arm64-v8a", "x86_64"], @@ -66,7 +67,7 @@ "targetSdkVersion": 35, "buildToolsVersion": "35.0.0", "kotlinVersion": "2.0.21", - "minSdkVersion": 24, + "minSdkVersion": 26, "usesCleartextTraffic": true, "packagingOptions": { "jniLibs": { @@ -84,12 +85,6 @@ "initialOrientation": "DEFAULT" } ], - [ - "expo-sensors", - { - "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." - } - ], "expo-localization", "expo-asset", [ @@ -120,7 +115,14 @@ ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidManifest.js"], ["./plugins/withTrustLocalCerts.js"], - ["./plugins/withGradleProperties.js"] + ["./plugins/withGradleProperties.js"], + [ + "./plugins/withGitPod.js", + { + "podName": "MPVKit-GPL", + "podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec" + } + ] ], "experiments": { "typedRoutes": true diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index dcdb94f2..8886cde2 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,7 +1,8 @@ import { Feather, Ionicons } from "@expo/vector-icons"; import { Stack, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; -import { Platform, TouchableOpacity, View } from "react-native"; +import { Platform, View } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); @@ -46,13 +47,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", title: t("home.downloads.downloads_title"), headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -65,13 +66,13 @@ export default function IndexLayout() { headerShadowVisible: false, title: t("home.downloads.tvseries"), headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -84,13 +85,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -102,13 +103,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -120,13 +121,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -138,13 +139,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -156,13 +157,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -174,13 +175,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -192,13 +193,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -210,13 +211,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -228,13 +229,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -246,13 +247,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -264,13 +265,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -282,13 +283,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -300,13 +301,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -318,13 +319,13 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios", headerShadowVisible: false, headerLeft: () => ( - _router.back()} className='pl-0.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > - + ), }} /> @@ -336,9 +337,9 @@ export default function IndexLayout() { options={{ title: "", headerLeft: () => ( - _router.back()} className='pl-0.5'> + _router.back()} className='pl-0.5'> - + ), headerShown: true, headerBlurEffect: "prominent", @@ -354,13 +355,13 @@ const SettingsButton = () => { const router = useRouter(); return ( - { router.push("/(auth)/settings"); }} > - + ); }; @@ -369,7 +370,7 @@ const SessionsButton = () => { const { sessions = [] } = useSessions({} as useSessionsProps); return ( - { router.push("/(auth)/sessions"); }} @@ -380,6 +381,6 @@ const SessionsButton = () => { color={sessions.length === 0 ? "white" : "#9333ea"} size={28} /> - + ); }; diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index c71bebd8..239d0a8c 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -3,13 +3,8 @@ import { useNavigation, useRouter } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Alert, - Platform, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; +import { Alert, Platform, ScrollView, View } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; @@ -103,12 +98,12 @@ export default function page() { useEffect(() => { navigation.setOptions({ headerRight: () => ( - f.item) || []} /> - + ), }); }, [downloadedFiles]); diff --git a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx index 6be20d97..7c5f38d8 100644 --- a/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx @@ -2,8 +2,8 @@ import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AudioToggles } from "@/components/settings/AudioToggles"; import { MediaProvider } from "@/components/settings/MediaContext"; +import { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; -import { VlcSubtitleSettings } from "@/components/settings/VlcSubtitleSettings"; export default function AudioSubtitlesPage() { const insets = useSafeAreaInsets(); @@ -23,7 +23,7 @@ export default function AudioSubtitlesPage() { - + diff --git a/app/(auth)/(tabs)/(watchlists)/_layout.tsx b/app/(auth)/(tabs)/(watchlists)/_layout.tsx index 05cede49..51758135 100644 --- a/app/(auth)/(tabs)/(watchlists)/_layout.tsx +++ b/app/(auth)/(tabs)/(watchlists)/_layout.tsx @@ -1,7 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; import { Stack, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; -import { Platform, TouchableOpacity } from "react-native"; +import { Platform } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { useStreamystatsEnabled } from "@/hooks/useWatchlists"; @@ -22,14 +23,14 @@ export default function WatchlistsLayout() { headerShadowVisible: false, headerRight: streamystatsEnabled ? () => ( - router.push("/(auth)/(tabs)/(watchlists)/create") } className='p-1.5' > - + ) : undefined, }} diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 40ccc834..0571e9a4 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -14,7 +14,7 @@ import { router, useGlobalSearchParams, useNavigation } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Platform, View } from "react-native"; +import { Alert, Platform, useWindowDimensions, View } from "react-native"; import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { BITRATES } from "@/components/BitrateSelector"; @@ -27,7 +27,6 @@ import { PlaybackSpeedScope, updatePlaybackSpeedSettings, } from "@/components/video-player/controls/utils/playback-speed-settings"; -import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants"; import { useHaptic } from "@/hooks/useHaptic"; import { useOrientation } from "@/hooks/useOrientation"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; @@ -35,24 +34,17 @@ import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useWebSocket } from "@/hooks/useWebsockets"; import { - type PlaybackStatePayload, - type ProgressUpdatePayload, - type SfOnErrorEventPayload, - type SfOnPictureInPictureChangePayload, - type SfOnPlaybackStateChangePayload, - type SfOnProgressEventPayload, - SfPlayerView, - type SfPlayerViewRef, - type SfVideoSource, - setHardwareDecode, - type VlcPlayerSource, - VlcPlayerView, - type VlcPlayerViewRef, + type MpvOnErrorEventPayload, + type MpvOnPlaybackStateChangePayload, + type MpvOnProgressEventPayload, + MpvPlayerView, + type MpvPlayerViewRef, + type MpvVideoSource, } from "@/modules"; import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings"; +import { useSettings } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getMpvAudioId, @@ -63,29 +55,21 @@ import { generateDeviceProfile } from "@/utils/profiles/native"; import { msToTicks, ticksToSeconds } from "@/utils/time"; export default function page() { - const videoRef = useRef(null); + const videoRef = useRef(null); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); const { t } = useTranslation(); const navigation = useNavigation(); const { settings, updateSettings } = useSettings(); - // Determine which player to use: - // - Android always uses VLC - // - iOS uses user setting (KSPlayer by default, VLC optional) - const useVlcPlayer = - Platform.OS === "android" || - (Platform.OS === "ios" && settings.videoPlayerIOS === VideoPlayerIOS.VLC); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, _setShowControls] = useState(true); const [isPipMode, setIsPipMode] = useState(false); - const [aspectRatio, setAspectRatio] = useState< - "default" | "16:9" | "4:3" | "1:1" | "21:9" - >("default"); - const [scaleFactor, setScaleFactor] = useState< - 0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0 - >(0); + const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">( + "default", + ); const [isZoomedToFill, setIsZoomedToFill] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); @@ -190,15 +174,11 @@ export default function page() { updateSettings, ); - // Apply speed to the current player + // Apply speed to the current player (MPV) setCurrentPlaybackSpeed(speed); - if (useVlcPlayer) { - await (videoRef.current as VlcPlayerViewRef)?.setRate?.(speed); - } else { - await (videoRef.current as SfPlayerViewRef)?.setSpeed?.(speed); - } + await videoRef.current?.setSpeed?.(speed); }, - [item, settings, updateSettings, useVlcPlayer], + [item, settings, updateSettings], ); /** Gets the initial playback position from the URL. */ @@ -311,11 +291,7 @@ export default function page() { maxStreamingBitrate: bitrateValue, mediaSourceId: mediaSourceId, subtitleStreamIndex: subtitleIndex, - deviceProfile: generateDeviceProfile({ - platform: Platform.OS as "ios" | "android", - player: useVlcPlayer ? "vlc" : "ksplayer", - audioMode: settings.audioTranscodeMode, - }), + deviceProfile: generateDeviceProfile(), }); if (!res) return; const { mediaSource, sessionId, url } = res; @@ -407,7 +383,6 @@ export default function page() { }); reportPlaybackStopped(); setIsPlaybackStopped(true); - // KSPlayer doesn't have a stop method, use pause instead videoRef.current?.pause(); revalidateProgressCache(); }, [videoRef, reportPlaybackStopped, progress]); @@ -465,13 +440,13 @@ export default function page() { [], ); - /** Progress handler for iOS (SfPlayer) - position in seconds */ - const onProgressSf = useCallback( - async (data: { nativeEvent: SfOnProgressEventPayload }) => { + /** Progress handler for MPV - position in seconds */ + const onProgress = useCallback( + async (data: { nativeEvent: MpvOnProgressEventPayload }) => { if (isSeeking.get() || isPlaybackStopped) return; const { position } = data.nativeEvent; - // KSPlayer reports position in seconds, convert to ms + // MPV reports position in seconds, convert to ms const currentTime = position * 1000; if (isBuffering) { @@ -514,63 +489,14 @@ export default function page() { ], ); - /** Progress handler for Android (VLC) - currentTime in milliseconds */ - const onProgressVlc = useCallback( - async (data: ProgressUpdatePayload) => { - if (isSeeking.get() || isPlaybackStopped) return; - - const { currentTime } = data.nativeEvent; - // VLC reports currentTime in milliseconds - - if (isBuffering) { - setIsBuffering(false); - } - - progress.set(currentTime); - - // Update URL immediately after seeking, or every 30 seconds during normal playback - const now = Date.now(); - const shouldUpdateUrl = wasJustSeeking.get(); - wasJustSeeking.value = false; - - if ( - shouldUpdateUrl || - now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL - ) { - router.setParams({ - playbackPosition: msToTicks(currentTime).toString(), - }); - lastUrlUpdateTime.value = now; - } - - if (!item?.Id) return; - - const progressInfo = currentPlayStateInfo(); - if (progressInfo) { - playbackManager.reportPlaybackProgress(progressInfo); - } - }, - [ - item?.Id, - audioIndex, - subtitleIndex, - mediaSourceId, - isPlaying, - stream, - isSeeking, - isPlaybackStopped, - isBuffering, - ], - ); - /** Gets the initial playback position in seconds. */ - const startPosition = useMemo(() => { + const _startPosition = useMemo(() => { return ticksToSeconds(getInitialPlaybackTicks()); }, [getInitialPlaybackTicks]); - /** Build video source config for iOS (SfPlayer/KSPlayer) */ - const sfVideoSource = useMemo(() => { - if (!stream?.url || useVlcPlayer) return undefined; + /** Build video source config for MPV */ + const videoSource = useMemo(() => { + if (!stream?.url) return undefined; const mediaSource = stream.mediaSource; const isTranscoding = Boolean(mediaSource?.TranscodingUrl); @@ -609,15 +535,10 @@ export default function page() { : (item?.UserData?.PlaybackPositionTicks ?? 0); const startPos = ticksToSeconds(startTicks); - // For transcoded streams, the server already handles seeking via startTimeTicks, - // so we should NOT also tell the player to seek (would cause double-seeking). - // For direct play/stream, the player needs to seek itself. - const playerStartPos = isTranscoding ? 0 : startPos; - // Build source config - headers only needed for online streaming - const source: SfVideoSource = { + const source: MpvVideoSource = { url: stream.url, - startPosition: playerStartPos, + startPosition: startPos, autoplay: true, initialSubtitleId, initialAudioId, @@ -646,167 +567,6 @@ export default function page() { subtitleIndex, audioIndex, offline, - useVlcPlayer, - ]); - - /** Build video source config for Android (VLC) */ - const vlcVideoSource = useMemo(() => { - if (!stream?.url || !useVlcPlayer) return undefined; - - const mediaSource = stream.mediaSource; - const isTranscoding = Boolean(mediaSource?.TranscodingUrl); - - // Get external subtitle URLs for VLC (need name and DeliveryUrl) - // - Online: prepend API base path to server URLs - // - Offline: use local file paths (stored in DeliveryUrl during download) - let externalSubs: { name: string; DeliveryUrl: string }[] | undefined; - if (!offline && api?.basePath) { - externalSubs = mediaSource?.MediaStreams?.filter( - (s) => - s.Type === "Subtitle" && - s.DeliveryMethod === "External" && - s.DeliveryUrl, - ).map((s) => ({ - name: s.DisplayTitle || s.Title || `Subtitle ${s.Index}`, - DeliveryUrl: `${api.basePath}${s.DeliveryUrl}`, - })); - } else if (offline) { - externalSubs = mediaSource?.MediaStreams?.filter( - (s) => - s.Type === "Subtitle" && - s.DeliveryMethod === "External" && - s.DeliveryUrl, - ).map((s) => ({ - name: s.DisplayTitle || s.Title || `Subtitle ${s.Index}`, - DeliveryUrl: s.DeliveryUrl!, - })); - } - - // Build VLC init options (required for VLC to work properly) - const initOptions: string[] = [""]; - - // Get all subtitle and audio streams - const allSubs = - mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? []; - const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream); - const allAudio = - mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; - - // Find chosen tracks - const chosenSubtitleTrack = allSubs.find((s) => s.Index === subtitleIndex); - const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex); - - // Set subtitle track - if ( - chosenSubtitleTrack && - (!isTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) - ) { - const finalIndex = !isTranscoding - ? allSubs.indexOf(chosenSubtitleTrack) - : [...textSubs].reverse().indexOf(chosenSubtitleTrack); - if (finalIndex >= 0) { - initOptions.push(`--sub-track=${finalIndex}`); - } - } - - // Set audio track - if (!isTranscoding && chosenAudioTrack) { - const audioTrackIndex = allAudio.indexOf(chosenAudioTrack); - if (audioTrackIndex >= 0) { - initOptions.push(`--audio-track=${audioTrackIndex}`); - } - } - - // Add VLC subtitle styling from settings - if (settings.subtitleSize) { - initOptions.push(`--sub-text-scale=${settings.subtitleSize}`); - } - initOptions.push(`--sub-margin=${settings.vlcSubtitleMargin ?? 40}`); - - // Text color - if ( - settings.vlcTextColor && - VLC_COLORS[settings.vlcTextColor] !== undefined - ) { - initOptions.push(`--freetype-color=${VLC_COLORS[settings.vlcTextColor]}`); - } - - // Background styling - if ( - settings.vlcBackgroundColor && - VLC_COLORS[settings.vlcBackgroundColor] !== undefined - ) { - initOptions.push( - `--freetype-background-color=${VLC_COLORS[settings.vlcBackgroundColor]}`, - ); - } - if (settings.vlcBackgroundOpacity !== undefined) { - initOptions.push( - `--freetype-background-opacity=${settings.vlcBackgroundOpacity}`, - ); - } - - // Outline styling - if ( - settings.vlcOutlineColor && - VLC_COLORS[settings.vlcOutlineColor] !== undefined - ) { - initOptions.push( - `--freetype-outline-color=${VLC_COLORS[settings.vlcOutlineColor]}`, - ); - } - if (settings.vlcOutlineOpacity !== undefined) { - initOptions.push( - `--freetype-outline-opacity=${settings.vlcOutlineOpacity}`, - ); - } - if ( - settings.vlcOutlineThickness && - OUTLINE_THICKNESS[settings.vlcOutlineThickness] !== undefined - ) { - initOptions.push( - `--freetype-outline-thickness=${OUTLINE_THICKNESS[settings.vlcOutlineThickness]}`, - ); - } - - // Bold text - if (settings.vlcIsBold) { - initOptions.push("--freetype-bold"); - } - - // For transcoded streams, the server already handles seeking via startTimeTicks, - // so we should NOT also tell the player to seek (would cause double-seeking). - // For direct play/stream, the player needs to seek itself. - const playerStartPos = isTranscoding ? 0 : startPosition; - - const source: VlcPlayerSource = { - uri: stream.url, - startPosition: playerStartPos, - autoplay: true, - isNetwork: !offline, - externalSubtitles: externalSubs, - initOptions, - }; - - return source; - }, [ - stream?.url, - stream?.mediaSource, - startPosition, - useVlcPlayer, - api?.basePath, - offline, - subtitleIndex, - audioIndex, - settings.subtitleSize, - settings.vlcTextColor, - settings.vlcBackgroundColor, - settings.vlcBackgroundOpacity, - settings.vlcOutlineColor, - settings.vlcOutlineOpacity, - settings.vlcOutlineThickness, - settings.vlcIsBold, - settings.vlcSubtitleMargin, ]); const volumeUpCb = useCallback(async () => { @@ -888,9 +648,9 @@ export default function page() { setVolume: setVolumeCb, }); - /** Playback state handler for iOS (SfPlayer) */ - const onPlaybackStateChangedSf = useCallback( - async (e: { nativeEvent: SfOnPlaybackStateChangePayload }) => { + /** Playback state handler for MPV */ + const onPlaybackStateChanged = useCallback( + async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => { const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent; if (playing) { @@ -924,52 +684,9 @@ export default function page() { [playbackManager, item?.Id, progress], ); - /** Playback state handler for Android (VLC) */ - const onPlaybackStateChangedVlc = useCallback( - async (e: PlaybackStatePayload) => { - const { - state, - isBuffering: buffering, - isPlaying: playing, - } = e.nativeEvent; - - if (state === "Playing" || playing) { - setIsPlaying(true); - setIsBuffering(false); - setHasPlaybackStarted(true); - setTracksReady(true); // VLC tracks are ready when playback starts - if (item?.Id) { - const progressInfo = currentPlayStateInfo(); - if (progressInfo) { - playbackManager.reportPlaybackProgress(progressInfo); - } - } - if (!Platform.isTV) await activateKeepAwakeAsync(); - return; - } - - if (state === "Paused") { - setIsPlaying(false); - if (item?.Id) { - const progressInfo = currentPlayStateInfo(); - if (progressInfo) { - playbackManager.reportPlaybackProgress(progressInfo); - } - } - if (!Platform.isTV) await deactivateKeepAwake(); - return; - } - - if (state === "Buffering" || buffering) { - setIsBuffering(true); - } - }, - [playbackManager, item?.Id, progress], - ); - - /** PiP handler for iOS (SfPlayer) */ - const onPictureInPictureChangeSf = useCallback( - (e: { nativeEvent: SfOnPictureInPictureChangePayload }) => { + /** PiP handler for MPV */ + const _onPictureInPictureChange = useCallback( + (e: { nativeEvent: { isActive: boolean } }) => { const { isActive } = e.nativeEvent; setIsPipMode(isActive); // Hide controls when entering PiP @@ -980,19 +697,6 @@ export default function page() { [], ); - /** PiP handler for Android (VLC) */ - const onPipStartedVlc = useCallback( - (e: { nativeEvent: { pipStarted: boolean } }) => { - const { pipStarted } = e.nativeEvent; - setIsPipMode(pipStarted); - // Hide controls when entering PiP - if (pipStarted) { - _setShowControls(false); - } - }, - [], - ); - const [isMounted, setIsMounted] = useState(false); // Add useEffect to handle mounting @@ -1014,96 +718,79 @@ export default function page() { videoRef.current?.pause?.(); }, []); - const seek = useCallback( - (position: number) => { - if (useVlcPlayer) { - // VLC expects milliseconds - videoRef.current?.seekTo?.(position); - } else { - // KSPlayer expects seconds, convert from ms - videoRef.current?.seekTo?.(position / 1000); - } - }, - [useVlcPlayer], - ); + const seek = useCallback((position: number) => { + // MPV expects seconds, convert from ms + videoRef.current?.seekTo?.(position / 1000); + }, []); const handleZoomToggle = useCallback(async () => { - // Zoom toggle only supported when using SfPlayer (KSPlayer) - if (useVlcPlayer) return; const newZoomState = !isZoomedToFill; + await videoRef.current?.setZoomedToFill?.(newZoomState); setIsZoomedToFill(newZoomState); - await (videoRef.current as SfPlayerViewRef)?.setVideoZoomToFill?.( - newZoomState, - ); - }, [isZoomedToFill, useVlcPlayer]); - // VLC-specific handlers for aspect ratio and scale factor - const handleSetVideoAspectRatio = useCallback( - async (newAspectRatio: string | null) => { - if (!useVlcPlayer) return; - const ratio = (newAspectRatio ?? "default") as - | "default" - | "16:9" - | "4:3" - | "1:1" - | "21:9"; - setAspectRatio(ratio); - await (videoRef.current as VlcPlayerViewRef)?.setVideoAspectRatio?.( - newAspectRatio, + // Adjust subtitle position to compensate for video cropping when zoomed + if (newZoomState) { + // Get video dimensions from mediaSource + const videoStream = stream?.mediaSource?.MediaStreams?.find( + (s) => s.Type === "Video", ); - }, - [useVlcPlayer], - ); + const videoWidth = videoStream?.Width ?? 1920; + const videoHeight = videoStream?.Height ?? 1080; - const handleSetVideoScaleFactor = useCallback( - async (newScaleFactor: number) => { - if (!useVlcPlayer) return; - setScaleFactor( - newScaleFactor as 0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0, - ); - await (videoRef.current as VlcPlayerViewRef)?.setVideoScaleFactor?.( - newScaleFactor, - ); - }, - [useVlcPlayer], - ); + const videoAR = videoWidth / videoHeight; + const screenAR = screenWidth / screenHeight; - // Apply KSPlayer global settings before video loads (only when using KSPlayer) - useEffect(() => { - if (Platform.OS === "ios" && !useVlcPlayer) { - setHardwareDecode(settings.ksHardwareDecode); + if (screenAR > videoAR) { + // Screen is wider than video - video height extends beyond screen + // Calculate how much of the video is cropped at the bottom (as % of video height) + const bottomCropPercent = 50 * (1 - videoAR / screenAR); + // Only adjust by 70% of the crop to keep a comfortable margin from the edge + // (subtitles already have some built-in padding from the bottom) + const adjustmentFactor = 0.7; + const newSubPos = Math.round( + 100 - bottomCropPercent * adjustmentFactor, + ); + await videoRef.current?.setSubtitlePosition?.(newSubPos); + } + // If videoAR >= screenAR, sides are cropped but bottom is visible, no adjustment needed + } else { + // Restore to default position (bottom of video frame) + await videoRef.current?.setSubtitlePosition?.(100); } - }, [settings.ksHardwareDecode, useVlcPlayer]); + }, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]); - // Apply subtitle settings when video loads (SfPlayer-specific) + // Apply subtitle settings when video loads useEffect(() => { - if (useVlcPlayer || !isVideoLoaded || !videoRef.current) return; + if (!isVideoLoaded || !videoRef.current) return; - const sfRef = videoRef.current as SfPlayerViewRef; const applySubtitleSettings = async () => { if (settings.mpvSubtitleScale !== undefined) { - await sfRef?.setSubtitleScale?.(settings.mpvSubtitleScale); + await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale); } if (settings.mpvSubtitleMarginY !== undefined) { - await sfRef?.setSubtitleMarginY?.(settings.mpvSubtitleMarginY); + await videoRef.current?.setSubtitleMarginY?.( + settings.mpvSubtitleMarginY, + ); } if (settings.mpvSubtitleAlignX !== undefined) { - await sfRef?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX); + await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX); } if (settings.mpvSubtitleAlignY !== undefined) { - await sfRef?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); + await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); } if (settings.mpvSubtitleFontSize !== undefined) { - await sfRef?.setSubtitleFontSize?.(settings.mpvSubtitleFontSize); + await videoRef.current?.setSubtitleFontSize?.( + settings.mpvSubtitleFontSize, + ); } // Apply subtitle size from general settings if (settings.subtitleSize) { - await sfRef?.setSubtitleFontSize?.(settings.subtitleSize); + await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize); } }; applySubtitleSettings(); - }, [isVideoLoaded, settings, useVlcPlayer]); + }, [isVideoLoaded, settings]); // Apply initial playback speed when video loads useEffect(() => { @@ -1112,20 +799,12 @@ export default function page() { const applyInitialPlaybackSpeed = async () => { if (initialPlaybackSpeed !== 1.0) { setCurrentPlaybackSpeed(initialPlaybackSpeed); - if (useVlcPlayer) { - await (videoRef.current as VlcPlayerViewRef)?.setRate?.( - initialPlaybackSpeed, - ); - } else { - await (videoRef.current as SfPlayerViewRef)?.setSpeed?.( - initialPlaybackSpeed, - ); - } + await videoRef.current?.setSpeed?.(initialPlaybackSpeed); } }; applyInitialPlaybackSpeed(); - }, [isVideoLoaded, initialPlaybackSpeed, useVlcPlayer]); + }, [isVideoLoaded, initialPlaybackSpeed]); // Show error UI first, before checking loading/missing‐data if (itemStatus.isError || streamStatus.isError) { @@ -1160,7 +839,6 @@ export default function page() { mediaSource={stream?.mediaSource} isVideoLoaded={isVideoLoaded} tracksReady={tracksReady} - useVlcPlayer={useVlcPlayer} offline={offline} downloadedItem={downloadedItem} > @@ -1183,51 +861,25 @@ export default function page() { justifyContent: "center", }} > - {useVlcPlayer ? ( - } - source={vlcVideoSource!} - style={{ width: "100%", height: "100%" }} - onVideoProgress={onProgressVlc} - onVideoStateChange={onPlaybackStateChangedVlc} - onPipStarted={onPipStartedVlc} - onVideoLoadEnd={() => { - // Note: VLC only fires this on error, not on successful load - // tracksReady is set in onPlaybackStateChangedVlc when state is "Playing" - setIsVideoLoaded(true); - }} - onVideoError={(e: PlaybackStatePayload) => { - console.error("Video Error:", e.nativeEvent); - Alert.alert( - t("player.error"), - t("player.an_error_occured_while_playing_the_video"), - ); - writeToLog("ERROR", "Video Error", e.nativeEvent); - }} - progressUpdateInterval={1000} - /> - ) : ( - } - source={sfVideoSource} - style={{ width: "100%", height: "100%" }} - onProgress={onProgressSf} - onPlaybackStateChange={onPlaybackStateChangedSf} - onPictureInPictureChange={onPictureInPictureChangeSf} - onLoad={() => setIsVideoLoaded(true)} - onError={(e: { nativeEvent: SfOnErrorEventPayload }) => { - console.error("Video Error:", e.nativeEvent); - Alert.alert( - t("player.error"), - t("player.an_error_occured_while_playing_the_video"), - ); - writeToLog("ERROR", "Video Error", e.nativeEvent); - }} - onTracksReady={() => { - setTracksReady(true); - }} - /> - )} + setIsVideoLoaded(true)} + onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { + console.error("Video Error:", e.nativeEvent); + Alert.alert( + t("player.error"), + t("player.an_error_occured_while_playing_the_video"), + ); + writeToLog("ERROR", "Video Error", e.nativeEvent); + }} + onTracksReady={() => { + setTracksReady(true); + }} + /> {!hasPlaybackStarted && ( { if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); @@ -54,7 +55,7 @@ export function Chromecast({ > - + ); } diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 6e1a90a7..91a1c5d5 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -209,6 +209,7 @@ export const DownloadItems: React.FC = ({ subtitleStreamIndex: subtitleIndex ?? -1, maxBitrate: selectedOptions?.bitrate || defaultBitrate, deviceId: api.deviceInfo.id, + audioMode: settings?.audioTranscodeMode, }); return { diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx index 3c3df522..5493d94f 100644 --- a/components/RoundButton.tsx +++ b/components/RoundButton.tsx @@ -1,7 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; import type { PropsWithChildren } from "react"; -import { Platform, TouchableOpacity, type ViewProps } from "react-native"; +import { Platform, type ViewProps } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { useHaptic } from "@/hooks/useHaptic"; interface Props extends ViewProps { @@ -38,7 +39,7 @@ export const RoundButton: React.FC> = ({ if (Platform.OS === "ios") { return ( - > = ({ /> ) : null} {children ? children : null} - + ); } if (fillColor) return ( - > = ({ /> ) : null} {children ? children : null} - + ); if (background === false) return ( - > = ({ /> ) : null} {children ? children : null} - + ); if (Platform.OS === "android") return ( - > = ({ /> ) : null} {children ? children : null} - + ); return ( - + > = ({ ) : null} {children ? children : null} - + ); }; diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx index 686cab5d..477bb5f7 100644 --- a/components/common/HeaderBackButton.tsx +++ b/components/common/HeaderBackButton.tsx @@ -1,42 +1,36 @@ import { Ionicons } from "@expo/vector-icons"; import { BlurView, type BlurViewProps } from "expo-blur"; import { useRouter } from "expo-router"; -import { - Platform, - TouchableOpacity, - type TouchableOpacityProps, -} from "react-native"; +import { Platform } from "react-native"; +import { Pressable, type PressableProps } from "react-native-gesture-handler"; interface Props extends BlurViewProps { background?: "blur" | "transparent"; - touchableOpacityProps?: TouchableOpacityProps; + pressableProps?: Omit; } export const HeaderBackButton: React.FC = ({ background = "transparent", - touchableOpacityProps, + pressableProps, ...props }) => { const router = useRouter(); if (Platform.OS === "ios") { return ( - router.back()} className='flex items-center justify-center w-9 h-9' - {...touchableOpacityProps} + {...pressableProps} > - + ); } if (background === "transparent" && Platform.OS !== "android") return ( - router.back()} - {...touchableOpacityProps} - > + router.back()} {...pressableProps}> = ({ color='white' /> - + ); return ( - router.back()} className=' rounded-full p-2' - {...touchableOpacityProps} + {...pressableProps} > = ({ size={24} color='white' /> - + ); }; diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 4da125c2..701c9949 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -21,9 +21,9 @@ import { Platform, RefreshControl, ScrollView, - TouchableOpacity, View, } from "react-native"; +import { Pressable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; @@ -118,7 +118,7 @@ export const Home = () => { } navigation.setOptions({ headerLeft: () => ( - { router.push("/(auth)/downloads"); }} @@ -130,7 +130,7 @@ export const Home = () => { color={hasDownloads ? Colors.primary : "white"} size={24} /> - + ), }); }, [navigation, router, hasDownloads]); diff --git a/components/settings/KSPlayerSettings.tsx b/components/settings/KSPlayerSettings.tsx deleted file mode 100644 index 1f99b5b1..00000000 --- a/components/settings/KSPlayerSettings.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type React from "react"; -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { Platform, Switch } from "react-native"; -import { setHardwareDecode } from "@/modules/sf-player"; -import { useSettings } from "@/utils/atoms/settings"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; - -export const KSPlayerSettings: React.FC = () => { - const { settings, updateSettings } = useSettings(); - const { t } = useTranslation(); - - const handleHardwareDecodeChange = useCallback( - (value: boolean) => { - updateSettings({ ksHardwareDecode: value }); - setHardwareDecode(value); - }, - [updateSettings], - ); - - if (Platform.OS !== "ios" || !settings) return null; - - return ( - - - - - - ); -}; diff --git a/components/settings/MpvSubtitleSettings.tsx b/components/settings/MpvSubtitleSettings.tsx new file mode 100644 index 00000000..0ceae68b --- /dev/null +++ b/components/settings/MpvSubtitleSettings.tsx @@ -0,0 +1,133 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useMemo } from "react"; +import { Platform, View, type ViewProps } from "react-native"; +import { Stepper } from "@/components/inputs/Stepper"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; +import { PlatformDropdown } from "../PlatformDropdown"; +import { useMedia } from "./MediaContext"; + +interface Props extends ViewProps {} + +type AlignX = "left" | "center" | "right"; +type AlignY = "top" | "center" | "bottom"; + +export const MpvSubtitleSettings: React.FC = ({ ...props }) => { + const isTv = Platform.isTV; + const media = useMedia(); + const { settings, updateSettings } = media; + + const alignXOptions: AlignX[] = ["left", "center", "right"]; + const alignYOptions: AlignY[] = ["top", "center", "bottom"]; + + const alignXLabels: Record = { + left: "Left", + center: "Center", + right: "Right", + }; + + const alignYLabels: Record = { + top: "Top", + center: "Center", + bottom: "Bottom", + }; + + const alignXOptionGroups = useMemo(() => { + const options = alignXOptions.map((align) => ({ + type: "radio" as const, + label: alignXLabels[align], + value: align, + selected: align === (settings?.mpvSubtitleAlignX ?? "center"), + onPress: () => updateSettings({ mpvSubtitleAlignX: align }), + })); + return [{ options }]; + }, [settings?.mpvSubtitleAlignX, updateSettings]); + + const alignYOptionGroups = useMemo(() => { + const options = alignYOptions.map((align) => ({ + type: "radio" as const, + label: alignYLabels[align], + value: align, + selected: align === (settings?.mpvSubtitleAlignY ?? "bottom"), + onPress: () => updateSettings({ mpvSubtitleAlignY: align }), + })); + return [{ options }]; + }, [settings?.mpvSubtitleAlignY, updateSettings]); + + if (isTv) return null; + if (!settings) return null; + + return ( + + + Advanced subtitle customization for MPV player + + } + > + + + updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 }) + } + /> + + + + updateSettings({ mpvSubtitleMarginY: value })} + /> + + + + + + {alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]} + + + + } + title='Horizontal Alignment' + /> + + + + + + {alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]} + + + + } + title='Vertical Alignment' + /> + + + + ); +}; diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index 452edab0..7bc66e6f 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -141,36 +141,6 @@ export const OtherSettings: React.FC = () => { /> - {/* {(Platform.OS === "ios" || Platform.isTVOS)&& ( - - t(`home.settings.other.video_players.${VideoPlayer[item]}`)} - title={ - - - {t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)} - - - - } - label={t("home.settings.other.orientation")} - onSelected={(defaultPlayer) => - updateSettings({ defaultPlayer }) - } - /> - - )} */} - { const { settings, updateSettings, pluginSettings } = useSettings(); @@ -231,8 +230,6 @@ export const PlaybackControlsSettings: React.FC = () => { /> - - ); }; diff --git a/components/settings/VideoPlayerSettings.tsx b/components/settings/VideoPlayerSettings.tsx deleted file mode 100644 index 5dc546da..00000000 --- a/components/settings/VideoPlayerSettings.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type React from "react"; -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Platform, Switch, View } from "react-native"; -import { setHardwareDecode } from "@/modules/sf-player"; -import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings"; -import { Text } from "../common/Text"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; -import { PlatformDropdown } from "../PlatformDropdown"; - -export const VideoPlayerSettings: React.FC = () => { - const { settings, updateSettings } = useSettings(); - const { t } = useTranslation(); - - const handleHardwareDecodeChange = useCallback( - (value: boolean) => { - updateSettings({ ksHardwareDecode: value }); - setHardwareDecode(value); - }, - [updateSettings], - ); - - const videoPlayerOptions = useMemo( - () => [ - { - options: [ - { - type: "radio" as const, - label: t("home.settings.video_player.ksplayer"), - value: VideoPlayerIOS.KSPlayer, - selected: settings?.videoPlayerIOS === VideoPlayerIOS.KSPlayer, - onPress: () => - updateSettings({ videoPlayerIOS: VideoPlayerIOS.KSPlayer }), - }, - { - type: "radio" as const, - label: t("home.settings.video_player.vlc"), - value: VideoPlayerIOS.VLC, - selected: settings?.videoPlayerIOS === VideoPlayerIOS.VLC, - onPress: () => - updateSettings({ videoPlayerIOS: VideoPlayerIOS.VLC }), - }, - ], - }, - ], - [settings?.videoPlayerIOS, t, updateSettings], - ); - - const getPlayerLabel = useCallback(() => { - switch (settings?.videoPlayerIOS) { - case VideoPlayerIOS.VLC: - return t("home.settings.video_player.vlc"); - default: - return t("home.settings.video_player.ksplayer"); - } - }, [settings?.videoPlayerIOS, t]); - - if (Platform.OS !== "ios" || !settings) return null; - - return ( - - - - {getPlayerLabel()} - - - } - title={t("home.settings.video_player.video_player")} - /> - - - {settings.videoPlayerIOS === VideoPlayerIOS.KSPlayer && ( - - - - )} - - ); -}; diff --git a/components/settings/VlcSubtitleSettings.tsx b/components/settings/VlcSubtitleSettings.tsx deleted file mode 100644 index 3d72a177..00000000 --- a/components/settings/VlcSubtitleSettings.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Platform, View, type ViewProps } from "react-native"; -import { Switch } from "react-native-gesture-handler"; -import { - OUTLINE_THICKNESS_OPTIONS, - VLC_COLOR_OPTIONS, -} from "@/constants/SubtitleConstants"; -import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings"; -import { Text } from "../common/Text"; -import { Stepper } from "../inputs/Stepper"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; -import { PlatformDropdown } from "../PlatformDropdown"; - -interface Props extends ViewProps {} - -/** - * VLC Subtitle Settings component - * Only shown when VLC is the active player (Android always, iOS when VLC selected) - * Note: These settings are applied via VLC init options and take effect on next playback - */ -export const VlcSubtitleSettings: React.FC = ({ ...props }) => { - const { t } = useTranslation(); - const { settings, updateSettings } = useSettings(); - - // Only show for VLC users - const isVlcPlayer = - Platform.OS === "android" || - (Platform.OS === "ios" && settings.videoPlayerIOS === VideoPlayerIOS.VLC); - - const textColorOptions = useMemo( - () => [ - { - options: VLC_COLOR_OPTIONS.map((color) => ({ - type: "radio" as const, - label: color, - value: color, - selected: settings.vlcTextColor === color, - onPress: () => updateSettings({ vlcTextColor: color }), - })), - }, - ], - [settings.vlcTextColor, updateSettings], - ); - - const backgroundColorOptions = useMemo( - () => [ - { - options: VLC_COLOR_OPTIONS.map((color) => ({ - type: "radio" as const, - label: color, - value: color, - selected: settings.vlcBackgroundColor === color, - onPress: () => updateSettings({ vlcBackgroundColor: color }), - })), - }, - ], - [settings.vlcBackgroundColor, updateSettings], - ); - - const outlineColorOptions = useMemo( - () => [ - { - options: VLC_COLOR_OPTIONS.map((color) => ({ - type: "radio" as const, - label: color, - value: color, - selected: settings.vlcOutlineColor === color, - onPress: () => updateSettings({ vlcOutlineColor: color }), - })), - }, - ], - [settings.vlcOutlineColor, updateSettings], - ); - - const outlineThicknessOptions = useMemo( - () => [ - { - options: OUTLINE_THICKNESS_OPTIONS.map((thickness) => ({ - type: "radio" as const, - label: thickness, - value: thickness, - selected: settings.vlcOutlineThickness === thickness, - onPress: () => updateSettings({ vlcOutlineThickness: thickness }), - })), - }, - ], - [settings.vlcOutlineThickness, updateSettings], - ); - - if (!isVlcPlayer) return null; - if (Platform.isTV) return null; - - return ( - - - {t("home.settings.vlc_subtitles.hint")} - - } - > - {/* Text Color */} - - - - {settings.vlcTextColor || "White"} - - - - } - title={t("home.settings.vlc_subtitles.text_color")} - /> - - - {/* Background Color */} - - - - {settings.vlcBackgroundColor || "Black"} - - - - } - title={t("home.settings.vlc_subtitles.background_color")} - /> - - - {/* Background Opacity */} - - - updateSettings({ - vlcBackgroundOpacity: Math.round((value / 100) * 255), - }) - } - /> - - - {/* Outline Color */} - - - - {settings.vlcOutlineColor || "Black"} - - - - } - title={t("home.settings.vlc_subtitles.outline_color")} - /> - - - {/* Outline Opacity */} - - - updateSettings({ - vlcOutlineOpacity: Math.round((value / 100) * 255), - }) - } - /> - - - {/* Outline Thickness */} - - - - {settings.vlcOutlineThickness || "Normal"} - - - - } - title={t("home.settings.vlc_subtitles.outline_thickness")} - /> - - - {/* Bold Text */} - - updateSettings({ vlcIsBold: value })} - /> - - - {/* Subtitle Margin */} - - - updateSettings({ vlcSubtitleMargin: Math.round(value) }) - } - /> - - - - ); -}; diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 87f92db6..db0f9b96 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -19,7 +19,14 @@ export const commonScreenOptions: ICommonScreenOptions = { headerLeft: () => , }; -const routes = ["persons/[personId]", "items/page", "series/[id]"]; +const routes = [ + "persons/[personId]", + "items/page", + "series/[id]", + "music/album/[albumId]", + "music/artist/[artistId]", + "music/playlist/[playlistId]", +]; export const nestedTabPageScreenOptions: Record = Object.fromEntries(routes.map((route) => [route, commonScreenOptions])); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 457a11f8..fb5b8d6f 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -96,11 +96,13 @@ export const BottomControls: FC = ({ style={[ { position: "absolute", - right: settings?.safeAreaInControlsEnabled ? insets.right : 0, - left: settings?.safeAreaInControlsEnabled ? insets.left : 0, - bottom: settings?.safeAreaInControlsEnabled - ? Math.max(insets.bottom - 17, 0) - : 0, + right: + (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, + left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, + bottom: + (settings?.safeAreaInControlsEnabled ?? true) + ? Math.max(insets.bottom - 17, 0) + : 0, }, ]} className={"flex flex-col px-2"} diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx index db92bd10..eab17bca 100644 --- a/components/video-player/controls/BrightnessSlider.tsx +++ b/components/video-player/controls/BrightnessSlider.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform, StyleSheet, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { useSharedValue } from "react-native-reanimated"; @@ -16,10 +16,19 @@ const BrightnessSlider = () => { const max = useSharedValue(100); const isUserInteracting = useRef(false); const lastKnownBrightness = useRef(50); + const brightnessSupportedRef = useRef(true); + const [brightnessSupported, setBrightnessSupported] = useState(true); // Update brightness from device const updateBrightnessFromDevice = async () => { - if (isTv || !Brightness || isUserInteracting.current) return; + // Check ref (not state) to avoid stale closure in setInterval + if ( + isTv || + !Brightness || + isUserInteracting.current || + !brightnessSupportedRef.current + ) + return; try { const currentBrightness = await Brightness.getBrightnessAsync(); @@ -31,7 +40,10 @@ const BrightnessSlider = () => { lastKnownBrightness.current = brightnessPercent; } } catch (error) { - console.error("Error fetching brightness:", error); + console.warn("Brightness not supported on this device:", error); + // Update both ref (stops interval) and state (triggers re-render to hide) + brightnessSupportedRef.current = false; + setBrightnessSupported(false); } }; @@ -66,7 +78,7 @@ const BrightnessSlider = () => { }, 100); }; - if (isTv) return null; + if (isTv || !brightnessSupported) return null; return ( diff --git a/components/video-player/controls/CenterControls.tsx b/components/video-player/controls/CenterControls.tsx index c1b384a5..76ce4bed 100644 --- a/components/video-player/controls/CenterControls.tsx +++ b/components/video-player/controls/CenterControls.tsx @@ -38,8 +38,8 @@ export const CenterControls: FC = ({ style={{ position: "absolute", top: "50%", - left: settings?.safeAreaInControlsEnabled ? insets.left : 0, - right: settings?.safeAreaInControlsEnabled ? insets.right : 0, + left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, + right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, flexDirection: "row", justifyContent: "space-between", alignItems: "center", diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 0797b416..07447147 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -37,7 +37,6 @@ import { useVideoTime } from "./hooks/useVideoTime"; import { useControlsTimeout } from "./useControlsTimeout"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { type AspectRatio } from "./VideoScalingModeSelector"; -import { type ScaleFactor } from "./VlcZoomControl"; interface Props { item: BaseItemDto; @@ -56,13 +55,7 @@ interface Props { startPictureInPicture?: () => Promise; play: () => void; pause: () => void; - useVlcPlayer?: boolean; - // VLC-specific props - setVideoAspectRatio?: (aspectRatio: string | null) => Promise; aspectRatio?: AspectRatio; - scaleFactor?: ScaleFactor; - setVideoScaleFactor?: (scaleFactor: number) => Promise; - // KSPlayer-specific props isZoomedToFill?: boolean; onZoomToggle?: () => void; api?: Api | null; @@ -87,11 +80,7 @@ export const Controls: FC = ({ showControls, setShowControls, mediaSource, - useVlcPlayer = false, - setVideoAspectRatio, aspectRatio = "default", - scaleFactor = 0, - setVideoScaleFactor, isZoomedToFill = false, onZoomToggle, offline = false, @@ -121,7 +110,7 @@ export const Controls: FC = ({ } = useTrickplay(item); const min = useSharedValue(0); - const max = useSharedValue(item.RunTimeTicks || 0); + const max = useSharedValue(ticksToMs(item.RunTimeTicks || 0)); // Animation values for controls const controlsOpacity = useSharedValue(showControls ? 1 : 0); @@ -483,11 +472,7 @@ export const Controls: FC = ({ goToNextItem={goToNextItem} previousItem={previousItem} nextItem={nextItem} - useVlcPlayer={useVlcPlayer} aspectRatio={aspectRatio} - setVideoAspectRatio={setVideoAspectRatio} - scaleFactor={scaleFactor} - setVideoScaleFactor={setVideoScaleFactor} isZoomedToFill={isZoomedToFill} onZoomToggle={onZoomToggle} playbackSpeed={playbackSpeed} diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index c4e594e7..ba93fb3c 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -7,19 +7,14 @@ import { useRouter } from "expo-router"; import { type FC, useCallback, useState } from "react"; import { Platform, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { PlaybackSpeedSelector } from "@/components/PlaybackSpeedSelector"; import { useHaptic } from "@/hooks/useHaptic"; import { useOrientation } from "@/hooks/useOrientation"; import { OrientationLock } from "@/packages/expo-screen-orientation"; -import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings"; +import { useSettings } from "@/utils/atoms/settings"; import { ICON_SIZES } from "./constants"; import DropdownView from "./dropdown/DropdownView"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; -import { - type AspectRatio, - AspectRatioSelector, -} from "./VideoScalingModeSelector"; -import { type ScaleFactor, VlcZoomControl } from "./VlcZoomControl"; +import { type AspectRatio } from "./VideoScalingModeSelector"; import { ZoomToggle } from "./ZoomToggle"; interface HeaderControlsProps { @@ -33,13 +28,7 @@ interface HeaderControlsProps { goToNextItem: (options: { isAutoPlay?: boolean }) => void; previousItem?: BaseItemDto | null; nextItem?: BaseItemDto | null; - useVlcPlayer?: boolean; - // VLC-specific props aspectRatio?: AspectRatio; - setVideoAspectRatio?: (aspectRatio: string | null) => Promise; - scaleFactor?: ScaleFactor; - setVideoScaleFactor?: (scaleFactor: number) => Promise; - // KSPlayer-specific props isZoomedToFill?: boolean; onZoomToggle?: () => void; // Playback speed props @@ -58,11 +47,7 @@ export const HeaderControls: FC = ({ goToNextItem, previousItem, nextItem, - useVlcPlayer = false, - aspectRatio = "default", - setVideoAspectRatio, - scaleFactor = 0, - setVideoScaleFactor, + aspectRatio: _aspectRatio = "default", isZoomedToFill = false, onZoomToggle, playbackSpeed = 1.0, @@ -109,9 +94,10 @@ export const HeaderControls: FC = ({ style={[ { position: "absolute", - top: settings?.safeAreaInControlsEnabled ? insets.top : 0, - left: settings?.safeAreaInControlsEnabled ? insets.left : 0, - right: settings?.safeAreaInControlsEnabled ? insets.right : 0, + top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, + left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, + right: + (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, }, ]} pointerEvents={showControls ? "auto" : "none"} @@ -120,7 +106,10 @@ export const HeaderControls: FC = ({ {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( - + )} @@ -142,20 +131,18 @@ export const HeaderControls: FC = ({ /> )} - {!Platform.isTV && - startPictureInPicture && - settings?.videoPlayerIOS !== VideoPlayerIOS.VLC && ( - - - - )} + {!Platform.isTV && startPictureInPicture && ( + + + + )} {item?.Type === "Episode" && ( = ({ /> )} - {/* Playback Speed Control */} - {!Platform.isTV && setPlaybackSpeed && ( - - )} - {/* VLC-specific controls: Aspect Ratio and Scale/Zoom */} - {useVlcPlayer && ( - { - if (setVideoAspectRatio) { - const aspectRatioString = - newRatio === "default" ? null : newRatio; - await setVideoAspectRatio(aspectRatioString); - } - }} - disabled={!setVideoAspectRatio} - /> - )} - {useVlcPlayer && ( - { - if (setVideoScaleFactor) { - await setVideoScaleFactor(newScale); - } - }} - disabled={!setVideoScaleFactor} - /> - )} - {/* KSPlayer-specific control: Zoom to Fill */} - {!useVlcPlayer && ( - {})} - disabled={!onZoomToggle} - /> - )} + {/* MPV Zoom Toggle */} + {})} + disabled={!onZoomToggle} + /> void; - disabled?: boolean; -} - -interface ScaleOption { - id: ScaleFactor; - label: string; - description: string; -} - -const SCALE_OPTIONS: ScaleOption[] = [ - { - id: 0, - label: "Fit", - description: "Fit video to screen", - }, - { - id: 0.25, - label: "25%", - description: "Quarter size", - }, - { - id: 0.5, - label: "50%", - description: "Half size", - }, - { - id: 0.75, - label: "75%", - description: "Three quarters", - }, - { - id: 1.0, - label: "100%", - description: "Original video size", - }, - { - id: 1.25, - label: "125%", - description: "Slight zoom", - }, - { - id: 1.5, - label: "150%", - description: "Medium zoom", - }, - { - id: 2.0, - label: "200%", - description: "Maximum zoom", - }, -]; - -export const VlcZoomControl: React.FC = ({ - currentScale, - onScaleChange, - disabled = false, -}) => { - const lightHapticFeedback = useHaptic("light"); - - const handleScaleSelect = (scale: ScaleFactor) => { - onScaleChange(scale); - lightHapticFeedback(); - }; - - const optionGroups = useMemo(() => { - return [ - { - options: SCALE_OPTIONS.map((option) => ({ - type: "radio" as const, - label: option.label, - value: option.id, - selected: option.id === currentScale, - onPress: () => handleScaleSelect(option.id), - disabled, - })), - }, - ]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentScale, disabled]); - - const trigger = useMemo( - () => ( - - - - ), - [disabled], - ); - - // Hide on TV platforms - if (Platform.isTV) return null; - - return ( - - ); -}; diff --git a/components/video-player/controls/contexts/PlayerContext.tsx b/components/video-player/controls/contexts/PlayerContext.tsx index 32acc30f..7d565b9b 100644 --- a/components/video-player/controls/contexts/PlayerContext.tsx +++ b/components/video-player/controls/contexts/PlayerContext.tsx @@ -9,19 +9,15 @@ import React, { useContext, useMemo, } from "react"; -import type { SfPlayerViewRef, VlcPlayerViewRef } from "@/modules"; +import type { MpvPlayerViewRef } from "@/modules"; import type { DownloadedItem } from "@/providers/Downloads/types"; -// Union type for both player refs -type PlayerRef = SfPlayerViewRef | VlcPlayerViewRef; - interface PlayerContextProps { - playerRef: MutableRefObject; + playerRef: MutableRefObject; item: BaseItemDto; mediaSource: MediaSourceInfo | null | undefined; isVideoLoaded: boolean; tracksReady: boolean; - useVlcPlayer: boolean; offline: boolean; downloadedItem: DownloadedItem | null; } @@ -30,12 +26,11 @@ const PlayerContext = createContext(undefined); interface PlayerProviderProps { children: ReactNode; - playerRef: MutableRefObject; + playerRef: MutableRefObject; item: BaseItemDto; mediaSource: MediaSourceInfo | null | undefined; isVideoLoaded: boolean; tracksReady: boolean; - useVlcPlayer: boolean; offline?: boolean; downloadedItem?: DownloadedItem | null; } @@ -47,7 +42,6 @@ export const PlayerProvider: React.FC = ({ mediaSource, isVideoLoaded, tracksReady, - useVlcPlayer, offline = false, downloadedItem = null, }) => { @@ -58,7 +52,6 @@ export const PlayerProvider: React.FC = ({ mediaSource, isVideoLoaded, tracksReady, - useVlcPlayer, offline, downloadedItem, }), @@ -68,7 +61,6 @@ export const PlayerProvider: React.FC = ({ mediaSource, isVideoLoaded, tracksReady, - useVlcPlayer, offline, downloadedItem, ], @@ -87,30 +79,26 @@ export const usePlayerContext = () => { return context; }; -// Player controls hook - supports both SfPlayer (iOS) and VlcPlayer (Android) +// Player controls hook - MPV player only export const usePlayerControls = () => { const { playerRef } = usePlayerContext(); - // Helper to get SfPlayer-specific ref (for iOS-only features) - const getSfRef = () => playerRef.current as SfPlayerViewRef | null; - return { - // Subtitle controls (both players support these, but with different interfaces) + // Subtitle controls getSubtitleTracks: async () => { return playerRef.current?.getSubtitleTracks?.() ?? null; }, setSubtitleTrack: (trackId: number) => { playerRef.current?.setSubtitleTrack?.(trackId); }, - // iOS only (SfPlayer) disableSubtitles: () => { - getSfRef()?.disableSubtitles?.(); + playerRef.current?.disableSubtitles?.(); }, addSubtitleFile: (url: string, select = true) => { - getSfRef()?.addSubtitleFile?.(url, select); + playerRef.current?.addSubtitleFile?.(url, select); }, - // Audio controls (both players) + // Audio controls getAudioTracks: async () => { return playerRef.current?.getAudioTracks?.() ?? null; }, @@ -118,26 +106,25 @@ export const usePlayerControls = () => { playerRef.current?.setAudioTrack?.(trackId); }, - // Playback controls (both players) + // Playback controls play: () => playerRef.current?.play?.(), pause: () => playerRef.current?.pause?.(), seekTo: (position: number) => playerRef.current?.seekTo?.(position), - // iOS only (SfPlayer) - seekBy: (offset: number) => getSfRef()?.seekBy?.(offset), - setSpeed: (speed: number) => getSfRef()?.setSpeed?.(speed), + seekBy: (offset: number) => playerRef.current?.seekBy?.(offset), + setSpeed: (speed: number) => playerRef.current?.setSpeed?.(speed), - // Subtitle positioning - iOS only (SfPlayer) - setSubtitleScale: (scale: number) => getSfRef()?.setSubtitleScale?.(scale), + // Subtitle positioning + setSubtitleScale: (scale: number) => + playerRef.current?.setSubtitleScale?.(scale), setSubtitlePosition: (position: number) => - getSfRef()?.setSubtitlePosition?.(position), + playerRef.current?.setSubtitlePosition?.(position), setSubtitleMarginY: (margin: number) => - getSfRef()?.setSubtitleMarginY?.(margin), + playerRef.current?.setSubtitleMarginY?.(margin), setSubtitleFontSize: (size: number) => - getSfRef()?.setSubtitleFontSize?.(size), + playerRef.current?.setSubtitleFontSize?.(size), - // PiP (both players) + // PiP startPictureInPicture: () => playerRef.current?.startPictureInPicture?.(), - // iOS only (SfPlayer) - stopPictureInPicture: () => getSfRef()?.stopPictureInPicture?.(), + stopPictureInPicture: () => playerRef.current?.stopPictureInPicture?.(), }; }; diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index 1f9fb2d4..1443af5b 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -8,7 +8,7 @@ * ============================================================================ * * - Jellyfin is source of truth for subtitle list (embedded + external) - * - KSPlayer only knows about: + * - MPV only knows about: * - Embedded subs it finds in the video stream * - External subs we explicitly add via addSubtitleFile() * - UI shows Jellyfin's complete list @@ -24,8 +24,8 @@ * - Value of -1 means disabled/none * * 2. MPV INDEX (track.mpvIndex) - * - KSPlayer's internal track ID - * - KSPlayer orders tracks as: [all embedded, then all external] + * - MPV's internal track ID + * - MPV orders tracks as: [all embedded, then all external] * - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external * - Value of -1 means track needs replacePlayer() (e.g., burned-in sub) * @@ -34,15 +34,15 @@ * ============================================================================ * * Embedded (DeliveryMethod.Embed): - * - Already in KSPlayer's track list + * - Already in MPV's track list * - Select via setSubtitleTrack(mpvId) * * External (DeliveryMethod.External): - * - Loaded into KSPlayer's srtControl on video start + * - Loaded into MPV on video start * - Select via setSubtitleTrack(embeddedCount + externalPosition + 1) * * Image-based during transcoding: - * - Burned into video by Jellyfin, not in KSPlayer + * - Burned into video by Jellyfin, not in MPV * - Requires replacePlayer() to change */ @@ -57,7 +57,7 @@ import { useMemo, useState, } from "react"; -import type { SfAudioTrack, TrackInfo } from "@/modules"; +import type { MpvAudioTrack } from "@/modules"; import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils"; import type { Track } from "../types"; import { usePlayerContext, usePlayerControls } from "./PlayerContext"; @@ -75,7 +75,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ const [subtitleTracks, setSubtitleTracks] = useState(null); const [audioTracks, setAudioTracks] = useState(null); - const { tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem } = + const { tracksReady, mediaSource, offline, downloadedItem } = usePlayerContext(); const playerControls = usePlayerControls(); @@ -149,7 +149,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ { name: downloadedTrack.DisplayTitle || "Audio", index: downloadedTrack.Index ?? 0, - mpvIndex: useVlcPlayer ? 0 : 1, // Only track in file + mpvIndex: 1, // Only track in file (MPV uses 1-based indexing) setTrack: () => { // Track is already selected (only one available) router.setParams({ audioIndex: String(downloadedTrack.Index) }); @@ -212,99 +212,12 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ return; } - // For VLC player, use simpler track handling with server indices - if (useVlcPlayer) { - // Get VLC track info (VLC returns TrackInfo[] with 'index' property) - const vlcSubtitleData = (await playerControls - .getSubtitleTracks() - .catch(() => null)) as TrackInfo[] | null; - const vlcAudioData = (await playerControls - .getAudioTracks() - .catch(() => null)) as TrackInfo[] | null; - - // VLC reverses HLS subtitles during transcoding - let vlcSubs: TrackInfo[] = vlcSubtitleData ? [...vlcSubtitleData] : []; - if (isTranscoding && vlcSubs.length > 1) { - vlcSubs = [vlcSubs[0], ...vlcSubs.slice(1).reverse()]; - } - - // Build subtitle tracks for VLC - const subs: Track[] = []; - let vlcSubIndex = 1; // VLC track indices start at 1 (0 is usually "Disable") - - for (const sub of allSubs) { - const isTextBased = - sub.DeliveryMethod === SubtitleDeliveryMethod.Embed || - sub.DeliveryMethod === SubtitleDeliveryMethod.Hls || - sub.DeliveryMethod === SubtitleDeliveryMethod.External; - - // Get VLC's internal index for this track - const vlcTrackIndex = vlcSubs[vlcSubIndex]?.index ?? -1; - if (isTextBased) vlcSubIndex++; - - // For image-based subs during transcoding, or non-text subs, use replacePlayer - const needsPlayerRefresh = - (isTranscoding && isImageBasedSubtitle(sub)) || !isTextBased; - - subs.push({ - name: sub.DisplayTitle || "Unknown", - index: sub.Index ?? -1, - mpvIndex: vlcTrackIndex, - setTrack: () => { - if (needsPlayerRefresh) { - replacePlayer({ subtitleIndex: String(sub.Index) }); - } else if (vlcTrackIndex !== -1) { - playerControls.setSubtitleTrack(vlcTrackIndex); - router.setParams({ subtitleIndex: String(sub.Index) }); - } else { - replacePlayer({ subtitleIndex: String(sub.Index) }); - } - }, - }); - } - - // Add "Disable" option - subs.unshift({ - name: "Disable", - index: -1, - mpvIndex: -1, - setTrack: () => { - playerControls.setSubtitleTrack(-1); - router.setParams({ subtitleIndex: "-1" }); - }, - }); - - // Build audio tracks for VLC - const vlcAudio: TrackInfo[] = vlcAudioData ? [...vlcAudioData] : []; - const audio: Track[] = allAudio.map((a, idx) => { - const vlcTrackIndex = vlcAudio[idx + 1]?.index ?? idx; - - return { - name: a.DisplayTitle || "Unknown", - index: a.Index ?? -1, - mpvIndex: vlcTrackIndex, - setTrack: () => { - if (isTranscoding) { - replacePlayer({ audioIndex: String(a.Index) }); - } else { - playerControls.setAudioTrack(vlcTrackIndex); - router.setParams({ audioIndex: String(a.Index) }); - } - }, - }; - }); - - setSubtitleTracks(subs.sort((a, b) => a.index - b.index)); - setAudioTracks(audio); - return; - } - - // KSPlayer track handling (original logic) + // MPV track handling const audioData = await playerControls.getAudioTracks().catch(() => null); - const playerAudio = (audioData as SfAudioTrack[]) ?? []; + const playerAudio = (audioData as MpvAudioTrack[]) ?? []; // Separate embedded vs external subtitles from Jellyfin's list - // KSPlayer orders tracks as: [all embedded, then all external] + // MPV orders tracks as: [all embedded, then all external] const embeddedSubs = allSubs.filter( (s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed, ); @@ -312,7 +225,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ (s) => s.DeliveryMethod === SubtitleDeliveryMethod.External, ); - // Count embedded subs that will be in KSPlayer + // Count embedded subs that will be in MPV // (excludes image-based subs during transcoding as they're burned in) const embeddedInPlayer = embeddedSubs.filter( (s) => !isTranscoding || !isImageBasedSubtitle(s), @@ -339,8 +252,8 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ continue; } - // Calculate KSPlayer track ID based on type - // KSPlayer IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external + // Calculate MPV track ID based on type + // MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external let mpvId = -1; if (isEmbedded) { @@ -428,7 +341,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ }; fetchTracks(); - }, [tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem]); + }, [tracksReady, mediaSource, offline, downloadedItem]); return ( diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index d471c758..18e861a8 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -7,9 +7,11 @@ import { type OptionGroup, PlatformDropdown, } from "@/components/PlatformDropdown"; +import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import { useSettings } from "@/utils/atoms/settings"; import { usePlayerContext } from "../contexts/PlayerContext"; import { useVideoContext } from "../contexts/VideoContext"; +import { PlaybackSpeedScope } from "../utils/playback-speed-settings"; // Subtitle size presets (stored as scale * 100, so 1.0 = 100) const SUBTITLE_SIZE_PRESETS = [ @@ -23,9 +25,17 @@ const SUBTITLE_SIZE_PRESETS = [ { label: "1.2", value: 120 }, ] as const; -const DropdownView = () => { +interface DropdownViewProps { + playbackSpeed?: number; + setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void; +} + +const DropdownView = ({ + playbackSpeed = 1.0, + setPlaybackSpeed, +}: DropdownViewProps) => { const { subtitleTracks, audioTracks } = useVideoContext(); - const { item, mediaSource, useVlcPlayer } = usePlayerContext(); + const { item, mediaSource } = usePlayerContext(); const { settings, updateSettings } = useSettings(); const router = useRouter(); @@ -110,19 +120,17 @@ const DropdownView = () => { })), }); - // Subtitle Size Section (KSPlayer only - VLC uses settings) - if (!useVlcPlayer) { - groups.push({ - title: "Subtitle Size", - options: SUBTITLE_SIZE_PRESETS.map((preset) => ({ - type: "radio" as const, - label: preset.label, - value: preset.value.toString(), - selected: settings.subtitleSize === preset.value, - onPress: () => updateSettings({ subtitleSize: preset.value }), - })), - }); - } + // Subtitle Size Section + groups.push({ + title: "Subtitle Size", + options: SUBTITLE_SIZE_PRESETS.map((preset) => ({ + type: "radio" as const, + label: preset.label, + value: preset.value.toString(), + selected: settings.subtitleSize === preset.value, + onPress: () => updateSettings({ subtitleSize: preset.value }), + })), + }); } // Audio Section @@ -139,6 +147,20 @@ const DropdownView = () => { }); } + // Speed Section + if (setPlaybackSpeed) { + groups.push({ + title: "Speed", + options: PLAYBACK_SPEEDS.map((speed) => ({ + type: "radio" as const, + label: speed.label, + value: speed.value.toString(), + selected: playbackSpeed === speed.value, + onPress: () => setPlaybackSpeed(speed.value, PlaybackSpeedScope.All), + })), + }); + } + return groups; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -151,7 +173,8 @@ const DropdownView = () => { audioIndex, settings.subtitleSize, updateSettings, - useVlcPlayer, + playbackSpeed, + setPlaybackSpeed, // Note: subtitleTracks and audioTracks are intentionally excluded // because we use subtitleTracksKey and audioTracksKey for stability ]); diff --git a/components/video-player/controls/hooks/useVolumeAndBrightness.ts b/components/video-player/controls/hooks/useVolumeAndBrightness.ts index 2863949b..c3ad0fa7 100644 --- a/components/video-player/controls/hooks/useVolumeAndBrightness.ts +++ b/components/video-player/controls/hooks/useVolumeAndBrightness.ts @@ -34,6 +34,7 @@ export const useVolumeAndBrightness = ({ const initialVolume = useRef(null); const initialBrightness = useRef(null); const dragStartY = useRef(null); + const brightnessSupported = useRef(true); const startVolumeDrag = useCallback(async (startY: number) => { if (Platform.isTV || !VolumeManager) return; @@ -88,20 +89,26 @@ export const useVolumeAndBrightness = ({ }, []); const startBrightnessDrag = useCallback(async (startY: number) => { - if (Platform.isTV || !Brightness) return; + if (Platform.isTV || !Brightness || !brightnessSupported.current) return; try { const brightness = await Brightness.getBrightnessAsync(); initialBrightness.current = brightness; dragStartY.current = startY; } catch (error) { - console.error("Error starting brightness drag:", error); + console.warn("Brightness not supported on this device:", error); + brightnessSupported.current = false; } }, []); const updateBrightnessDrag = useCallback( async (deltaY: number) => { - if (Platform.isTV || !Brightness || initialBrightness.current === null) + if ( + Platform.isTV || + !Brightness || + initialBrightness.current === null || + !brightnessSupported.current + ) return; try { @@ -118,7 +125,8 @@ export const useVolumeAndBrightness = ({ const brightnessPercent = Math.round(newBrightness * 100); onBrightnessChange?.(brightnessPercent); } catch (error) { - console.error("Error updating brightness:", error); + console.warn("Brightness not supported on this device:", error); + brightnessSupported.current = false; } }, [onBrightnessChange], diff --git a/constants/SubtitleConstants.ts b/constants/SubtitleConstants.ts deleted file mode 100644 index 0a0df902..00000000 --- a/constants/SubtitleConstants.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * VLC subtitle styling constants - * These values are used with VLC's FreeType subtitle rendering engine - */ - -// VLC color values (decimal representation of hex colors) -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, -}; - -// VLC color names for UI display -export const VLC_COLOR_OPTIONS = Object.keys(VLC_COLORS); - -// VLC outline thickness values in pixels -export const OUTLINE_THICKNESS: Record = { - None: 0, - Thin: 2, - Normal: 4, - Thick: 6, -}; - -// Outline thickness options for UI -export const OUTLINE_THICKNESS_OPTIONS = Object.keys( - OUTLINE_THICKNESS, -) as Array<"None" | "Thin" | "Normal" | "Thick">; diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts index a68439f3..e21687fa 100644 --- a/hooks/useMarkAsPlayed.ts +++ b/hooks/useMarkAsPlayed.ts @@ -1,12 +1,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; -import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useHaptic } from "./useHaptic"; import { usePlaybackManager } from "./usePlaybackManager"; import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache"; export const useMarkAsPlayed = (items: BaseItemDto[]) => { - const queryClient = useNetworkAwareQueryClient(); + const queryClient = useQueryClient(); const lightHapticFeedback = useHaptic("light"); const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); 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 9d262e7b..00000000 --- a/modules/VlcPlayerView.tsx +++ /dev/null @@ -1,152 +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 baseSource: VlcPlayerSource = - typeof source === "string" - ? ({ uri: source } as unknown as VlcPlayerSource) - : source; - - // Create a new object to avoid mutating frozen source - const processedSource: VlcPlayerSource = { - ...baseSource, - startPosition: - baseSource.startPosition !== undefined - ? Math.floor(baseSource.startPosition) - : undefined, - }; - - return ( - - ); - }, -); - -export default VlcPlayerView; diff --git a/modules/index.ts b/modules/index.ts index 9012abe7..e026be73 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -8,39 +8,17 @@ export type { } from "./background-downloader"; export { default as BackgroundDownloader } from "./background-downloader"; -// Streamyfin Player (KSPlayer-based) - GPU acceleration + native PiP (iOS) +// MPV Player (iOS + Android) export type { - AudioTrack as SfAudioTrack, - OnErrorEventPayload as SfOnErrorEventPayload, - OnLoadEventPayload as SfOnLoadEventPayload, - OnPictureInPictureChangePayload as SfOnPictureInPictureChangePayload, - OnPlaybackStateChangePayload as SfOnPlaybackStateChangePayload, - OnProgressEventPayload as SfOnProgressEventPayload, - OnTracksReadyEventPayload as SfOnTracksReadyEventPayload, - SfPlayerViewProps, - SfPlayerViewRef, - SubtitleTrack as SfSubtitleTrack, - VideoSource as SfVideoSource, -} from "./sf-player"; -export { - getHardwareDecode, - SfPlayerView, - setHardwareDecode, -} from "./sf-player"; - -// VLC Player (Android) -export type { - ChapterInfo, - NowPlayingMetadata, - PipStartedPayload, - PlaybackStatePayload, - ProgressUpdatePayload, - TrackInfo, - VideoLoadStartPayload, - VideoProgressPayload, - VideoStateChangePayload, - VlcPlayerSource, - VlcPlayerViewProps, - VlcPlayerViewRef, -} from "./VlcPlayer.types"; -export { default as VlcPlayerView } from "./VlcPlayerView"; + AudioTrack as MpvAudioTrack, + MpvPlayerViewProps, + MpvPlayerViewRef, + OnErrorEventPayload as MpvOnErrorEventPayload, + OnLoadEventPayload as MpvOnLoadEventPayload, + OnPlaybackStateChangePayload as MpvOnPlaybackStateChangePayload, + OnProgressEventPayload as MpvOnProgressEventPayload, + OnTracksReadyEventPayload as MpvOnTracksReadyEventPayload, + SubtitleTrack as MpvSubtitleTrack, + VideoSource as MpvVideoSource, +} from "./mpv-player"; +export { MpvPlayerView } from "./mpv-player"; diff --git a/modules/mpv-player/android/build.gradle b/modules/mpv-player/android/build.gradle index 6096354d..ec59bcd3 100644 --- a/modules/mpv-player/android/build.gradle +++ b/modules/mpv-player/android/build.gradle @@ -25,7 +25,7 @@ if (useManagedAndroidSdkVersions) { project.android { compileSdkVersion safeExtGet("compileSdkVersion", 36) defaultConfig { - minSdkVersion safeExtGet("minSdkVersion", 24) + minSdkVersion safeExtGet("minSdkVersion", 26) targetSdkVersion safeExtGet("targetSdkVersion", 36) } } @@ -36,8 +36,22 @@ android { defaultConfig { versionCode 1 versionName "0.7.6" + ndk { + // Architectures supported by mpv-android + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64' + } } lintOptions { abortOnError false } + sourceSets { + main { + jniLibs.srcDirs = ['libs'] + } + } +} + +dependencies { + // libmpv from Maven Central + implementation 'dev.jdtech.mpv:libmpv:0.5.1' } diff --git a/modules/mpv-player/android/src/main/AndroidManifest.xml b/modules/mpv-player/android/src/main/AndroidManifest.xml index bdae66c8..c6f2e479 100644 --- a/modules/mpv-player/android/src/main/AndroidManifest.xml +++ b/modules/mpv-player/android/src/main/AndroidManifest.xml @@ -1,2 +1,9 @@ - + + + + + + diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt new file mode 100644 index 00000000..0cb80a1a --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -0,0 +1,552 @@ +package expo.modules.mpvplayer + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Surface + +/** + * MPV renderer that wraps libmpv for video playback. + * This mirrors the iOS MPVLayerRenderer implementation. + */ +class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { + + companion object { + private const val TAG = "MPVLayerRenderer" + + // Property observation format types + const val MPV_FORMAT_NONE = 0 + const val MPV_FORMAT_STRING = 1 + const val MPV_FORMAT_OSD_STRING = 2 + const val MPV_FORMAT_FLAG = 3 + const val MPV_FORMAT_INT64 = 4 + const val MPV_FORMAT_DOUBLE = 5 + const val MPV_FORMAT_NODE = 6 + } + + interface Delegate { + fun onPositionChanged(position: Double, duration: Double) + fun onPauseChanged(isPaused: Boolean) + fun onLoadingChanged(isLoading: Boolean) + fun onReadyToSeek() + fun onTracksReady() + fun onError(message: String) + fun onVideoDimensionsChanged(width: Int, height: Int) + } + + var delegate: Delegate? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + private var surface: Surface? = null + private var isRunning = false + private var isStopping = false + + // Cached state + private var cachedPosition: Double = 0.0 + private var cachedDuration: Double = 0.0 + private var _isPaused: Boolean = true + private var _isLoading: Boolean = false + private var _playbackSpeed: Double = 1.0 + private var isReadyToSeek: Boolean = false + + // Video dimensions + private var _videoWidth: Int = 0 + private var _videoHeight: Int = 0 + + val videoWidth: Int + get() = _videoWidth + + val videoHeight: Int + get() = _videoHeight + + // Current video config + private var currentUrl: String? = null + private var currentHeaders: Map? = null + private var pendingExternalSubtitles: List = emptyList() + private var initialSubtitleId: Int? = null + private var initialAudioId: Int? = null + + val isPausedState: Boolean + get() = _isPaused + + val currentPosition: Double + get() = cachedPosition + + val duration: Double + get() = cachedDuration + + fun start() { + if (isRunning) return + + try { + MPVLib.create(context) + MPVLib.addObserver(this) + + // Configure mpv options before initialization (based on Findroid) + MPVLib.setOptionString("vo", "gpu") + MPVLib.setOptionString("gpu-context", "android") + MPVLib.setOptionString("opengl-es", "yes") + + // Hardware video decoding + MPVLib.setOptionString("hwdec", "mediacodec-copy") + MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + + // Cache settings for better network streaming + MPVLib.setOptionString("cache", "yes") + MPVLib.setOptionString("cache-pause-initial", "yes") + MPVLib.setOptionString("demuxer-max-bytes", "150MiB") + MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB") + MPVLib.setOptionString("demuxer-readahead-secs", "20") + + // Seeking optimization - faster seeking at the cost of less precision + // Use keyframe seeking by default (much faster for network streams) + MPVLib.setOptionString("hr-seek", "no") + // Drop frames during seeking for faster response + MPVLib.setOptionString("hr-seek-framedrop", "yes") + + // Subtitle settings + MPVLib.setOptionString("sub-scale-with-window", "yes") + MPVLib.setOptionString("sub-use-margins", "no") + MPVLib.setOptionString("subs-match-os-language", "yes") + MPVLib.setOptionString("subs-fallback", "yes") + + // Important: Start with force-window=no, will be set to yes when surface is attached + MPVLib.setOptionString("force-window", "no") + MPVLib.setOptionString("keep-open", "always") + + MPVLib.initialize() + + // Observe properties + observeProperties() + + isRunning = true + Log.i(TAG, "MPV renderer started") + } catch (e: Exception) { + Log.e(TAG, "Failed to start MPV renderer: ${e.message}") + delegate?.onError("Failed to start renderer: ${e.message}") + } + } + + fun stop() { + if (isStopping) return + if (!isRunning) return + + isStopping = true + isRunning = false + + try { + MPVLib.removeObserver(this) + MPVLib.detachSurface() + MPVLib.destroy() + } catch (e: Exception) { + Log.e(TAG, "Error stopping MPV: ${e.message}") + } + + isStopping = false + } + + /** + * Attach surface and re-enable video output. + * Based on Findroid's implementation. + */ + fun attachSurface(surface: Surface) { + this.surface = surface + if (isRunning) { + MPVLib.attachSurface(surface) + // Re-enable video output after attaching surface (Findroid approach) + MPVLib.setOptionString("force-window", "yes") + MPVLib.setOptionString("vo", "gpu") + Log.i(TAG, "Surface attached, video output re-enabled") + } + } + + /** + * Detach surface and disable video output. + * Based on Findroid's implementation. + */ + fun detachSurface() { + this.surface = null + if (isRunning) { + try { + // Disable video output before detaching surface (Findroid approach) + MPVLib.setOptionString("vo", "null") + MPVLib.setOptionString("force-window", "no") + Log.i(TAG, "Video output disabled before surface detach") + } catch (e: Exception) { + Log.e(TAG, "Failed to disable video output: ${e.message}") + } + + MPVLib.detachSurface() + } + } + + /** + * Updates the surface size. Called from surfaceChanged. + * Based on Findroid's implementation. + */ + fun updateSurfaceSize(width: Int, height: Int) { + if (isRunning) { + MPVLib.setPropertyString("android-surface-size", "${width}x$height") + Log.i(TAG, "Surface size updated: ${width}x$height") + } + } + + fun load( + url: String, + headers: Map? = null, + startPosition: Double? = null, + externalSubtitles: List? = null, + initialSubtitleId: Int? = null, + initialAudioId: Int? = null + ) { + currentUrl = url + currentHeaders = headers + pendingExternalSubtitles = externalSubtitles ?: emptyList() + this.initialSubtitleId = initialSubtitleId + this.initialAudioId = initialAudioId + + _isLoading = true + isReadyToSeek = false + mainHandler.post { delegate?.onLoadingChanged(true) } + + // Stop previous playback + MPVLib.command(arrayOf("stop")) + + // Set HTTP headers if provided + updateHttpHeaders(headers) + + // Set start position + if (startPosition != null && startPosition > 0) { + MPVLib.setPropertyString("start", String.format("%.2f", startPosition)) + } else { + MPVLib.setPropertyString("start", "0") + } + + // Set initial audio track if specified + if (initialAudioId != null && initialAudioId > 0) { + setAudioTrack(initialAudioId) + } + + // Set initial subtitle track if no external subs + if (pendingExternalSubtitles.isEmpty()) { + if (initialSubtitleId != null) { + setSubtitleTrack(initialSubtitleId) + } else { + disableSubtitles() + } + } else { + disableSubtitles() + } + + // Load the file + MPVLib.command(arrayOf("loadfile", url, "replace")) + } + + fun reloadCurrentItem() { + currentUrl?.let { url -> + load(url, currentHeaders) + } + } + + private fun updateHttpHeaders(headers: Map?) { + if (headers.isNullOrEmpty()) { + // Clear headers + return + } + + val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" } + MPVLib.setPropertyString("http-header-fields", headerString) + } + + private fun observeProperties() { + MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) + MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) + MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + // Video dimensions for PiP aspect ratio + MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) + MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64) + } + + // MARK: - Playback Controls + + fun play() { + MPVLib.setPropertyBoolean("pause", false) + } + + fun pause() { + MPVLib.setPropertyBoolean("pause", true) + } + + fun togglePause() { + if (_isPaused) play() else pause() + } + + fun seekTo(seconds: Double) { + val clamped = maxOf(0.0, seconds) + cachedPosition = clamped + MPVLib.command(arrayOf("seek", clamped.toString(), "absolute")) + } + + fun seekBy(seconds: Double) { + val newPosition = maxOf(0.0, cachedPosition + seconds) + cachedPosition = newPosition + MPVLib.command(arrayOf("seek", seconds.toString(), "relative")) + } + + fun setSpeed(speed: Double) { + _playbackSpeed = speed + MPVLib.setPropertyDouble("speed", speed) + } + + fun getSpeed(): Double { + return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed + } + + // MARK: - Subtitle Controls + + fun getSubtitleTracks(): List> { + val tracks = mutableListOf>() + + val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + + for (i in 0 until trackCount) { + val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue + if (trackType != "sub") continue + + val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val track = mutableMapOf("id" to trackId) + + MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + + val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false + track["selected"] = selected + + tracks.add(track) + } + + return tracks + } + + fun setSubtitleTrack(trackId: Int) { + Log.i(TAG, "setSubtitleTrack: setting sid to $trackId") + if (trackId < 0) { + MPVLib.setPropertyString("sid", "no") + } else { + MPVLib.setPropertyInt("sid", trackId) + } + } + + fun disableSubtitles() { + MPVLib.setPropertyString("sid", "no") + } + + fun getCurrentSubtitleTrack(): Int { + return MPVLib.getPropertyInt("sid") ?: 0 + } + + fun addSubtitleFile(url: String, select: Boolean = true) { + val flag = if (select) "select" else "cached" + MPVLib.command(arrayOf("sub-add", url, flag)) + } + + // MARK: - Subtitle Positioning + + fun setSubtitlePosition(position: Int) { + MPVLib.setPropertyInt("sub-pos", position) + } + + fun setSubtitleScale(scale: Double) { + MPVLib.setPropertyDouble("sub-scale", scale) + } + + fun setSubtitleMarginY(margin: Int) { + MPVLib.setPropertyInt("sub-margin-y", margin) + } + + fun setSubtitleAlignX(alignment: String) { + MPVLib.setPropertyString("sub-align-x", alignment) + } + + fun setSubtitleAlignY(alignment: String) { + MPVLib.setPropertyString("sub-align-y", alignment) + } + + fun setSubtitleFontSize(size: Int) { + MPVLib.setPropertyInt("sub-font-size", size) + } + + // MARK: - Audio Track Controls + + fun getAudioTracks(): List> { + val tracks = mutableListOf>() + + val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + + for (i in 0 until trackCount) { + val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue + if (trackType != "audio") continue + + val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val track = mutableMapOf("id" to trackId) + + MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it } + + val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels") + if (channels != null && channels > 0) { + track["channels"] = channels + } + + val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false + track["selected"] = selected + + tracks.add(track) + } + + return tracks + } + + fun setAudioTrack(trackId: Int) { + Log.i(TAG, "setAudioTrack: setting aid to $trackId") + MPVLib.setPropertyInt("aid", trackId) + } + + fun getCurrentAudioTrack(): Int { + return MPVLib.getPropertyInt("aid") ?: 0 + } + + // MARK: - Video Scaling + + fun setZoomedToFill(zoomed: Boolean) { + // panscan: 0.0 = fit (letterbox), 1.0 = fill (crop) + val panscanValue = if (zoomed) 1.0 else 0.0 + Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue") + MPVLib.setPropertyDouble("panscan", panscanValue) + } + + // MARK: - MPVLib.EventObserver + + override fun eventProperty(property: String) { + // Property changed but no value provided + } + + override fun eventProperty(property: String, value: Long) { + when (property) { + "track-list/count" -> { + if (value > 0) { + Log.i(TAG, "Track list updated: $value tracks available") + mainHandler.post { delegate?.onTracksReady() } + } + } + "video-params/w" -> { + val width = value.toInt() + if (width > 0 && width != _videoWidth) { + _videoWidth = width + notifyVideoDimensionsIfReady() + } + } + "video-params/h" -> { + val height = value.toInt() + if (height > 0 && height != _videoHeight) { + _videoHeight = height + notifyVideoDimensionsIfReady() + } + } + } + } + + private fun notifyVideoDimensionsIfReady() { + if (_videoWidth > 0 && _videoHeight > 0) { + Log.i(TAG, "Video dimensions: ${_videoWidth}x${_videoHeight}") + mainHandler.post { delegate?.onVideoDimensionsChanged(_videoWidth, _videoHeight) } + } + } + + override fun eventProperty(property: String, value: Boolean) { + when (property) { + "pause" -> { + if (value != _isPaused) { + _isPaused = value + mainHandler.post { delegate?.onPauseChanged(value) } + } + } + "paused-for-cache" -> { + if (value != _isLoading) { + _isLoading = value + mainHandler.post { delegate?.onLoadingChanged(value) } + } + } + } + } + + override fun eventProperty(property: String, value: String) { + // Handle string properties if needed + } + + override fun eventProperty(property: String, value: Double) { + when (property) { + "duration" -> { + cachedDuration = value + mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } + } + "time-pos" -> { + cachedPosition = value + mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } + } + } + } + + override fun event(eventId: Int) { + when (eventId) { + MPVLib.MPV_EVENT_FILE_LOADED -> { + // Add external subtitles now that file is loaded + if (pendingExternalSubtitles.isNotEmpty()) { + for (subUrl in pendingExternalSubtitles) { + MPVLib.command(arrayOf("sub-add", subUrl)) + } + pendingExternalSubtitles = emptyList() + + // Set subtitle after external subs are added + initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() + } + + if (!isReadyToSeek) { + isReadyToSeek = true + mainHandler.post { delegate?.onReadyToSeek() } + } + + if (_isLoading) { + _isLoading = false + mainHandler.post { delegate?.onLoadingChanged(false) } + } + } + MPVLib.MPV_EVENT_SEEK -> { + // Seek started - show loading indicator + if (!_isLoading) { + _isLoading = true + mainHandler.post { delegate?.onLoadingChanged(true) } + } + } + MPVLib.MPV_EVENT_PLAYBACK_RESTART -> { + // Video playback has started/restarted (including after seek) + if (_isLoading) { + _isLoading = false + mainHandler.post { delegate?.onLoadingChanged(false) } + } + } + MPVLib.MPV_EVENT_END_FILE -> { + Log.i(TAG, "Playback ended") + } + MPVLib.MPV_EVENT_SHUTDOWN -> { + Log.w(TAG, "MPV shutdown") + } + } + } +} + diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt new file mode 100644 index 00000000..5c0f422e --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt @@ -0,0 +1,220 @@ +package expo.modules.mpvplayer + +import android.content.Context +import android.util.Log +import android.view.Surface +import dev.jdtech.mpv.MPVLib as LibMPV + +/** + * Wrapper around the dev.jdtech.mpv.MPVLib class. + * This provides a consistent interface for the rest of the app. + */ +object MPVLib { + private const val TAG = "MPVLib" + + private var initialized = false + + // Event observer interface + interface EventObserver { + fun eventProperty(property: String) + fun eventProperty(property: String, value: Long) + fun eventProperty(property: String, value: Boolean) + fun eventProperty(property: String, value: String) + fun eventProperty(property: String, value: Double) + fun event(eventId: Int) + } + + private val observers = mutableListOf() + + // Library event observer that forwards to our observers + private val libObserver = object : LibMPV.EventObserver { + override fun eventProperty(property: String) { + synchronized(observers) { + for (observer in observers) { + observer.eventProperty(property) + } + } + } + + override fun eventProperty(property: String, value: Long) { + synchronized(observers) { + for (observer in observers) { + observer.eventProperty(property, value) + } + } + } + + override fun eventProperty(property: String, value: Boolean) { + synchronized(observers) { + for (observer in observers) { + observer.eventProperty(property, value) + } + } + } + + override fun eventProperty(property: String, value: String) { + synchronized(observers) { + for (observer in observers) { + observer.eventProperty(property, value) + } + } + } + + override fun eventProperty(property: String, value: Double) { + synchronized(observers) { + for (observer in observers) { + observer.eventProperty(property, value) + } + } + } + + override fun event(eventId: Int) { + synchronized(observers) { + for (observer in observers) { + observer.event(eventId) + } + } + } + } + + fun addObserver(observer: EventObserver) { + synchronized(observers) { + observers.add(observer) + } + } + + fun removeObserver(observer: EventObserver) { + synchronized(observers) { + observers.remove(observer) + } + } + + // MPV Event IDs + const val MPV_EVENT_NONE = 0 + const val MPV_EVENT_SHUTDOWN = 1 + const val MPV_EVENT_LOG_MESSAGE = 2 + const val MPV_EVENT_GET_PROPERTY_REPLY = 3 + const val MPV_EVENT_SET_PROPERTY_REPLY = 4 + const val MPV_EVENT_COMMAND_REPLY = 5 + const val MPV_EVENT_START_FILE = 6 + const val MPV_EVENT_END_FILE = 7 + const val MPV_EVENT_FILE_LOADED = 8 + const val MPV_EVENT_IDLE = 11 + const val MPV_EVENT_TICK = 14 + const val MPV_EVENT_CLIENT_MESSAGE = 16 + const val MPV_EVENT_VIDEO_RECONFIG = 17 + const val MPV_EVENT_AUDIO_RECONFIG = 18 + const val MPV_EVENT_SEEK = 20 + const val MPV_EVENT_PLAYBACK_RESTART = 21 + const val MPV_EVENT_PROPERTY_CHANGE = 22 + const val MPV_EVENT_QUEUE_OVERFLOW = 24 + + // End file reason + const val MPV_END_FILE_REASON_EOF = 0 + const val MPV_END_FILE_REASON_STOP = 2 + const val MPV_END_FILE_REASON_QUIT = 3 + const val MPV_END_FILE_REASON_ERROR = 4 + const val MPV_END_FILE_REASON_REDIRECT = 5 + + /** + * Create and initialize the MPV library + */ + fun create(context: Context, configDir: String? = null) { + if (initialized) return + + try { + LibMPV.create(context) + LibMPV.addObserver(libObserver) + initialized = true + Log.i(TAG, "libmpv created successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to create libmpv: ${e.message}") + throw e + } + } + + fun initialize() { + LibMPV.init() + } + + fun destroy() { + if (!initialized) return + try { + LibMPV.removeObserver(libObserver) + LibMPV.destroy() + } catch (e: Exception) { + Log.e(TAG, "Error destroying mpv: ${e.message}") + } + initialized = false + } + + fun isInitialized(): Boolean = initialized + + fun attachSurface(surface: Surface) { + LibMPV.attachSurface(surface) + } + + fun detachSurface() { + LibMPV.detachSurface() + } + + fun command(cmd: Array) { + LibMPV.command(cmd) + } + + fun setOptionString(name: String, value: String): Int { + return LibMPV.setOptionString(name, value) + } + + fun getPropertyInt(name: String): Int? { + return try { + LibMPV.getPropertyInt(name) + } catch (e: Exception) { + null + } + } + + fun getPropertyDouble(name: String): Double? { + return try { + LibMPV.getPropertyDouble(name) + } catch (e: Exception) { + null + } + } + + fun getPropertyBoolean(name: String): Boolean? { + return try { + LibMPV.getPropertyBoolean(name) + } catch (e: Exception) { + null + } + } + + fun getPropertyString(name: String): String? { + return try { + LibMPV.getPropertyString(name) + } catch (e: Exception) { + null + } + } + + fun setPropertyInt(name: String, value: Int) { + LibMPV.setPropertyInt(name, value) + } + + fun setPropertyDouble(name: String, value: Double) { + LibMPV.setPropertyDouble(name, value) + } + + fun setPropertyBoolean(name: String, value: Boolean) { + LibMPV.setPropertyBoolean(name, value) + } + + fun setPropertyString(name: String, value: String) { + LibMPV.setPropertyString(name, value) + } + + fun observeProperty(name: String, format: Int) { + LibMPV.observeProperty(name, format) + } +} diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 7c8a4b00..053082e1 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -2,49 +2,179 @@ package expo.modules.mpvplayer import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition -import java.net.URL class MpvPlayerModule : Module() { - // Each module class must implement the definition function. The definition consists of components - // that describes the module's functionality and behavior. - // See https://docs.expo.dev/modules/module-api for more details about available components. - override fun definition() = ModuleDefinition { - // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. - // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. - // The module will be accessible from `requireNativeModule('MpvPlayer')` in JavaScript. - Name("MpvPlayer") + override fun definition() = ModuleDefinition { + Name("MpvPlayer") - // Defines constant property on the module. - Constant("PI") { - Math.PI + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + "Hello from MPV Player! 👋" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { value: String -> + sendEvent("onChange", mapOf("value" to value)) + } + + // Enables the module to be used as a native view. + View(MpvPlayerView::class) { + // All video load options are passed via a single "source" prop + Prop("source") { view: MpvPlayerView, source: Map? -> + if (source == null) return@Prop + + val urlString = source["url"] as? String ?: return@Prop + + @Suppress("UNCHECKED_CAST") + val config = VideoLoadConfig( + url = urlString, + headers = source["headers"] as? Map, + externalSubtitles = source["externalSubtitles"] as? List, + startPosition = (source["startPosition"] as? Number)?.toDouble(), + autoplay = (source["autoplay"] as? Boolean) ?: true, + initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), + initialAudioId = (source["initialAudioId"] as? Number)?.toInt() + ) + + view.loadVideo(config) + } + + // Async function to play video + AsyncFunction("play") { view: MpvPlayerView -> + view.play() + } + + // Async function to pause video + AsyncFunction("pause") { view: MpvPlayerView -> + view.pause() + } + + // Async function to seek to position + AsyncFunction("seekTo") { view: MpvPlayerView, position: Double -> + view.seekTo(position) + } + + // Async function to seek by offset + AsyncFunction("seekBy") { view: MpvPlayerView, offset: Double -> + view.seekBy(offset) + } + + // Async function to set playback speed + AsyncFunction("setSpeed") { view: MpvPlayerView, speed: Double -> + view.setSpeed(speed) + } + + // Function to get current speed + AsyncFunction("getSpeed") { view: MpvPlayerView -> + view.getSpeed() + } + + // Function to check if paused + AsyncFunction("isPaused") { view: MpvPlayerView -> + view.isPaused() + } + + // Function to get current position + AsyncFunction("getCurrentPosition") { view: MpvPlayerView -> + view.getCurrentPosition() + } + + // Function to get duration + AsyncFunction("getDuration") { view: MpvPlayerView -> + view.getDuration() + } + + // Picture in Picture functions + AsyncFunction("startPictureInPicture") { view: MpvPlayerView -> + view.startPictureInPicture() + } + + AsyncFunction("stopPictureInPicture") { view: MpvPlayerView -> + view.stopPictureInPicture() + } + + AsyncFunction("isPictureInPictureSupported") { view: MpvPlayerView -> + view.isPictureInPictureSupported() + } + + AsyncFunction("isPictureInPictureActive") { view: MpvPlayerView -> + view.isPictureInPictureActive() + } + + // Subtitle functions + AsyncFunction("getSubtitleTracks") { view: MpvPlayerView -> + view.getSubtitleTracks() + } + + AsyncFunction("setSubtitleTrack") { view: MpvPlayerView, trackId: Int -> + view.setSubtitleTrack(trackId) + } + + AsyncFunction("disableSubtitles") { view: MpvPlayerView -> + view.disableSubtitles() + } + + AsyncFunction("getCurrentSubtitleTrack") { view: MpvPlayerView -> + view.getCurrentSubtitleTrack() + } + + AsyncFunction("addSubtitleFile") { view: MpvPlayerView, url: String, select: Boolean -> + view.addSubtitleFile(url, select) + } + + // Subtitle positioning functions + AsyncFunction("setSubtitlePosition") { view: MpvPlayerView, position: Int -> + view.setSubtitlePosition(position) + } + + AsyncFunction("setSubtitleScale") { view: MpvPlayerView, scale: Double -> + view.setSubtitleScale(scale) + } + + AsyncFunction("setSubtitleMarginY") { view: MpvPlayerView, margin: Int -> + view.setSubtitleMarginY(margin) + } + + AsyncFunction("setSubtitleAlignX") { view: MpvPlayerView, alignment: String -> + view.setSubtitleAlignX(alignment) + } + + AsyncFunction("setSubtitleAlignY") { view: MpvPlayerView, alignment: String -> + view.setSubtitleAlignY(alignment) + } + + AsyncFunction("setSubtitleFontSize") { view: MpvPlayerView, size: Int -> + view.setSubtitleFontSize(size) + } + + // Audio track functions + AsyncFunction("getAudioTracks") { view: MpvPlayerView -> + view.getAudioTracks() + } + + AsyncFunction("setAudioTrack") { view: MpvPlayerView, trackId: Int -> + view.setAudioTrack(trackId) + } + + AsyncFunction("getCurrentAudioTrack") { view: MpvPlayerView -> + view.getCurrentAudioTrack() + } + + // Video scaling functions + AsyncFunction("setZoomedToFill") { view: MpvPlayerView, zoomed: Boolean -> + view.setZoomedToFill(zoomed) + } + + AsyncFunction("isZoomedToFill") { view: MpvPlayerView -> + view.isZoomedToFill() + } + + // Defines events that the view can send to JavaScript + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") + } } - - // Defines event names that the module can send to JavaScript. - Events("onChange") - - // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. - Function("hello") { - "Hello world! 👋" - } - - // Defines a JavaScript function that always returns a Promise and whose native code - // is by default dispatched on the different thread than the JavaScript runtime runs on. - AsyncFunction("setValueAsync") { value: String -> - // Send an event to JavaScript. - sendEvent("onChange", mapOf( - "value" to value - )) - } - - // Enables the module to be used as a native view. Definition components that are accepted as part of - // the view definition: Prop, Events. - View(MpvPlayerView::class) { - // Defines a setter for the `url` prop. - Prop("url") { view: MpvPlayerView, url: URL -> - view.webView.loadUrl(url.toString()) - } - // Defines an event that the view can send to JavaScript. - Events("onLoad") - } - } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index 1c35cb5f..ecc7ab52 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -1,30 +1,398 @@ package expo.modules.mpvplayer import android.content.Context -import android.webkit.WebView -import android.webkit.WebViewClient +import android.graphics.Color +import android.os.Build +import android.util.Log +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.widget.FrameLayout import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView -class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { - // Creates and initializes an event dispatcher for the `onLoad` event. - // The name of the event is inferred from the value and needs to match the event name defined in the module. - private val onLoad by EventDispatcher() +/** + * Configuration for loading a video + */ +data class VideoLoadConfig( + val url: String, + val headers: Map? = null, + val externalSubtitles: List? = null, + val startPosition: Double? = null, + val autoplay: Boolean = true, + val initialSubtitleId: Int? = null, + val initialAudioId: Int? = null +) - // Defines a WebView that will be used as the root subview. - internal val webView = WebView(context).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView, url: String) { - // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript. - onLoad(mapOf("url" to url)) - } +/** + * MpvPlayerView - ExpoView that hosts the MPV player. + * This mirrors the iOS MpvPlayerView implementation. + */ +class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), + MPVLayerRenderer.Delegate, SurfaceHolder.Callback { + + companion object { + private const val TAG = "MpvPlayerView" + + /** + * Detect if running on an Android emulator. + * MPV player has EGL/OpenGL compatibility issues on emulators. + */ + private fun isEmulator(): Boolean { + return (Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || "google_sdk" == Build.PRODUCT + || Build.HARDWARE.contains("goldfish") + || Build.HARDWARE.contains("ranchu")) + } + } + + // Event dispatchers + val onLoad by EventDispatcher() + val onPlaybackStateChange by EventDispatcher() + val onProgress by EventDispatcher() + val onError by EventDispatcher() + val onTracksReady by EventDispatcher() + + private var surfaceView: SurfaceView + private var renderer: MPVLayerRenderer? = null + private var pipController: PiPController? = null + + private var currentUrl: String? = null + private var cachedPosition: Double = 0.0 + private var cachedDuration: Double = 0.0 + private var intendedPlayState: Boolean = false + private var surfaceReady: Boolean = false + private var pendingConfig: VideoLoadConfig? = null + + init { + setBackgroundColor(Color.BLACK) + + // Create SurfaceView for video rendering + surfaceView = SurfaceView(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + holder.addCallback(this@MpvPlayerView) + } + addView(surfaceView) + + // Initialize renderer + renderer = MPVLayerRenderer(context) + renderer?.delegate = this + + // Initialize PiP controller with Expo's AppContext for proper activity access + pipController = PiPController(context, appContext) + pipController?.setPlayerView(surfaceView) + pipController?.delegate = object : PiPController.Delegate { + override fun onPlay() { + play() + } + + override fun onPause() { + pause() + } + + override fun onSeekBy(seconds: Double) { + seekBy(seconds) + } + } + + // Start the renderer (skip on emulators to avoid EGL crashes) + if (isEmulator()) { + Log.w(TAG, "Running on emulator - MPV player disabled due to EGL/OpenGL compatibility issues") + // Don't start renderer on emulator, will show error when trying to play + } else { + try { + renderer?.start() + } catch (e: Exception) { + Log.e(TAG, "Failed to start renderer: ${e.message}") + onError(mapOf("error" to "Failed to start renderer: ${e.message}")) + } + } } - } - init { - // Adds the WebView to the view hierarchy. - addView(webView) - } + private var isOnEmulator: Boolean = isEmulator() + + // MARK: - SurfaceHolder.Callback + + override fun surfaceCreated(holder: SurfaceHolder) { + Log.i(TAG, "Surface created") + surfaceReady = true + renderer?.attachSurface(holder.surface) + + // If we have a pending load, execute it now + pendingConfig?.let { config -> + loadVideoInternal(config) + pendingConfig = null + } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.i(TAG, "Surface changed: ${width}x${height}") + // Update MPV with the new surface size (Findroid approach) + renderer?.updateSurfaceSize(width, height) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Log.i(TAG, "Surface destroyed") + surfaceReady = false + renderer?.detachSurface() + } + + // MARK: - Video Loading + + fun loadVideo(config: VideoLoadConfig) { + // Block video loading on emulators + if (isOnEmulator) { + Log.w(TAG, "Cannot load video on emulator - MPV player not supported") + onError(mapOf("error" to "MPV player is not supported on emulators. Please test on a real device.")) + return + } + + // Skip reload if same URL is already playing + if (currentUrl == config.url) { + return + } + + if (!surfaceReady) { + // Surface not ready, store config and load when ready + pendingConfig = config + return + } + + loadVideoInternal(config) + } + + private fun loadVideoInternal(config: VideoLoadConfig) { + currentUrl = config.url + + renderer?.load( + url = config.url, + headers = config.headers, + startPosition = config.startPosition, + externalSubtitles = config.externalSubtitles, + initialSubtitleId = config.initialSubtitleId, + initialAudioId = config.initialAudioId + ) + + if (config.autoplay) { + play() + } + + onLoad(mapOf("url" to config.url)) + } + + // Convenience method for simple loads + fun loadVideo(url: String, headers: Map? = null) { + loadVideo(VideoLoadConfig(url = url, headers = headers)) + } + + // MARK: - Playback Controls + + fun play() { + intendedPlayState = true + renderer?.play() + pipController?.setPlaybackRate(1.0) + } + + fun pause() { + intendedPlayState = false + renderer?.pause() + pipController?.setPlaybackRate(0.0) + } + + fun seekTo(position: Double) { + renderer?.seekTo(position) + } + + fun seekBy(offset: Double) { + renderer?.seekBy(offset) + } + + fun setSpeed(speed: Double) { + renderer?.setSpeed(speed) + } + + fun getSpeed(): Double { + return renderer?.getSpeed() ?: 1.0 + } + + fun isPaused(): Boolean { + return renderer?.isPausedState ?: true + } + + fun getCurrentPosition(): Double { + return cachedPosition + } + + fun getDuration(): Double { + return cachedDuration + } + + // MARK: - Picture in Picture + + fun startPictureInPicture() { + Log.i(TAG, "startPictureInPicture called") + pipController?.startPictureInPicture() + } + + fun stopPictureInPicture() { + pipController?.stopPictureInPicture() + } + + fun isPictureInPictureSupported(): Boolean { + return pipController?.isPictureInPictureSupported() ?: false + } + + fun isPictureInPictureActive(): Boolean { + return pipController?.isPictureInPictureActive() ?: false + } + + // MARK: - Subtitle Controls + + fun getSubtitleTracks(): List> { + return renderer?.getSubtitleTracks() ?: emptyList() + } + + fun setSubtitleTrack(trackId: Int) { + renderer?.setSubtitleTrack(trackId) + } + + fun disableSubtitles() { + renderer?.disableSubtitles() + } + + fun getCurrentSubtitleTrack(): Int { + return renderer?.getCurrentSubtitleTrack() ?: 0 + } + + fun addSubtitleFile(url: String, select: Boolean = true) { + renderer?.addSubtitleFile(url, select) + } + + // MARK: - Subtitle Positioning + + fun setSubtitlePosition(position: Int) { + renderer?.setSubtitlePosition(position) + } + + fun setSubtitleScale(scale: Double) { + renderer?.setSubtitleScale(scale) + } + + fun setSubtitleMarginY(margin: Int) { + renderer?.setSubtitleMarginY(margin) + } + + fun setSubtitleAlignX(alignment: String) { + renderer?.setSubtitleAlignX(alignment) + } + + fun setSubtitleAlignY(alignment: String) { + renderer?.setSubtitleAlignY(alignment) + } + + fun setSubtitleFontSize(size: Int) { + renderer?.setSubtitleFontSize(size) + } + + // MARK: - Audio Track Controls + + fun getAudioTracks(): List> { + return renderer?.getAudioTracks() ?: emptyList() + } + + fun setAudioTrack(trackId: Int) { + renderer?.setAudioTrack(trackId) + } + + fun getCurrentAudioTrack(): Int { + return renderer?.getCurrentAudioTrack() ?: 0 + } + + // MARK: - Video Scaling + + private var _isZoomedToFill: Boolean = false + + fun setZoomedToFill(zoomed: Boolean) { + _isZoomedToFill = zoomed + renderer?.setZoomedToFill(zoomed) + } + + fun isZoomedToFill(): Boolean { + return _isZoomedToFill + } + + // MARK: - MPVLayerRenderer.Delegate + + override fun onPositionChanged(position: Double, duration: Double) { + cachedPosition = position + cachedDuration = duration + + // Update PiP progress + if (pipController?.isPictureInPictureActive() == true) { + pipController?.setCurrentTime(position, duration) + } + + onProgress(mapOf( + "position" to position, + "duration" to duration, + "progress" to if (duration > 0) position / duration else 0.0 + )) + } + + override fun onPauseChanged(isPaused: Boolean) { + // Sync PiP playback rate + pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0) + + onPlaybackStateChange(mapOf( + "isPaused" to isPaused, + "isPlaying" to !isPaused + )) + } + + override fun onLoadingChanged(isLoading: Boolean) { + onPlaybackStateChange(mapOf( + "isLoading" to isLoading + )) + } + + override fun onReadyToSeek() { + onPlaybackStateChange(mapOf( + "isReadyToSeek" to true + )) + } + + override fun onTracksReady() { + onTracksReady(emptyMap()) + } + + override fun onVideoDimensionsChanged(width: Int, height: Int) { + // Update PiP controller with video dimensions for proper aspect ratio + pipController?.setVideoDimensions(width, height) + } + + override fun onError(message: String) { + onError(mapOf("error" to message)) + } + + // MARK: - Cleanup + + fun cleanup() { + pipController?.stopPictureInPicture() + renderer?.stop() + surfaceView.holder.removeCallback(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + cleanup() + } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt new file mode 100644 index 00000000..438ccaa1 --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt @@ -0,0 +1,263 @@ +package expo.modules.mpvplayer + +import android.app.Activity +import android.app.PictureInPictureParams +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Rect +import android.os.Build +import android.util.Log +import android.util.Rational +import android.view.View +import androidx.annotation.RequiresApi +import expo.modules.kotlin.AppContext + +/** + * Picture-in-Picture controller for Android. + * This mirrors the iOS PiPController implementation. + */ +class PiPController(private val context: Context, private val appContext: AppContext? = null) { + + companion object { + private const val TAG = "PiPController" + private const val DEFAULT_ASPECT_WIDTH = 16 + private const val DEFAULT_ASPECT_HEIGHT = 9 + } + + interface Delegate { + fun onPlay() + fun onPause() + fun onSeekBy(seconds: Double) + } + + var delegate: Delegate? = null + + private var currentPosition: Double = 0.0 + private var currentDuration: Double = 0.0 + private var playbackRate: Double = 1.0 + + // Video dimensions for proper aspect ratio + private var videoWidth: Int = 0 + private var videoHeight: Int = 0 + + // Reference to the player view for source rect + private var playerView: View? = null + + /** + * Check if Picture-in-Picture is supported on this device + */ + fun isPictureInPictureSupported(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } else { + false + } + } + + /** + * Check if Picture-in-Picture is currently active + */ + fun isPictureInPictureActive(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + return activity?.isInPictureInPictureMode ?: false + } + return false + } + + /** + * Start Picture-in-Picture mode + */ + fun startPictureInPicture() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + if (activity == null) { + Log.e(TAG, "Cannot start PiP: no activity found") + return + } + + if (!isPictureInPictureSupported()) { + Log.e(TAG, "PiP not supported on this device") + return + } + + try { + val params = buildPiPParams(forEntering = true) + activity.enterPictureInPictureMode(params) + Log.i(TAG, "Entered PiP mode") + } catch (e: Exception) { + Log.e(TAG, "Failed to enter PiP: ${e.message}") + } + } else { + Log.w(TAG, "PiP requires Android O or higher") + } + } + + /** + * Stop Picture-in-Picture mode + */ + fun stopPictureInPicture() { + // On Android, exiting PiP is typically done by the user + // or by finishing the activity. We can request to move task to back. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + if (activity?.isInPictureInPictureMode == true) { + // Move task to back which will exit PiP + activity.moveTaskToBack(false) + } + } + } + + /** + * Update the current playback position and duration + * Note: We don't update PiP params here as we're not using progress in PiP controls + */ + fun setCurrentTime(position: Double, duration: Double) { + currentPosition = position + currentDuration = duration + } + + /** + * Set the playback rate (0.0 for paused, 1.0 for playing) + */ + fun setPlaybackRate(rate: Double) { + playbackRate = rate + + // Update PiP params to reflect play/pause state + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + if (activity?.isInPictureInPictureMode == true) { + try { + activity.setPictureInPictureParams(buildPiPParams()) + } catch (e: Exception) { + Log.e(TAG, "Failed to update PiP params: ${e.message}") + } + } + } + } + + /** + * Set the video dimensions for proper aspect ratio calculation + */ + fun setVideoDimensions(width: Int, height: Int) { + if (width > 0 && height > 0) { + videoWidth = width + videoHeight = height + Log.i(TAG, "Video dimensions set: ${width}x${height}") + + // Update PiP params if active + updatePiPParamsIfNeeded() + } + } + + /** + * Set the player view reference for source rect hint + */ + fun setPlayerView(view: View?) { + playerView = view + } + + private fun updatePiPParamsIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val activity = getActivity() + if (activity?.isInPictureInPictureMode == true) { + try { + activity.setPictureInPictureParams(buildPiPParams()) + } catch (e: Exception) { + Log.e(TAG, "Failed to update PiP params: ${e.message}") + } + } + } + } + + /** + * Build Picture-in-Picture params for the current player state. + * Calculates proper aspect ratio and source rect based on video and view dimensions. + */ + @RequiresApi(Build.VERSION_CODES.O) + private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams { + val view = playerView + val viewWidth = view?.width ?: 0 + val viewHeight = view?.height ?: 0 + + // Display aspect ratio from view (exactly like Findroid) + val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1)) + + // Video aspect ratio with 2.39:1 clamping (exactly like Findroid) + // Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()), + // it.height.coerceAtMost((it.width * 2.39f).toInt())) + val aspectRatio = if (videoWidth > 0 && videoHeight > 0) { + Rational( + videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()), + videoHeight.coerceAtMost((videoWidth * 2.39f).toInt()) + ) + } else { + Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT) + } + + // Source rect hint calculation (exactly like Findroid) + val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) { + if (displayAspectRatio < aspectRatio) { + // Letterboxing - black bars top/bottom + val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt() + Rect( + 0, + space, + viewWidth, + (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space + ) + } else { + // Pillarboxing - black bars left/right + val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt() + Rect( + space, + 0, + (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, + viewHeight + ) + } + } else { + null + } + + val builder = PictureInPictureParams.Builder() + .setAspectRatio(aspectRatio) + + sourceRectHint?.let { builder.setSourceRectHint(it) } + + // On Android 12+, enable auto-enter (like Findroid) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(true) + } + + return builder.build() + } + + private fun getActivity(): Activity? { + // First try Expo's AppContext (preferred in React Native) + appContext?.currentActivity?.let { return it } + + // Fallback: Try to get from context wrapper chain + var ctx = context + while (ctx is android.content.ContextWrapper) { + if (ctx is Activity) { + return ctx + } + ctx = ctx.baseContext + } + return null + } + + /** + * Handle PiP action (called from activity when user taps PiP controls) + */ + fun handlePiPAction(action: String) { + when (action) { + "play" -> delegate?.onPlay() + "pause" -> delegate?.onPause() + "skip_forward" -> delegate?.onSeekBy(10.0) + "skip_backward" -> delegate?.onSeekBy(-10.0) + } + } +} + diff --git a/modules/mpv-player/expo-module.config.json b/modules/mpv-player/expo-module.config.json index b9ad072b..f5092bad 100644 --- a/modules/mpv-player/expo-module.config.json +++ b/modules/mpv-player/expo-module.config.json @@ -1,5 +1,8 @@ { - "platforms": ["android", "web"], + "platforms": ["apple", "android", "web"], + "apple": { + "modules": ["MpvPlayerModule"] + }, "android": { "modules": ["expo.modules.mpvplayer.MpvPlayerModule"] } diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift new file mode 100644 index 00000000..7f66e7b5 --- /dev/null +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -0,0 +1,726 @@ +import UIKit +import MPVKit +import CoreMedia +import CoreVideo +import AVFoundation + +protocol MPVLayerRendererDelegate: AnyObject { + func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) + func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) + func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) + func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) + func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) +} + +/// MPV player using vo_avfoundation for video output. +/// This renders video directly to AVSampleBufferDisplayLayer for PiP support. +final class MPVLayerRenderer { + enum RendererError: Error { + case mpvCreationFailed + case mpvInitialization(Int32) + } + + private let displayLayer: AVSampleBufferDisplayLayer + private let queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated) + private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent) + + private var mpv: OpaquePointer? + + private var currentPreset: PlayerPreset? + private var currentURL: URL? + private var currentHeaders: [String: String]? + private var pendingExternalSubtitles: [String] = [] + private var initialSubtitleId: Int? + private var initialAudioId: Int? + + private var isRunning = false + private var isStopping = false + + weak var delegate: MPVLayerRendererDelegate? + + // Thread-safe state for playback + private var _cachedDuration: Double = 0 + private var _cachedPosition: Double = 0 + private var _isPaused: Bool = true + private var _playbackSpeed: Double = 1.0 + private var _isLoading: Bool = false + private var _isReadyToSeek: Bool = false + + // Thread-safe accessors + private var cachedDuration: Double { + get { stateQueue.sync { _cachedDuration } } + set { stateQueue.async(flags: .barrier) { self._cachedDuration = newValue } } + } + private var cachedPosition: Double { + get { stateQueue.sync { _cachedPosition } } + set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } + } + private var isPaused: Bool { + get { stateQueue.sync { _isPaused } } + set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } + } + private var playbackSpeed: Double { + get { stateQueue.sync { _playbackSpeed } } + set { stateQueue.async(flags: .barrier) { self._playbackSpeed = newValue } } + } + private var isLoading: Bool { + get { stateQueue.sync { _isLoading } } + set { stateQueue.async(flags: .barrier) { self._isLoading = newValue } } + } + private var isReadyToSeek: Bool { + get { stateQueue.sync { _isReadyToSeek } } + set { stateQueue.async(flags: .barrier) { self._isReadyToSeek = newValue } } + } + + var isPausedState: Bool { + return isPaused + } + + init(displayLayer: AVSampleBufferDisplayLayer) { + self.displayLayer = displayLayer + } + + deinit { + stop() + } + + func start() throws { + guard !isRunning else { return } + guard let handle = mpv_create() else { + throw RendererError.mpvCreationFailed + } + mpv = handle + + // Logging - only warnings and errors in release, verbose in debug + #if DEBUG + checkError(mpv_request_log_messages(handle, "warn")) + #else + checkError(mpv_request_log_messages(handle, "no")) + #endif + + // Detect if running on simulator + #if targetEnvironment(simulator) + let isSimulator = true + #else + let isSimulator = false + #endif + + // Pass the AVSampleBufferDisplayLayer to mpv via --wid + // The vo_avfoundation driver expects this + let layerPtrInt = Int(bitPattern: Unmanaged.passUnretained(displayLayer).toOpaque()) + var displayLayerPtr = Int64(layerPtrInt) + checkError(mpv_set_option(handle, "wid", MPV_FORMAT_INT64, &displayLayerPtr)) + + // Use AVFoundation video output - required for PiP support + checkError(mpv_set_option_string(handle, "vo", "avfoundation")) + + // Enable composite OSD mode - renders subtitles directly onto video frames using GPU + // This is better for PiP as subtitles are baked into the video + checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) + + // Hardware decoding with VideoToolbox + // On simulator, use software decoding since VideoToolbox is not available + // On device, use VideoToolbox with software fallback enabled + let hwdecValue = isSimulator ? "no" : "videotoolbox" + checkError(mpv_set_option_string(handle, "hwdec", hwdecValue)) + checkError(mpv_set_option_string(handle, "hwdec-codecs", "all")) + checkError(mpv_set_option_string(handle, "hwdec-software-fallback", "yes")) + + // Subtitle and audio settings + checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) + checkError(mpv_set_option_string(mpv, "subs-fallback", "yes")) + + // Initialize mpv + let initStatus = mpv_initialize(handle) + guard initStatus >= 0 else { + throw RendererError.mpvInitialization(initStatus) + } + + // Observe properties + observeProperties() + + // Setup wakeup callback + mpv_set_wakeup_callback(handle, { ctx in + guard let ctx = ctx else { return } + let instance = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + instance.processEvents() + }, Unmanaged.passUnretained(self).toOpaque()) + isRunning = true + } + + func stop() { + if isStopping { return } + if !isRunning, mpv == nil { return } + isRunning = false + isStopping = true + + queue.sync { [weak self] in + guard let self, let handle = self.mpv else { return } + + mpv_set_wakeup_callback(handle, nil, nil) + mpv_terminate_destroy(handle) + self.mpv = nil + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if #available(iOS 18.0, *) { + self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) + } else { + self.displayLayer.flushAndRemoveImage() + } + } + + isStopping = false + } + + func load( + url: URL, + with preset: PlayerPreset, + headers: [String: String]? = nil, + startPosition: Double? = nil, + externalSubtitles: [String]? = nil, + initialSubtitleId: Int? = nil, + initialAudioId: Int? = nil + ) { + currentPreset = preset + currentURL = url + currentHeaders = headers + pendingExternalSubtitles = externalSubtitles ?? [] + self.initialSubtitleId = initialSubtitleId + self.initialAudioId = initialAudioId + queue.async { [weak self] in + guard let self else { return } + self.isLoading = true + self.isReadyToSeek = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: true) + } + + guard let handle = self.mpv else { return } + + self.apply(commands: preset.commands, on: handle) + // Stop previous playback before loading new file + self.command(handle, ["stop"]) + self.updateHTTPHeaders(headers) + // Set start position + if let startPos = startPosition, startPos > 0 { + self.setProperty(name: "start", value: String(format: "%.2f", startPos)) + } else { + self.setProperty(name: "start", value: "0") + } + // Set initial audio track if specified + if let audioId = self.initialAudioId, audioId > 0 { + self.setAudioTrack(audioId) + } + // Set initial subtitle track if no external subs + if self.pendingExternalSubtitles.isEmpty { + if let subId = self.initialSubtitleId { + self.setSubtitleTrack(subId) + } else { + self.disableSubtitles() + } + } else { + self.disableSubtitles() + } + let target = url.isFileURL ? url.path : url.absoluteString + self.command(handle, ["loadfile", target, "replace"]) + } + } + + func reloadCurrentItem() { + guard let url = currentURL, let preset = currentPreset else { return } + load(url: url, with: preset, headers: currentHeaders) + } + + func applyPreset(_ preset: PlayerPreset) { + currentPreset = preset + guard let handle = mpv else { return } + queue.async { [weak self] in + guard let self else { return } + self.apply(commands: preset.commands, on: handle) + } + } + + // MARK: - Property Helpers + + private func setOption(name: String, value: String) { + guard let handle = mpv else { return } + checkError(mpv_set_option_string(handle, name, value)) + } + + private func setProperty(name: String, value: String) { + guard let handle = mpv else { return } + let status = mpv_set_property_string(handle, name, value) + if status < 0 { + Logger.shared.log("Failed to set property \(name)=\(value) (\(status))", type: "Warn") + } + } + + private func clearProperty(name: String) { + guard let handle = mpv else { return } + let status = mpv_set_property(handle, name, MPV_FORMAT_NONE, nil) + if status < 0 { + Logger.shared.log("Failed to clear property \(name) (\(status))", type: "Warn") + } + } + + private func updateHTTPHeaders(_ headers: [String: String]?) { + guard let headers, !headers.isEmpty else { + clearProperty(name: "http-header-fields") + return + } + + let headerString = headers + .map { key, value in "\(key): \(value)" } + .joined(separator: "\r\n") + setProperty(name: "http-header-fields", value: headerString) + } + + private func observeProperties() { + guard let handle = mpv else { return } + let properties: [(String, mpv_format)] = [ + ("duration", MPV_FORMAT_DOUBLE), + ("time-pos", MPV_FORMAT_DOUBLE), + ("pause", MPV_FORMAT_FLAG), + ("track-list/count", MPV_FORMAT_INT64), + ("paused-for-cache", MPV_FORMAT_FLAG) + ] + for (name, format) in properties { + mpv_observe_property(handle, 0, name, format) + } + } + + private func apply(commands: [[String]], on handle: OpaquePointer) { + for command in commands { + guard !command.isEmpty else { continue } + self.command(handle, command) + } + } + + private func command(_ handle: OpaquePointer, _ args: [String]) { + guard !args.isEmpty else { return } + _ = withCStringArray(args) { pointer in + mpv_command_async(handle, 0, pointer) + } + } + + private func commandSync(_ handle: OpaquePointer, _ args: [String]) -> Int32 { + guard !args.isEmpty else { return -1 } + return withCStringArray(args) { pointer in + mpv_command(handle, pointer) + } + } + + private func checkError(_ status: CInt) { + if status < 0 { + Logger.shared.log("MPV API error: \(String(cString: mpv_error_string(status)))", type: "Error") + } + } + + // MARK: - Event Handling + + private func processEvents() { + queue.async { [weak self] in + guard let self else { return } + + while self.mpv != nil && !self.isStopping { + guard let handle = self.mpv, + let eventPointer = mpv_wait_event(handle, 0) else { return } + let event = eventPointer.pointee + if event.event_id == MPV_EVENT_NONE { break } + self.handleEvent(event) + if event.event_id == MPV_EVENT_SHUTDOWN { break } + } + } + } + + private func handleEvent(_ event: mpv_event) { + switch event.event_id { + case MPV_EVENT_FILE_LOADED: + // Add external subtitles now that the file is loaded + let hadExternalSubs = !pendingExternalSubtitles.isEmpty + if hadExternalSubs, let handle = mpv { + for subUrl in pendingExternalSubtitles { + command(handle, ["sub-add", subUrl]) + } + pendingExternalSubtitles = [] + // Set subtitle after external subs are added + if let subId = initialSubtitleId { + setSubtitleTrack(subId) + } else { + disableSubtitles() + } + } + if !isReadyToSeek { + isReadyToSeek = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didBecomeReadyToSeek: true) + } + } + // Notify loading ended + if isLoading { + isLoading = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: false) + } + } + + case MPV_EVENT_SEEK: + // Seek started - show loading indicator + if !isLoading { + isLoading = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: true) + } + } + + case MPV_EVENT_PLAYBACK_RESTART: + // Video playback has started/restarted (including after seek) + if isLoading { + isLoading = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: false) + } + } + case MPV_EVENT_PROPERTY_CHANGE: + if let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee.name { + let name = String(cString: property) + refreshProperty(named: name, event: event) + } + + case MPV_EVENT_SHUTDOWN: + Logger.shared.log("mpv shutdown", type: "Warn") + + case MPV_EVENT_LOG_MESSAGE: + if let logMessagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { + let component = String(cString: logMessagePointer.pointee.prefix) + let text = String(cString: logMessagePointer.pointee.text) + let lower = text.lowercased() + if lower.contains("error") { + Logger.shared.log("mpv[\(component)] \(text)", type: "Error") + } else if lower.contains("warn") || lower.contains("warning") { + Logger.shared.log("mpv[\(component)] \(text)", type: "Warn") + } + } + default: + break + } + } + + private func refreshProperty(named name: String, event: mpv_event) { + guard let handle = mpv else { return } + switch name { + case "duration": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedDuration = value + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) + } + } + case "time-pos": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedPosition = value + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) + } + } + case "pause": + var flag: Int32 = 0 + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) + if status >= 0 { + let newPaused = flag != 0 + if newPaused != isPaused { + isPaused = newPaused + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangePause: self.isPaused) + } + } + } + case "paused-for-cache": + var flag: Int32 = 0 + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) + if status >= 0 { + let buffering = flag != 0 + if buffering != isLoading { + isLoading = buffering + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: buffering) + } + } + } + case "track-list/count": + var trackCount: Int64 = 0 + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_INT64, value: &trackCount) + if status >= 0 && trackCount > 0 { + Logger.shared.log("Track list updated: \(trackCount) tracks available", type: "Info") + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didBecomeTracksReady: true) + } + } + default: + break + } + } + + private func getStringProperty(handle: OpaquePointer, name: String) -> String? { + var result: String? + if let cString = mpv_get_property_string(handle, name) { + result = String(cString: cString) + mpv_free(cString) + } + return result + } + + @discardableResult + private func getProperty(handle: OpaquePointer, name: String, format: mpv_format, value: inout T) -> Int32 { + return withUnsafeMutablePointer(to: &value) { mutablePointer in + return mpv_get_property(handle, name, format, mutablePointer) + } + } + + @inline(__always) + private func withCStringArray(_ args: [String], body: (UnsafeMutablePointer?>?) -> R) -> R { + var cStrings = [UnsafeMutablePointer?]() + cStrings.reserveCapacity(args.count + 1) + for s in args { + cStrings.append(strdup(s)) + } + cStrings.append(nil) + defer { + for ptr in cStrings where ptr != nil { + free(ptr) + } + } + + return cStrings.withUnsafeMutableBufferPointer { buffer in + return buffer.baseAddress!.withMemoryRebound(to: UnsafePointer?.self, capacity: buffer.count) { rebound in + return body(UnsafeMutablePointer(mutating: rebound)) + } + } + } + + // MARK: - Playback Controls + + func play() { + setProperty(name: "pause", value: "no") + } + + func pausePlayback() { + setProperty(name: "pause", value: "yes") + } + + func togglePause() { + if isPaused { play() } else { pausePlayback() } + } + + func seek(to seconds: Double) { + guard let handle = mpv else { return } + let clamped = max(0, seconds) + cachedPosition = clamped + commandSync(handle, ["seek", String(clamped), "absolute"]) + } + + func seek(by seconds: Double) { + guard let handle = mpv else { return } + let newPosition = max(0, cachedPosition + seconds) + cachedPosition = newPosition + commandSync(handle, ["seek", String(seconds), "relative"]) + } + + /// Sync timebase - no-op for vo_avfoundation (mpv handles timing) + func syncTimebase() { + // vo_avfoundation manages its own timebase + } + + func setSpeed(_ speed: Double) { + playbackSpeed = speed + setProperty(name: "speed", value: String(speed)) + } + + func getSpeed() -> Double { + guard let handle = mpv else { return 1.0 } + var speed: Double = 1.0 + getProperty(handle: handle, name: "speed", format: MPV_FORMAT_DOUBLE, value: &speed) + return speed + } + + // MARK: - Subtitle Controls + + func getSubtitleTracks() -> [[String: Any]] { + guard let handle = mpv else { + Logger.shared.log("getSubtitleTracks: mpv handle is nil", type: "Warn") + return [] + } + var tracks: [[String: Any]] = [] + + var trackCount: Int64 = 0 + getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) + + for i in 0.. Int { + guard let handle = mpv else { return 0 } + var sid: Int64 = 0 + getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid) + return Int(sid) + } + + func addSubtitleFile(url: String, select: Bool = true) { + guard let handle = mpv else { return } + let flag = select ? "select" : "cached" + commandSync(handle, ["sub-add", url, flag]) + } + + // MARK: - Subtitle Positioning + + func setSubtitlePosition(_ position: Int) { + setProperty(name: "sub-pos", value: String(position)) + } + + func setSubtitleScale(_ scale: Double) { + setProperty(name: "sub-scale", value: String(scale)) + } + + func setSubtitleMarginY(_ margin: Int) { + setProperty(name: "sub-margin-y", value: String(margin)) + } + + func setSubtitleAlignX(_ alignment: String) { + setProperty(name: "sub-align-x", value: alignment) + } + + func setSubtitleAlignY(_ alignment: String) { + setProperty(name: "sub-align-y", value: alignment) + } + + func setSubtitleFontSize(_ size: Int) { + setProperty(name: "sub-font-size", value: String(size)) + } + + // MARK: - Audio Track Controls + + func getAudioTracks() -> [[String: Any]] { + guard let handle = mpv else { + Logger.shared.log("getAudioTracks: mpv handle is nil", type: "Warn") + return [] + } + var tracks: [[String: Any]] = [] + + var trackCount: Int64 = 0 + getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) + + for i in 0.. 0 { + track["channels"] = Int(channels) + } + + var selected: Int32 = 0 + getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected) + track["selected"] = selected != 0 + + Logger.shared.log("getAudioTracks: found audio track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info") + tracks.append(track) + } + + Logger.shared.log("getAudioTracks: returning \(tracks.count) audio tracks", type: "Info") + return tracks + } + + func setAudioTrack(_ trackId: Int) { + guard mpv != nil else { + Logger.shared.log("setAudioTrack: mpv handle is nil", type: "Warn") + return + } + Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info") + setProperty(name: "aid", value: String(trackId)) + } + + func getCurrentAudioTrack() -> Int { + guard let handle = mpv else { return 0 } + var aid: Int64 = 0 + getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid) + return Int(aid) + } +} diff --git a/modules/mpv-player/ios/MPVSoftwareRenderer.swift b/modules/mpv-player/ios/MPVSoftwareRenderer.swift deleted file mode 100644 index df19c10e..00000000 --- a/modules/mpv-player/ios/MPVSoftwareRenderer.swift +++ /dev/null @@ -1,1389 +0,0 @@ -import UIKit -import Libmpv -import CoreMedia -import CoreVideo -import AVFoundation - -protocol MPVSoftwareRendererDelegate: AnyObject { - func renderer(_ renderer: MPVSoftwareRenderer, didUpdatePosition position: Double, duration: Double) - func renderer(_ renderer: MPVSoftwareRenderer, didChangePause isPaused: Bool) - func renderer(_ renderer: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) - func renderer(_ renderer: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) - func renderer(_ renderer: MPVSoftwareRenderer, didBecomeTracksReady: Bool) -} - -final class MPVSoftwareRenderer { - enum RendererError: Error { - case mpvCreationFailed - case mpvInitialization(Int32) - case renderContextCreation(Int32) - } - - private let displayLayer: AVSampleBufferDisplayLayer - private let renderQueue = DispatchQueue(label: "mpv.software.render", qos: .userInitiated) - private let eventQueue = DispatchQueue(label: "mpv.software.events", qos: .utility) - private let stateQueue = DispatchQueue(label: "mpv.software.state", attributes: .concurrent) - private let eventQueueGroup = DispatchGroup() - private let renderQueueKey = DispatchSpecificKey() - - private var dimensionsArray = [Int32](repeating: 0, count: 2) - private var renderParams = [mpv_render_param](repeating: mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), count: 5) - - private var mpv: OpaquePointer? - private var renderContext: OpaquePointer? - private var videoSize: CGSize = .zero - private var pixelBufferPool: CVPixelBufferPool? - private var pixelBufferPoolAuxAttributes: CFDictionary? - private var formatDescription: CMVideoFormatDescription? - private var didFlushForFormatChange = false - private var poolWidth: Int = 0 - private var poolHeight: Int = 0 - private var preAllocatedBuffers: [CVPixelBuffer] = [] - private let maxPreAllocatedBuffers = 12 - - private var currentPreset: PlayerPreset? - private var currentURL: URL? - private var currentHeaders: [String: String]? - private var pendingExternalSubtitles: [String] = [] - private var initialSubtitleId: Int? - private var initialAudioId: Int? - - private var disposeBag: [() -> Void] = [] - - private var isRunning = false - private var isStopping = false - private var shouldClearPixelBuffer = false - private let bgraFormatCString: [CChar] = Array("bgra\0".utf8CString) - private let maxInFlightBuffers = 3 - private var inFlightBufferCount = 0 - private let inFlightLock = NSLock() - - weak var delegate: MPVSoftwareRendererDelegate? - - // Thread-safe state for playback (uses existing stateQueue to prevent races causing stutter) - private var _cachedDuration: Double = 0 - private var _cachedPosition: Double = 0 - private var _isPaused: Bool = true - private var _playbackSpeed: Double = 1.0 - private var _isSeeking: Bool = false - private var _positionUpdateTime: CFTimeInterval = 0 // Host time when position was last updated - private var _lastPTS: Double = 0 // Last presentation timestamp (ensures monotonic increase) - - // Thread-safe accessors - private var cachedDuration: Double { - get { stateQueue.sync { _cachedDuration } } - set { stateQueue.async(flags: .barrier) { self._cachedDuration = newValue } } - } - private var cachedPosition: Double { - get { stateQueue.sync { _cachedPosition } } - set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } - } - private var isPaused: Bool { - get { stateQueue.sync { _isPaused } } - set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } - } - private var playbackSpeed: Double { - get { stateQueue.sync { _playbackSpeed } } - set { stateQueue.async(flags: .barrier) { self._playbackSpeed = newValue } } - } - private var isSeeking: Bool { - get { stateQueue.sync { _isSeeking } } - set { stateQueue.async(flags: .barrier) { self._isSeeking = newValue } } - } - private var positionUpdateTime: CFTimeInterval { - get { stateQueue.sync { _positionUpdateTime } } - set { stateQueue.async(flags: .barrier) { self._positionUpdateTime = newValue } } - } - private var lastPTS: Double { - get { stateQueue.sync { _lastPTS } } - set { stateQueue.async(flags: .barrier) { self._lastPTS = newValue } } - } - - /// Get next monotonically increasing PTS based on video position - /// This ensures frames always have increasing timestamps (prevents stutter from drops) - private func nextMonotonicPTS() -> Double { - let currentPos = interpolatedPosition() - let last = lastPTS - - // Ensure PTS always increases (by at least 1ms) to prevent frame drops - let pts = max(currentPos, last + 0.001) - lastPTS = pts - return pts - } - - /// Calculate smooth interpolated position based on last known position + elapsed time - private func interpolatedPosition() -> Double { - let basePosition = cachedPosition - let lastUpdate = positionUpdateTime - let paused = isPaused - let speed = playbackSpeed - - guard !paused, lastUpdate > 0 else { - return basePosition - } - - let elapsed = CACurrentMediaTime() - lastUpdate - return basePosition + (elapsed * speed) - } - - private var isLoading: Bool = false - private var isRenderScheduled = false - private var lastRenderTime: CFTimeInterval = 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: ()) - } - - deinit { - stop() - } - - func start() throws { - guard !isRunning else { return } - guard let handle = mpv_create() else { - throw RendererError.mpvCreationFailed - } - mpv = handle - setOption(name: "terminal", value: "yes") - setOption(name: "msg-level", value: "status") - setOption(name: "keep-open", value: "yes") - setOption(name: "idle", value: "yes") - setOption(name: "vo", value: "libmpv") - setOption(name: "hwdec", value: "videotoolbox-copy") - setOption(name: "gpu-api", value: "metal") - 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 options - use vf=sub to burn subtitles into video frames - // This happens at the filter level, BEFORE the software renderer - setOption(name: "vf", value: "sub") - setOption(name: "sub-visibility", value: "yes") - - let initStatus = mpv_initialize(handle) - guard initStatus >= 0 else { - throw RendererError.mpvInitialization(initStatus) - } - - mpv_request_log_messages(handle, "warn") - - try createRenderContext() - observeProperties() - installWakeupHandler() - isRunning = true - } - - func stop() { - if isStopping { return } - if !isRunning, mpv == nil { return } - isRunning = false - isStopping = true - - var handleForShutdown: OpaquePointer? - - renderQueue.sync { [weak self] in - guard let self else { return } - - if let ctx = self.renderContext { - mpv_render_context_set_update_callback(ctx, nil, nil) - mpv_render_context_free(ctx) - self.renderContext = nil - } - - handleForShutdown = self.mpv - if let handle = handleForShutdown { - mpv_set_wakeup_callback(handle, nil, nil) - self.command(handle, ["quit"]) - mpv_wakeup(handle) - } - - self.formatDescription = nil - self.preAllocatedBuffers.removeAll() - self.pixelBufferPool = nil - self.poolWidth = 0 - self.poolHeight = 0 - self.lastRenderDimensions = .zero - } - - eventQueueGroup.wait() - - renderQueue.sync { [weak self] in - guard let self else { return } - - if let handle = handleForShutdown { - mpv_destroy(handle) - } - self.mpv = nil - - self.preAllocatedBuffers.removeAll() - self.pixelBufferPool = nil - self.pixelBufferPoolAuxAttributes = nil - self.formatDescription = nil - self.poolWidth = 0 - self.poolHeight = 0 - self.lastRenderDimensions = .zero - - self.disposeBag.forEach { $0() } - self.disposeBag.removeAll() - } - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - if #available(iOS 18.0, *) { - self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) - } else { - self.displayLayer.flushAndRemoveImage() - } - } - - isStopping = false - } - - func load( - url: URL, - with preset: PlayerPreset, - headers: [String: String]? = nil, - startPosition: Double? = nil, - externalSubtitles: [String]? = nil, - initialSubtitleId: Int? = nil, - initialAudioId: Int? = nil - ) { - currentPreset = preset - currentURL = url - currentHeaders = headers - pendingExternalSubtitles = externalSubtitles ?? [] - self.initialSubtitleId = initialSubtitleId - self.initialAudioId = initialAudioId - - renderQueue.async { [weak self] in - guard let self else { return } - self.isLoading = true - self.isReadyToSeek = false - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.delegate?.renderer(self, didChangeLoading: true) - } - - guard let handle = self.mpv else { return } - - self.apply(commands: preset.commands, on: handle) - // Sync stop to ensure previous playback is stopped before loading new file - self.commandSync(handle, ["stop"]) - self.updateHTTPHeaders(headers) - - // Set start position using property (setOption only works before mpv_initialize) - if let startPos = startPosition, startPos > 0 { - self.setProperty(name: "start", value: String(format: "%.2f", startPos)) - } else { - self.setProperty(name: "start", value: "0") - } - - // Set initial audio track if specified - if let audioId = self.initialAudioId, audioId > 0 { - self.setAudioTrack(audioId) - } - - // Set initial subtitle track if no external subs (external subs change track IDs) - if self.pendingExternalSubtitles.isEmpty { - if let subId = self.initialSubtitleId { - self.setSubtitleTrack(subId) - } else { - self.disableSubtitles() - } - } else { - // External subs will be added after file loads, set sid then - self.disableSubtitles() - } - - var finalURL = url - if !url.isFileURL { - finalURL = url - } - - let target = finalURL.isFileURL ? finalURL.path : finalURL.absoluteString - self.command(handle, ["loadfile", target, "replace"]) - } - } - - func reloadCurrentItem() { - guard let url = currentURL, let preset = currentPreset else { return } - load(url: url, with: preset, headers: currentHeaders) - } - - func applyPreset(_ preset: PlayerPreset) { - currentPreset = preset - guard let handle = mpv else { return } - renderQueue.async { [weak self] in - guard let self else { return } - self.apply(commands: preset.commands, on: handle) - } - } - - private func setOption(name: String, value: String) { - guard let handle = mpv else { return } - _ = value.withCString { valuePointer in - name.withCString { namePointer in - mpv_set_option_string(handle, namePointer, valuePointer) - } - } - } - - private func setProperty(name: String, value: String) { - guard let handle = mpv else { return } - let status = value.withCString { valuePointer in - name.withCString { namePointer in - mpv_set_property_string(handle, namePointer, valuePointer) - } - } - if status < 0 { - Logger.shared.log("Failed to set property \(name)=\(value) (\(status))", type: "Warn") - } - } - - private func clearProperty(name: String) { - guard let handle = mpv else { return } - let status = name.withCString { namePointer in - mpv_set_property(handle, namePointer, MPV_FORMAT_NONE, nil) - } - if status < 0 { - Logger.shared.log("Failed to clear property \(name) (\(status))", type: "Warn") - } - } - - private func updateHTTPHeaders(_ headers: [String: String]?) { - guard let headers, !headers.isEmpty else { - clearProperty(name: "http-header-fields") - return - } - - let headerString = headers - .map { key, value in - "\(key): \(value)" - } - .joined(separator: "\r\n") - setProperty(name: "http-header-fields", value: headerString) - } - - private func createRenderContext() throws { - guard let handle = mpv else { return } - - var apiType = MPV_RENDER_API_TYPE_SW - let status = withUnsafePointer(to: &apiType) { apiTypePtr in - var params = [ - mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: UnsafeMutableRawPointer(mutating: apiTypePtr)), - mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil) - ] - - return params.withUnsafeMutableBufferPointer { pointer -> Int32 in - pointer.baseAddress?.withMemoryRebound(to: mpv_render_param.self, capacity: pointer.count) { parameters in - return mpv_render_context_create(&renderContext, handle, parameters) - } ?? -1 - } - } - - guard status >= 0, renderContext != nil else { - throw RendererError.renderContextCreation(status) - } - - mpv_render_context_set_update_callback(renderContext, { context in - guard let context = context else { return } - let instance = Unmanaged.fromOpaque(context).takeUnretainedValue() - instance.scheduleRender() - }, Unmanaged.passUnretained(self).toOpaque()) - } - - private func observeProperties() { - guard let handle = mpv else { return } - let properties: [(String, mpv_format)] = [ - ("dwidth", MPV_FORMAT_INT64), - ("dheight", MPV_FORMAT_INT64), - ("duration", MPV_FORMAT_DOUBLE), - ("time-pos", MPV_FORMAT_DOUBLE), - ("pause", MPV_FORMAT_FLAG), - ("track-list/count", MPV_FORMAT_INT64) // Notify when tracks are available - ] - - for (name, format) in properties { - _ = name.withCString { pointer in - mpv_observe_property(handle, 0, pointer, format) - } - } - } - - private func installWakeupHandler() { - guard let handle = mpv else { return } - mpv_set_wakeup_callback(handle, { userdata in - guard let userdata else { return } - let instance = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - instance.processEvents() - }, Unmanaged.passUnretained(self).toOpaque()) - renderQueue.async { [weak self] in - guard let self else { return } - self.disposeBag.append { [weak self] in - guard let self, let handle = self.mpv else { return } - mpv_set_wakeup_callback(handle, nil, nil) - } - } - } - - private func scheduleRender() { - renderQueue.async { [weak self] in - guard let self, self.isRunning, !self.isStopping else { return } - - let currentTime = CACurrentMediaTime() - 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 - } - - self.isRenderScheduled = true - self.lastRenderTime = currentTime - self.performRenderUpdate() - self.isRenderScheduled = false - } - } - - private func performRenderUpdate() { - guard let context = renderContext else { return } - let status = mpv_render_context_update(context) - - let updateFlags = UInt32(status) - - if updateFlags & MPV_RENDER_UPDATE_FRAME.rawValue != 0 { - renderFrame() - } - - if status > 0 { - scheduleRender() - } - } - - private func renderFrame() { - guard let context = renderContext else { return } - let videoSize = currentVideoSize() - guard videoSize.width > 0, videoSize.height > 0 else { return } - - 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) - } - - var pixelBuffer: CVPixelBuffer? - var status: CVReturn = kCVReturnError - - if !preAllocatedBuffers.isEmpty { - pixelBuffer = preAllocatedBuffers.removeFirst() - status = kCVReturnSuccess - } else if let pool = pixelBufferPool { - status = CVPixelBufferPoolCreatePixelBufferWithAuxAttributes(kCFAllocatorDefault, pool, pixelBufferPoolAuxAttributes, &pixelBuffer) - } - - if status != kCVReturnSuccess || pixelBuffer == nil { - let attrs: [CFString: Any] = [ - kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, - kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue!, - kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue!, - kCVPixelBufferMetalCompatibilityKey: kCFBooleanTrue!, - kCVPixelBufferWidthKey: width, - kCVPixelBufferHeightKey: height, - kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA - ] - status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer) - } - - guard status == kCVReturnSuccess, let buffer = pixelBuffer else { - Logger.shared.log("Failed to create pixel buffer for rendering (status: \(status))", type: "Error") - return - } - - let actualFormat = CVPixelBufferGetPixelFormatType(buffer) - if actualFormat != kCVPixelFormatType_32BGRA { - Logger.shared.log("Pixel buffer format mismatch: expected BGRA (0x42475241), got \(actualFormat)", type: "Error") - } - - CVPixelBufferLockBaseAddress(buffer, []) - guard let baseAddress = CVPixelBufferGetBaseAddress(buffer) else { - CVPixelBufferUnlockBaseAddress(buffer, []) - return - } - - if shouldClearPixelBuffer { - let bufferDataSize = CVPixelBufferGetDataSize(buffer) - memset(baseAddress, 0, bufferDataSize) - shouldClearPixelBuffer = false - } - - dimensionsArray[0] = Int32(width) - dimensionsArray[1] = Int32(height) - let stride = Int32(CVPixelBufferGetBytesPerRow(buffer)) - let expectedMinStride = Int32(width * 4) - if stride < expectedMinStride { - Logger.shared.log("Unexpected pixel buffer stride \(stride) < expected \(expectedMinStride) — skipping render to avoid memory corruption", type: "Error") - CVPixelBufferUnlockBaseAddress(buffer, []) - return - } - - let pointerValue = baseAddress - dimensionsArray.withUnsafeMutableBufferPointer { dimsPointer in - bgraFormatCString.withUnsafeBufferPointer { formatPointer in - withUnsafePointer(to: stride) { stridePointer in - renderParams[0] = mpv_render_param(type: MPV_RENDER_PARAM_SW_SIZE, data: UnsafeMutableRawPointer(dimsPointer.baseAddress)) - renderParams[1] = mpv_render_param(type: MPV_RENDER_PARAM_SW_FORMAT, data: UnsafeMutableRawPointer(mutating: formatPointer.baseAddress)) - renderParams[2] = mpv_render_param(type: MPV_RENDER_PARAM_SW_STRIDE, data: UnsafeMutableRawPointer(mutating: stridePointer)) - renderParams[3] = mpv_render_param(type: MPV_RENDER_PARAM_SW_POINTER, data: pointerValue) - renderParams[4] = mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil) - - let rc = mpv_render_context_render(context, &renderParams) - if rc < 0 { - Logger.shared.log("mpv_render_context_render returned error \(rc)", type: "Error") - } - } - } - } - - CVPixelBufferUnlockBaseAddress(buffer, []) - - enqueue(buffer: buffer) - - 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) { - guard width > 0, height > 0 else { return } - - let pixelFormat = kCVPixelFormatType_32BGRA - - let attrs: [CFString: Any] = [ - kCVPixelBufferPixelFormatTypeKey: pixelFormat, - kCVPixelBufferWidthKey: width, - kCVPixelBufferHeightKey: height, - kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, - kCVPixelBufferMetalCompatibilityKey: kCFBooleanTrue!, - kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue!, - kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue! - ] - - let poolAttrs: [CFString: Any] = [ - kCVPixelBufferPoolMinimumBufferCountKey: maxPreAllocatedBuffers, - kCVPixelBufferPoolMaximumBufferAgeKey: 0 - ] - - let auxAttrs: [CFString: Any] = [ - kCVPixelBufferPoolAllocationThresholdKey: 8 - ] - - var pool: CVPixelBufferPool? - let status = CVPixelBufferPoolCreate(kCFAllocatorDefault, poolAttrs as CFDictionary, attrs as CFDictionary, &pool) - if status == kCVReturnSuccess, let pool { - renderQueueSync { - self.pixelBufferPool = pool - self.pixelBufferPoolAuxAttributes = auxAttrs as CFDictionary - self.poolWidth = width - self.poolHeight = height - } - - renderQueue.async { [weak self] in - self?.preAllocateBuffers() - } - } else { - Logger.shared.log("Failed to create CVPixelBufferPool (status: \(status))", type: "Error") - } - } - - private func recreatePixelBufferPool(width: Int, height: Int) { - renderQueueSync { - self.preAllocatedBuffers.removeAll() - self.pixelBufferPool = nil - self.formatDescription = nil - self.poolWidth = 0 - self.poolHeight = 0 - } - - createPixelBufferPool(width: width, height: height) - } - - private func preAllocateBuffers() { - guard DispatchQueue.getSpecific(key: renderQueueKey) != nil else { - renderQueue.async { [weak self] in - self?.preAllocateBuffers() - } - return - } - - guard let pool = pixelBufferPool else { return } - - let targetCount = min(maxPreAllocatedBuffers, 8) - let currentCount = preAllocatedBuffers.count - - guard currentCount < targetCount else { return } - - let bufferCount = targetCount - currentCount - - for _ in 0.. Bool { - var didChange = false - let width = Int32(CVPixelBufferGetWidth(buffer)) - let height = Int32(CVPixelBufferGetHeight(buffer)) - let pixelFormat = CVPixelBufferGetPixelFormatType(buffer) - - renderQueueSync { - var needsRecreate = false - - if let description = formatDescription { - let currentDimensions = CMVideoFormatDescriptionGetDimensions(description) - let currentPixelFormat = CMFormatDescriptionGetMediaSubType(description) - - if currentDimensions.width != width || - currentDimensions.height != height || - currentPixelFormat != pixelFormat { - needsRecreate = true - } - } else { - needsRecreate = true - } - - if needsRecreate { - var newDescription: CMVideoFormatDescription? - - let status = CMVideoFormatDescriptionCreateForImageBuffer( - allocator: kCFAllocatorDefault, - imageBuffer: buffer, - formatDescriptionOut: &newDescription - ) - - if status == noErr, let newDescription = newDescription { - formatDescription = newDescription - didChange = true - Logger.shared.log("Created new format description: \(width)x\(height), format: \(pixelFormat)", type: "Info") - } else { - Logger.shared.log("Failed to create format description (status: \(status))", type: "Error") - } - } - } - return didChange - } - - private func renderQueueSync(_ block: () -> Void) { - if DispatchQueue.getSpecific(key: renderQueueKey) != nil { - block() - } else { - renderQueue.sync(execute: block) - } - } - - private func currentVideoSize() -> CGSize { - stateQueue.sync { - videoSize - } - } - - private func updateVideoSize(width: Int, height: Int) { - let size = CGSize(width: max(width, 0), height: max(height, 0)) - stateQueue.async(flags: .barrier) { - self.videoSize = size - } - renderQueue.async { [weak self] in - guard let self else { return } - - if self.poolWidth != width || self.poolHeight != height { - self.recreatePixelBufferPool(width: max(width, 0), height: max(height, 0)) - } - } - } - - private func apply(commands: [[String]], on handle: OpaquePointer) { - for command in commands { - guard !command.isEmpty else { continue } - self.command(handle, command) - } - } - - /// Async command - returns immediately, mpv processes later - private func command(_ handle: OpaquePointer, _ args: [String]) { - guard !args.isEmpty else { return } - _ = withCStringArray(args) { pointer in - mpv_command_async(handle, 0, pointer) - } - } - - /// Sync command - waits for mpv to process before returning - private func commandSync(_ handle: OpaquePointer, _ args: [String]) -> Int32 { - guard !args.isEmpty else { return -1 } - return withCStringArray(args) { pointer in - mpv_command(handle, pointer) - } - } - - private func processEvents() { - eventQueueGroup.enter() - let group = eventQueueGroup - eventQueue.async { [weak self] in - defer { group.leave() } - guard let self else { return } - while !self.isStopping { - guard let handle = self.mpv else { return } - guard let eventPointer = mpv_wait_event(handle, 0) else { return } - let event = eventPointer.pointee - if event.event_id == MPV_EVENT_NONE { continue } - self.handleEvent(event) - if event.event_id == MPV_EVENT_SHUTDOWN { break } - } - } - } - - private func handleEvent(_ event: mpv_event) { - switch event.event_id { - case MPV_EVENT_VIDEO_RECONFIG: - refreshVideoState() - case MPV_EVENT_FILE_LOADED: - // Add external subtitles now that the file is loaded - let hadExternalSubs = !pendingExternalSubtitles.isEmpty - if hadExternalSubs, let handle = mpv { - for subUrl in pendingExternalSubtitles { - command(handle, ["sub-add", subUrl]) - } - pendingExternalSubtitles = [] - - // Set subtitle after external subs are added (track IDs have changed) - if let subId = initialSubtitleId { - setSubtitleTrack(subId) - } else { - disableSubtitles() - } - } - - if !isReadyToSeek { - isReadyToSeek = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.delegate?.renderer(self, didBecomeReadyToSeek: true) - } - } - case MPV_EVENT_PROPERTY_CHANGE: - if let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee.name { - let name = String(cString: property) - refreshProperty(named: name) - } - case MPV_EVENT_SHUTDOWN: - Logger.shared.log("mpv shutdown", type: "Warn") - case MPV_EVENT_LOG_MESSAGE: - if let logMessagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { - let component = String(cString: logMessagePointer.pointee.prefix) - let text = String(cString: logMessagePointer.pointee.text) - let lower = text.lowercased() - if lower.contains("error") { - Logger.shared.log("mpv[\(component)] \(text)", type: "Error") - } else if lower.contains("warn") || lower.contains("warning") || lower.contains("deprecated") { - Logger.shared.log("mpv[\(component)] \(text)", type: "Warn") - } - } - default: - break - } - } - - private func refreshVideoState() { - guard let handle = mpv else { return } - var width: Int64 = 0 - var height: Int64 = 0 - getProperty(handle: handle, name: "dwidth", format: MPV_FORMAT_INT64, value: &width) - getProperty(handle: handle, name: "dheight", format: MPV_FORMAT_INT64, value: &height) - updateVideoSize(width: Int(width), height: Int(height)) - } - - private func refreshProperty(named name: String) { - guard let handle = mpv else { return } - switch name { - case "duration": - var value = Double(0) - let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) - if status >= 0 { - cachedDuration = value - delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: cachedDuration) - } - case "time-pos": - // Skip updates while seeking to prevent race condition - guard !isSeeking else { return } - var value = Double(0) - let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) - if status >= 0 { - cachedPosition = value - positionUpdateTime = CACurrentMediaTime() // Record when we got this update - delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: cachedDuration) - } - case "pause": - var flag: Int32 = 0 - let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) - if status >= 0 { - let newPaused = flag != 0 - if newPaused != isPaused { - isPaused = newPaused - // Update timebase rate - use playbackSpeed when playing, 0 when paused - let speed = self.playbackSpeed - DispatchQueue.main.async { [weak self] in - if let timebase = self?.displayLayer.controlTimebase { - CMTimebaseSetRate(timebase, rate: newPaused ? 0 : speed) - } - } - delegate?.renderer(self, didChangePause: isPaused) - } - } - case "track-list/count": - var trackCount: Int64 = 0 - let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_INT64, value: &trackCount) - if status >= 0 && trackCount > 0 { - Logger.shared.log("Track list updated: \(trackCount) tracks available", type: "Info") - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.delegate?.renderer(self, didBecomeTracksReady: true) - } - } - default: - break - } - } - - private func getStringProperty(handle: OpaquePointer, name: String) -> String? { - var result: String? - name.withCString { pointer in - if let cString = mpv_get_property_string(handle, pointer) { - result = String(cString: cString) - mpv_free(cString) - } - } - return result - } - - @discardableResult - private func getProperty(handle: OpaquePointer, name: String, format: mpv_format, value: inout T) -> Int32 { - return name.withCString { pointer in - return withUnsafeMutablePointer(to: &value) { mutablePointer in - return mpv_get_property(handle, pointer, format, mutablePointer) - } - } - } - - @inline(__always) - private func withCStringArray(_ args: [String], body: (UnsafeMutablePointer?>?) -> R) -> R { - var cStrings = [UnsafeMutablePointer?]() - cStrings.reserveCapacity(args.count + 1) - for s in args { - cStrings.append(strdup(s)) - } - cStrings.append(nil) - defer { - for ptr in cStrings where ptr != nil { - free(ptr) - } - } - - return cStrings.withUnsafeMutableBufferPointer { buffer in - return buffer.baseAddress!.withMemoryRebound(to: UnsafePointer?.self, capacity: buffer.count) { rebound in - return body(UnsafeMutablePointer(mutating: rebound)) - } - } - } - - // MARK: - Playback Controls - func play() { - setProperty(name: "pause", value: "no") - } - - func pausePlayback() { - setProperty(name: "pause", value: "yes") - } - - func togglePause() { - if isPaused { play() } else { pausePlayback() } - } - - func seek(to seconds: Double) { - guard let handle = mpv else { return } - let clamped = max(0, seconds) - let wasPaused = isPaused - // Prevent time-pos updates from overwriting during seek - isSeeking = true - // Update cached position BEFORE seek so new frames get correct timestamp - cachedPosition = clamped - positionUpdateTime = CACurrentMediaTime() // Reset interpolation base - lastPTS = clamped // Reset monotonic PTS to new position - // Update timebase to match new position (sets rate to 1 for frame display) - syncTimebase(to: clamped) - // Sync seek for accurate positioning - commandSync(handle, ["seek", String(clamped), "absolute"]) - isSeeking = false - // Restore paused rate after seek completes - if wasPaused { - restoreTimebaseRate() - } - } - - func seek(by seconds: Double) { - guard let handle = mpv else { return } - let wasPaused = isPaused - // Prevent time-pos updates from overwriting during seek - isSeeking = true - // Update cached position BEFORE seek - let newPosition = max(0, cachedPosition + seconds) - cachedPosition = newPosition - positionUpdateTime = CACurrentMediaTime() // Reset interpolation base - lastPTS = newPosition // Reset monotonic PTS to new position - // Update timebase to match new position (sets rate to 1 for frame display) - syncTimebase(to: newPosition) - // Sync seek for accurate positioning - commandSync(handle, ["seek", String(seconds), "relative"]) - isSeeking = false - // Restore paused rate after seek completes - if wasPaused { - restoreTimebaseRate() - } - } - - private func restoreTimebaseRate() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in - guard let self = self, self.isPaused else { return } - if let timebase = self.displayLayer.controlTimebase { - CMTimebaseSetRate(timebase, rate: 0) - } - } - } - - private func syncTimebase(to position: Double) { - let speed = playbackSpeed - let doWork = { [weak self] in - guard let self = self else { return } - // Flush old frames to avoid "old frames with new clock" mismatches - if #available(iOS 17.0, *) { - self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: false, completionHandler: nil) - } else { - self.displayLayer.flush() - } - if let timebase = self.displayLayer.controlTimebase { - // Update timebase to new position - CMTimebaseSetTime(timebase, time: CMTime(seconds: position, preferredTimescale: 1000)) - // Set rate to playback speed during seek to ensure frame displays - // restoreTimebaseRate() will set it back to 0 if paused - CMTimebaseSetRate(timebase, rate: speed) - } - } - - if Thread.isMainThread { - doWork() - } else { - DispatchQueue.main.sync { doWork() } - } - } - - /// Sync timebase with current position without flushing (for smooth PiP transitions) - func syncTimebase() { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - if let timebase = self.displayLayer.controlTimebase { - CMTimebaseSetTime(timebase, time: CMTime(seconds: self.cachedPosition, preferredTimescale: 1000)) - CMTimebaseSetRate(timebase, rate: self.isPaused ? 0 : self.playbackSpeed) - } - } - } - - func setSpeed(_ speed: Double) { - playbackSpeed = speed - setProperty(name: "speed", value: String(speed)) - // Sync timebase rate with playback speed - DispatchQueue.main.async { [weak self] in - guard let self = self, - let timebase = self.displayLayer.controlTimebase else { return } - let rate = self.isPaused ? 0.0 : speed - CMTimebaseSetRate(timebase, rate: rate) - } - } - - func getSpeed() -> Double { - guard let handle = mpv else { return 1.0 } - var speed: Double = 1.0 - getProperty(handle: handle, name: "speed", format: MPV_FORMAT_DOUBLE, value: &speed) - return speed - } - - // MARK: - Subtitle Controls - - func getSubtitleTracks() -> [[String: Any]] { - guard let handle = mpv else { - Logger.shared.log("getSubtitleTracks: mpv handle is nil", type: "Warn") - return [] - } - var tracks: [[String: Any]] = [] - - var trackCount: Int64 = 0 - getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) - - for i in 0.. Int { - guard let handle = mpv else { return 0 } - var sid: Int64 = 0 - getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid) - return Int(sid) - } - - func addSubtitleFile(url: String, select: Bool = true) { - guard let handle = mpv else { return } - // "cached" adds without selecting, "select" adds and selects - let flag = select ? "select" : "cached" - commandSync(handle, ["sub-add", url, flag]) - } - - // MARK: - Subtitle Positioning - - func setSubtitlePosition(_ position: Int) { - setProperty(name: "sub-pos", value: String(position)) - } - - func setSubtitleScale(_ scale: Double) { - setProperty(name: "sub-scale", value: String(scale)) - } - - func setSubtitleMarginY(_ margin: Int) { - setProperty(name: "sub-margin-y", value: String(margin)) - } - - func setSubtitleAlignX(_ alignment: String) { - setProperty(name: "sub-align-x", value: alignment) - } - - func setSubtitleAlignY(_ alignment: String) { - setProperty(name: "sub-align-y", value: alignment) - } - - func setSubtitleFontSize(_ size: Int) { - setProperty(name: "sub-font-size", value: String(size)) - } - - // MARK: - Audio Track Controls - - func getAudioTracks() -> [[String: Any]] { - guard let handle = mpv else { - Logger.shared.log("getAudioTracks: mpv handle is nil", type: "Warn") - return [] - } - var tracks: [[String: Any]] = [] - - var trackCount: Int64 = 0 - getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) - - for i in 0.. 0 { - track["channels"] = Int(channels) - } - - var selected: Int32 = 0 - getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected) - track["selected"] = selected != 0 - - Logger.shared.log("getAudioTracks: found audio track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info") - tracks.append(track) - } - - Logger.shared.log("getAudioTracks: returning \(tracks.count) audio tracks", type: "Info") - return tracks - } - - func setAudioTrack(_ trackId: Int) { - guard let handle = mpv else { - Logger.shared.log("setAudioTrack: mpv handle is nil", type: "Warn") - return - } - Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info") - - // Use setProperty for synchronous behavior - setProperty(name: "aid", value: String(trackId)) - } - - func getCurrentAudioTrack() -> Int { - guard let handle = mpv else { return 0 } - var aid: Int64 = 0 - getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid) - return Int(aid) - } -} diff --git a/modules/mpv-player/ios/MpvPlayer.podspec b/modules/mpv-player/ios/MpvPlayer.podspec index e0960aa6..2a6c2ed6 100644 --- a/modules/mpv-player/ios/MpvPlayer.podspec +++ b/modules/mpv-player/ios/MpvPlayer.podspec @@ -13,16 +13,21 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' - s.dependency 'MPVKit', '~> 0.40.0' + s.dependency 'MPVKit-GPL' # Swift/Objective-C compatibility s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', - # Strip debug symbols to avoid DWARF errors from MPVKit + 'VALID_ARCHS' => 'arm64', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', 'DEBUG_INFORMATION_FORMAT' => 'dwarf', 'STRIP_INSTALLED_PRODUCT' => 'YES', 'DEPLOYMENT_POSTPROCESSING' => 'YES', } - s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" + s.user_target_xcconfig = { + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' + } + + s.source_files = "*.{h,m,mm,swift,hpp,cpp}" end diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index 87c60400..c8355791 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -164,6 +164,15 @@ public class MpvPlayerModule: Module { return view.getCurrentAudioTrack() } + // Video scaling functions + AsyncFunction("setZoomedToFill") { (view: MpvPlayerView, zoomed: Bool) in + view.setZoomedToFill(zoomed) + } + + AsyncFunction("isZoomedToFill") { (view: MpvPlayerView) -> Bool in + return view.isZoomedToFill() + } + // Defines events that the view can send to JavaScript Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") } diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index a6348a7b..1981db5d 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -38,7 +38,7 @@ struct VideoLoadConfig { // to apply the proper styling (e.g. border radius and shadows). class MpvPlayerView: ExpoView { private let displayLayer = AVSampleBufferDisplayLayer() - private var renderer: MPVSoftwareRenderer? + private var renderer: MPVLayerRenderer? private var videoContainer: UIView! private var pipController: PiPController? @@ -52,6 +52,7 @@ class MpvPlayerView: ExpoView { private var cachedPosition: Double = 0 private var cachedDuration: Double = 0 private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek + private var _isZoomedToFill: Bool = false required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -83,7 +84,7 @@ class MpvPlayerView: ExpoView { videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor) ]) - renderer = MPVSoftwareRenderer(displayLayer: displayLayer) + renderer = MPVLayerRenderer(displayLayer: displayLayer) renderer?.delegate = self // Setup PiP @@ -148,12 +149,14 @@ class MpvPlayerView: ExpoView { func play() { intendedPlayState = true renderer?.play() + pipController?.setPlaybackRate(1.0) pipController?.updatePlaybackState() } func pause() { intendedPlayState = false renderer?.pausePlayback() + pipController?.setPlaybackRate(0.0) pipController?.updatePlaybackState() } @@ -267,6 +270,17 @@ class MpvPlayerView: ExpoView { renderer?.setSubtitleFontSize(size) } + // MARK: - Video Scaling + + func setZoomedToFill(_ zoomed: Bool) { + _isZoomedToFill = zoomed + displayLayer.videoGravity = zoomed ? .resizeAspectFill : .resizeAspect + } + + func isZoomedToFill() -> Bool { + return _isZoomedToFill + } + deinit { pipController?.stopPictureInPicture() renderer?.stop() @@ -274,18 +288,18 @@ class MpvPlayerView: ExpoView { } } -// MARK: - MPVSoftwareRendererDelegate +// MARK: - MPVLayerRendererDelegate -extension MpvPlayerView: MPVSoftwareRendererDelegate { - func renderer(_: MPVSoftwareRenderer, didUpdatePosition position: Double, duration: Double) { +extension MpvPlayerView: MPVLayerRendererDelegate { + func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) { cachedPosition = position cachedDuration = duration DispatchQueue.main.async { [weak self] in guard let self else { return } - // Only update PiP state when PiP is active + // Update PiP current time for progress bar if self.pipController?.isPictureInPictureActive == true { - self.pipController?.updatePlaybackState() + self.pipController?.setCurrentTimeFromSeconds(position, duration: duration) } self.onProgress([ @@ -296,21 +310,23 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate { } } - func renderer(_: MPVSoftwareRenderer, didChangePause isPaused: Bool) { + func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } // Don't update intendedPlayState here - it's only set by user actions (play/pause) // This prevents PiP UI flicker during seeking + + // Sync timebase rate with actual playback state + self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0) + self.onPlaybackStateChange([ "isPaused": isPaused, "isPlaying": !isPaused, ]) - // Note: Don't call updatePlaybackState() here to avoid flicker - // PiP queries pipControllerIsPlaying when it needs the state } } - func renderer(_: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) { + func renderer(_: MPVLayerRenderer, didChangeLoading isLoading: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onPlaybackStateChange([ @@ -319,7 +335,7 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate { } } - func renderer(_: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) { + func renderer(_: MPVLayerRenderer, didBecomeReadyToSeek: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onPlaybackStateChange([ @@ -328,7 +344,7 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate { } } - func renderer(_: MPVSoftwareRenderer, didBecomeTracksReady: Bool) { + func renderer(_: MPVLayerRenderer, didBecomeTracksReady: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onTracksReady([:]) @@ -343,12 +359,14 @@ extension MpvPlayerView: PiPControllerDelegate { print("PiP will start") // Sync timebase before PiP starts for smooth transition renderer?.syncTimebase() - pipController?.updatePlaybackState() + // Set current time for PiP progress bar + pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) } func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) { print("PiP did start: \(didStartPictureInPicture)") - pipController?.updatePlaybackState() + // Ensure current time is synced when PiP starts + pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) } func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { @@ -371,12 +389,16 @@ extension MpvPlayerView: PiPControllerDelegate { func pipControllerPlay(_ controller: PiPController) { print("PiP play requested") - play() + intendedPlayState = true + renderer?.play() + pipController?.setPlaybackRate(1.0) } func pipControllerPause(_ controller: PiPController) { print("PiP pause requested") - pause() + intendedPlayState = false + renderer?.pausePlayback() + pipController?.setPlaybackRate(0.0) } func pipController(_ controller: PiPController, skipByInterval interval: CMTime) { @@ -394,4 +416,8 @@ extension MpvPlayerView: PiPControllerDelegate { func pipControllerDuration(_ controller: PiPController) -> Double { return getDuration() } + + func pipControllerCurrentPosition(_ controller: PiPController) -> Double { + return getCurrentPosition() + } } diff --git a/modules/mpv-player/ios/PiPController.swift b/modules/mpv-player/ios/PiPController.swift index 80680896..7a58cb38 100644 --- a/modules/mpv-player/ios/PiPController.swift +++ b/modules/mpv-player/ios/PiPController.swift @@ -12,6 +12,7 @@ protocol PiPControllerDelegate: AnyObject { func pipController(_ controller: PiPController, skipByInterval interval: CMTime) func pipControllerIsPlaying(_ controller: PiPController) -> Bool func pipControllerDuration(_ controller: PiPController) -> Double + func pipControllerCurrentPosition(_ controller: PiPController) -> Double } final class PiPController: NSObject { @@ -20,6 +21,13 @@ final class PiPController: NSObject { weak var delegate: PiPControllerDelegate? + // Timebase for PiP progress tracking + private var timebase: CMTimebase? + + // Track current time for PiP progress + private var currentTime: CMTime = .zero + private var currentDuration: Double = 0 + var isPictureInPictureSupported: Bool { return AVPictureInPictureController.isPictureInPictureSupported() } @@ -35,9 +43,29 @@ final class PiPController: NSObject { init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { self.sampleBufferDisplayLayer = sampleBufferDisplayLayer super.init() + setupTimebase() setupPictureInPicture() } + private func setupTimebase() { + // Create a timebase for tracking playback time + var newTimebase: CMTimebase? + let status = CMTimebaseCreateWithSourceClock( + allocator: kCFAllocatorDefault, + sourceClock: CMClockGetHostTimeClock(), + timebaseOut: &newTimebase + ) + + if status == noErr, let tb = newTimebase { + timebase = tb + CMTimebaseSetTime(tb, time: .zero) + CMTimebaseSetRate(tb, rate: 0) // Start paused + + // Set the control timebase on the display layer + sampleBufferDisplayLayer?.controlTimebase = tb + } + } + private func setupPictureInPicture() { guard isPictureInPictureSupported, let displayLayer = sampleBufferDisplayLayer else { @@ -81,6 +109,9 @@ final class PiPController: NSObject { } func updatePlaybackState() { + // Only invalidate when PiP is active to avoid "no context menu visible" warnings + guard isPictureInPictureActive else { return } + if Thread.isMainThread { pipController?.invalidatePlaybackState() } else { @@ -89,6 +120,36 @@ final class PiPController: NSObject { } } } + + /// Updates the current playback time for PiP progress display + func setCurrentTime(_ time: CMTime) { + currentTime = time + + // Update the timebase to reflect current position + if let tb = timebase { + CMTimebaseSetTime(tb, time: time) + } + + // Only invalidate when PiP is active to avoid unnecessary updates + if isPictureInPictureActive { + updatePlaybackState() + } + } + + /// Updates the current playback time from seconds + func setCurrentTimeFromSeconds(_ seconds: Double, duration: Double) { + guard seconds >= 0 else { return } + currentDuration = duration + let time = CMTime(seconds: seconds, preferredTimescale: 1000) + setCurrentTime(time) + } + + /// Updates the playback rate on the timebase (1.0 = playing, 0.0 = paused) + func setPlaybackRate(_ rate: Float) { + if let tb = timebase { + CMTimebaseSetRate(tb, rate: Float64(rate)) + } + } } // MARK: - AVPictureInPictureControllerDelegate diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 4359f515..8ed61d51 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -86,6 +86,9 @@ export interface MpvPlayerViewRef { getAudioTracks: () => Promise; setAudioTrack: (trackId: number) => Promise; getCurrentAudioTrack: () => Promise; + // Video scaling + setZoomedToFill: (zoomed: boolean) => Promise; + isZoomedToFill: () => Promise; } export type SubtitleTrack = { diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index e29d3ad5..e5e0ccda 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -94,6 +94,13 @@ export default React.forwardRef( getCurrentAudioTrack: async () => { return await nativeRef.current?.getCurrentAudioTrack(); }, + // Video scaling + setZoomedToFill: async (zoomed: boolean) => { + await nativeRef.current?.setZoomedToFill(zoomed); + }, + isZoomedToFill: async () => { + return await nativeRef.current?.isZoomedToFill(); + }, })); return ; diff --git a/modules/mpv-player/src/MpvPlayerView.web.tsx b/modules/mpv-player/src/MpvPlayerView.web.tsx index 323f4341..dc322ad7 100644 --- a/modules/mpv-player/src/MpvPlayerView.web.tsx +++ b/modules/mpv-player/src/MpvPlayerView.web.tsx @@ -1,14 +1,14 @@ import { MpvPlayerViewProps } from "./MpvPlayer.types"; export default function MpvPlayerView(props: MpvPlayerViewProps) { - const url = props.source?.url; + const url = props.source?.url ?? ""; return (