diff --git a/app.json b/app.json index 39154663..67e5224f 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.20.1", + "version": "0.21.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", 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 d332cb8d..4666209e 100644 --- a/app/(auth)/player/player.tsx +++ b/app/(auth)/player/player.tsx @@ -1,8 +1,8 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; -import { Controls } from "@/components/video-player/Controls"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; +import { Controls } from "@/components/video-player/controls/Controls"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; @@ -20,7 +20,10 @@ 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, @@ -32,6 +35,7 @@ import { useAtomValue } from "jotai"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Pressable, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; +import transcoding from "@/utils/profiles/transcoding"; export default function page() { const videoRef = useRef(null); @@ -137,7 +141,7 @@ export default function page() { maxStreamingBitrate: bitrateValue, mediaSourceId: mediaSourceId, subtitleStreamIndex: subtitleIndex, - deviceProfile: native, + deviceProfile: !bitrateValue ? native : transcoding, }); if (!res) return null; @@ -339,13 +343,50 @@ export default function page() { ); - if (!stream || !item) - return ( - - No stream or item - Offline: {offline} - - ); + if (!stream || !item) return null; + + // 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 + ); + const allAudio = + stream.mediaSource.MediaStreams?.filter( + (audio) => audio.Type === "Audio" + ) || []; + const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); + + // 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}`, + }; + } + + if (!chosenAudioTrack) throw new Error("No audio track found"); + + initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); + } else { + // Transcoded playback CASE + if (chosenSubtitleTrack?.DeliveryMethod === "Hls") { + externalTrack = { + name: `subs ${chosenSubtitleTrack.DisplayTitle}`, + DeliveryUrl: "", + }; + } + } return ( = React.memo( defaultSubtitleIndex, } = useDefaultPlaySettings(item, settings); + // Needs to automatically change the selected to the default values for default indexes. useEffect(() => { console.log(defaultAudioIndex, defaultSubtitleIndex); setSelectedOptions(() => ({ diff --git a/components/video-player/Controls.tsx b/components/video-player/Controls.tsx deleted file mode 100644 index 5b766dd0..00000000 --- a/components/video-player/Controls.tsx +++ /dev/null @@ -1,751 +0,0 @@ -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 { 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 { Text } from "../common/Text"; -import { Loader } from "../Loader"; -import BottomSheet, { - BottomSheetBackdrop, - BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; - -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 api = useAtomValue(apiAtom); - - 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); - - 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); - - 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 - ); - - 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; - }; - - const allSubtitleTracks = useMemo(() => { - const embeddedSubs = - subtitleTracks - ?.map((s) => ({ - name: s.name, - index: s.index, - deliveryUrl: undefined, - })) - .filter((sub) => !sub.name.endsWith("[External]")) || []; - - console.log("embeddedSubs ~", embeddedSubs); - - 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]); - - return ( - - - - - - - - - - - - Subtitle - - - {allSubtitleTracks?.map((sub, idx: number) => ( - { - if ("deliveryUrl" in sub && sub.deliveryUrl) { - setSubtitleURL && - setSubtitleURL( - api?.basePath + sub.deliveryUrl, - sub.name - ); - - console.log( - "Set sub url: ", - api?.basePath + sub.deliveryUrl - ); - } else { - console.log("Set sub index: ", sub.index); - setSubtitleTrack && setSubtitleTrack(sub.index); - } - - console.log("Subtitle: ", sub); - }} - > - - - {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..dda18d60 --- /dev/null +++ b/components/video-player/controls/Controls.tsx @@ -0,0 +1,617 @@ +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +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 { useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, 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 { useSafeAreaInsets } from "react-native-safe-area-context"; +import { VideoRef } from "react-native-video"; +import { ControlProvider } from "./contexts/ControlContext"; +import { VideoProvider } from "./contexts/VideoContext"; +import DropdownView from "./DropdownView"; + +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")} + + + + + + + + ); +}; diff --git a/components/video-player/controls/DropdownView.tsx b/components/video-player/controls/DropdownView.tsx new file mode 100644 index 00000000..44303e91 --- /dev/null +++ b/components/video-player/controls/DropdownView.tsx @@ -0,0 +1,266 @@ +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]); + + // const audioForTranscodingStream = mediaSource?.MediaStreams?.filter( + // (x) => x.Type === "Audio" + // ).map((x) => ({ + // name: x.DisplayTitle!, + // index: x.Index!, + // })); + + // 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"; + + // TODO: Add support for text sorting subtitles renaming. + 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 [ + { name: 'Disable', index: -1, IsTextSubtitleStream: true } as TranscodedSubtitle, + ...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; 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/eas.json b/eas.json index 9da08286..336a9d1b 100644 --- a/eas.json +++ b/eas.json @@ -22,13 +22,13 @@ } }, "production": { - "channel": "0.20.1", + "channel": "0.21.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.20.1", + "channel": "0.21.0", "android": { "buildType": "apk", "image": "latest" 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 2b6ea6b1..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,6 +52,7 @@ 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() @@ -145,14 +148,26 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context mediaPlayer?.setSpuTrack(trackIndex) } + // fun getSubtitleTracks(): List>? { + // return mediaPlayer?.getSpuTracks()?.map { trackDescription -> + // mapOf("name" to trackDescription.name, "index" to trackDescription.id) + // } + // } + fun getSubtitleTracks(): List>? { - return mediaPlayer?.getSpuTracks()?.map { trackDescription -> + val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription -> mapOf("name" to trackDescription.name, "index" to trackDescription.id) } + + // Debug statement to print the result + Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks") + + return subtitleTracks } 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() { @@ -224,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 @@ -235,7 +260,20 @@ 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 - if (isTranscodedStream) { + 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/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index 40a06d3d..35bcab12 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -14,6 +14,7 @@ class VlcPlayerView: ExpoView { private var startPosition: Int32 = 0 private var isTranscodedStream: Bool = false private var isMediaReady: Bool = false + private var externalTrack: [String: String]? // MARK: - Initialization @@ -105,6 +106,7 @@ class VlcPlayerView: ExpoView { guard let self = self else { return } let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] + self.externalTrack = source["externalTrack"] as? [String: String] var initOptions = source["initOptions"] as? [Any] ?? [] startPosition = source["startPosition"] as? Int32 ?? 0 initOptions.append("--start-time=\(startPosition)") @@ -313,6 +315,25 @@ class VlcPlayerView: ExpoView { return tracks } + private func setSubtitleTrackByName(_ trackName: String) { + guard let mediaPlayer = self.mediaPlayer else { return } + + // Get the subtitle tracks and their indexes + if let names = mediaPlayer.videoSubTitlesNames as? [String], + let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] + { + for (index, name) in zip(indexes, names) { + if name.starts(with: trackName) { + let trackIndex = index.intValue + print("Track Index setting to: \(trackIndex)") + setSubtitleTrack(trackIndex) + return + } + } + } + print("Track not found for name: \(trackName)") + } + // @objc func getSubtitleTracks( // _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock // ) { @@ -563,7 +584,8 @@ extension VlcPlayerView: VLCMediaPlayerDelegate { "duration": player.media?.length.intValue ?? 0, "error": false, ] - + // Playing and not transcoding, we can let it in no HLS issue. + // We should also mark it as playing when the media is ready. // Fix HLS issue. if player.isPlaying && (!self.isTranscodedStream || self.isMediaReady) { stateInfo["isPlaying"] = true @@ -629,9 +651,36 @@ extension VlcPlayerView: VLCMediaPlayerDelegate { // Handle when VLC starts at cloest earliest segment skip to the start time, for transcoded streams. if player.isPlaying && !self.isMediaReady { self.isMediaReady = true - if self.isTranscodedStream { - self.seekTo(self.startPosition * 1000) + if let externalTrack = self.externalTrack { + if let name = externalTrack["name"] as? String, !name.isEmpty { + let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? "" + if !self.isTranscodedStream { + self.setSubtitleURL(deliveryUrl, name: name) + } else { + self.setSubtitleTrackByName(name) + } + } } + + // HLS bug. + if self.isTranscodedStream { + if self.startPosition > 0 { + print("Seeking to start position: \(self.startPosition)") + self.seekTo(self.startPosition * 1000) + } else { + var stateInfo: [String: Any] = [ + "target": self.reactTag ?? NSNull(), + "currentTime": player.time.intValue, + "duration": player.media?.length.intValue ?? 0, + "error": false, + "isPlaying": true, + "isBuffering": false, + "state": "Playing", + ] + self.onVideoStateChange?(stateInfo) + } + } + } self.onVideoProgress?([ "currentTime": currentTimeMs, 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; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 28d99215..a6af0322 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -52,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.20.1" }, + clientInfo: { name: "Streamyfin", version: "0.21.0" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -86,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.20.1"`, + }, DeviceId="${deviceId}", Version="0.21.0"`, }; }, [deviceId]); diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 692248ea..0b3b853d 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -109,12 +109,27 @@ export const getStreamUrl = async ({ if (item.MediaType === "Video") { if (mediaSource?.TranscodingUrl) { + + const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object + + // If there is no subtitle stream index, add it to the URL. + if (subtitleStreamIndex == -1) { + urlObj.searchParams.set("SubtitleMethod", "Hls"); + } + + // Add 'SubtitleMethod=Hls' if it doesn't exist + if (!urlObj.searchParams.has("SubtitleMethod")) { + urlObj.searchParams.append("SubtitleMethod", "Hls"); + } + // Get the updated URL + const transcodeUrl = urlObj.toString(); + console.log( "Video has transcoding URL:", - `${api.basePath}${mediaSource.TranscodingUrl}` + `${transcodeUrl}` ); return { - url: `${api.basePath}${mediaSource.TranscodingUrl}`, + url: transcodeUrl, sessionId: sessionId, mediaSource, }; diff --git a/utils/profiles/native.js b/utils/profiles/native.js index e54c8764..a04f65c4 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.js @@ -11,7 +11,7 @@ import MediaTypes from "../../constants/MediaTypes"; export default { Name: "1. Vlc Player", MaxStaticBitrate: 20_000_000, - MaxStreamingBitrate: 12_000_000, + MaxStreamingBitrate: 20_000_000, CodecProfiles: [ { Type: MediaTypes.Video, diff --git a/utils/profiles/transcoding.js b/utils/profiles/transcoding.js new file mode 100644 index 00000000..cad16a63 --- /dev/null +++ b/utils/profiles/transcoding.js @@ -0,0 +1,88 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import MediaTypes from "../../constants/MediaTypes"; + + +export default { + Name: "Vlc Player for HLS streams.", + MaxStaticBitrate: 20_000_000, + MaxStreamingBitrate: 12_000_000, + CodecProfiles: [ + { + Type: MediaTypes.Video, + Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1", + }, + { + Type: MediaTypes.Audio, + Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma", + }, + ], + DirectPlayProfiles: [ + { + Type: MediaTypes.Video, + Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls", + VideoCodec: + "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video", + AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma", + }, + { + Type: MediaTypes.Audio, + Container: "mp3,aac,flac,alac,wav,ogg,wma", + AudioCodec: + "mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape", + }, + ], + TranscodingProfiles: [ + { + Type: MediaTypes.Video, + Context: "Streaming", + Protocol: "hls", + Container: "ts", + VideoCodec: "h264, hevc", + AudioCodec: "aac,mp3,ac3", + CopyTimestamps: false, + EnableSubtitlesInManifest: true, + }, + { + Type: MediaTypes.Audio, + Context: "Streaming", + Protocol: "http", + Container: "mp3", + AudioCodec: "mp3", + MaxAudioChannels: "2", + }, + ], + SubtitleProfiles: [ + // Text based subtitles must use HLS. + { Format: "ass", Method: "Hls" }, + { Format: "microdvd", Method: "Hls" }, + { Format: "mov_text", Method: "Hls" }, + { Format: "mpl2", Method: "Hls" }, + { Format: "pjs", Method: "Hls" }, + { Format: "realtext", Method: "Hls" }, + { Format: "scc", Method: "Hls" }, + { Format: "smi", Method: "Hls" }, + { Format: "srt", Method: "Hls" }, + { Format: "ssa", Method: "Hls" }, + { Format: "stl", Method: "Hls" }, + { Format: "sub", Method: "Hls" }, + { Format: "subrip", Method: "Hls" }, + { Format: "subviewer", Method: "Hls" }, + { Format: "teletext", Method: "Hls" }, + { Format: "text", Method: "Hls" }, + { Format: "ttml", Method: "Hls" }, + { Format: "vplayer", Method: "Hls" }, + { Format: "vtt", Method: "Hls" }, + { Format: "webvtt", Method: "Hls" }, + + + // Image based subs use encode. + { Format: "dvdsub", Method: "Encode" }, + { Format: "pgs", Method: "Encode" }, + { Format: "pgssub", Method: "Encode" }, + { Format: "xsub", Method: "Encode" }, + ], +}; \ No newline at end of file