ios VLCKit 4.0 & All platform PiP support

This commit is contained in:
herrrta
2025-02-09 19:13:47 -05:00
parent e71d5cc176
commit 505ef39ee7
13 changed files with 508 additions and 345 deletions

View File

@@ -106,11 +106,21 @@
} }
} }
], ],
["react-native-bottom-tabs"], [
["./plugins/withChangeNativeAndroidTextToWhite.js"], "react-native-bottom-tabs"
["./plugins/withGoogleCastActivity.js"], ],
["./plugins/withTrustLocalCerts.js"], [
["./plugins/withGradleProperties.js"], "./plugins/withChangeNativeAndroidTextToWhite.js"
],
[
"./plugins/withAndroidManifest.js"
],
[
"./plugins/withTrustLocalCerts.js"
],
[
"./plugins/withGradleProperties.js"
],
[ [
"expo-splash-screen", "expo-splash-screen",
{ {

View File

@@ -3,12 +3,11 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player"; import { VlcPlayerView } from "@/modules/vlc-player";
import { import {
PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
@@ -18,13 +17,10 @@ const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider") ? require("@/providers/DownloadProvider")
: null; : null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { import {
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
@@ -42,14 +38,12 @@ import React, {
} from "react"; } from "react";
import { import {
Alert, Alert,
BackHandler,
View, View,
AppState, AppState,
AppStateStatus, AppStateStatus,
Platform, Platform,
} from "react-native"; } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -67,6 +61,7 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
@@ -190,37 +185,23 @@ export default function page() {
lightHapticFeedback(); lightHapticFeedback();
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); 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 { } else {
videoRef.current?.play(); videoRef.current?.play();
if (!offline && stream) { }
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!, if (!offline && stream) {
audioStreamIndex: audioIndex ? audioIndex : undefined, await getPlaystateApi(api).onPlaybackProgress({
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, itemId: item?.Id!,
mediaSourceId: mediaSourceId, audioStreamIndex: audioIndex ? audioIndex : undefined,
positionTicks: msToTicks(progress.value), subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
isPaused: false, mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") positionTicks: msToTicks(progress.get()),
? "Transcode" isPaused: !isPlaying,
: "DirectStream", playMethod: stream?.url.includes("m3u8")
playSessionId: stream.sessionId, ? "Transcode"
}); : "DirectStream",
} playSessionId: stream.sessionId,
});
} }
}, [ }, [
isPlaying, isPlaying,
@@ -232,13 +213,13 @@ export default function page() {
subtitleIndex, subtitleIndex,
mediaSourceId, mediaSourceId,
offline, offline,
progress.value, progress,
]); ]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.value); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
@@ -273,8 +254,7 @@ export default function page() {
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return; if (isSeeking.get() || isPlaybackStopped) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
@@ -282,7 +262,7 @@ export default function page() {
setIsBuffering(false); setIsBuffering(false);
} }
progress.value = currentTime; progress.set(currentTime);
if (offline) return; if (offline) return;
@@ -301,7 +281,7 @@ export default function page() {
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
}, },
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex] [item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
); );
useWebSocket({ useWebSocket({
@@ -311,6 +291,11 @@ export default function page() {
offline, offline,
}); });
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted)
}, [])
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
@@ -340,25 +325,13 @@ export default function page() {
: 0; : 0;
}, [item]); }, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState); const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => { useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => { const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") { // Handle app going to the background
// Handle app coming to the foreground if (nextAppState.match(/inactive|background/)) {
} else if (nextAppState.match(/inactive|background/)) { _setShowControls(false)
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
} }
setAppState(nextAppState); setAppState(nextAppState);
}; };
@@ -373,10 +346,9 @@ export default function page() {
// Cleanup the event listener when the component is unmounted // Cleanup the event listener when the component is unmounted
subscription.remove(); subscription.remove();
}; };
}, [appState]); }, [appState, isPipStarted, isPlaying]);
// Preselection of audio and subtitle tracks. // Preselection of audio and subtitle tracks.
if (!settings) return null; if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; 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)}`); initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else { } else {
// Transcoded playback CASE // Transcoded playback CASE
@@ -466,6 +438,7 @@ export default function page() {
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}} onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
@@ -496,6 +469,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play} play={videoRef.current?.play}
pause={videoRef.current?.pause} pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo} seek={videoRef.current?.seekTo}
@@ -513,22 +487,3 @@ export default function page() {
</View> </View>
); );
} }
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;
}

View File

@@ -24,7 +24,7 @@ import {
ticksToMs, ticksToMs,
ticksToSeconds, ticksToSeconds,
} from "@/utils/time"; } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; import {Ionicons, MaterialIcons} from "@expo/vector-icons";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -35,7 +35,7 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react"; 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 { Slider } from "react-native-awesome-slider";
import { import {
runOnJS, runOnJS,
@@ -75,6 +75,7 @@ interface Props {
isVideoLoaded?: boolean; isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null; mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void; seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void); play: (() => Promise<void>) | (() => void);
pause: () => void; pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]); getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -91,6 +92,7 @@ const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({ export const Controls: React.FC<Props> = ({
item, item,
seek, seek,
startPictureInPicture,
play, play,
pause, pause,
togglePlay, togglePlay,
@@ -212,6 +214,8 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
stop()
if (!bitrateValue) { if (!bitrateValue) {
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
@@ -250,6 +254,8 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
stop()
if (!bitrateValue) { if (!bitrateValue) {
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
@@ -413,6 +419,8 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
stop()
if (!bitrateValue) { if (!bitrateValue) {
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
@@ -499,6 +507,15 @@ export const Controls: React.FC<Props> = ({
); );
}, [trickPlayUrl, trickplayInfo, time]); }, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
stop()
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
};
return ( return (
<ControlProvider <ControlProvider
item={item} item={item}
@@ -551,6 +568,19 @@ export const Controls: React.FC<Props> = ({
</View> </View>
<View className="flex flex-row items-center space-x-2 "> <View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
<TouchableOpacity
onPress={startPictureInPicture}
>
<MaterialIcons
name="picture-in-picture"
size={24}
color="white"
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && !offline && ( {item?.Type === "Episode" && !offline && (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -592,13 +622,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity> </TouchableOpacity>
{/* )} */} {/* )} */}
<TouchableOpacity <TouchableOpacity
onPress={async () => { onPress={onClose}
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2" className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
> >
<Ionicons name="close" size={24} color="white" /> <Ionicons name="close" size={24} color="white" />

View File

@@ -26,9 +26,14 @@ class VlcPlayerModule : Module() {
"onVideoLoadStart", "onVideoLoadStart",
"onVideoLoadEnd", "onVideoLoadEnd",
"onVideoProgress", "onVideoProgress",
"onVideoError" "onVideoError",
"onPipStarted"
) )
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView -> AsyncFunction("play") { view: VlcPlayerView ->
view.play() view.play()
} }

View File

@@ -1,23 +1,40 @@
package expo.modules.vlcplayer 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.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.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.ViewGroup import androidx.annotation.RequiresApi
import android.widget.FrameLayout import androidx.core.app.ComponentActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleObserver
import android.net.Uri
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import org.videolan.libvlc.LibVLC import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media import org.videolan.libvlc.Media
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.MediaPlayer import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener { 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 libVLC: LibVLC? = null
private var mediaPlayer: MediaPlayer? = 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 onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher() private val onVideoStateChange by EventDispatcher()
private val onVideoLoadEnd by EventDispatcher() private val onVideoLoadEnd by EventDispatcher()
private val onPipStarted by EventDispatcher()
private var startPosition: Int? = 0 private var startPosition: Int? = 0
private var isMediaReady: Boolean = false private var isMediaReady: Boolean = false
@@ -44,9 +62,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
handler.postDelayed(this, updateInterval) handler.postDelayed(this, updateInterval)
} }
} }
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = 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 { init {
setupView() 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() { private fun setupView() {
@@ -59,6 +100,76 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
Log.d("VlcPlayerView", "View setup complete") 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<String, Any>) { fun setSource(source: Map<String, Any>) {
if (hasSource) { if (hasSource) {
mediaPlayer?.attachViews(videoLayout, null, false, false) 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() { fun play() {
mediaPlayer?.play() mediaPlayer?.play()
isPaused = false isPaused = false
@@ -284,3 +401,12 @@ 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")
}

View File

@@ -1,7 +1,6 @@
import { import {
NativeModulesProxy,
EventEmitter, EventEmitter,
Subscription, EventSubscription,
} from "expo-modules-core"; } from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule"; import VlcPlayerModule from "./src/VlcPlayerModule";
@@ -19,13 +18,11 @@ import {
VlcPlayerViewRef, VlcPlayerViewRef,
} from "./src/VlcPlayer.types"; } from "./src/VlcPlayer.types";
const emitter = new EventEmitter( const emitter = new EventEmitter(VlcPlayerModule);
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
);
export function addPlaybackStateListener( export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void listener: (event: PlaybackStatePayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<PlaybackStatePayload>( return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged", "onPlaybackStateChanged",
listener listener
@@ -34,7 +31,7 @@ export function addPlaybackStateListener(
export function addVideoLoadStartListener( export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void listener: (event: VideoLoadStartPayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<VideoLoadStartPayload>( return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart", "onVideoLoadStart",
listener listener
@@ -43,7 +40,7 @@ export function addVideoLoadStartListener(
export function addVideoStateChangeListener( export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void listener: (event: VideoStateChangePayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<VideoStateChangePayload>( return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange", "onVideoStateChange",
listener listener
@@ -52,7 +49,7 @@ export function addVideoStateChangeListener(
export function addVideoProgressListener( export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void listener: (event: VideoProgressPayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener); return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
} }

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'VlcPlayer' s.name = 'VlcPlayer'
s.version = '1.0.0' s.version = '4.0.0a10'
s.summary = 'A sample project summary' s.summary = 'A sample project summary'
s.description = 'A sample project description' s.description = 'A sample project description'
s.author = '' s.author = ''
@@ -10,8 +10,8 @@ Pod::Spec.new do |s|
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1' s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1' s.tvos.dependency 'VLCKit', s.version
# Swift/Objective-C compatibility # Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {

View File

@@ -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( Events(
"onPlaybackStateChanged", "onPlaybackStateChanged",
"onVideoStateChange", "onVideoStateChange",
"onVideoLoadStart", "onVideoLoadStart",
"onVideoLoadEnd", "onVideoLoadEnd",
"onVideoProgress", "onVideoProgress",
"onVideoError" "onVideoError",
"onPipStarted"
) )
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayerView) in AsyncFunction("play") { (view: VlcPlayerView) in
view.play() view.play()
} }
@@ -69,14 +62,6 @@ public class VlcPlayerModule: Module {
return view.getSubtitleTracks() return view.getSubtitleTracks()
} }
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
// view.setVideoCropGeometry(geometry)
// }
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
// return view.getVideoCropGeometry()
// }
AsyncFunction("setSubtitleURL") { AsyncFunction("setSubtitleURL") {
(view: VlcPlayerView, url: String, name: String) in (view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name) view.setSubtitleURL(url, name: name)

View File

@@ -1,54 +1,168 @@
import ExpoModulesCore import ExpoModulesCore
#if os(tvOS) import VLCKit
import TVVLCKit
#else
import MobileVLCKit
#endif
import UIKit 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 { class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer? private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false 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 customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0 private var startPosition: Int32 = 0
private var isMediaReady: Bool = false private var isMediaReady: Bool = false
private var externalTrack: [String: String]? private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
var hasSource = false var hasSource = false
// MARK: - Initialization // MARK: - Initialization
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
setupView() setupVLC()
setupNotifications() setupNotifications()
} }
// MARK: - Setup // MARK: - Setup
private func setupVLC() {
private func setupView() { vlc.setup(
DispatchQueue.main.async { parent: self,
self.backgroundColor = .black updatePlayerState: updatePlayerState,
self.videoView = UIView() updateVideoProgress: updateVideoProgress
self.videoView?.translatesAutoresizingMaskIntoConstraints = false )
if let videoView = self.videoView {
self.addSubview(videoView)
NSLayoutConstraint.activate([
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
videoView.topAnchor.constraint(equalTo: self.topAnchor),
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
}
} }
private func setupNotifications() { private func setupNotifications() {
@@ -61,37 +175,44 @@ class VlcPlayerView: ExpoView {
} }
// MARK: - Public Methods // MARK: - Public Methods
func startPictureInPicture() {
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
self.onPipStarted?(["pipStarted": isStarted])
}
self.vlc.pipController?.startPictureInPicture()
}
@objc func play() { @objc func play() {
self.mediaPlayer?.play() self.vlc.player.play()
self.isPaused = false self.isPaused = false
print("Play") print("Play")
} }
@objc func pause() { @objc func pause() {
self.mediaPlayer?.pause() self.vlc.player.pause()
self.isPaused = true self.isPaused = true
} }
@objc func seekTo(_ time: Int32) { @objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return } let wasPlaying = vlc.player.isPlaying
let wasPlaying = player.isPlaying
if wasPlaying { if wasPlaying {
self.pause() 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)") print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end // If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime) vlc.player.time = VLCTime(int: seekTime)
if wasPlaying {
self.play()
}
self.updatePlayerState() self.updatePlayerState()
// Let mediaPlayerStateChanged handle play state change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if wasPlaying {
self.play()
}
}
} else { } else {
print("Error: Unable to retrieve video duration") print("Error: Unable to retrieve video duration")
} }
@@ -104,11 +225,15 @@ class VlcPlayerView: ExpoView {
return return
} }
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String] 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 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 { guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI") print("Error: Invalid or empty URI")
@@ -120,12 +245,8 @@ class VlcPlayerView: ExpoView {
let isNetwork = source["isNetwork"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) 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 { if isNetwork {
print("Loading network file: \(uri)") print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!) media = VLCMedia(url: URL(string: uri)!)
@@ -141,38 +262,33 @@ class VlcPlayerView: ExpoView {
print("Debug: Media options: \(mediaOptions)") print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions) media.addOptions(mediaOptions)
self.mediaPlayer?.media = media self.vlc.player.media = media
self.hasSource = true self.hasSource = true
if autoplay { if autoplay {
print("Playing...") print("Playing...")
self.play() self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
} }
} }
} }
@objc func setAudioTrack(_ trackIndex: Int) { @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]]? { @objc func getAudioTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.audioTrackNames, return vlc.player.audioTracks.enumerated().map {
let trackIndexes = mediaPlayer?.audioTrackIndexes return ["name": $1.trackName, "index": $0 ]
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
} }
} }
@objc func setSubtitleTrack(_ trackIndex: Int) { @objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)") print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex) let track = self.vlc.player.textTracks[trackIndex]
print( track.isSelectedExclusively = true;
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)" print("Debug: Current subtitle track index after setting: \(track.trackName)")
)
} }
@objc func setSubtitleURL(_ subtitleURL: String, name: String) { @objc func setSubtitleURL(_ subtitleURL: String, name: String) {
@@ -180,9 +296,9 @@ class VlcPlayerView: ExpoView {
print("Error: Invalid subtitle URL") print("Error: Invalid subtitle URL")
return return
} }
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: true)
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true) if result > 0 {
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)" let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)") print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name)) self.customSubtitles.append((internalName: internalName, originalName: name))
@@ -192,51 +308,34 @@ class VlcPlayerView: ExpoView {
} }
@objc func getSubtitleTracks() -> [[String: Any]]? { @objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else { if self.vlc.player.textTracks.count == 0 {
return nil return nil
} }
let count = mediaPlayer.numberOfSubtitlesTracks print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
print("Debug: Number of subtitle tracks: \(count)")
guard count > 0 else { let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
return nil if let customSubtitle = customSubtitles.first(where: { $0.internalName == track.trackName }) {
} return ["name": customSubtitle.originalName, "index": index ]
}
var tracks: [[String: Any]] = [] else {
return ["name": track.trackName, "index": index ]
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else {
tracks.append(["name": name, "index": index.intValue])
}
} }
} }
print("Debug: Subtitle tracks: \(tracks)") print("Debug: Subtitle tracks: \(tracks)")
return tracks return tracks
} }
private func setSubtitleTrackByName(_ trackName: String) { private func setSubtitleTrackByName(_ trackName: String) {
guard let mediaPlayer = self.mediaPlayer else { return } for track in self.vlc.player.textTracks {
if (track.trackName.starts(with: trackName)) {
// Get the subtitle tracks and their indexes print("Track Index setting to: \(track.trackName)")
if let names = mediaPlayer.videoSubTitlesNames as? [String], track.isSelectedExclusively = true
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] return
{
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
}
} }
} }
print("Track not found for name: \(trackName)") print("Track not found for name: \(trackName)")
} }
@@ -269,32 +368,27 @@ class VlcPlayerView: ExpoView {
private func performStop(completion: (() -> Void)? = nil) { private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player // Stop the media player
mediaPlayer?.stop() vlc.player.stop()
// Remove observer // Remove observer
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
// Clear the video view // Clear the video view
videoView?.removeFromSuperview() vlc.getPlayerView().removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
isStopping = false isStopping = false
completion?() completion?()
} }
private func updateVideoProgress() { 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 currentTimeMs = self.vlc.player.time.intValue
let durationMs = player.media?.length.intValue ?? 0 let durationMs = self.vlc.player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)") print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs { if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady { if !self.isMediaReady {
self.isMediaReady = true self.isMediaReady = true
// Set external track subtitle when starting. // Set external track subtitle when starting.
if let externalTrack = self.externalTrack { 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 // MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock? @objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock? @objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock? @objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock? @objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock? @objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock? @objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization // 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 { extension VLCMediaPlayerState {
var description: String { var description: String {
switch self { switch self {
@@ -396,9 +442,7 @@ extension VLCMediaPlayerState {
case .playing: return "Playing" case .playing: return "Playing"
case .paused: return "Paused" case .paused: return "Paused"
case .stopped: return "Stopped" case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error" case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown" @unknown default: return "Unknown"
} }
} }

View File

@@ -24,6 +24,12 @@ export type VideoLoadStartPayload = {
}; };
}; };
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload; export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload; export type VideoProgressPayload = ProgressUpdatePayload;
@@ -64,9 +70,11 @@ export type VlcPlayerViewProps = {
onVideoLoadStart?: (event: VideoLoadStartPayload) => void; onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void; onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void; onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
}; };
export interface VlcPlayerViewRef { export interface VlcPlayerViewRef {
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>; play: () => Promise<void>;
pause: () => Promise<void>; pause: () => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;

View File

@@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const nativeRef = React.useRef<NativeViewRef>(null); const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture()
},
play: async () => { play: async () => {
await nativeRef.current?.play(); await nativeRef.current?.play();
}, },
@@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoProgress, onVideoProgress,
onVideoLoadEnd, onVideoLoadEnd,
onVideoError, onVideoError,
onPipStarted,
...otherProps ...otherProps
} = props; } = props;
@@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoStateChange={onVideoStateChange} onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress} onVideoProgress={onVideoProgress}
onVideoError={onVideoError} onVideoError={onVideoError}
onPipStarted={onPipStarted}
/> />
); );
} }

View File

@@ -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;

View File

@@ -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;