From 0b0afb448d94c271a4595195fd00dfc67d18e431 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Sun, 17 Nov 2024 05:48:29 +1100 Subject: [PATCH] WIP --- .gitignore | 1 + .idea/.gitignore | 3 + .idea/caches/deviceStreaming.xml | 329 +++++++++++++++ .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/streamyfin.iml | 9 + .idea/vcs.xml | 6 + .vscode/settings.json | 4 +- components/video-player/player.android.tsx | 399 ++++++------------ modules/vlc-player/android/build.gradle | 70 +++ .../android/src/main/AndroidManifest.xml | 2 + .../expo/modules/vlcplayer/VlcPlayerModule.kt | 69 +++ .../expo/modules/vlcplayer/VlcPlayerView.kt | 251 +++++++++++ 13 files changed, 892 insertions(+), 265 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/caches/deviceStreaming.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/streamyfin.iml create mode 100644 .idea/vcs.xml create mode 100644 modules/vlc-player/android/build.gradle create mode 100644 modules/vlc-player/android/src/main/AndroidManifest.xml create mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt create mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt diff --git a/.gitignore b/.gitignore index c098c3a5..63fdb823 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ npm-debug.* *.mobileprovision *.orig.* web-build/ +modules/vlc-player/android/build # macOS .DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 00000000..b81700b5 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,329 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..639900d1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..ba6d5c31 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/streamyfin.iml b/.idea/streamyfin.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/.idea/streamyfin.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 22480b68..4571e3a1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,7 @@ }, "[swift]": { "editor.defaultFormatter": "sswg.swift-lang" - } + }, + "java.configuration.updateBuildConfiguration": "interactive", + "java.compile.nullAnalysis.mode": "automatic" } diff --git a/components/video-player/player.android.tsx b/components/video-player/player.android.tsx index 92f857c8..6f4bd52f 100644 --- a/components/video-player/player.android.tsx +++ b/components/video-player/player.android.tsx @@ -1,18 +1,22 @@ +import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; -import { TrackInfo } from "@/modules/vlc-player"; +import { VlcPlayerView } from "@/modules/vlc-player"; +import { + PlaybackStatePayload, + ProgressUpdatePayload, + VlcPlayerViewRef, +} from "@/modules/vlc-player/src/VlcPlayer.types"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; +import { writeToLog } from "@/utils/log"; import native from "@/utils/profiles/native"; -import android from "@/utils/profiles/android"; -import { secondsToTicks } from "@/utils/secondsToTicks"; +import { msToTicks, ticksToMs } from "@/utils/time"; import { Api } from "@jellyfin/sdk"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { @@ -21,32 +25,26 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; -import { useFocusEffect, useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; import React, { useCallback, useMemo, useRef, useState } from "react"; -import { Pressable, useWindowDimensions, View } from "react-native"; +import { Alert, Pressable, useWindowDimensions, View } from "react-native"; +import { SystemBars } from "react-native-edge-to-edge"; import { useSharedValue } from "react-native-reanimated"; -import Video, { - OnProgressData, - SelectedTrack, - SelectedTrackType, - VideoRef, -} from "react-native-video"; const Player = () => { - const api = useAtomValue(apiAtom); + const videoRef = useRef(null); const user = useAtomValue(userAtom); - const [settings] = useSettings(); - const videoRef = useRef(null); + const api = useAtomValue(apiAtom); - const firstTime = useRef(true); - const dimensions = useWindowDimensions(); + const windowDimensions = useWindowDimensions(); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, setShowControls] = useState(true); const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isBuffering, setIsBuffering] = useState(true); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); const progress = useSharedValue(0); const isSeeking = useSharedValue(false); @@ -67,12 +65,10 @@ const Player = () => { }>(); const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; - const subtitleIndex = subtitleIndexStr - ? parseInt(subtitleIndexStr, 10) - : undefined; + const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) - : undefined; + : BITRATES[0].value; const { data: item, @@ -81,15 +77,7 @@ const Player = () => { } = useQuery({ queryKey: ["item", itemId], queryFn: async () => { - if (!api) { - throw new Error("No api"); - } - - if (!itemId) { - console.warn("No itemId"); - return null; - } - + if (!api) return; const res = await getUserLibraryApi(api).getItem({ itemId, userId: user?.Id, @@ -97,6 +85,7 @@ const Player = () => { return res.data; }, + enabled: !!itemId && !!api, staleTime: 0, }); @@ -110,20 +99,11 @@ const Player = () => { itemId, audioIndex, subtitleIndex, - bitrateValue, - user, mediaSourceId, + bitrateValue, ], queryFn: async () => { - if (!api) { - throw new Error("No api"); - } - - if (!item) { - console.warn("No item", itemId, item); - return null; - } - + if (!api) return; const res = await getStreamUrl({ api, item, @@ -133,17 +113,16 @@ const Player = () => { maxStreamingBitrate: bitrateValue, mediaSourceId: mediaSourceId, subtitleStreamIndex: subtitleIndex, - deviceProfile: android, + deviceProfile: native, }); if (!res) return null; const { mediaSource, sessionId, url } = res; - if (!sessionId || !mediaSource || !url) { - console.warn("No sessionId or mediaSource or url", url); - return null; - } + if (!sessionId || !mediaSource || !url) return null; + + console.log(url); return { mediaSource, @@ -151,43 +130,44 @@ const Player = () => { url, }; }, - enabled: !!item, + enabled: !!itemId && !!api && !!item, staleTime: 0, }); - const poster = usePoster(item, api); - const videoSource = useVideoSource(item, api, poster, stream?.url); - const togglePlay = useCallback( - async (ticks: number) => { + async (ms: number) => { + if (!api || !stream) return; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (isPlaying) { - videoRef.current?.pause(); - await getPlaystateApi(api!).onPlaybackProgress({ + await videoRef.current?.pause(); + + await getPlaystateApi(api).onPlaybackProgress({ itemId: item?.Id!, audioStreamIndex: audioIndex ? audioIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, mediaSourceId: mediaSourceId, - positionTicks: Math.floor(ticks), + positionTicks: msToTicks(ms), isPaused: true, - playMethod: stream?.url.includes("m3u8") + playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, + playSessionId: stream.sessionId, }); + console.log("ACtually marked as paused"); } else { - videoRef.current?.resume(); - await getPlaystateApi(api!).onPlaybackProgress({ + videoRef.current?.play(); + await getPlaystateApi(api).onPlaybackProgress({ itemId: item?.Id!, audioStreamIndex: audioIndex ? audioIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, mediaSourceId: mediaSourceId, - positionTicks: Math.floor(ticks), + positionTicks: msToTicks(ms), isPaused: false, playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, + playSessionId: stream.sessionId, }); } }, @@ -195,9 +175,8 @@ const Player = () => { isPlaying, api, item, - videoRef, - settings, stream, + videoRef, audioIndex, subtitleIndex, mediaSourceId, @@ -205,7 +184,7 @@ const Player = () => { ); const play = useCallback(() => { - videoRef.current?.resume(); + videoRef.current?.play(); reportPlaybackStart(); }, [videoRef]); @@ -215,89 +194,60 @@ const Player = () => { const stop = useCallback(() => { setIsPlaybackStopped(true); - videoRef.current?.pause(); + videoRef.current?.stop(); reportPlaybackStopped(); }, [videoRef]); - const seek = useCallback( - (seconds: number) => { - videoRef.current?.seek(seconds); - }, - [videoRef] - ); - const reportPlaybackStopped = async () => { - if (!item?.Id) return; + const currentTimeInTicks = msToTicks(progress.value); + await getPlaystateApi(api!).onPlaybackStopped({ - itemId: item.Id, + itemId: item?.Id!, mediaSourceId: mediaSourceId, - positionTicks: Math.floor(progress.value), - playSessionId: stream?.sessionId, + positionTicks: currentTimeInTicks, + playSessionId: stream?.sessionId!, }); }; const reportPlaybackStart = async () => { - if (!item?.Id) return; - await getPlaystateApi(api!).onPlaybackStart({ - itemId: item.Id, + if (!api || !stream) return; + await getPlaystateApi(api).onPlaybackStart({ + itemId: item?.Id!, audioStreamIndex: audioIndex ? audioIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, mediaSourceId: mediaSourceId, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, + playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream", + playSessionId: stream?.sessionId ? stream?.sessionId : undefined, }); }; const onProgress = useCallback( - async (data: OnProgressData) => { + async (data: ProgressUpdatePayload) => { if (isSeeking.value === true) return; if (isPlaybackStopped === true) return; + if (!item?.Id || !api || !stream) return; - const ticks = secondsToTicks(data.currentTime); + const { currentTime } = data.nativeEvent; - progress.value = ticks; - cacheProgress.value = secondsToTicks(data.playableDuration); - - // TODO: Use this when streaming with HLS url, but NOT when direct playing - // TODO: since playable duration is always 0 then. - // setIsBuffering(data.playableDuration === 0); - - if (!item?.Id || data.currentTime === 0) { - return; + if (isBuffering) { + setIsBuffering(false); } - await getPlaystateApi(api!).onPlaybackProgress({ + progress.value = currentTime; + const currentTimeInTicks = msToTicks(currentTime); + + await getPlaystateApi(api).onPlaybackProgress({ itemId: item.Id, audioStreamIndex: audioIndex ? audioIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, mediaSourceId: mediaSourceId, - positionTicks: Math.round(ticks), + positionTicks: Math.floor(currentTimeInTicks), isPaused: !isPlaying, playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, + playSessionId: stream.sessionId, }); }, - [ - item, - isPlaying, - api, - isPlaybackStopped, - isSeeking, - stream, - mediaSourceId, - audioIndex, - subtitleIndex, - ] - ); - - useFocusEffect( - useCallback(() => { - play(); - - return () => { - stop(); - }; - }, [play, stop]) + [item?.Id, isPlaying, api, isPlaybackStopped] ); useOrientation(); @@ -310,38 +260,25 @@ const Player = () => { stopPlayback: stop, }); - const [selectedTextTrack, setSelectedTextTrack] = useState< - SelectedTrack | undefined - >(); + const onPlaybackStateChanged = (e: PlaybackStatePayload) => { + const { state, isBuffering, isPlaying } = e.nativeEvent; - const [embededTextTracks, setEmbededTextTracks] = useState< - { - index: number; - language?: string | undefined; - selected?: boolean | undefined; - title?: string | undefined; - type: any; - }[] - >([]); + if (state === "Playing") { + setIsPlaying(true); + return; + } - const [audioTracks, setAudioTracks] = useState([]); - const [selectedAudioTrack, setSelectedAudioTrack] = useState< - SelectedTrack | undefined - >(undefined); + if (state === "Paused") { + setIsPlaying(false); + return; + } - const getAudioTracks = (): TrackInfo[] => { - return audioTracks.map((t) => ({ - name: t.name, - index: t.index, - })); - }; - - const getSubtitleTracks = (): TrackInfo[] => { - return embededTextTracks.map((t) => ({ - name: t.title ?? "", - index: t.index, - language: t.language, - })); + if (isPlaying) { + setIsPlaying(true); + setIsBuffering(false); + } else if (isBuffering) { + setIsBuffering(true); + } }; if (isLoadingItem || isLoadingStreamUrl) @@ -358,88 +295,60 @@ const Player = () => { ); + if (!stream || !item) return null; + + const startPosition = item?.UserData?.PlaybackPositionTicks + ? ticksToMs(item.UserData.PlaybackPositionTicks) + : 0; + return ( { setShowControls(!showControls); }} - style={{ - flex: 1, - height: "100%", - width: "100%", - position: "absolute", - top: 0, - left: 0, - zIndex: 0, - }} + className="absolute z-0 h-full w-full" > - {videoSource ? ( - - {item && ( + {videoRef.current && ( { setShowControls={setShowControls} setIgnoreSafeAreas={setIgnoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas} - seek={seek} - play={play} - pause={pause} - getSubtitleTracks={getSubtitleTracks} - setSubtitleTrack={(i) => - setSelectedTextTrack({ - type: SelectedTrackType.INDEX, - value: i, - }) - } - getAudioTracks={getAudioTracks} - setAudioTrack={(i) => { - console.log("setAudioTrack ~", i); - setSelectedAudioTrack({ - type: SelectedTrackType.INDEX, - value: i, - }); - }} + isVideoLoaded={isVideoLoaded} + play={videoRef.current?.play} + pause={videoRef.current?.pause} + seek={videoRef.current?.seekTo} + enableTrickplay={true} + getAudioTracks={videoRef.current?.getAudioTracks} + getSubtitleTracks={videoRef.current?.getSubtitleTracks} + offline={false} + setSubtitleTrack={videoRef.current.setSubtitleTrack} + setSubtitleURL={videoRef.current.setSubtitleURL} + setAudioTrack={videoRef.current.setAudioTrack} + stop={stop} + isVlc /> )} @@ -475,7 +379,7 @@ const Player = () => { }; export function usePoster( - item: BaseItemDto | null | undefined, + item: BaseItemDto, api: Api | null ): string | undefined { const poster = useMemo(() => { @@ -493,37 +397,4 @@ export function usePoster( return poster ?? undefined; } -export function useVideoSource( - item: BaseItemDto | null | undefined, - api: Api | null, - poster: string | undefined, - url?: string | null -) { - const videoSource = useMemo(() => { - if (!item || !api || !url) { - return null; - } - - const startPosition = item?.UserData?.PlaybackPositionTicks - ? Math.round(item.UserData.PlaybackPositionTicks / 10000) - : 0; - - return { - uri: url, - isNetwork: true, - startPosition, - headers: getAuthHeaders(api), - metadata: { - artist: item?.AlbumArtist ?? undefined, - title: item?.Name || "Unknown", - description: item?.Overview ?? undefined, - imageUri: poster, - subtitle: item?.Album ?? undefined, - }, - }; - }, [item, api, poster, url]); - - return videoSource; -} - export default Player; diff --git a/modules/vlc-player/android/build.gradle b/modules/vlc-player/android/build.gradle new file mode 100644 index 00000000..53fe8c57 --- /dev/null +++ b/modules/vlc-player/android/build.gradle @@ -0,0 +1,70 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' + +group = 'expo.modules.vlcplayer' +version = '0.6.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:7.1.3" + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 34) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + } + } +} + +dependencies { + implementation 'org.videolan.android:libvlc-all:4.0.0-eap15' + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31" +} + +android { + namespace "expo.modules.vlcplayer" + compileSdkVersion 34 + defaultConfig { + minSdkVersion 21 + targetSdkVersion 34 + versionCode 1 + versionName "0.6.0" + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + lintOptions { + abortOnError false + } +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"] + jvmTarget = "17" + } +} \ No newline at end of file diff --git a/modules/vlc-player/android/src/main/AndroidManifest.xml b/modules/vlc-player/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..bdae66c8 --- /dev/null +++ b/modules/vlc-player/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt new file mode 100644 index 00000000..124e37f4 --- /dev/null +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt @@ -0,0 +1,69 @@ +package expo.modules.vlcplayer + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class VlcPlayerModule : Module() { + override fun definition() = ModuleDefinition { + Name("VlcPlayer") + + View(VlcPlayerView::class) { + Prop("source") { view: VlcPlayerView, source: Map -> + view.setSource(source) + } + + Prop("paused") { view: VlcPlayerView, paused: Boolean -> + if (paused) { + view.pause() + } else { + view.play() + } + } + + Events( + "onPlaybackStateChanged", + "onVideoStateChange", + "onVideoLoadStart", + "onVideoLoadEnd", + "onVideoProgress", + "onVideoError" + ) + + AsyncFunction("play") { view: VlcPlayerView -> + view.play() + } + + AsyncFunction("pause") { view: VlcPlayerView -> + view.pause() + } + + AsyncFunction("stop") { view: VlcPlayerView -> + view.stop() + } + + AsyncFunction("seekTo") { view: VlcPlayerView, time: Int -> + view.seekTo(time) + } + + // AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int -> + // view.setAudioTrack(trackIndex) + // } + + // AsyncFunction("getAudioTracks") { view: VlcPlayerView -> List>? -> + // view.getAudioTracks() + // } + + // AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int -> + // view.setSubtitleTrack(trackIndex) + // } + + // AsyncFunction("getSubtitleTracks") { view: VlcPlayerView -> List>? -> + // view.getSubtitleTracks() + // } + + // AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String -> + // view.setSubtitleURL(url, name) + // } + } + } +} \ No newline at end of file diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt new file mode 100644 index 00000000..ea54469c --- /dev/null +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt @@ -0,0 +1,251 @@ +package expo.modules.vlcplayer + +import android.content.Context +import android.util.Log +import android.view.ViewGroup +import android.widget.FrameLayout +import android.net.Uri +import androidx.lifecycle.LifecycleObserver +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.views.ExpoView +import org.videolan.libvlc.LibVLC +import org.videolan.libvlc.Media +import org.videolan.libvlc.MediaPlayer +import org.videolan.libvlc.util.VLCVideoLayout + +// Needs to inhert from MediaPlayer.EventListener +class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener { + + private var libVLC: LibVLC? = null + private var mediaPlayer: MediaPlayer? = null + private lateinit var videoLayout: VLCVideoLayout + private var isPaused: Boolean = false + private var isMediaReady: Boolean = false + private var lastReportedState: Int? = null + private var lastReportedIsPlaying: Boolean? = null + private var startPosition: Int? = null + + init { + setupView() + } + + private fun setupView() { + setBackgroundColor(android.graphics.Color.WHITE) + videoLayout = VLCVideoLayout(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + addView(videoLayout) + } + + fun setSource(source: Map) { + val mediaOptions = source["mediaOptions"] as? Map ?: emptyMap() + val initOptions = source["initOptions"] as? List ?: emptyList() + val uri = source["uri"] as? String + val autoplay = source["autoplay"] as? Boolean ?: false + val isNetwork = source["isNetwork"] as? Boolean ?: false + startPosition = source["startPosition"] as? Int ?: 0 + + // Handle video load start event + // onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null")) + + libVLC = LibVLC(context, initOptions) + mediaPlayer = MediaPlayer(libVLC) + mediaPlayer?.attachViews(videoLayout, null, false, false) + mediaPlayer?.setEventListener(this) + + Log.d("VlcPlayerView", "Loading network file: $uri") + val media = Media(libVLC, Uri.parse(uri)) + mediaPlayer?.media = media + + + Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions") + // media.addOptions(mediaOptions) + + // Apply subtitle options + // val subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?: -1 + // Log.d("VlcPlayerView", "Debug: Subtitle track index from source: $subtitleTrackIndex") + + // if (subtitleTrackIndex >= -1) { + // setSubtitleTrack(subtitleTrackIndex) + // Log.d("VlcPlayerView", "Debug: Set subtitle track to index: $subtitleTrackIndex") + // } else { + // Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting") + // } + + + if (autoplay) { + Log.d("VlcPlayerView", "Playing...") + play() + // if (startPosition > 0) { + // Log.d("VlcPlayerView", "Debug: Starting at position: $startPosition") + // seekTo(startPosition) + // } + } + } + + fun play() { + mediaPlayer?.play() + isPaused = false + } + + fun pause() { + mediaPlayer?.pause() + isPaused = true + } + + fun stop() { + mediaPlayer?.stop() + } + + fun seekTo(time: Int) { + mediaPlayer?.let { player -> + val wasPlaying = player.isPlaying + if (wasPlaying) { + player.pause() + } + + val duration = player.length.toInt() + val seekTime = if (time > duration) duration - 1000 else time + player.time = seekTime.toLong() + + if (wasPlaying) { + player.play() + } + } + } + + // fun setAudioTrack(trackIndex: Int) { + // mediaPlayer?.setAudioTrack(trackIndex) + // } + + // fun getAudioTracks(): List>? { + // val trackNames = mediaPlayer?.audioTrackNames ?: return null + // val trackIndexes = mediaPlayer?.audioTracks ?: return null + + // return trackNames.zip(trackIndexes).map { (name, index) -> + // mapOf("name" to name, "index" to index) + // } + // } + + // fun setSubtitleTrack(trackIndex: Int) { + // mediaPlayer?.setSpuTrack(trackIndex) + // } + + // fun getSubtitleTracks(): List>? { + // val trackNames = mediaPlayer?.spuTrackNames ?: return null + // val trackIndexes = mediaPlayer?.spuTracks ?: return null + + // return trackNames.zip(trackIndexes).map { (name, index) -> + // mapOf("name" to name, "index" to index) + // } + // } + + // fun setSubtitleURL(subtitleURL: String, name: String) { + // val media = mediaPlayer?.media ?: return + // media.addSlave(Media.Slave(Media.Slave.Type.Subtitle, subtitleURL, true)) + // } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + mediaPlayer?.release() + libVLC?.release() + } + + override fun onEvent(event: MediaPlayer.Event) { + when (event.type) { + MediaPlayer.Event.Playing, + MediaPlayer.Event.Paused, + MediaPlayer.Event.Stopped, + MediaPlayer.Event.Buffering, + MediaPlayer.Event.EndReached, + MediaPlayer.Event.EncounteredError -> updatePlayerState(event) + MediaPlayer.Event.TimeChanged -> updateVideoProgress() + } + } + + private fun updatePlayerState(event: MediaPlayer.Event) { + val player = mediaPlayer ?: return + val currentState = event.type + + val stateInfo = mutableMapOf( + "target" to "null", // Replace with actual target if needed + "currentTime" to player.time.toInt(), + "duration" to (player.media?.duration?.toInt() ?: 0), + "error" to false + ) + + when (currentState) { + MediaPlayer.Event.Playing -> { + stateInfo["isPlaying"] = true + stateInfo["isBuffering"] = false + stateInfo["state"] = "Playing" + } + MediaPlayer.Event.Paused -> { + stateInfo["isPlaying"] = false + stateInfo["state"] = "Paused" + } + MediaPlayer.Event.Buffering -> { + stateInfo["isBuffering"] = true + stateInfo["state"] = "Buffering" + } + MediaPlayer.Event.EncounteredError -> { + Log.e("VlcPlayerView", "player.state ~ error") + stateInfo["state"] = "Error" + onVideoLoadEnd?.invoke(stateInfo) + } + MediaPlayer.Event.Opening -> { + Log.d("VlcPlayerView", "player.state ~ opening") + stateInfo["state"] = "Opening" + } + } + + // Determine if the media has finished loading + if (player.isPlaying && !isMediaReady) { + isMediaReady = true + onVideoLoadEnd?.invoke(stateInfo) + seekToStartTime() + } + + if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) { + lastReportedState = currentState + lastReportedIsPlaying = player.isPlaying + onVideoStateChange?.invoke(stateInfo) + } + } + + private fun seekToStartTime() { + val player = mediaPlayer ?: return + val startPosition = startPosition ?: return + + if (startPosition > 0) { + Log.d("VlcPlayerView", "Debug: Seeking to start position: $startPosition") + player.time = startPosition.toLong() + + // Ensure the player continues playing after seeking + if (!player.isPlaying) { + player.play() + } + } + } + + private fun updateVideoProgress() { + val player = mediaPlayer ?: return + + val currentTimeMs = player.time.toInt() + val durationMs = player.media?.duration?.toInt() ?: 0 + + println("currentTimeMs: $currentTimeMs") + if (currentTimeMs >= 0 && currentTimeMs < durationMs) { + onVideoProgress?.invoke( + mapOf( + "currentTime" to currentTimeMs, + "duration" to durationMs + ) + ) + } + } + + var onVideoLoadEnd: ((Map) -> Unit)? = null + var onVideoStateChange: ((Map) -> Unit)? = null + var onVideoProgress: ((Map) -> Unit)? = null +} \ No newline at end of file