From c5077953a8037e515950fd6f1345ce81ff798e50 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Sat, 23 Nov 2024 22:37:16 +1100 Subject: [PATCH] Got preselect subtitles working for Android --- app/(auth)/player/music-player.tsx | 2 +- app/(auth)/player/player.tsx | 42 +- components/video-player/Controls.tsx | 846 ------------------ components/video-player/controls/Controls.tsx | 633 +++++++++++++ .../video-player/controls/DropdownView.tsx | 234 +++++ .../video-player/controls/SliderScrubbter.tsx | 144 +++ .../controls/contexts/ControlContext.tsx | 34 + .../controls/contexts/VideoContext.tsx | 62 ++ components/video-player/controls/types.ts | 20 + .../video-player/offline-player.android.tsx | 2 +- .../video-player/offline-player.ios.tsx | 2 +- components/video-player/player.android.tsx | 2 +- components/video-player/player.ios.tsx | 2 +- .../expo/modules/vlcplayer/VlcPlayerView.kt | 32 +- modules/vlc-player/src/VlcPlayer.types.ts | 1 + 15 files changed, 1195 insertions(+), 863 deletions(-) delete mode 100644 components/video-player/Controls.tsx create mode 100644 components/video-player/controls/Controls.tsx create mode 100644 components/video-player/controls/DropdownView.tsx create mode 100644 components/video-player/controls/SliderScrubbter.tsx create mode 100644 components/video-player/controls/contexts/ControlContext.tsx create mode 100644 components/video-player/controls/contexts/VideoContext.tsx create mode 100644 components/video-player/controls/types.ts diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx index 61d3ad0a..d2f5de15 100644 --- a/app/(auth)/player/music-player.tsx +++ b/app/(auth)/player/music-player.tsx @@ -1,7 +1,7 @@ import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import AlbumCover from "@/components/posters/AlbumCover"; -import { Controls } from "@/components/video-player/Controls"; +import { Controls } from "@/components/video-player/controls/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; diff --git a/app/(auth)/player/player.tsx b/app/(auth)/player/player.tsx index 196ea285..8cdaf66f 100644 --- a/app/(auth)/player/player.tsx +++ b/app/(auth)/player/player.tsx @@ -1,7 +1,7 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; -import { Controls } from "@/components/video-player/Controls"; +import { Controls } from "@/components/video-player/controls/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; @@ -18,7 +18,7 @@ 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 { BaseItemDto, MediaSourceType } from "@jellyfin/sdk/lib/generated-client"; import { getPlaystateApi, getUserLibraryApi, @@ -301,11 +301,40 @@ export default function page() { if (!stream || !item) return null; - console.log("AudioIndex", audioIndex); const startPosition = item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0; + + // Preselection of audio and subtitle tracks. + + let initOptions = ["--sub-text-scale=60"] + let externalTrack = { name: "", DeliveryUrl: "" }; + + const allSubs = stream.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle") || []; + const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex); + // Direct playback CASE + if (!bitrateValue) { + // If Subtitle is embedded we can use the position to select it straight away. + if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) { + initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`); + } else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) { + // If Subtitle is external we need to pass the URL to the player. + externalTrack = { + name: chosenSubtitleTrack.DisplayTitle || "", + DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}` + }; + } + } else { + // Transcoded playback CASE + if (chosenSubtitleTrack?.DeliveryMethod === "Hls") { + externalTrack = { + name: `subs ${chosenSubtitleTrack.DisplayTitle}` , + DeliveryUrl: "" + }; + } + } + return ( ; - isPlaying: boolean; - isSeeking: SharedValue; - cacheProgress: SharedValue; - progress: SharedValue; - isBuffering: boolean; - showControls: boolean; - ignoreSafeAreas?: boolean; - setIgnoreSafeAreas: React.Dispatch>; - enableTrickplay?: boolean; - togglePlay: (ticks: number) => void; - setShowControls: (shown: boolean) => void; - offline?: boolean; - isVideoLoaded?: boolean; - mediaSource?: MediaSourceInfo | null; - seek: (ticks: number) => void; - play: (() => Promise) | (() => void); - pause: () => void; - getAudioTracks?: (() => Promise) | (() => TrackInfo[]); - getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]); - setSubtitleURL?: (url: string, customName: string) => void; - setSubtitleTrack?: (index: number) => void; - setAudioTrack?: (index: number) => void; - stop?: (() => Promise) | (() => void); - isVlc?: boolean; -} - -export const Controls: React.FC = ({ - item, - videoRef, - seek, - play, - pause, - togglePlay, - isPlaying, - isSeeking, - progress, - isBuffering, - cacheProgress, - showControls, - setShowControls, - ignoreSafeAreas, - setIgnoreSafeAreas, - mediaSource, - isVideoLoaded, - getAudioTracks, - getSubtitleTracks, - setSubtitleURL, - setSubtitleTrack, - setAudioTrack, - stop, - offline = false, - enableTrickplay = true, - isVlc = false, -}) => { - const [settings] = useSettings(); - const router = useRouter(); - const insets = useSafeAreaInsets(); - const { setPlaySettings, playSettings } = usePlaySettings(); - const api = useAtomValue(apiAtom); - const windowDimensions = Dimensions.get("window"); - - const { - audioIndex: audioIndexStr, - subtitleIndex: subtitleIndexStr, - } = useLocalSearchParams<{ - itemId: string; - audioIndex: string; - subtitleIndex: string; - mediaSourceId: string; - bitrateValue: string; - }>(); - - - const { previousItem, nextItem } = useAdjacentItems({ item }); - const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( - item, - !offline && enableTrickplay - ); - - const [currentTime, setCurrentTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(0); - - // Only needed for transcoding streams. - const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState(subtitleIndexStr); - const [onTextSubtitle, setOnTextSubtitle] = useState(Boolean(mediaSource?.MediaStreams?.find((x) => - x.Index === parseInt(subtitleIndexStr) && x.IsTextSubtitleStream || currentSubtitleIndex === "-1" - )) ?? false); - - const min = useSharedValue(0); - const max = useSharedValue(item.RunTimeTicks || 0); - - const wasPlayingRef = useRef(false); - const lastProgressRef = useRef(0); - - const { showSkipButton, skipIntro } = useIntroSkipper( - offline ? undefined : item.Id, - currentTime, - seek, - play, - isVlc - ); - - const { showSkipCreditButton, skipCredit } = useCreditSkipper( - offline ? undefined : item.Id, - currentTime, - seek, - play, - isVlc - ); - - const goToPreviousItem = useCallback(() => { - if (!previousItem || !settings) return; - - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(previousItem, settings); - - setPlaySettings({ - item: previousItem, - bitrate, - mediaSource, - audioIndex, - subtitleIndex, - }); - - const queryParams = new URLSearchParams({ - itemId: previousItem.Id ?? "", // Ensure itemId is a string - audioIndex: audioIndex?.toString() ?? "", - subtitleIndex: subtitleIndex?.toString() ?? "", - mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string - bitrateValue: bitrate.toString(), - }).toString(); - - // @ts-expect-error - router.replace(`player/player?${queryParams}`); - }, [previousItem, settings]); - - const goToNextItem = useCallback(() => { - if (!nextItem || !settings) return; - - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(nextItem, settings); - - setPlaySettings({ - item: nextItem, - bitrate, - mediaSource, - audioIndex, - subtitleIndex, - }); - - const queryParams = new URLSearchParams({ - itemId: nextItem.Id ?? "", // Ensure itemId is a string - audioIndex: audioIndex?.toString() ?? "", - subtitleIndex: subtitleIndex?.toString() ?? "", - mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string - bitrateValue: bitrate.toString(), - }).toString(); - - // @ts-expect-error - router.replace(`player/player?${queryParams}`); - }, [nextItem, settings]); - - const updateTimes = useCallback( - (currentProgress: number, maxValue: number) => { - const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); - const remaining = isVlc - ? maxValue - currentProgress - : ticksToSeconds(maxValue - currentProgress); - - setCurrentTime(current); - setRemainingTime(remaining); - - // Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround. - if (currentProgress === maxValue) { - setShowControls(true); - // Automatically play the next item if it exists - goToNextItem(); - } - }, - [goToNextItem, isVlc] - ); - - useAnimatedReaction( - () => ({ - progress: progress.value, - max: max.value, - isSeeking: isSeeking.value, - }), - (result) => { - // console.log("Progress changed", result); - if (result.isSeeking === false) { - runOnJS(updateTimes)(result.progress, result.max); - } - }, - [updateTimes] - ); - - useEffect(() => { - if (item) { - progress.value = isVlc - ? ticksToMs(item?.UserData?.PlaybackPositionTicks) - : item?.UserData?.PlaybackPositionTicks || 0; - max.value = isVlc - ? ticksToMs(item.RunTimeTicks || 0) - : item.RunTimeTicks || 0; - } - }, [item, isVlc]); - - const toggleControls = () => setShowControls(!showControls); - - const handleSliderComplete = useCallback( - async (value: number) => { - isSeeking.value = false; - progress.value = value; - - await seek( - Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))) - ); - if (wasPlayingRef.current === true) play(); - }, - [isVlc] - ); - - const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); - - const handleSliderChange = (value: number) => { - const progressInTicks = isVlc ? msToTicks(value) : value; - calculateTrickplayUrl(progressInTicks); - - const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); - const hours = Math.floor(progressInSeconds / 3600); - const minutes = Math.floor((progressInSeconds % 3600) / 60); - const seconds = progressInSeconds % 60; - setTime({ hours, minutes, seconds }); - }; - - const handleSliderStart = useCallback(() => { - if (showControls === false) return; - - wasPlayingRef.current = isPlaying; - lastProgressRef.current = progress.value; - - pause(); - isSeeking.value = true; - }, [showControls, isPlaying]); - - const handleSkipBackward = useCallback(async () => { - if (!settings?.rewindSkipTime) return; - wasPlayingRef.current = isPlaying; - try { - const curr = progress.value; - if (curr !== undefined) { - const newTime = isVlc - ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) - : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); - await seek(newTime); - if (wasPlayingRef.current === true) play(); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video backwards", error); - } - }, [settings, isPlaying, isVlc]); - - const handleSkipForward = useCallback(async () => { - if (!settings?.forwardSkipTime) return; - wasPlayingRef.current = isPlaying; - try { - const curr = progress.value; - console.log(curr); - if (curr !== undefined) { - const newTime = isVlc - ? curr + secondsToMs(settings.forwardSkipTime) - : ticksToSeconds(curr) + settings.forwardSkipTime; - await seek(Math.max(0, newTime)); - if (wasPlayingRef.current === true) play(); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video forwards", error); - } - }, [settings, isPlaying, isVlc]); - - const toggleIgnoreSafeAreas = useCallback(() => { - setIgnoreSafeAreas((prev) => !prev); - }, []); - - const [selectedSubtitleTrack, setSelectedSubtitleTrack] = useState< - MediaStream | undefined - >(undefined); - - const [audioTracks, setAudioTracks] = useState(null); - const [subtitleTracks, setSubtitleTracks] = useState( - null - ); - - // Only fetch tracks if the media source is not transcoded. - - useEffect(() => { - const fetchTracks = async () => { - if (getSubtitleTracks) { - const subtitles = await getSubtitleTracks(); - console.log("Getting embeded subtitles...", subtitles); - setSubtitleTracks(subtitles); - } - if (getAudioTracks) { - const audio = await getAudioTracks(); - setAudioTracks(audio); - } - }; - fetchTracks(); - }, [isVideoLoaded, getAudioTracks, getSubtitleTracks]); - - type EmbeddedSubtitle = { - name: string; - index: number; - isExternal: boolean; - }; - - type ExternalSubtitle = { - name: string; - index: number; - isExternal: boolean; - deliveryUrl: string; - }; - - type TranscodedSubtitle = { - name: string; - index: number; - IsTextSubtitleStream: boolean; - } - - const allSubtitleTracksForDirectPlay = useMemo(() => { - if (mediaSource?.TranscodingUrl) return null; - const embeddedSubs = - subtitleTracks - ?.map((s) => ({ - name: s.name, - index: s.index, - deliveryUrl: undefined, - })) - .filter((sub) => !sub.name.endsWith("[External]")) || []; - - const externalSubs = - mediaSource?.MediaStreams?.filter( - (stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl - ).map((s) => ({ - name: s.DisplayTitle! + " [External]", - index: s.Index!, - deliveryUrl: s.DeliveryUrl, - })) || []; - - // Combine embedded and unique external subs - return [...embeddedSubs, ...externalSubs] as ( - | EmbeddedSubtitle - | ExternalSubtitle - )[]; - }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]); - - const allSubtitleTracksForTranscodingStream = useMemo(() => { - const allSubs = mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? []; - console.log('here') - if (onTextSubtitle) { - const textSubtitles = - subtitleTracks - ?.map((s) => ({ - name: s.name, - index: s.index, - IsTextSubtitleStream: true, - })) || []; - - console.log("Text subtitles: ", textSubtitles); - const imageSubtitles = - allSubs.filter((x) => !x.IsTextSubtitleStream).map((x) => ( - { name: x.DisplayTitle!, - index: x.Index!, - IsTextSubtitleStream: x.IsTextSubtitleStream - } as TranscodedSubtitle)); - - return [...textSubtitles, ...imageSubtitles]; - } - - const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({ - name: x.DisplayTitle!, - index: x.Index!, - IsTextSubtitleStream: x.IsTextSubtitleStream! - })); - - return transcodedSubtitle; - - }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, onTextSubtitle]); - - return ( - - - - - - - - - - - - Subtitle - - - {!mediaSource?.TranscodingUrl && allSubtitleTracksForDirectPlay?.map((sub, idx: number) => ( - { - if ("deliveryUrl" in sub && sub.deliveryUrl) { - setSubtitleURL && - setSubtitleURL( - api?.basePath + sub.deliveryUrl, - sub.name - ); - - console.log("Set external subtitle: ", api?.basePath + sub.deliveryUrl); - } else { - console.log("Set sub index: ", sub.index); - setSubtitleTrack && setSubtitleTrack(sub.index); - } - - console.log("Subtitle: ", sub); - }} - > - - - {sub.name} - - - ))} - {mediaSource?.TranscodingUrl && allSubtitleTracksForTranscodingStream?.map((sub, idx: number) => ( - { - if (currentSubtitleIndex === sub.index.toString()) return; - - if (sub.IsTextSubtitleStream && onTextSubtitle) { - setSubtitleTrack && setSubtitleTrack(sub.index); - return; - } - - // Needs a full reload of the player. - - }} - > - - - {sub.name} - - - ))} - - - - - Audio - - - {audioTracks?.map((track, idx: number) => ( - { - setAudioTrack && setAudioTrack(track.index); - }} - > - - - {track.name} - - - ))} - - - - - - - - - Skip Intro - - - - - - Skip Credits - - - - { - toggleControls(); - }} - style={{ - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - opacity: showControls ? 0.5 : 0, - backgroundColor: "black", - }} - > - - - - - - - {Platform.OS !== "ios" && ( - - - - )} - { - if (stop) await stop(); - router.back(); - }} - className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" - > - - - - - - - {item?.Name} - {item?.Type === "Episode" && ( - {item.SeriesName} - )} - {item?.Type === "Movie" && ( - {item?.ProductionYear} - )} - {item?.Type === "Audio" && ( - {item?.Album} - )} - - - - - - - - - - { - togglePlay(progress.value); - }} - > - - - - - - - - - - - { - if (!trickPlayUrl || !trickplayInfo) { - return null; - } - const { x, y, url } = trickPlayUrl; - - const tileWidth = 150; - const tileHeight = 150 / trickplayInfo.aspectRatio!; - return ( - - - - {`${time.hours > 0 ? `${time.hours}:` : ""}${ - time.minutes < 10 ? `0${time.minutes}` : time.minutes - }:${ - time.seconds < 10 ? `0${time.seconds}` : time.seconds - }`} - - - ); - }} - sliderHeight={10} - thumbWidth={0} - progress={progress} - minimumValue={min} - maximumValue={max} - /> - - - {formatTimeString(currentTime, isVlc ? "ms" : "s")} - - - -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} - - - - - - - ); -}; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx new file mode 100644 index 00000000..e3c5e872 --- /dev/null +++ b/components/video-player/controls/Controls.tsx @@ -0,0 +1,633 @@ +import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; +import { useCreditSkipper } from "@/hooks/useCreditSkipper"; +import { useIntroSkipper } from "@/hooks/useIntroSkipper"; +import { useTrickplay } from "@/hooks/useTrickplay"; +import { + TrackInfo, + VlcPlayerViewRef, +} from "@/modules/vlc-player/src/VlcPlayer.types"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { usePlaySettings } from "@/providers/PlaySettingsProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; +import { writeToLog } from "@/utils/log"; +import { + formatTimeString, + msToTicks, + secondsToMs, + ticksToMs, + ticksToSeconds, +} from "@/utils/time"; +import { Ionicons } from "@expo/vector-icons"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Dimensions, + Platform, + Pressable, + TouchableOpacity, + View, +} from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { + runOnJS, + SharedValue, + useAnimatedReaction, + useSharedValue, +} from "react-native-reanimated"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { VideoRef } from "react-native-video"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import index from "@/app/(auth)/(tabs)/(home)"; +import { all } from "axios"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { VideoProvider } from "./contexts/VideoContext"; +import DropdownView from "./DropdownView"; +import { ControlProvider } from "./contexts/ControlContext"; + +interface Props { + item: BaseItemDto; + videoRef: React.MutableRefObject; + isPlaying: boolean; + isSeeking: SharedValue; + cacheProgress: SharedValue; + progress: SharedValue; + isBuffering: boolean; + showControls: boolean; + ignoreSafeAreas?: boolean; + setIgnoreSafeAreas: React.Dispatch>; + enableTrickplay?: boolean; + togglePlay: (ticks: number) => void; + setShowControls: (shown: boolean) => void; + offline?: boolean; + isVideoLoaded?: boolean; + mediaSource?: MediaSourceInfo | null; + seek: (ticks: number) => void; + play: (() => Promise) | (() => void); + pause: () => void; + getAudioTracks?: (() => Promise) | (() => TrackInfo[]); + getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]); + setSubtitleURL?: (url: string, customName: string) => void; + setSubtitleTrack?: (index: number) => void; + setAudioTrack?: (index: number) => void; + stop?: (() => Promise) | (() => void); + isVlc?: boolean; +} + +export const Controls: React.FC = ({ + item, + videoRef, + seek, + play, + pause, + togglePlay, + isPlaying, + isSeeking, + progress, + isBuffering, + cacheProgress, + showControls, + setShowControls, + ignoreSafeAreas, + setIgnoreSafeAreas, + mediaSource, + isVideoLoaded, + getAudioTracks, + getSubtitleTracks, + setSubtitleURL, + setSubtitleTrack, + setAudioTrack, + stop, + offline = false, + enableTrickplay = true, + isVlc = false, +}) => { + const [settings] = useSettings(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { setPlaySettings, playSettings } = usePlaySettings(); + const api = useAtomValue(apiAtom); + const windowDimensions = Dimensions.get("window"); + + + const { previousItem, nextItem } = useAdjacentItems({ item }); + const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( + item, + !offline && enableTrickplay + ); + + const [currentTime, setCurrentTime] = useState(0); + const [remainingTime, setRemainingTime] = useState(0); + + const min = useSharedValue(0); + const max = useSharedValue(item.RunTimeTicks || 0); + + const wasPlayingRef = useRef(false); + const lastProgressRef = useRef(0); + + const { showSkipButton, skipIntro } = useIntroSkipper( + offline ? undefined : item.Id, + currentTime, + seek, + play, + isVlc + ); + + const { showSkipCreditButton, skipCredit } = useCreditSkipper( + offline ? undefined : item.Id, + currentTime, + seek, + play, + isVlc + ); + + const goToPreviousItem = useCallback(() => { + if (!previousItem || !settings) return; + + const { bitrate, mediaSource, audioIndex, subtitleIndex } = + getDefaultPlaySettings(previousItem, settings); + + setPlaySettings({ + item: previousItem, + bitrate, + mediaSource, + audioIndex, + subtitleIndex, + }); + + const queryParams = new URLSearchParams({ + itemId: previousItem.Id ?? "", // Ensure itemId is a string + audioIndex: audioIndex?.toString() ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", + mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrate.toString(), + }).toString(); + + // @ts-expect-error + router.replace(`player/player?${queryParams}`); + }, [previousItem, settings]); + + const goToNextItem = useCallback(() => { + if (!nextItem || !settings) return; + + const { bitrate, mediaSource, audioIndex, subtitleIndex } = + getDefaultPlaySettings(nextItem, settings); + + setPlaySettings({ + item: nextItem, + bitrate, + mediaSource, + audioIndex, + subtitleIndex, + }); + + const queryParams = new URLSearchParams({ + itemId: nextItem.Id ?? "", // Ensure itemId is a string + audioIndex: audioIndex?.toString() ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", + mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrate.toString(), + }).toString(); + + // @ts-expect-error + router.replace(`player/player?${queryParams}`); + }, [nextItem, settings]); + + const updateTimes = useCallback( + (currentProgress: number, maxValue: number) => { + const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); + const remaining = isVlc + ? maxValue - currentProgress + : ticksToSeconds(maxValue - currentProgress); + + setCurrentTime(current); + setRemainingTime(remaining); + + // Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround. + if (currentProgress === maxValue) { + setShowControls(true); + // Automatically play the next item if it exists + goToNextItem(); + } + }, + [goToNextItem, isVlc] + ); + + useAnimatedReaction( + () => ({ + progress: progress.value, + max: max.value, + isSeeking: isSeeking.value, + }), + (result) => { + // console.log("Progress changed", result); + if (result.isSeeking === false) { + runOnJS(updateTimes)(result.progress, result.max); + } + }, + [updateTimes] + ); + + useEffect(() => { + if (item) { + progress.value = isVlc + ? ticksToMs(item?.UserData?.PlaybackPositionTicks) + : item?.UserData?.PlaybackPositionTicks || 0; + max.value = isVlc + ? ticksToMs(item.RunTimeTicks || 0) + : item.RunTimeTicks || 0; + } + }, [item, isVlc]); + + const toggleControls = () => setShowControls(!showControls); + + const handleSliderComplete = useCallback( + async (value: number) => { + isSeeking.value = false; + progress.value = value; + + await seek( + Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))) + ); + if (wasPlayingRef.current === true) play(); + }, + [isVlc] + ); + + const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + + const handleSliderChange = (value: number) => { + const progressInTicks = isVlc ? msToTicks(value) : value; + calculateTrickplayUrl(progressInTicks); + + const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); + const hours = Math.floor(progressInSeconds / 3600); + const minutes = Math.floor((progressInSeconds % 3600) / 60); + const seconds = progressInSeconds % 60; + setTime({ hours, minutes, seconds }); + }; + + const handleSliderStart = useCallback(() => { + if (showControls === false) return; + + wasPlayingRef.current = isPlaying; + lastProgressRef.current = progress.value; + + pause(); + isSeeking.value = true; + }, [showControls, isPlaying]); + + const handleSkipBackward = useCallback(async () => { + if (!settings?.rewindSkipTime) return; + wasPlayingRef.current = isPlaying; + try { + const curr = progress.value; + if (curr !== undefined) { + const newTime = isVlc + ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) + : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); + await seek(newTime); + if (wasPlayingRef.current === true) play(); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video backwards", error); + } + }, [settings, isPlaying, isVlc]); + + const handleSkipForward = useCallback(async () => { + if (!settings?.forwardSkipTime) return; + wasPlayingRef.current = isPlaying; + try { + const curr = progress.value; + console.log(curr); + if (curr !== undefined) { + const newTime = isVlc + ? curr + secondsToMs(settings.forwardSkipTime) + : ticksToSeconds(curr) + settings.forwardSkipTime; + await seek(Math.max(0, newTime)); + if (wasPlayingRef.current === true) play(); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video forwards", error); + } + }, [settings, isPlaying, isVlc]); + + const toggleIgnoreSafeAreas = useCallback(() => { + setIgnoreSafeAreas((prev) => !prev); + }, []); + + + return ( + + + + + + + + + Skip Intro + + + + + + Skip Credits + + + + { + toggleControls(); + }} + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + opacity: showControls ? 0.5 : 0, + backgroundColor: "black", + }} + > + + + + + + + {Platform.OS !== "ios" && ( + + + + )} + { + if (stop) await stop(); + router.back(); + }} + className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" + > + + + + + + + {item?.Name} + {item?.Type === "Episode" && ( + {item.SeriesName} + )} + {item?.Type === "Movie" && ( + {item?.ProductionYear} + )} + {item?.Type === "Audio" && ( + {item?.Album} + )} + + + + + + + + + + { + togglePlay(progress.value); + }} + > + + + + + + + + + + + { + if (!trickPlayUrl || !trickplayInfo) { + return null; + } + const { x, y, url } = trickPlayUrl; + + const tileWidth = 150; + const tileHeight = 150 / trickplayInfo.aspectRatio!; + return ( + + + + {`${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${ + time.seconds < 10 ? `0${time.seconds}` : time.seconds + }`} + + + ); + }} + sliderHeight={10} + thumbWidth={0} + progress={progress} + minimumValue={min} + maximumValue={max} + /> + + + {formatTimeString(currentTime, isVlc ? "ms" : "s")} + + + -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} + + + + + + + + ); +}; \ No newline at end of file diff --git a/components/video-player/controls/DropdownView.tsx b/components/video-player/controls/DropdownView.tsx new file mode 100644 index 00000000..dbfdd725 --- /dev/null +++ b/components/video-player/controls/DropdownView.tsx @@ -0,0 +1,234 @@ + +import React, { useCallback, useMemo } from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { useControlContext } from './contexts/ControlContext'; +import { useVideoContext } from './contexts/VideoContext'; +import { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle } from './types'; +import { useAtomValue } from 'jotai'; +import { apiAtom } from '@/providers/JellyfinProvider'; +import { useLocalSearchParams, useRouter } from 'expo-router'; + +interface DropdownViewProps { + showControls: boolean; +} + +const DropdownView: React.FC = ({ showControls }) => { + const router = useRouter(); + const api = useAtomValue(apiAtom); + const ControlContext = useControlContext(); + const mediaSource = ControlContext?.mediaSource; + const item = ControlContext?.item; + const isVideoLoaded = ControlContext?.isVideoLoaded; + + const videoContext = useVideoContext(); + const { subtitleTracks, audioTracks, setSubtitleURL, setSubtitleTrack, setAudioTrack } = videoContext; + + const allSubtitleTracksForDirectPlay = useMemo(() => { + if (mediaSource?.TranscodingUrl) return null; + const embeddedSubs = + subtitleTracks + ?.map((s) => ({ + name: s.name, + index: s.index, + deliveryUrl: undefined, + })) + .filter((sub) => !sub.name.endsWith("[External]")) || []; + + const externalSubs = + mediaSource?.MediaStreams?.filter( + (stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl + ).map((s) => ({ + name: s.DisplayTitle! + " [External]", + index: s.Index!, + deliveryUrl: s.DeliveryUrl, + })) || []; + + // Combine embedded and unique external subs + return [...embeddedSubs, ...externalSubs] as ( + | EmbeddedSubtitle + | ExternalSubtitle + )[]; + }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]); + + // Only used for transcoding streams. + const { + subtitleIndex: subtitleIndexStr, + audioIndex, + bitrateValue, + } = useLocalSearchParams<{ + itemId: string; + audioIndex: string; + subtitleIndex: string; + mediaSourceId: string; + bitrateValue: string; + }>(); + + // Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles. + const isOnTextSubtitle = mediaSource?.MediaStreams?.find((x) => x.Index === parseInt(subtitleIndexStr) + && x.IsTextSubtitleStream) + || subtitleIndexStr === "-1"; + + const allSubtitleTracksForTranscodingStream = useMemo(() => { + const allSubs = mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? []; + if (isOnTextSubtitle) { + const textSubtitles = + subtitleTracks + ?.map((s) => ({ + name: s.name, + index: s.index, + IsTextSubtitleStream: true, + })) || []; + + const imageSubtitles = + allSubs.filter((x) => !x.IsTextSubtitleStream).map((x) => ( + { name: x.DisplayTitle!, + index: x.Index!, + IsTextSubtitleStream: x.IsTextSubtitleStream + } as TranscodedSubtitle)); + + return [...textSubtitles, ...imageSubtitles]; + } + + const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({ + name: x.DisplayTitle!, + index: x.Index!, + IsTextSubtitleStream: x.IsTextSubtitleStream! + })); + + return transcodedSubtitle; + + }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]); + + const ChangeTranscodingSubtitle = useCallback((subtitleIndex: number) => { + const queryParams = new URLSearchParams({ + itemId: item.Id ?? "", // Ensure itemId is a string + audioIndex: audioIndex?.toString() ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", + mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrateValue, + }).toString(); + + // @ts-expect-error + router.replace(`player/player?${queryParams}`); + }, [mediaSource]); + + + + + return ( + + + + + + + + + + + Subtitle + + + {!mediaSource?.TranscodingUrl && allSubtitleTracksForDirectPlay?.map((sub, idx: number) => ( + { + if ("deliveryUrl" in sub && sub.deliveryUrl) { + setSubtitleURL && + setSubtitleURL( + api?.basePath + sub.deliveryUrl, + sub.name + ); + + console.log("Set external subtitle: ", api?.basePath + sub.deliveryUrl); + } else { + console.log("Set sub index: ", sub.index); + setSubtitleTrack && setSubtitleTrack(sub.index); + } + + console.log("Subtitle: ", sub); + }} + > + + + {sub.name} + + + ))} + {mediaSource?.TranscodingUrl && allSubtitleTracksForTranscodingStream?.map((sub, idx: number) => ( + { + if (subtitleIndexStr === sub.index.toString()) return; + + if (sub.IsTextSubtitleStream && isOnTextSubtitle) { + setSubtitleTrack && setSubtitleTrack(sub.index); + return; + } + ChangeTranscodingSubtitle(sub.index); + }} + > + + + {sub.name} + + + ))} + + + + + Audio + + + {audioTracks?.map((track, idx: number) => ( + { + setAudioTrack && setAudioTrack(track.index); + }} + > + + + {track.name} + + + ))} + + + + + + ); +}; + +export default DropdownView; \ No newline at end of file diff --git a/components/video-player/controls/SliderScrubbter.tsx b/components/video-player/controls/SliderScrubbter.tsx new file mode 100644 index 00000000..a618a350 --- /dev/null +++ b/components/video-player/controls/SliderScrubbter.tsx @@ -0,0 +1,144 @@ +import { useTrickplay } from '@/hooks/useTrickplay'; +import { formatTimeString, msToTicks, ticksToSeconds } from '@/utils/time'; +import React, { useRef, useState } from 'react'; +import { View, Text } from 'react-native'; +import { Image } from "expo-image"; +import { Slider } from "react-native-awesome-slider"; +import { SharedValue, useSharedValue } from 'react-native-reanimated'; +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; + +interface SliderScrubberProps { + cacheProgress: SharedValue; + handleSliderStart: () => void; + handleSliderComplete: (value: number) => void; + progress: SharedValue; + min: SharedValue; + max: SharedValue; + currentTime: number; + remainingTime: number; + item: BaseItemDto; +} + +const SliderScrubber: React.FC = ({ + cacheProgress, + handleSliderStart, + handleSliderComplete, + progress, + min, + max, + currentTime, + remainingTime, + item, +}) => { + + + const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( + item, + ); + + const handleSliderChange = (value: number) => { + const progressInTicks = msToTicks(value); + calculateTrickplayUrl(progressInTicks); + + const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); + const hours = Math.floor(progressInSeconds / 3600); + const minutes = Math.floor((progressInSeconds % 3600) / 60); + const seconds = progressInSeconds % 60; + setTime({ hours, minutes, seconds }); + }; + + return ( + + { + if (!trickPlayUrl || !trickplayInfo) { + return null; + } + const { x, y, url } = trickPlayUrl; + + const tileWidth = 150; + const tileHeight = 150 / trickplayInfo.aspectRatio!; + return ( + + + + {`${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${ + time.seconds < 10 ? `0${time.seconds}` : time.seconds + }`} + + + ); + }} + sliderHeight={10} + thumbWidth={0} + progress={progress} + minimumValue={min} + maximumValue={max} + /> + + + {formatTimeString(currentTime, "ms")} + + + -{formatTimeString(remainingTime, "ms")} + + + + ); +}; + +export default SliderScrubber; \ No newline at end of file diff --git a/components/video-player/controls/contexts/ControlContext.tsx b/components/video-player/controls/contexts/ControlContext.tsx new file mode 100644 index 00000000..4d2a8df4 --- /dev/null +++ b/components/video-player/controls/contexts/ControlContext.tsx @@ -0,0 +1,34 @@ +import { TrackInfo } from '@/modules/vlc-player'; +import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'; +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface ControlContextProps { + item: BaseItemDto; + mediaSource: MediaSourceInfo | null | undefined; + isVideoLoaded: boolean | undefined; +} + +const ControlContext = createContext(undefined); + +interface ControlProviderProps { + children: ReactNode; + item: BaseItemDto; + mediaSource: MediaSourceInfo | null | undefined; + isVideoLoaded: boolean | undefined; +} + +export const ControlProvider: React.FC = ({ children, item, mediaSource, isVideoLoaded }) => { + return ( + + {children} + + ); +}; + +export const useControlContext = () => { + const context = useContext(ControlContext); + if (context === undefined) { + throw new Error('useControlContext must be used within a ControlProvider'); + } + return context; +}; \ No newline at end of file diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx new file mode 100644 index 00000000..2193301f --- /dev/null +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -0,0 +1,62 @@ +import { TrackInfo } from '@/modules/vlc-player'; +import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'; +import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react'; +import { useControlContext } from './ControlContext'; + +interface VideoContextProps { + audioTracks: TrackInfo[] | null; + subtitleTracks: TrackInfo[] | null; + setAudioTrack: ((index: number) => void) | undefined; + setSubtitleTrack: ((index: number) => void) | undefined; + setSubtitleURL: ((url: string, customName: string) => void) | undefined; +} + +const VideoContext = createContext(undefined); + +interface VideoProviderProps { + children: ReactNode; + getAudioTracks: (() => Promise) | (() => TrackInfo[]) | undefined; + getSubtitleTracks: (() => Promise) | (() => TrackInfo[]) | undefined; + setAudioTrack: ((index: number) => void) | undefined; + setSubtitleTrack: ((index: number) => void) | undefined; + setSubtitleURL: ((url: string, customName: string) => void) | undefined; +} + +export const VideoProvider: React.FC = ({ children, getSubtitleTracks, getAudioTracks, setSubtitleTrack, setSubtitleURL, setAudioTrack }) => { + const [audioTracks, setAudioTracks] = useState(null); + const [subtitleTracks, setSubtitleTracks] = useState( + null + ); + + const ControlContext = useControlContext(); + const isVideoLoaded = ControlContext?.isVideoLoaded; + + useEffect(() => { + const fetchTracks = async () => { + if (getSubtitleTracks) { + const subtitles = await getSubtitleTracks(); + console.log("Getting embeded subtitles...", subtitles); + setSubtitleTracks(subtitles); + } + if (getAudioTracks) { + const audio = await getAudioTracks(); + setAudioTracks(audio); + } + }; + fetchTracks(); + }, [isVideoLoaded, getAudioTracks, getSubtitleTracks]); + + return ( + + {children} + + ); +}; + +export const useVideoContext = () => { + const context = useContext(VideoContext); + if (context === undefined) { + throw new Error('useVideoContext must be used within a VideoProvider'); + } + return context; +}; \ No newline at end of file diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts new file mode 100644 index 00000000..b66ecd8e --- /dev/null +++ b/components/video-player/controls/types.ts @@ -0,0 +1,20 @@ +type EmbeddedSubtitle = { + name: string; + index: number; + isExternal: boolean; +}; + +type ExternalSubtitle = { + name: string; + index: number; + isExternal: boolean; + deliveryUrl: string; +}; + +type TranscodedSubtitle = { + name: string; + index: number; + IsTextSubtitleStream: boolean; +} + +export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle }; \ No newline at end of file diff --git a/components/video-player/offline-player.android.tsx b/components/video-player/offline-player.android.tsx index a77912be..452449bd 100644 --- a/components/video-player/offline-player.android.tsx +++ b/components/video-player/offline-player.android.tsx @@ -1,4 +1,4 @@ -import { Controls } from "@/components/video-player/Controls"; +import { Controls } from "@/components/video-player/controls/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { apiAtom } from "@/providers/JellyfinProvider"; diff --git a/components/video-player/offline-player.ios.tsx b/components/video-player/offline-player.ios.tsx index 451e446b..cb6ad3c8 100644 --- a/components/video-player/offline-player.ios.tsx +++ b/components/video-player/offline-player.ios.tsx @@ -1,4 +1,4 @@ -import { Controls } from "@/components/video-player/Controls"; +import { Controls } from "@/components/video-player/controls/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { VlcPlayerView } from "@/modules/vlc-player"; diff --git a/components/video-player/player.android.tsx b/components/video-player/player.android.tsx index 6f4bd52f..d7707fb2 100644 --- a/components/video-player/player.android.tsx +++ b/components/video-player/player.android.tsx @@ -1,7 +1,7 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; -import { Controls } from "@/components/video-player/Controls"; +import { Controls } from "@/components/video-player/controls/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; diff --git a/components/video-player/player.ios.tsx b/components/video-player/player.ios.tsx index 6f4bd52f..d7707fb2 100644 --- a/components/video-player/player.ios.tsx +++ b/components/video-player/player.ios.tsx @@ -1,7 +1,7 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; -import { Controls } from "@/components/video-player/Controls"; +import { Controls } from "@/components/video-player/controls/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt index 9e4fb4cb..5c3c8079 100644 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt @@ -32,6 +32,8 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context private var startPosition: Int? = 0 private var isTranscodedStream: Boolean = false private var isMediaReady: Boolean = false + private var externalTrack: Map? = null + init { setupView() } @@ -50,13 +52,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context val mediaOptions = source["mediaOptions"] as? Map ?: emptyMap() val autoplay = source["autoplay"] as? Boolean ?: false val isNetwork = source["isNetwork"] as? Boolean ?: false + externalTrack = source["externalTrack"] as? Map startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0 val initOptions = source["initOptions"] as? MutableList ?: mutableListOf() initOptions.add("--start-time=$startPosition") - val externalSubs = source["externalSubs"] as? MutableList ?: mutableListOf() - val uri = source["uri"] as? String if (uri != null && uri.contains("m3u8")) { @@ -165,8 +166,8 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context } fun setSubtitleURL(subtitleURL: String, name: String) { - mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true) - + println("Setting subtitle URL: $subtitleURL, name: $name") + mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true) } override fun onDetachedFromWindow() { @@ -238,6 +239,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context } } + // Only used for HLS transcoded streams + private fun setSubtitleTrackByName(trackName: String) { + val track = mediaPlayer?.getSpuTracks()?.firstOrNull { it.name.startsWith(trackName) } + val trackIndex = track?.id ?: -1 + println("Track Index setting to: $trackIndex") + if (trackIndex != -1) { + setSubtitleTrack(trackIndex) + } + } + private fun updateVideoProgress() { val player = mediaPlayer ?: return @@ -249,6 +260,19 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context // Handle when VLC starts at cloest earliest segment skip to the start time, for transcoded streams. if (player.isPlaying && !isMediaReady) { isMediaReady = true + externalTrack?.let { + val name = it["name"] + val deliveryUrl = it["DeliveryUrl"] ?: "" + if (!name.isNullOrEmpty()) { + if (!isTranscodedStream) { + setSubtitleURL(deliveryUrl, name) + } + else { + setSubtitleTrackByName(name) + } + } + } + if (isTranscodedStream && startPosition != 0) { seekTo((startPosition ?: 0) * 1000) } diff --git a/modules/vlc-player/src/VlcPlayer.types.ts b/modules/vlc-player/src/VlcPlayer.types.ts index 9d0f087d..d0a71483 100644 --- a/modules/vlc-player/src/VlcPlayer.types.ts +++ b/modules/vlc-player/src/VlcPlayer.types.ts @@ -33,6 +33,7 @@ export type VlcPlayerSource = { type?: string; isNetwork?: boolean; autoplay?: boolean; + externalTrack?: { name: string, DeliveryUrl: string }; initOptions?: any[]; mediaOptions?: { [key: string]: any }; startPosition?: number;