mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
ios VLCKit 4.0 & All platform PiP support
This commit is contained in:
20
app.json
20
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",
|
||||
{
|
||||
|
||||
@@ -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,6 +185,9 @@ export default function page() {
|
||||
lightHapticFeedback();
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
}
|
||||
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
@@ -197,31 +195,14 @@ export default function page() {
|
||||
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,
|
||||
positionTicks: msToTicks(progress.get()),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
@@ -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();
|
||||
}
|
||||
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}`];
|
||||
@@ -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}
|
||||
@@ -513,22 +487,3 @@ export default function page() {
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
play: (() => Promise<void>) | (() => void);
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
@@ -91,6 +92,7 @@ const CONTROLS_TIMEOUT = 4000;
|
||||
export const Controls: React.FC<Props> = ({
|
||||
item,
|
||||
seek,
|
||||
startPictureInPicture,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
@@ -212,6 +214,8 @@ export const Controls: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
);
|
||||
}, [trickPlayUrl, trickplayInfo, time]);
|
||||
|
||||
const onClose = async () => {
|
||||
stop()
|
||||
lightHapticFeedback();
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
@@ -551,6 +568,19 @@ export const Controls: React.FC<Props> = ({
|
||||
</View>
|
||||
|
||||
<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 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
@@ -592,13 +622,7 @@ export const Controls: React.FC<Props> = ({
|
||||
</TouchableOpacity>
|
||||
{/* )} */}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
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"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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<String, Any>) {
|
||||
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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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<PlaybackStatePayload>(
|
||||
"onPlaybackStateChanged",
|
||||
listener
|
||||
@@ -34,7 +31,7 @@ export function addPlaybackStateListener(
|
||||
|
||||
export function addVideoLoadStartListener(
|
||||
listener: (event: VideoLoadStartPayload) => void
|
||||
): Subscription {
|
||||
): EventSubscription {
|
||||
return emitter.addListener<VideoLoadStartPayload>(
|
||||
"onVideoLoadStart",
|
||||
listener
|
||||
@@ -43,7 +40,7 @@ export function addVideoLoadStartListener(
|
||||
|
||||
export function addVideoStateChangeListener(
|
||||
listener: (event: VideoStateChangePayload) => void
|
||||
): Subscription {
|
||||
): EventSubscription {
|
||||
return emitter.addListener<VideoStateChangePayload>(
|
||||
"onVideoStateChange",
|
||||
listener
|
||||
@@ -52,7 +49,7 @@ export function addVideoStateChangeListener(
|
||||
|
||||
export function addVideoProgressListener(
|
||||
listener: (event: VideoProgressPayload) => void
|
||||
): Subscription {
|
||||
): EventSubscription {
|
||||
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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()
|
||||
}
|
||||
self.updatePlayerState()
|
||||
}
|
||||
} 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,28 +308,18 @@ 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 ]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,21 +328,14 @@ class VlcPlayerView: ExpoView {
|
||||
}
|
||||
|
||||
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)
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
|
||||
@@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture()
|
||||
},
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
@@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
onVideoProgress,
|
||||
onVideoLoadEnd,
|
||||
onVideoError,
|
||||
onPipStarted,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
onVideoStateChange={onVideoStateChange}
|
||||
onVideoProgress={onVideoProgress}
|
||||
onVideoError={onVideoError}
|
||||
onPipStarted={onPipStarted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
38
plugins/withAndroidManifest.js
Normal file
38
plugins/withAndroidManifest.js
Normal 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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user