From 505ef39ee72789020e425c0b55eefaf971381db4 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Sun, 9 Feb 2025 19:13:47 -0500 Subject: [PATCH] ios VLCKit 4.0 & All platform PiP support --- app.json | 20 +- app/(auth)/player/direct-player.tsx | 115 ++--- components/video-player/controls/Controls.tsx | 42 +- .../expo/modules/vlcplayer/VlcPlayerModule.kt | 7 +- .../expo/modules/vlcplayer/VlcPlayerView.kt | 136 +++++- modules/vlc-player/index.ts | 15 +- modules/vlc-player/ios/VlcPlayer.podspec | 6 +- modules/vlc-player/ios/VlcPlayerModule.swift | 27 +- modules/vlc-player/ios/VlcPlayerView.swift | 400 ++++++++++-------- modules/vlc-player/src/VlcPlayer.types.ts | 8 + modules/vlc-player/src/VlcPlayerView.tsx | 5 + plugins/withAndroidManifest.js | 38 ++ plugins/withGoogleCastActivity.js | 34 -- 13 files changed, 508 insertions(+), 345 deletions(-) create mode 100644 plugins/withAndroidManifest.js delete mode 100644 plugins/withGoogleCastActivity.js diff --git a/app.json b/app.json index 1155d5a4..1aae5cda 100644 --- a/app.json +++ b/app.json @@ -106,11 +106,21 @@ } } ], - ["react-native-bottom-tabs"], - ["./plugins/withChangeNativeAndroidTextToWhite.js"], - ["./plugins/withGoogleCastActivity.js"], - ["./plugins/withTrustLocalCerts.js"], - ["./plugins/withGradleProperties.js"], + [ + "react-native-bottom-tabs" + ], + [ + "./plugins/withChangeNativeAndroidTextToWhite.js" + ], + [ + "./plugins/withAndroidManifest.js" + ], + [ + "./plugins/withTrustLocalCerts.js" + ], + [ + "./plugins/withGradleProperties.js" + ], [ "expo-splash-screen", { diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 60ae7796..d36c9f4c 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -3,12 +3,11 @@ import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/controls/Controls"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; -import { useOrientation } from "@/hooks/useOrientation"; -import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useWebSocket } from "@/hooks/useWebsockets"; import { VlcPlayerView } from "@/modules/vlc-player"; import { + PipStartedPayload, PlaybackStatePayload, ProgressUpdatePayload, VlcPlayerViewRef, @@ -18,13 +17,10 @@ const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { writeToLog } from "@/utils/log"; import native from "@/utils/profiles/native"; import { msToTicks, ticksToSeconds } from "@/utils/time"; -import { Api } from "@jellyfin/sdk"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getPlaystateApi, getUserLibraryApi, @@ -42,14 +38,12 @@ import React, { } from "react"; import { Alert, - BackHandler, View, AppState, AppStateStatus, Platform, } from "react-native"; import { useSharedValue } from "react-native-reanimated"; -import settings from "../(tabs)/(home)/settings"; import { useSettings } from "@/utils/atoms/settings"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -67,6 +61,7 @@ export default function page() { const [isPlaying, setIsPlaying] = useState(false); const [isBuffering, setIsBuffering] = useState(true); const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [isPipStarted, setIsPipStarted] = useState(false); const progress = useSharedValue(0); const isSeeking = useSharedValue(false); @@ -190,37 +185,23 @@ export default function page() { lightHapticFeedback(); if (isPlaying) { await videoRef.current?.pause(); - - if (!offline && stream) { - await getPlaystateApi(api).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: msToTicks(progress.value), - isPaused: true, - playMethod: stream.url?.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream.sessionId, - }); - } } else { videoRef.current?.play(); - if (!offline && stream) { - await getPlaystateApi(api).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: msToTicks(progress.value), - isPaused: false, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", - playSessionId: stream.sessionId, - }); - } + } + + if (!offline && stream) { + await getPlaystateApi(api).onPlaybackProgress({ + itemId: item?.Id!, + audioStreamIndex: audioIndex ? audioIndex : undefined, + subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + mediaSourceId: mediaSourceId, + positionTicks: msToTicks(progress.get()), + isPaused: !isPlaying, + playMethod: stream?.url.includes("m3u8") + ? "Transcode" + : "DirectStream", + playSessionId: stream.sessionId, + }); } }, [ isPlaying, @@ -232,13 +213,13 @@ export default function page() { subtitleIndex, mediaSourceId, offline, - progress.value, + progress, ]); const reportPlaybackStopped = useCallback(async () => { if (offline) return; - const currentTimeInTicks = msToTicks(progress.value); + const currentTimeInTicks = msToTicks(progress.get()); await getPlaystateApi(api!).onPlaybackStopped({ itemId: item?.Id!, @@ -273,8 +254,7 @@ export default function page() { const onProgress = useCallback( async (data: ProgressUpdatePayload) => { - if (isSeeking.value === true) return; - if (isPlaybackStopped === true) return; + if (isSeeking.get() || isPlaybackStopped) return; const { currentTime } = data.nativeEvent; @@ -282,7 +262,7 @@ export default function page() { setIsBuffering(false); } - progress.value = currentTime; + progress.set(currentTime); if (offline) return; @@ -301,7 +281,7 @@ export default function page() { playSessionId: stream.sessionId, }); }, - [item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex] + [item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex] ); useWebSocket({ @@ -311,6 +291,11 @@ export default function page() { offline, }); + const onPipStarted = useCallback((e: PipStartedPayload) => { + const { pipStarted } = e.nativeEvent; + setIsPipStarted(pipStarted) + }, []) + const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const { state, isBuffering, isPlaying } = e.nativeEvent; @@ -340,25 +325,13 @@ export default function page() { : 0; }, [item]); - useFocusEffect( - React.useCallback(() => { - return async () => { - stop(); - }; - }, []) - ); - const [appState, setAppState] = useState(AppState.currentState); useEffect(() => { const handleAppStateChange = (nextAppState: AppStateStatus) => { - if (appState.match(/inactive|background/) && nextAppState === "active") { - // Handle app coming to the foreground - } else if (nextAppState.match(/inactive|background/)) { - // Handle app going to the background - if (videoRef.current && videoRef.current.pause) { - videoRef.current.pause(); - } + // Handle app going to the background + if (nextAppState.match(/inactive|background/)) { + _setShowControls(false) } setAppState(nextAppState); }; @@ -373,10 +346,9 @@ export default function page() { // Cleanup the event listener when the component is unmounted subscription.remove(); }; - }, [appState]); + }, [appState, isPipStarted, isPlaying]); // Preselection of audio and subtitle tracks. - if (!settings) return null; let initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; @@ -410,7 +382,7 @@ export default function page() { }; } - if (chosenAudioTrack) + if (chosenAudioTrack) initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); } else { // Transcoded playback CASE @@ -466,6 +438,7 @@ export default function page() { onVideoProgress={onProgress} progressUpdateInterval={1000} onVideoStateChange={onPlaybackStateChanged} + onPipStarted={onPipStarted} onVideoLoadStart={() => {}} onVideoLoadEnd={() => { setIsVideoLoaded(true); @@ -496,6 +469,7 @@ export default function page() { setIgnoreSafeAreas={setIgnoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas} isVideoLoaded={isVideoLoaded} + startPictureInPicture={videoRef?.current?.startPictureInPicture} play={videoRef.current?.play} pause={videoRef.current?.pause} seek={videoRef.current?.seekTo} @@ -512,23 +486,4 @@ export default function page() { )} ); -} - -export function usePoster( - item: BaseItemDto, - api: Api | null -): string | undefined { - const poster = useMemo(() => { - if (!item || !api) return undefined; - return item.Type === "Audio" - ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` - : getBackdropUrl({ - api, - item: item, - quality: 70, - width: 200, - }); - }, [item, api]); - - return poster ?? undefined; -} +} \ No newline at end of file diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 8dd5b9cc..b52e98c7 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -24,7 +24,7 @@ import { ticksToMs, ticksToSeconds, } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; +import {Ionicons, MaterialIcons} from "@expo/vector-icons"; import { BaseItemDto, MediaSourceInfo, @@ -35,7 +35,7 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useAtom } from "jotai"; import { debounce } from "lodash"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { TouchableOpacity, useWindowDimensions, View } from "react-native"; +import {Platform, TouchableOpacity, useWindowDimensions, View} from "react-native"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, @@ -75,6 +75,7 @@ interface Props { isVideoLoaded?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; + startPictureInPicture: () => Promise; play: (() => Promise) | (() => void); pause: () => void; getAudioTracks?: (() => Promise) | (() => TrackInfo[]); @@ -91,6 +92,7 @@ const CONTROLS_TIMEOUT = 4000; export const Controls: React.FC = ({ item, seek, + startPictureInPicture, play, pause, togglePlay, @@ -212,6 +214,8 @@ export const Controls: React.FC = ({ bitrateValue: bitrateValue.toString(), }).toString(); + stop() + if (!bitrateValue) { // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); @@ -250,6 +254,8 @@ export const Controls: React.FC = ({ bitrateValue: bitrateValue.toString(), }).toString(); + stop() + if (!bitrateValue) { // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); @@ -413,6 +419,8 @@ export const Controls: React.FC = ({ bitrateValue: bitrateValue.toString(), }).toString(); + stop() + if (!bitrateValue) { // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); @@ -499,6 +507,15 @@ export const Controls: React.FC = ({ ); }, [trickPlayUrl, trickplayInfo, time]); + const onClose = async () => { + stop() + lightHapticFeedback(); + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP + ); + router.back(); + }; + return ( = ({ + {!Platform.isTV && ( + + + + )} + {item?.Type === "Episode" && !offline && ( { @@ -592,13 +622,7 @@ export const Controls: React.FC = ({ {/* )} */} { - lightHapticFeedback(); - await ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP - ); - router.back(); - }} + onPress={onClose} className="aspect-square flex flex-col rounded-xl items-center justify-center p-2" > diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt index 070e13a8..54b40399 100644 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt @@ -26,9 +26,14 @@ class VlcPlayerModule : Module() { "onVideoLoadStart", "onVideoLoadEnd", "onVideoProgress", - "onVideoError" + "onVideoError", + "onPipStarted" ) + AsyncFunction("startPictureInPicture") { view: VlcPlayerView -> + view.startPictureInPicture() + } + AsyncFunction("play") { view: VlcPlayerView -> view.play() } diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt index 1770e337..9c40f809 100644 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt @@ -1,23 +1,40 @@ package expo.modules.vlcplayer +import android.R +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log -import android.view.ViewGroup -import android.widget.FrameLayout +import androidx.annotation.RequiresApi +import androidx.core.app.ComponentActivity +import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleObserver -import android.net.Uri import expo.modules.kotlin.AppContext -import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView import org.videolan.libvlc.LibVLC import org.videolan.libvlc.Media -import org.videolan.libvlc.interfaces.IMedia import org.videolan.libvlc.MediaPlayer +import org.videolan.libvlc.interfaces.IMedia import org.videolan.libvlc.util.VLCVideoLayout + class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener { + private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION" + private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION" + private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION" private var libVLC: LibVLC? = null private var mediaPlayer: MediaPlayer? = null @@ -30,6 +47,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context private val onVideoProgress by EventDispatcher() private val onVideoStateChange by EventDispatcher() private val onVideoLoadEnd by EventDispatcher() + private val onPipStarted by EventDispatcher() private var startPosition: Int? = 0 private var isMediaReady: Boolean = false @@ -44,9 +62,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context handler.postDelayed(this, updateInterval) } } + private val currentActivity get() = context.findActivity() + private val actions: MutableList = mutableListOf() + + private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + PIP_PLAY_PAUSE_ACTION -> if (isPaused) play() else pause() + PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000) + PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000) + } + } + } init { setupView() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setupPipActions() + currentActivity.apply { + setPictureInPictureParams(getPipParams()!!) + addOnPictureInPictureModeChangedListener { info -> + onPipStarted(mapOf( + "pipStarted" to info.isInPictureInPictureMode + )) + } + } + } } private fun setupView() { @@ -59,6 +100,76 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context Log.d("VlcPlayerView", "View setup complete") } + @RequiresApi(Build.VERSION_CODES.O) + private fun setupPipActions() { + val remoteActionFilter = IntentFilter() + val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName) + val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName) + val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName) + + remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION) + remoteActionFilter.addAction(PIP_FORWARD_ACTION) + remoteActionFilter.addAction(PIP_REWIND_ACTION) + + actions.addAll( + listOf( + RemoteAction( + Icon.createWithResource(context, R.drawable.ic_media_rew), + "Rewind", + "Rewind Video", + PendingIntent.getBroadcast( + context, + 0, + rewindIntent, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + ) + ), + RemoteAction( + Icon.createWithResource(context, R.drawable.ic_media_play), + "Play", + "Play Video", + PendingIntent.getBroadcast( + context, + 0, + playPauseIntent, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + ) + ), + RemoteAction( + Icon.createWithResource(context, R.drawable.ic_media_ff), + "Skip", + "Skip Forward", + PendingIntent.getBroadcast( + context, + 0, + forwardIntent, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + ) + ) + ) + ) + + ContextCompat.registerReceiver( + context, + actionReceiver, + remoteActionFilter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + private fun getPipParams(): PictureInPictureParams? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + var builder = PictureInPictureParams.Builder() + .setActions(actions) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder = builder.setAutoEnterEnabled(true) + } + return builder.build() + } + return null + } + fun setSource(source: Map) { if (hasSource) { mediaPlayer?.attachViews(videoLayout, null, false, false) @@ -112,6 +223,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context } } + fun startPictureInPicture() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + currentActivity.enterPictureInPictureMode(getPipParams()!!) + } + } + fun play() { mediaPlayer?.play() isPaused = false @@ -283,4 +400,13 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context )); } } +} + +internal fun Context.findActivity(): androidx.activity.ComponentActivity { + var context = this + while (context is ContextWrapper) { + if (context is androidx.activity.ComponentActivity) return context + context = context.baseContext + } + throw IllegalStateException("Failed to find ComponentActivity") } \ No newline at end of file diff --git a/modules/vlc-player/index.ts b/modules/vlc-player/index.ts index 6514a420..d4b089cf 100644 --- a/modules/vlc-player/index.ts +++ b/modules/vlc-player/index.ts @@ -1,7 +1,6 @@ import { - NativeModulesProxy, EventEmitter, - Subscription, + EventSubscription, } from "expo-modules-core"; import VlcPlayerModule from "./src/VlcPlayerModule"; @@ -19,13 +18,11 @@ import { VlcPlayerViewRef, } from "./src/VlcPlayer.types"; -const emitter = new EventEmitter( - VlcPlayerModule ?? NativeModulesProxy.VlcPlayer -); +const emitter = new EventEmitter(VlcPlayerModule); export function addPlaybackStateListener( listener: (event: PlaybackStatePayload) => void -): Subscription { +): EventSubscription { return emitter.addListener( "onPlaybackStateChanged", listener @@ -34,7 +31,7 @@ export function addPlaybackStateListener( export function addVideoLoadStartListener( listener: (event: VideoLoadStartPayload) => void -): Subscription { +): EventSubscription { return emitter.addListener( "onVideoLoadStart", listener @@ -43,7 +40,7 @@ export function addVideoLoadStartListener( export function addVideoStateChangeListener( listener: (event: VideoStateChangePayload) => void -): Subscription { +): EventSubscription { return emitter.addListener( "onVideoStateChange", listener @@ -52,7 +49,7 @@ export function addVideoStateChangeListener( export function addVideoProgressListener( listener: (event: VideoProgressPayload) => void -): Subscription { +): EventSubscription { return emitter.addListener("onVideoProgress", listener); } diff --git a/modules/vlc-player/ios/VlcPlayer.podspec b/modules/vlc-player/ios/VlcPlayer.podspec index eaa622ac..97f58881 100644 --- a/modules/vlc-player/ios/VlcPlayer.podspec +++ b/modules/vlc-player/ios/VlcPlayer.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'VlcPlayer' - s.version = '1.0.0' + s.version = '4.0.0a10' s.summary = 'A sample project summary' s.description = 'A sample project description' s.author = '' @@ -10,8 +10,8 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' - s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1' - s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1' + s.ios.dependency 'VLCKit', s.version + s.tvos.dependency 'VLCKit', s.version # Swift/Objective-C compatibility s.pod_target_xcconfig = { diff --git a/modules/vlc-player/ios/VlcPlayerModule.swift b/modules/vlc-player/ios/VlcPlayerModule.swift index 64d6cad5..38299392 100644 --- a/modules/vlc-player/ios/VlcPlayerModule.swift +++ b/modules/vlc-player/ios/VlcPlayerModule.swift @@ -16,27 +16,20 @@ public class VlcPlayerModule: Module { } } - // Prop("muted") { (view: VlcPlayerView, muted: Bool) in - // view.setMuted(muted) - // } - - // Prop("volume") { (view: VlcPlayerView, volume: Int) in - // view.setVolume(volume) - // } - - // Prop("videoAspectRatio") { (view: VlcPlayerView, ratio: String) in - // view.setVideoAspectRatio(ratio) - // } - Events( "onPlaybackStateChanged", "onVideoStateChange", "onVideoLoadStart", "onVideoLoadEnd", "onVideoProgress", - "onVideoError" + "onVideoError", + "onPipStarted" ) + AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in + view.startPictureInPicture() + } + AsyncFunction("play") { (view: VlcPlayerView) in view.play() } @@ -69,14 +62,6 @@ public class VlcPlayerModule: Module { return view.getSubtitleTracks() } - // AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in - // view.setVideoCropGeometry(geometry) - // } - - // AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in - // return view.getVideoCropGeometry() - // } - AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String, name: String) in view.setSubtitleURL(url, name: name) diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index cb75721f..fe18e709 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -1,54 +1,168 @@ import ExpoModulesCore -#if os(tvOS) -import TVVLCKit -#else -import MobileVLCKit -#endif +import VLCKit import UIKit + +public class VLCPlayerView: UIView { + func setupView(parent: UIView) { + self.backgroundColor = .black + self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.leadingAnchor.constraint(equalTo: parent.leadingAnchor), + self.trailingAnchor.constraint(equalTo: parent.trailingAnchor), + self.topAnchor.constraint(equalTo: parent.topAnchor), + self.bottomAnchor.constraint(equalTo: parent.bottomAnchor), + ]) + } + + public override func layoutSubviews() { + super.layoutSubviews() + + for subview in subviews { + subview.frame = bounds + } + } +} + +class VLCPlayerWrapper: NSObject { + private var lastProgressCall = Date().timeIntervalSince1970 + public var player: VLCMediaPlayer = VLCMediaPlayer() + private var updatePlayerState: (() -> ())? + private var updateVideoProgress: (() -> ())? + private var playerView: VLCPlayerView = VLCPlayerView() + public weak var pipController: VLCPictureInPictureWindowControlling? + + override public init() { + super.init() + player.delegate = self + player.drawable = self + player.scaleFactor = 0 + } + + public func setup( + parent: UIView, + updatePlayerState: (() -> ())?, + updateVideoProgress: (() -> ())? + ) { + self.updatePlayerState = updatePlayerState + self.updateVideoProgress = updateVideoProgress + + player.delegate = self + parent.addSubview(playerView) + playerView.setupView(parent: parent) + } + + public func getPlayerView() -> UIView { + return playerView + } +} + +// MARK: - VLCPictureInPictureDrawable +extension VLCPlayerWrapper: VLCPictureInPictureDrawable { + public func mediaController() -> (any VLCPictureInPictureMediaControlling)! { + return self + } + + public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! { + return { [weak self] controller in + self?.pipController = controller + } + } +} + +// MARK: - VLCPictureInPictureMediaControlling +extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling { + func mediaTime() -> Int64 { + return player.time.value?.int64Value ?? 0 + } + + func mediaLength() -> Int64 { + return player.media?.length.value?.int64Value ?? 0 + } + + func play() { + player.play() + } + + func pause() { + player.pause() + } + + func seek(by offset: Int64, completion: @escaping () -> ()) { + player.jump(withOffset: Int32(offset), completion: completion) + } + + func isMediaSeekable() -> Bool { + return player.isSeekable + } + + func isMediaPlaying() -> Bool { + return player.isPlaying + } +} + +// MARK: - VLCDrawable +extension VLCPlayerWrapper: VLCDrawable { + public func addSubview(_ view: UIView) { + playerView.addSubview(view) + } + + public func bounds() -> CGRect { + return playerView.bounds + } +} + +// MARK: - VLCMediaPlayerDelegate +extension VLCPlayerWrapper: VLCMediaPlayerDelegate { + func mediaPlayerTimeChanged(_ aNotification: Notification) { + let timeNow = Date().timeIntervalSince1970 + if timeNow - lastProgressCall >= 1 { + lastProgressCall = timeNow + updateVideoProgress?() + } + } + + func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) { + self.updatePlayerState?() + + guard let pipController = self.pipController else { return } + DispatchQueue.main.async(execute: { + pipController.invalidatePlaybackState() + }) + } +} + +// MARK: - VLCMediaDelegate +extension VLCPlayerWrapper: VLCMediaDelegate { + // Implement VLCMediaDelegate methods if needed +} + + class VlcPlayerView: ExpoView { - private var mediaPlayer: VLCMediaPlayer? - private var videoView: UIView? + private var vlc: VLCPlayerWrapper = VLCPlayerWrapper() private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var isPaused: Bool = false - private var currentGeometryCString: [CChar]? - private var lastReportedState: VLCMediaPlayerState? - private var lastReportedIsPlaying: Bool? private var customSubtitles: [(internalName: String, originalName: String)] = [] private var startPosition: Int32 = 0 private var isMediaReady: Bool = false private var externalTrack: [String: String]? - private var progressTimer: DispatchSourceTimer? private var isStopping: Bool = false // Define isStopping here - private var lastProgressCall = Date().timeIntervalSince1970 var hasSource = false // MARK: - Initialization - required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - setupView() + setupVLC() setupNotifications() } // MARK: - Setup - - private func setupView() { - DispatchQueue.main.async { - self.backgroundColor = .black - self.videoView = UIView() - self.videoView?.translatesAutoresizingMaskIntoConstraints = false - - if let videoView = self.videoView { - self.addSubview(videoView) - NSLayoutConstraint.activate([ - videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - videoView.topAnchor.constraint(equalTo: self.topAnchor), - videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - ]) - } - } + private func setupVLC() { + vlc.setup( + parent: self, + updatePlayerState: updatePlayerState, + updateVideoProgress: updateVideoProgress + ) } private func setupNotifications() { @@ -61,37 +175,44 @@ class VlcPlayerView: ExpoView { } // MARK: - Public Methods + func startPictureInPicture() { + self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in + self.onPipStarted?(["pipStarted": isStarted]) + } + self.vlc.pipController?.startPictureInPicture() + } @objc func play() { - self.mediaPlayer?.play() + self.vlc.player.play() self.isPaused = false print("Play") } @objc func pause() { - self.mediaPlayer?.pause() + self.vlc.player.pause() self.isPaused = true } @objc func seekTo(_ time: Int32) { - guard let player = self.mediaPlayer else { return } - - let wasPlaying = player.isPlaying + let wasPlaying = vlc.player.isPlaying if wasPlaying { self.pause() } - if let duration = player.media?.length.intValue { + if let duration = vlc.player.media?.length.intValue { print("Seeking to time: \(time) Video Duration \(duration)") // If the specified time is greater than the duration, seek to the end let seekTime = time > duration ? duration - 1000 : time - player.time = VLCTime(int: seekTime) - - if wasPlaying { - self.play() - } + vlc.player.time = VLCTime(int: seekTime) self.updatePlayerState() + + // Let mediaPlayerStateChanged handle play state change + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if wasPlaying { + self.play() + } + } } else { print("Error: Unable to retrieve video duration") } @@ -104,11 +225,15 @@ class VlcPlayerView: ExpoView { return } - let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] + var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] self.externalTrack = source["externalTrack"] as? [String: String] - var initOptions = source["initOptions"] as? [Any] ?? [] + let initOptions: [String] = source["initOptions"] as? [String] ?? [] self.startPosition = source["startPosition"] as? Int32 ?? 0 - initOptions.append("--start-time=\(self.startPosition)") + + for item in initOptions { + let option = item.components(separatedBy: "=") + mediaOptions.updateValue(option[1], forKey: option[0].replacingOccurrences(of: "--", with: "")) + } guard let uri = source["uri"] as? String, !uri.isEmpty else { print("Error: Invalid or empty URI") @@ -120,12 +245,8 @@ class VlcPlayerView: ExpoView { let isNetwork = source["isNetwork"] as? Bool ?? false self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) - self.mediaPlayer = VLCMediaPlayer(options: initOptions) - self.mediaPlayer?.delegate = self - self.mediaPlayer?.drawable = self.videoView - self.mediaPlayer?.scaleFactor = 0 - let media: VLCMedia + let media: VLCMedia! if isNetwork { print("Loading network file: \(uri)") media = VLCMedia(url: URL(string: uri)!) @@ -141,38 +262,33 @@ class VlcPlayerView: ExpoView { print("Debug: Media options: \(mediaOptions)") media.addOptions(mediaOptions) - self.mediaPlayer?.media = media + self.vlc.player.media = media self.hasSource = true if autoplay { print("Playing...") self.play() + self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) } } } @objc func setAudioTrack(_ trackIndex: Int) { - self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex) + let track = self.vlc.player.audioTracks[trackIndex] + track.isSelectedExclusively = true; } @objc func getAudioTracks() -> [[String: Any]]? { - guard let trackNames = mediaPlayer?.audioTrackNames, - let trackIndexes = mediaPlayer?.audioTrackIndexes - else { - return nil - } - - return zip(trackNames, trackIndexes).map { name, index in - return ["name": name, "index": index] + return vlc.player.audioTracks.enumerated().map { + return ["name": $1.trackName, "index": $0 ] } } @objc func setSubtitleTrack(_ trackIndex: Int) { print("Debug: Attempting to set subtitle track to index: \(trackIndex)") - self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex) - print( - "Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)" - ) + let track = self.vlc.player.textTracks[trackIndex] + track.isSelectedExclusively = true; + print("Debug: Current subtitle track index after setting: \(track.trackName)") } @objc func setSubtitleURL(_ subtitleURL: String, name: String) { @@ -180,9 +296,9 @@ class VlcPlayerView: ExpoView { print("Error: Invalid subtitle URL") return } + let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: true) - let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true) - if let result = result { + if result > 0 { let internalName = "Track \(self.customSubtitles.count + 1)" print("Subtitle added with result: \(result) \(internalName)") self.customSubtitles.append((internalName: internalName, originalName: name)) @@ -192,51 +308,34 @@ class VlcPlayerView: ExpoView { } @objc func getSubtitleTracks() -> [[String: Any]]? { - guard let mediaPlayer = self.mediaPlayer else { + if self.vlc.player.textTracks.count == 0 { return nil } - let count = mediaPlayer.numberOfSubtitlesTracks - print("Debug: Number of subtitle tracks: \(count)") + print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)") - guard count > 0 else { - return nil - } - - var tracks: [[String: Any]] = [] - - if let names = mediaPlayer.videoSubTitlesNames as? [String], - let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] - { - for (index, name) in zip(indexes, names) { - if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) { - tracks.append(["name": customSubtitle.originalName, "index": index.intValue]) - } else { - tracks.append(["name": name, "index": index.intValue]) - } + let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in + if let customSubtitle = customSubtitles.first(where: { $0.internalName == track.trackName }) { + return ["name": customSubtitle.originalName, "index": index ] + } + else { + return ["name": track.trackName, "index": index ] } } - print("Debug: Subtitle tracks: \(tracks)") - return tracks + print("Debug: Subtitle tracks: \(tracks)") + return tracks } private func setSubtitleTrackByName(_ trackName: String) { - guard let mediaPlayer = self.mediaPlayer else { return } - - // Get the subtitle tracks and their indexes - if let names = mediaPlayer.videoSubTitlesNames as? [String], - let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] - { - for (index, name) in zip(indexes, names) { - if name.starts(with: trackName) { - let trackIndex = index.intValue - print("Track Index setting to: \(trackIndex)") - setSubtitleTrack(trackIndex) - return - } + for track in self.vlc.player.textTracks { + if (track.trackName.starts(with: trackName)) { + print("Track Index setting to: \(track.trackName)") + track.isSelectedExclusively = true + return } } + print("Track not found for name: \(trackName)") } @@ -269,32 +368,27 @@ class VlcPlayerView: ExpoView { private func performStop(completion: (() -> Void)? = nil) { // Stop the media player - mediaPlayer?.stop() + vlc.player.stop() // Remove observer NotificationCenter.default.removeObserver(self) // Clear the video view - videoView?.removeFromSuperview() - videoView = nil - - // Release the media player - mediaPlayer?.delegate = nil - mediaPlayer = nil + vlc.getPlayerView().removeFromSuperview() isStopping = false completion?() } private func updateVideoProgress() { - guard let player = self.mediaPlayer else { return } + guard let media = self.vlc.player.media else { return } - let currentTimeMs = player.time.intValue - let durationMs = player.media?.length.intValue ?? 0 + let currentTimeMs = self.vlc.player.time.intValue + let durationMs = self.vlc.player.media?.length.intValue ?? 0 print("Debug: Current time: \(currentTimeMs)") if currentTimeMs >= 0 && currentTimeMs < durationMs { - if player.isPlaying && !self.isMediaReady { + if !self.isMediaReady { self.isMediaReady = true // Set external track subtitle when starting. if let externalTrack = self.externalTrack { @@ -304,21 +398,34 @@ class VlcPlayerView: ExpoView { } } } - self.onVideoProgress?([ - "currentTime": currentTimeMs, - "duration": durationMs, - ]) } + self.onVideoProgress?([ + "currentTime": currentTimeMs, + "duration": durationMs, + ]) + } + + private func updatePlayerState() { + let player = self.vlc.player + self.onVideoStateChange?([ + "target": self.reactTag ?? NSNull(), + "currentTime": player.time.intValue, + "duration": player.media?.length.intValue ?? 0, + "error": false, + "isPlaying": player.isPlaying, + "isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering, + "state": player.state.description + ]) } // MARK: - Expo Events - @objc var onPlaybackStateChanged: RCTDirectEventBlock? @objc var onVideoLoadStart: RCTDirectEventBlock? @objc var onVideoStateChange: RCTDirectEventBlock? @objc var onVideoProgress: RCTDirectEventBlock? @objc var onVideoLoadEnd: RCTDirectEventBlock? @objc var onVideoError: RCTDirectEventBlock? + @objc var onPipStarted: RCTDirectEventBlock? // MARK: - Deinitialization @@ -327,67 +434,6 @@ class VlcPlayerView: ExpoView { } } -extension VlcPlayerView: VLCMediaPlayerDelegate { - func mediaPlayerTimeChanged(_ aNotification: Notification) { - // self?.updateVideoProgress() - let timeNow = Date().timeIntervalSince1970 - if timeNow - lastProgressCall >= 1 { - lastProgressCall = timeNow - updateVideoProgress() - } - } - - func mediaPlayerStateChanged(_ aNotification: Notification) { - self.updatePlayerState() - } - - private func updatePlayerState() { - guard let player = self.mediaPlayer else { return } - let currentState = player.state - - var stateInfo: [String: Any] = [ - "target": self.reactTag ?? NSNull(), - "currentTime": player.time.intValue, - "duration": player.media?.length.intValue ?? 0, - "error": false, - ] - - if player.isPlaying { - stateInfo["isPlaying"] = true - stateInfo["isBuffering"] = false - stateInfo["state"] = "Playing" - } else { - stateInfo["isPlaying"] = false - stateInfo["state"] = "Paused" - } - - if player.state == VLCMediaPlayerState.buffering { - stateInfo["isBuffering"] = true - stateInfo["state"] = "Buffering" - } else if player.state == VLCMediaPlayerState.error { - print("player.state ~ error") - stateInfo["state"] = "Error" - self.onVideoLoadEnd?(stateInfo) - } else if player.state == VLCMediaPlayerState.opening { - print("player.state ~ opening") - stateInfo["state"] = "Opening" - } - - if self.lastReportedState != currentState - || self.lastReportedIsPlaying != player.isPlaying - { - self.lastReportedState = currentState - self.lastReportedIsPlaying = player.isPlaying - self.onVideoStateChange?(stateInfo) - } - - } -} - -extension VlcPlayerView: VLCMediaDelegate { - // Implement VLCMediaDelegate methods if needed -} - extension VLCMediaPlayerState { var description: String { switch self { @@ -396,9 +442,7 @@ extension VLCMediaPlayerState { case .playing: return "Playing" case .paused: return "Paused" case .stopped: return "Stopped" - case .ended: return "Ended" case .error: return "Error" - case .esAdded: return "ESAdded" @unknown default: return "Unknown" } } diff --git a/modules/vlc-player/src/VlcPlayer.types.ts b/modules/vlc-player/src/VlcPlayer.types.ts index d0a71483..91922a1b 100644 --- a/modules/vlc-player/src/VlcPlayer.types.ts +++ b/modules/vlc-player/src/VlcPlayer.types.ts @@ -24,6 +24,12 @@ export type VideoLoadStartPayload = { }; }; +export type PipStartedPayload = { + nativeEvent: { + pipStarted: boolean; + }; +}; + export type VideoStateChangePayload = PlaybackStatePayload; export type VideoProgressPayload = ProgressUpdatePayload; @@ -64,9 +70,11 @@ export type VlcPlayerViewProps = { 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; diff --git a/modules/vlc-player/src/VlcPlayerView.tsx b/modules/vlc-player/src/VlcPlayerView.tsx index df44090e..8195d6a9 100644 --- a/modules/vlc-player/src/VlcPlayerView.tsx +++ b/modules/vlc-player/src/VlcPlayerView.tsx @@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef( const nativeRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ + startPictureInPicture: async () => { + await nativeRef.current?.startPictureInPicture() + }, play: async () => { await nativeRef.current?.play(); }, @@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef( onVideoProgress, onVideoLoadEnd, onVideoError, + onPipStarted, ...otherProps } = props; @@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef( onVideoStateChange={onVideoStateChange} onVideoProgress={onVideoProgress} onVideoError={onVideoError} + onPipStarted={onPipStarted} /> ); } diff --git a/plugins/withAndroidManifest.js b/plugins/withAndroidManifest.js new file mode 100644 index 00000000..acc1192a --- /dev/null +++ b/plugins/withAndroidManifest.js @@ -0,0 +1,38 @@ +const { withAndroidManifest: NativeAndroidManifest } = require("@expo/config-plugins"); + +const withAndroidManifest = (config) => + NativeAndroidManifest(config, async (config) => { + const mainApplication = config.modResults.manifest.application[0]; + + // Initialize activity array if it doesn't exist + if (!mainApplication.activity) { + mainApplication.activity = []; + } + + const googleCastActivityExists = mainApplication.activity.some(activity => + activity.$?.["android:name"] === "com.reactnative.googlecast.RNGCExpandedControllerActivity" + ); + + // Only add the activity if it doesn't already exist + if (!googleCastActivityExists) { + mainApplication.activity.push({ + $: { + "android:name": "com.reactnative.googlecast.RNGCExpandedControllerActivity", + "android:theme": "@style/Theme.MaterialComponents.NoActionBar", + "android:launchMode": "singleTask", + }, + }); + } + + const mainActivity = mainApplication.activity.find(activity => + activity.$?.["android:name"] === ".MainActivity" + ); + + if (mainActivity) { + mainActivity.$["android:supportsPictureInPicture"] = "true" + } + + return config; + }); + +module.exports = withAndroidManifest; diff --git a/plugins/withGoogleCastActivity.js b/plugins/withGoogleCastActivity.js deleted file mode 100644 index 1a8c0a30..00000000 --- a/plugins/withGoogleCastActivity.js +++ /dev/null @@ -1,34 +0,0 @@ -const { withAndroidManifest } = require("@expo/config-plugins"); - -const withGoogleCastActivity = (config) => - withAndroidManifest(config, async (config) => { - const mainApplication = config.modResults.manifest.application[0]; - - // Initialize activity array if it doesn't exist - if (!mainApplication.activity) { - mainApplication.activity = []; - } - - // Check if the activity already exists - const activityExists = mainApplication.activity.some( - (activity) => - activity.$?.["android:name"] === - "com.reactnative.googlecast.RNGCExpandedControllerActivity" - ); - - // Only add the activity if it doesn't already exist - if (!activityExists) { - mainApplication.activity.push({ - $: { - "android:name": - "com.reactnative.googlecast.RNGCExpandedControllerActivity", - "android:theme": "@style/Theme.MaterialComponents.NoActionBar", - "android:launchMode": "singleTask", - }, - }); - } - - return config; - }); - -module.exports = withGoogleCastActivity;