From 4c14c08b352ded70af5b492e39b287094aa98a02 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 15 Feb 2025 21:10:36 +0100 Subject: [PATCH] fix: move from react-native-video -> VLC for transcoded streams (#529) Co-authored-by: Alex Kim --- app/(auth)/player/_layout.tsx | 9 - app/(auth)/player/direct-player.tsx | 227 ++++---- app/(auth)/player/transcoding-player.tsx | 546 ------------------ components/ItemContent.tsx | 33 -- components/PlayButton.tsx | 6 +- components/PlayButton.tv.tsx | 6 +- components/SubtitleTrackSelector.tsx | 17 +- components/video-player/controls/Controls.tsx | 51 +- .../controls/contexts/VideoContext.tsx | 156 ++++- ...ropdownViewDirect.tsx => DropdownView.tsx} | 81 +-- .../dropdown/DropdownViewTranscoding.tsx | 228 -------- components/video-player/controls/types.ts | 9 +- modules/vlc-player/ios/VlcPlayerView.swift | 162 +++--- modules/vlc-player/src/VlcPlayer.types.ts | 2 +- utils/SubtitleHelper.ts | 134 ----- utils/jellyfin/media/getStreamUrl.ts | 9 - utils/profiles/native.js | 58 +- utils/profiles/transcoding.js | 86 --- 18 files changed, 379 insertions(+), 1441 deletions(-) delete mode 100644 app/(auth)/player/transcoding-player.tsx rename components/video-player/controls/dropdown/{DropdownViewDirect.tsx => DropdownView.tsx} (50%) delete mode 100644 components/video-player/controls/dropdown/DropdownViewTranscoding.tsx delete mode 100644 utils/SubtitleHelper.ts delete mode 100644 utils/profiles/transcoding.js diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx index 1d6c7188..bee527e5 100644 --- a/app/(auth)/player/_layout.tsx +++ b/app/(auth)/player/_layout.tsx @@ -36,15 +36,6 @@ export default function Layout() { animation: "fade", }} /> - ); diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 51a5f54d..8f722302 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -36,17 +36,12 @@ import React, { useState, useEffect, } from "react"; -import { - Alert, - View, - AppState, - AppStateStatus, - Platform, -} from "react-native"; +import { Alert, View, AppState, AppStateStatus, Platform } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import { useSettings } from "@/utils/atoms/settings"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client"; export default function page() { console.log("Direct Player"); @@ -128,57 +123,80 @@ export default function page() { staleTime: 0, }); - const { - data: stream, - isLoading: isLoadingStreamUrl, - isError: isErrorStreamUrl, - } = useQuery({ - queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], - queryFn: async () => { - if (offline && !Platform.isTV) { - const data = await getDownloadedItem.getDownloadedItem(itemId); - if (!data?.mediaSource) return null; + const [stream, setStream] = useState<{ + mediaSource: MediaSourceInfo; + url: string; + sessionId: string | undefined; + } | null>(null); + const [isLoadingStream, setIsLoadingStream] = useState(true); + const [isErrorStream, setIsErrorStream] = useState(false); - const url = await getDownloadedFileUrl(data.item.Id!); + useEffect(() => { + const fetchStream = async () => { + setIsLoadingStream(true); + setIsErrorStream(false); - if (item) - return { - mediaSource: data.mediaSource, - url, - sessionId: undefined, - }; + try { + if (offline && !Platform.isTV) { + const data = await getDownloadedItem.getDownloadedItem(itemId); + if (!data?.mediaSource) { + setStream(null); + return; + } + + const url = await getDownloadedFileUrl(data.item.Id!); + + if (item) { + setStream({ + mediaSource: data.mediaSource as MediaSourceInfo, + url, + sessionId: undefined, + }); + return; + } + } + + const res = await getStreamUrl({ + api, + item, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + userId: user?.Id, + audioStreamIndex: audioIndex, + maxStreamingBitrate: bitrateValue, + mediaSourceId: mediaSourceId, + subtitleStreamIndex: subtitleIndex, + deviceProfile: native, + }); + + if (!res) { + setStream(null); + return; + } + + const { mediaSource, sessionId, url } = res; + + if (!sessionId || !mediaSource || !url) { + Alert.alert(t("player.error"), t("player.failed_to_get_stream_url")); + setStream(null); + return; + } + + setStream({ + mediaSource, + sessionId, + url, + }); + } catch (error) { + console.error("Error fetching stream:", error); + setIsErrorStream(true); + setStream(null); + } finally { + setIsLoadingStream(false); } + }; - const res = await getStreamUrl({ - api, - item, - startTimeTicks: item?.UserData?.PlaybackPositionTicks!, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: bitrateValue, - mediaSourceId: mediaSourceId, - subtitleStreamIndex: subtitleIndex, - deviceProfile: native, - }); - - if (!res) return null; - - const { mediaSource, sessionId, url } = res; - - if (!sessionId || !mediaSource || !url) { - Alert.alert(t("player.error"), t("player.failed_to_get_stream_url")); - return null; - } - - return { - mediaSource, - sessionId, - url, - }; - }, - enabled: !!itemId && !!item, - staleTime: 0, - }); + fetchStream(); + }, [itemId, mediaSourceId]); const togglePlay = useCallback(async () => { if (!api) return; @@ -198,9 +216,7 @@ export default function page() { mediaSourceId: mediaSourceId, positionTicks: msToTicks(progress.get()), isPaused: !isPlaying, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", playSessionId: stream.sessionId, }); } @@ -238,21 +254,6 @@ export default function page() { videoRef.current?.stop(); }, [videoRef, reportPlaybackStopped]); - // TODO: unused should remove. - const reportPlaybackStart = useCallback(async () => { - if (offline) return; - - if (!stream) return; - await getPlaystateApi(api!).onPlaybackStart({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId ? stream?.sessionId : undefined, - }); - }, [api, item, mediaSourceId, stream]); - const onProgress = useCallback( async (data: ProgressUpdatePayload) => { if (isSeeking.get() || isPlaybackStopped) return; @@ -294,8 +295,8 @@ export default function page() { const onPipStarted = useCallback((e: PipStartedPayload) => { const { pipStarted } = e.nativeEvent; - setIsPipStarted(pipStarted) - }, []) + setIsPipStarted(pipStarted); + }, []); const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const { state, isBuffering, isPlaying } = e.nativeEvent; @@ -332,7 +333,7 @@ export default function page() { const handleAppStateChange = (nextAppState: AppStateStatus) => { // Handle app going to the background if (nextAppState.match(/inactive|background/)) { - _setShowControls(false) + _setShowControls(false); } setAppState(nextAppState); }; @@ -351,73 +352,67 @@ export default function page() { // Preselection of audio and subtitle tracks. if (!settings) return null; - let initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; - let externalTrack = { name: "", DeliveryUrl: "" }; - const allSubs = - stream?.mediaSource.MediaStreams?.filter( - (sub: { Type: string }) => sub.Type === "Subtitle" - ) || []; - const chosenSubtitleTrack = allSubs.find( - (sub: { Index: number }) => sub.Index === subtitleIndex - ); const allAudio = stream?.mediaSource.MediaStreams?.filter( - (audio: { Type: string }) => audio.Type === "Audio" + (audio) => audio.Type === "Audio" ) || []; - const chosenAudioTrack = allAudio.find( - (audio: { Index: number | undefined }) => audio.Index === audioIndex + const allSubs = + stream?.mediaSource.MediaStreams?.filter( + (sub) => sub.Type === "Subtitle" + ) || []; + const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); + + const chosenSubtitleTrack = allSubs.find( + (sub) => sub.Index === subtitleIndex ); + 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}`, - }; - } + const notTranscoding = !stream?.mediaSource.TranscodingUrl; + if ( + chosenSubtitleTrack && + (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) + ) { + const finalIndex = notTranscoding + ? allSubs.indexOf(chosenSubtitleTrack) + : textSubs.indexOf(chosenSubtitleTrack); + initOptions.push(`--sub-track=${finalIndex}`); + } - if (chosenAudioTrack) - initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); - } else { - // Transcoded playback CASE - if (chosenSubtitleTrack?.DeliveryMethod === "Hls") { - externalTrack = { - name: `subs ${chosenSubtitleTrack.DisplayTitle}`, - DeliveryUrl: "", - }; - } + if (notTranscoding && chosenAudioTrack) { + initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); } const insets = useSafeAreaInsets(); - useEffect(() => { - const beforeRemoveListener = navigation.addListener('beforeRemove', stop); + const beforeRemoveListener = navigation.addListener("beforeRemove", stop); return () => { beforeRemoveListener(); }; }, [navigation]); - if (!item || isLoadingItem || isLoadingStreamUrl || !stream) + if (!item || isLoadingItem || !stream) return ( ); - if (isErrorItem || isErrorStreamUrl) + if (isErrorItem || isErrorStream) return ( {t("player.error")} ); + const externalSubtitles = allSubs + .filter((sub: any) => sub.DeliveryMethod === "External") + .map((sub: any) => ({ + name: sub.DisplayTitle, + DeliveryUrl: api?.basePath + sub.DeliveryUrl, + })); + return ( ); -} \ No newline at end of file +} diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx deleted file mode 100644 index b1768b36..00000000 --- a/app/(auth)/player/transcoding-player.tsx +++ /dev/null @@ -1,546 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { Loader } from "@/components/Loader"; -import { Controls } from "@/components/video-player/controls/Controls"; -import { useOrientation } from "@/hooks/useOrientation"; -import { useOrientationSettings } from "@/hooks/useOrientationSettings"; -import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; -import { useWebSocket } from "@/hooks/useWebsockets"; -import { TrackInfo } from "@/modules/vlc-player"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import transcoding from "@/utils/profiles/transcoding"; -import { secondsToTicks } from "@/utils/secondsToTicks"; -import { Api } from "@jellyfin/sdk"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { - getPlaystateApi, - getUserLibraryApi, -} from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { useHaptic } from "@/hooks/useHaptic"; -import { useFocusEffect, useLocalSearchParams } from "expo-router"; -import { useAtomValue } from "jotai"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { View } from "react-native"; -import { useSharedValue } from "react-native-reanimated"; -import Video, { - OnProgressData, - SelectedTrack, - SelectedTrackType, - VideoRef, -} from "react-native-video"; -import { SubtitleHelper } from "@/utils/SubtitleHelper"; -import { useTranslation } from "react-i18next"; - -const Player = () => { - console.log("Transcoding Player"); - - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const [settings] = useSettings(); - const videoRef = useRef(null); - const { t } = useTranslation(); - - const firstTime = useRef(true); - const revalidateProgressCache = useInvalidatePlaybackProgressCache(); - const lightHapticFeedback = useHaptic("light"); - - const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); - const [showControls, _setShowControls] = useState(true); - const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - const [isBuffering, setIsBuffering] = useState(true); - const [isVideoLoaded, setIsVideoLoaded] = useState(false); - - const setShowControls = useCallback((show: boolean) => { - _setShowControls(show); - lightHapticFeedback(); - }, []); - - const progress = useSharedValue(0); - const isSeeking = useSharedValue(false); - const cacheProgress = useSharedValue(0); - - const { - itemId, - audioIndex: audioIndexStr, - subtitleIndex: subtitleIndexStr, - mediaSourceId, - bitrateValue: bitrateValueStr, - } = useLocalSearchParams<{ - itemId: string; - audioIndex: string; - subtitleIndex: string; - mediaSourceId: string; - bitrateValue: string; - }>(); - - const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; - const subtitleIndex = subtitleIndexStr - ? parseInt(subtitleIndexStr, 10) - : undefined; - const bitrateValue = bitrateValueStr - ? parseInt(bitrateValueStr, 10) - : undefined; - - const { - data: item, - isLoading: isLoadingItem, - isError: isErrorItem, - } = useQuery({ - queryKey: ["item", itemId], - queryFn: async () => { - if (!api) { - throw new Error("No api"); - } - - if (!itemId) { - console.warn("No itemId"); - return null; - } - - const res = await getUserLibraryApi(api).getItem({ - itemId, - userId: user?.Id, - }); - - return res.data; - }, - staleTime: 0, - }); - - // TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG. - // MOST LIKELY LIKELY NEED A MASSIVE REFACTOR. - const { - data: stream, - isLoading: isLoadingStreamUrl, - isError: isErrorStreamUrl, - } = useQuery({ - queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex], - - queryFn: async () => { - if (!api) { - throw new Error("No api"); - } - - if (!item) { - console.warn("No item", itemId, item); - return null; - } - - const res = await getStreamUrl({ - api, - item, - startTimeTicks: item?.UserData?.PlaybackPositionTicks!, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: bitrateValue, - mediaSourceId: mediaSourceId, - subtitleStreamIndex: subtitleIndex, - deviceProfile: transcoding, - }); - - if (!res) return null; - - const { mediaSource, sessionId, url } = res; - - if (!sessionId || !mediaSource || !url) { - console.warn("No sessionId or mediaSource or url", url); - return null; - } - - return { - mediaSource, - sessionId, - url, - }; - }, - enabled: !!item, - staleTime: 0, - }); - - const poster = usePoster(item, api); - const videoSource = useVideoSource(item, api, poster, stream?.url); - - const togglePlay = useCallback(async () => { - lightHapticFeedback(); - if (isPlaying) { - videoRef.current?.pause(); - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(progress.value), - isPaused: true, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, - }); - } else { - videoRef.current?.resume(); - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item?.Id!, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(progress.value), - isPaused: false, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, - }); - } - }, [ - isPlaying, - api, - item, - videoRef, - settings, - stream, - audioIndex, - subtitleIndex, - mediaSourceId, - ]); - - const play = useCallback(() => { - videoRef.current?.resume(); - reportPlaybackStart(); - }, [videoRef]); - - const pause = useCallback(() => { - videoRef.current?.pause(); - }, [videoRef]); - - const seek = useCallback( - (seconds: number) => { - videoRef.current?.seek(seconds); - }, - [videoRef] - ); - - const reportPlaybackStopped = async () => { - if (!item?.Id) return; - await getPlaystateApi(api!).onPlaybackStopped({ - itemId: item.Id, - mediaSourceId: mediaSourceId, - positionTicks: Math.floor(progress.value), - playSessionId: stream?.sessionId, - }); - revalidateProgressCache(); - }; - - const stop = useCallback(() => { - reportPlaybackStopped(); - videoRef.current?.pause(); - setIsPlaybackStopped(true); - }, [videoRef, reportPlaybackStopped]); - - const reportPlaybackStart = async () => { - if (!item?.Id) return; - await getPlaystateApi(api!).onPlaybackStart({ - itemId: item.Id, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, - }); - }; - - const onProgress = useCallback( - async (data: OnProgressData) => { - if (isSeeking.value === true) return; - if (isPlaybackStopped === true) return; - - const ticks = secondsToTicks(data.currentTime); - - progress.value = ticks; - cacheProgress.value = secondsToTicks(data.playableDuration); - - // TODO: Use this when streaming with HLS url, but NOT when direct playing - // TODO: since playable duration is always 0 then. - setIsBuffering(data.playableDuration === 0); - - if (!item?.Id || data.currentTime === 0) { - return; - } - - await getPlaystateApi(api!).onPlaybackProgress({ - itemId: item.Id, - audioStreamIndex: audioIndex ? audioIndex : undefined, - subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, - mediaSourceId: mediaSourceId, - positionTicks: Math.round(ticks), - isPaused: !isPlaying, - playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", - playSessionId: stream?.sessionId, - }); - }, - [ - item, - isPlaying, - api, - isPlaybackStopped, - isSeeking, - stream, - mediaSourceId, - audioIndex, - subtitleIndex, - ] - ); - - useWebSocket({ - isPlaying: isPlaying, - togglePlay: togglePlay, - stopPlayback: stop, - offline: false, - }); - - const [selectedTextTrack, setSelectedTextTrack] = useState< - SelectedTrack | undefined - >(); - - const [embededTextTracks, setEmbededTextTracks] = useState< - { - index: number; - language?: string | undefined; - selected?: boolean | undefined; - title?: string | undefined; - type: any; - }[] - >([]); - - const [audioTracks, setAudioTracks] = useState([]); - const [selectedAudioTrack, setSelectedAudioTrack] = useState< - SelectedTrack | undefined - >(undefined); - - useEffect(() => { - if (selectedTextTrack === undefined) { - const subtitleHelper = new SubtitleHelper( - stream?.mediaSource.MediaStreams ?? [] - ); - const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex( - subtitleIndex! - ); - - // Most likely the subtitle is burned in. - if (embeddedTrackIndex === -1) return; - - setSelectedTextTrack({ - type: SelectedTrackType.INDEX, - value: embeddedTrackIndex, - }); - } - }, [embededTextTracks]); - - const getAudioTracks = (): TrackInfo[] => { - return audioTracks.map((t) => ({ - name: t.name, - index: t.index, - })); - }; - - const getSubtitleTracks = (): TrackInfo[] => { - return embededTextTracks.map((t) => ({ - name: t.title ?? "", - index: t.index, - language: t.language, - })); - }; - - useFocusEffect( - React.useCallback(() => { - return async () => { - stop(); - }; - }, []) - ); - - if (isLoadingItem || isLoadingStreamUrl) - return ( - - - - ); - - if (isErrorItem || isErrorStreamUrl) - return ( - - {t("player.error")} - - ); - - return ( - - - {videoSource ? ( - <> - - - {item && ( - { - if (i === -1) { - setSelectedTextTrack({ - type: SelectedTrackType.DISABLED, - value: undefined, - }); - return; - } - setSelectedTextTrack({ - type: SelectedTrackType.INDEX, - value: i, - }); - }} - getAudioTracks={getAudioTracks} - setAudioTrack={(i) => { - setSelectedAudioTrack({ - type: SelectedTrackType.INDEX, - value: i, - }); - }} - /> - )} - - ); -}; - -export function usePoster( - item: BaseItemDto | null | undefined, - api: Api | null -): string | undefined { - const poster = useMemo(() => { - if (!item || !api) return undefined; - return item.Type === "Audio" - ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` - : getBackdropUrl({ - api, - item: item, - quality: 70, - width: 200, - }); - }, [item, api]); - - return poster ?? undefined; -} - -export function useVideoSource( - item: BaseItemDto | null | undefined, - api: Api | null, - poster: string | undefined, - url?: string | null -) { - const videoSource = useMemo(() => { - if (!item || !api || !url) { - return null; - } - - const startPosition = item?.UserData?.PlaybackPositionTicks - ? Math.round(item.UserData.PlaybackPositionTicks / 10000) - : 0; - - return { - uri: url, - isNetwork: true, - startPosition, - headers: getAuthHeaders(api), - metadata: { - title: item?.Name || "Unknown", - description: item?.Overview ?? undefined, - imageUri: poster, - subtitle: item?.Album ?? undefined, - }, - }; - }, [item, api, poster, url]); - - return videoSource; -} - -export default Player; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index f39db05f..39aa1660 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -16,7 +16,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColors } from "@/hooks/useImageColors"; import { useOrientation } from "@/hooks/useOrientation"; import { apiAtom } from "@/providers/JellyfinProvider"; -import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { @@ -118,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( const loading = useMemo(() => { return Boolean(logoUrl && loadingLogo); }, [loadingLogo, logoUrl]); - - const [isTranscoding, setIsTranscoding] = useState(false); - const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] = - useState(selectedOptions?.subtitleIndex); - - useEffect(() => { - const isTranscoding = Boolean(selectedOptions?.bitrate.value); - if (isTranscoding) { - setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex); - const subHelper = new SubtitleHelper( - selectedOptions?.mediaSource?.MediaStreams ?? [] - ); - - const newSubtitleIndex = subHelper.getMostCommonSubtitleByName( - selectedOptions?.subtitleIndex - ); - - setSelectedOptions((prev) => ({ - ...prev!, - subtitleIndex: newSubtitleIndex ?? -1, - })); - } - if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) { - setSelectedOptions((prev) => ({ - ...prev!, - subtitleIndex: previouslyChosenSubtitleIndex, - })); - } - setIsTranscoding(isTranscoding); - }, [selectedOptions?.bitrate]); - if (!selectedOptions) return null; return ( @@ -239,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( selected={selectedOptions.audioIndex} /> setSelectedOptions( diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index a9195aa6..4a6a2e30 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -73,11 +73,7 @@ export const PlayButton: React.FC = ({ const goToPlayer = useCallback( (q: string, bitrateValue: number | undefined) => { - if (!bitrateValue) { - router.push(`/player/direct-player?${q}`); - return; - } - router.push(`/player/transcoding-player?${q}`); + router.push(`/player/direct-player?${q}`); }, [router] ); diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index 65f70ad3..128c2184 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -58,11 +58,7 @@ export const PlayButton: React.FC = ({ const goToPlayer = useCallback( (q: string, bitrateValue: number | undefined) => { - if (!bitrateValue) { - router.push(`/player/direct-player?${q}`); - return; - } - router.push(`/player/transcoding-player?${q}`); + router.push(`/player/direct-player?${q}`); }, [router] ); diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 9b864f29..77e26c1b 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -4,40 +4,31 @@ import { useMemo } from "react"; import { Platform, TouchableOpacity, View } from "react-native"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; import { Text } from "./common/Text"; -import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { useTranslation } from "react-i18next"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; onChange: (value: number) => void; selected?: number | undefined; - isTranscoding?: boolean; } export const SubtitleTrackSelector: React.FC = ({ source, onChange, selected, - isTranscoding, ...props }) => { if (Platform.isTV) return null; const subtitleStreams = useMemo(() => { - const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []); - - if (isTranscoding && Platform.OS === "ios") { - return subtitleHelper.getUniqueSubtitles(); - } - - return subtitleHelper.getSubtitles(); - }, [source, isTranscoding]); + return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); + }, [source]); const selectedSubtitleSteam = useMemo( - () => subtitleStreams.find((x) => x.Index === selected), + () => subtitleStreams?.find((x) => x.Index === selected), [subtitleStreams, selected] ); - if (subtitleStreams.length === 0) return null; + if (subtitleStreams?.length === 0) return null; const { t } = useTranslation(); diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index b52e98c7..b8453ea5 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -24,7 +24,7 @@ import { ticksToMs, ticksToSeconds, } from "@/utils/time"; -import {Ionicons, MaterialIcons} from "@expo/vector-icons"; +import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import { BaseItemDto, MediaSourceInfo, @@ -35,7 +35,12 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useAtom } from "jotai"; import { debounce } from "lodash"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import {Platform, TouchableOpacity, useWindowDimensions, View} from "react-native"; +import { + Platform, + TouchableOpacity, + useWindowDimensions, + View, +} from "react-native"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, @@ -49,8 +54,7 @@ import AudioSlider from "./AudioSlider"; import BrightnessSlider from "./BrightnessSlider"; import { ControlProvider } from "./contexts/ControlContext"; import { VideoProvider } from "./contexts/VideoContext"; -import DropdownViewDirect from "./dropdown/DropdownViewDirect"; -import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; +import DropdownView from "./dropdown/DropdownView"; import { EpisodeList } from "./EpisodeList"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; @@ -214,15 +218,10 @@ export const Controls: React.FC = ({ bitrateValue: bitrateValue.toString(), }).toString(); - stop() + stop(); - if (!bitrateValue) { - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } // @ts-expect-error - router.replace(`player/transcoding-player?${queryParams}`); + router.replace(`player/direct-player?${queryParams}`); }, [previousItem, settings, subtitleIndex, audioIndex]); const goToNextItem = useCallback(() => { @@ -254,15 +253,10 @@ export const Controls: React.FC = ({ bitrateValue: bitrateValue.toString(), }).toString(); - stop() + stop(); - if (!bitrateValue) { - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } // @ts-expect-error - router.replace(`player/transcoding-player?${queryParams}`); + router.replace(`player/direct-player?${queryParams}`); }, [nextItem, settings, subtitleIndex, audioIndex]); const updateTimes = useCallback( @@ -419,15 +413,10 @@ export const Controls: React.FC = ({ bitrateValue: bitrateValue.toString(), }).toString(); - stop() + stop(); - if (!bitrateValue) { - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } // @ts-expect-error - router.replace(`player/transcoding-player?${queryParams}`); + router.replace(`player/direct-player?${queryParams}`); } catch (error) { console.error("Error in gotoEpisode:", error); } @@ -508,7 +497,7 @@ export const Controls: React.FC = ({ }, [trickPlayUrl, trickplayInfo, time]); const onClose = async () => { - stop() + stop(); lightHapticFeedback(); await ScreenOrientation.lockAsync( ScreenOrientation.OrientationLock.PORTRAIT_UP @@ -559,19 +548,13 @@ export const Controls: React.FC = ({ setSubtitleTrack={setSubtitleTrack} setSubtitleURL={setSubtitleURL} > - {!mediaSource?.TranscodingUrl ? ( - - ) : ( - - )} + {!Platform.isTV && ( - + void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined; @@ -45,30 +48,155 @@ export const VideoProvider: React.FC = ({ setSubtitleURL, setAudioTrack, }) => { - const [audioTracks, setAudioTracks] = useState(null); - const [subtitleTracks, setSubtitleTracks] = useState( - null - ); + const [audioTracks, setAudioTracks] = useState(null); + const [subtitleTracks, setSubtitleTracks] = useState(null); const ControlContext = useControlContext(); const isVideoLoaded = ControlContext?.isVideoLoaded; + const mediaSource = ControlContext?.mediaSource; + + const allSubs = + mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || []; + + const { itemId, audioIndex, bitrateValue, subtitleIndex } = + useLocalSearchParams<{ + itemId: string; + audioIndex: string; + subtitleIndex: string; + mediaSourceId: string; + bitrateValue: string; + }>(); + + const onTextBasedSubtitle = useMemo( + () => + allSubs.find( + (s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream + ) || subtitleIndex === "-1", + [allSubs, subtitleIndex] + ); + + const setPlayerParams = ({ + chosenAudioIndex = audioIndex, + chosenSubtitleIndex = subtitleIndex, + }: { + chosenAudioIndex?: string; + chosenSubtitleIndex?: string; + }) => { + console.log("chosenSubtitleIndex", chosenSubtitleIndex); + const queryParams = new URLSearchParams({ + itemId: itemId ?? "", + audioIndex: chosenAudioIndex, + subtitleIndex: chosenSubtitleIndex, + mediaSourceId: mediaSource?.Id ?? "", + bitrateValue: bitrateValue, + }).toString(); + + //@ts-ignore + router.replace(`player/direct-player?${queryParams}`); + }; + + const setTrackParams = ( + type: "audio" | "subtitle", + index: number, + serverIndex: number + ) => { + const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack; + const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex"; + + // If we're transcoding and we're going from a image based subtitle + // to a text based subtitle, we need to change the player params. + + const shouldChangePlayerParams = + type === "subtitle" && + mediaSource?.TranscodingUrl && + !onTextBasedSubtitle; + + console.log("Set player params", index, serverIndex); + if (shouldChangePlayerParams) { + setPlayerParams({ + chosenSubtitleIndex: serverIndex.toString(), + }); + return; + } + setTrack && setTrack(index); + router.setParams({ + [paramKey]: serverIndex.toString(), + }); + }; useEffect(() => { const fetchTracks = async () => { - if ( - getSubtitleTracks && - (subtitleTracks === null || subtitleTracks.length === 0) - ) { - const subtitles = await getSubtitleTracks(); - console.log("Getting embeded subtitles...", subtitles); + if (getSubtitleTracks) { + const subtitleData = await getSubtitleTracks(); + + let textSubIndex = 0; + const subtitles: Track[] = allSubs?.map((sub) => { + // Always increment for non-transcoding subtitles + // Only increment for text-based subtitles when transcoding + const shouldIncrement = + !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream; + + const displayTitle = sub.DisplayTitle || "Undefined Subtitle"; + const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1; + + const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1; + + if (shouldIncrement) textSubIndex++; + return { + name: displayTitle, + index: sub.Index ?? -1, + originalIndex: finalIndex, + setTrack: () => + shouldIncrement + ? setTrackParams("subtitle", finalIndex, sub.Index ?? -1) + : setPlayerParams({ + chosenSubtitleIndex: sub.Index?.toString(), + }), + }; + }); + + // Add a "Disable Subtitles" option + subtitles.unshift({ + name: "Disable", + index: -1, + setTrack: () => + !mediaSource?.TranscodingUrl || onTextBasedSubtitle + ? setTrackParams("subtitle", -1, -1) + : setPlayerParams({ chosenSubtitleIndex: "-1" }), + }); + setSubtitleTracks(subtitles); } if ( getAudioTracks && (audioTracks === null || audioTracks.length === 0) ) { - const audio = await getAudioTracks(); - setAudioTracks(audio); + const audioData = await getAudioTracks(); + if (!audioData) return; + + console.log("audioData", audioData); + + const allAudio = + mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || []; + + const audioTracks: Track[] = allAudio?.map((audio, idx) => { + if (!mediaSource?.TranscodingUrl) { + const vlcIndex = audioData?.at(idx)?.index ?? -1; + return { + name: audio.DisplayTitle ?? "Undefined Audio", + index: audio.Index ?? -1, + setTrack: () => + setTrackParams("audio", vlcIndex, audio.Index ?? -1), + }; + } + return { + name: audio.DisplayTitle ?? "Undefined Audio", + index: audio.Index ?? -1, + setTrack: () => + setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }), + }; + }); + setAudioTracks(audioTracks); } }; fetchTracks(); diff --git a/components/video-player/controls/dropdown/DropdownViewDirect.tsx b/components/video-player/controls/dropdown/DropdownView.tsx similarity index 50% rename from components/video-player/controls/dropdown/DropdownViewDirect.tsx rename to components/video-player/controls/dropdown/DropdownView.tsx index f6c2bfe4..0ee51dc1 100644 --- a/components/video-player/controls/dropdown/DropdownViewDirect.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -1,67 +1,21 @@ -import React, { useMemo, useState } from "react"; -import { View, TouchableOpacity, Platform } from "react-native"; +import React from "react"; +import { TouchableOpacity, Platform } from "react-native"; import { Ionicons } from "@expo/vector-icons"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; -import { useControlContext } from "../contexts/ControlContext"; import { useVideoContext } from "../contexts/VideoContext"; -import { EmbeddedSubtitle, ExternalSubtitle } from "../types"; -import { useAtomValue } from "jotai"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { router, useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; -interface DropdownViewDirectProps { +interface DropdownViewProps { showControls: boolean; offline?: boolean; // used to disable external subs for downloads } -const DropdownViewDirect: React.FC = ({ +const DropdownView: React.FC = ({ showControls, offline = false, }) => { - 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 subs with external subs only if not offline - if (!offline) { - return [...embeddedSubs, ...externalSubs] as ( - | EmbeddedSubtitle - | ExternalSubtitle - )[]; - } - return embeddedSubs as EmbeddedSubtitle[]; - }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]); + const { subtitleTracks, audioTracks } = videoContext; const { subtitleIndex, audioIndex } = useLocalSearchParams<{ itemId: string; @@ -98,21 +52,11 @@ const DropdownViewDirect: React.FC = ({ loop={true} sideOffset={10} > - {allSubtitleTracksForDirectPlay?.map((sub, idx: number) => ( + {subtitleTracks?.map((sub, idx: number) => ( { - if ("deliveryUrl" in sub && sub.deliveryUrl) { - setSubtitleURL && - setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name); - } else { - setSubtitleTrack && setSubtitleTrack(sub.index); - } - router.setParams({ - subtitleIndex: sub.index.toString(), - }); - }} + onValueChange={() => sub.setTrack()} > {sub.name} @@ -136,12 +80,7 @@ const DropdownViewDirect: React.FC = ({ { - setAudioTrack && setAudioTrack(track.index); - router.setParams({ - audioIndex: track.index.toString(), - }); - }} + onValueChange={() => track.setTrack()} > {track.name} @@ -155,4 +94,4 @@ const DropdownViewDirect: React.FC = ({ ); }; -export default DropdownViewDirect; +export default DropdownView; diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx deleted file mode 100644 index eccc68a1..00000000 --- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React, { useCallback, useMemo, useState } from "react"; -import { View, TouchableOpacity, Platform } from "react-native"; -import { Ionicons } from "@expo/vector-icons"; -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; -import { useControlContext } from "../contexts/ControlContext"; -import { useVideoContext } from "../contexts/VideoContext"; -import { TranscodedSubtitle } from "../types"; -import { useAtomValue } from "jotai"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { SubtitleHelper } from "@/utils/SubtitleHelper"; - -interface DropdownViewProps { - showControls: boolean; - offline?: boolean; // used to disable external subs for downloads -} - -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, setSubtitleTrack } = videoContext; - - const { subtitleIndex, 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 = useMemo(() => { - const res = Boolean( - mediaSource?.MediaStreams?.find( - (x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream - ) || subtitleIndex === "-1" - ); - return res; - }, []); - - const allSubs = - mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? []; - - const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []); - - const allSubtitleTracksForTranscodingStream = useMemo(() => { - const disableSubtitle = { - name: "Disable", - index: -1, - IsTextSubtitleStream: true, - } as TranscodedSubtitle; - if (isOnTextSubtitle) { - const textSubtitles = - subtitleTracks?.map((s) => ({ - name: s.name, - index: s.index, - IsTextSubtitleStream: true, - })) || []; - - const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles); - - return [disableSubtitle, ...sortedSubtitles]; - } - - const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({ - name: x.DisplayTitle!, - index: x.Index!, - IsTextSubtitleStream: x.IsTextSubtitleStream!, - })); - - return [disableSubtitle, ...transcodedSubtitle]; - }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]); - - const changeToImageBasedSub = 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/transcoding-player?${queryParams}`); - }, - [mediaSource] - ); - - // Audio tracks for transcoding streams. - const allAudio = - mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({ - name: x.DisplayTitle!, - index: x.Index!, - })) || []; - - const ChangeTranscodingAudio = useCallback( - (audioIndex: 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/transcoding-player?${queryParams}`); - }, - [mediaSource, subtitleIndex, audioIndex] - ); - - return ( - - - - - - - - - - - Subtitle - - - {allSubtitleTracksForTranscodingStream?.map( - (sub, idx: number) => ( - { - if ( - subtitleIndex === - (isOnTextSubtitle && sub.IsTextSubtitleStream - ? subtitleHelper - .getSourceSubtitleIndex(sub.index) - .toString() - : sub?.index.toString()) - ) - return; - - router.setParams({ - subtitleIndex: subtitleHelper - .getSourceSubtitleIndex(sub.index) - .toString(), - }); - - if (sub.IsTextSubtitleStream && isOnTextSubtitle) { - setSubtitleTrack && setSubtitleTrack(sub.index); - return; - } - changeToImageBasedSub(sub.index); - }} - > - - {sub.name} - - - ) - )} - - - - - Audio - - - {allAudio?.map((track, idx: number) => ( - { - if (audioIndex === track.index.toString()) return; - router.setParams({ - audioIndex: track.index.toString(), - }); - ChangeTranscodingAudio(track.index); - }} - > - - {track.name} - - - ))} - - - - - - ); -}; - -export default DropdownView; diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts index ac739049..8040f6d3 100644 --- a/components/video-player/controls/types.ts +++ b/components/video-player/controls/types.ts @@ -13,7 +13,14 @@ type ExternalSubtitle = { type TranscodedSubtitle = { name: string; index: number; + deliveryUrl: string; IsTextSubtitleStream: boolean; }; -export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle }; +type Track = { + name: string; + index: number; + setTrack: () => void; +}; + +export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track }; diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index fe18e709..b721cb9e 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -1,34 +1,33 @@ import ExpoModulesCore -import VLCKit import UIKit - +import VLCKit public class VLCPlayerView: UIView { - func setupView(parent: UIView) { - self.backgroundColor = .black - self.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - self.leadingAnchor.constraint(equalTo: parent.leadingAnchor), - self.trailingAnchor.constraint(equalTo: parent.trailingAnchor), - self.topAnchor.constraint(equalTo: parent.topAnchor), - self.bottomAnchor.constraint(equalTo: parent.bottomAnchor), - ]) - } + func setupView(parent: UIView) { + self.backgroundColor = .black + self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.leadingAnchor.constraint(equalTo: parent.leadingAnchor), + self.trailingAnchor.constraint(equalTo: parent.trailingAnchor), + self.topAnchor.constraint(equalTo: parent.topAnchor), + self.bottomAnchor.constraint(equalTo: parent.bottomAnchor), + ]) + } - public override func layoutSubviews() { - super.layoutSubviews() + public override func layoutSubviews() { + super.layoutSubviews() - for subview in subviews { - subview.frame = bounds - } - } + for subview in subviews { + subview.frame = bounds + } + } } class VLCPlayerWrapper: NSObject { private var lastProgressCall = Date().timeIntervalSince1970 public var player: VLCMediaPlayer = VLCMediaPlayer() - private var updatePlayerState: (() -> ())? - private var updateVideoProgress: (() -> ())? + private var updatePlayerState: (() -> Void)? + private var updateVideoProgress: (() -> Void)? private var playerView: VLCPlayerView = VLCPlayerView() public weak var pipController: VLCPictureInPictureWindowControlling? @@ -41,8 +40,8 @@ class VLCPlayerWrapper: NSObject { public func setup( parent: UIView, - updatePlayerState: (() -> ())?, - updateVideoProgress: (() -> ())? + updatePlayerState: (() -> Void)?, + updateVideoProgress: (() -> Void)? ) { self.updatePlayerState = updatePlayerState self.updateVideoProgress = updateVideoProgress @@ -52,9 +51,9 @@ class VLCPlayerWrapper: NSObject { playerView.setupView(parent: parent) } - public func getPlayerView() -> UIView { - return playerView - } + public func getPlayerView() -> UIView { + return playerView + } } // MARK: - VLCPictureInPictureDrawable @@ -63,7 +62,8 @@ extension VLCPlayerWrapper: VLCPictureInPictureDrawable { return self } - public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! { + public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! + { return { [weak self] controller in self?.pipController = controller } @@ -88,7 +88,7 @@ extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling { player.pause() } - func seek(by offset: Int64, completion: @escaping () -> ()) { + func seek(by offset: Int64, completion: @escaping () -> Void) { player.jump(withOffset: Int32(offset), completion: completion) } @@ -115,20 +115,24 @@ extension VLCPlayerWrapper: VLCDrawable { // MARK: - VLCMediaPlayerDelegate extension VLCPlayerWrapper: VLCMediaPlayerDelegate { func mediaPlayerTimeChanged(_ aNotification: Notification) { - let timeNow = Date().timeIntervalSince1970 - if timeNow - lastProgressCall >= 1 { - lastProgressCall = timeNow - updateVideoProgress?() + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let timeNow = Date().timeIntervalSince1970 + if timeNow - self.lastProgressCall >= 1 { + self.lastProgressCall = timeNow + self.updateVideoProgress?() + } } } func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) { - self.updatePlayerState?() + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.updatePlayerState?() - guard let pipController = self.pipController else { return } - DispatchQueue.main.async(execute: { + guard let pipController = self.pipController else { return } pipController.invalidatePlaybackState() - }) + } } } @@ -137,16 +141,15 @@ extension VLCPlayerWrapper: VLCMediaDelegate { // Implement VLCMediaDelegate methods if needed } - class VlcPlayerView: ExpoView { private var vlc: VLCPlayerWrapper = VLCPlayerWrapper() private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var isPaused: Bool = false private var customSubtitles: [(internalName: String, originalName: String)] = [] private var startPosition: Int32 = 0 - private var isMediaReady: Bool = false private var externalTrack: [String: String]? private var isStopping: Bool = false // Define isStopping here + private var externalSubtitles: [[String: String]]? var hasSource = false // MARK: - Initialization @@ -229,10 +232,12 @@ class VlcPlayerView: ExpoView { self.externalTrack = source["externalTrack"] as? [String: String] let initOptions: [String] = source["initOptions"] as? [String] ?? [] self.startPosition = source["startPosition"] as? Int32 ?? 0 + self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] for item in initOptions { let option = item.components(separatedBy: "=") - mediaOptions.updateValue(option[1], forKey: option[0].replacingOccurrences(of: "--", with: "")) + mediaOptions.updateValue( + option[1], forKey: option[0].replacingOccurrences(of: "--", with: "")) } guard let uri = source["uri"] as? String, !uri.isEmpty else { @@ -263,8 +268,8 @@ class VlcPlayerView: ExpoView { media.addOptions(mediaOptions) self.vlc.player.media = media + self.setInitialExternalSubtitles() self.hasSource = true - if autoplay { print("Playing...") self.play() @@ -274,20 +279,28 @@ class VlcPlayerView: ExpoView { } @objc func setAudioTrack(_ trackIndex: Int) { + print("Setting audio track: \(trackIndex)") let track = self.vlc.player.audioTracks[trackIndex] - track.isSelectedExclusively = true; + track.isSelectedExclusively = true } @objc func getAudioTracks() -> [[String: Any]]? { return vlc.player.audioTracks.enumerated().map { - return ["name": $1.trackName, "index": $0 ] + return ["name": $1.trackName, "index": $0] } } @objc func setSubtitleTrack(_ trackIndex: Int) { print("Debug: Attempting to set subtitle track to index: \(trackIndex)") + if trackIndex == -1 { + print("Debug: Disabling all subtitles") + for track in self.vlc.player.textTracks { + track.isSelected = false + } + return + } let track = self.vlc.player.textTracks[trackIndex] - track.isSelectedExclusively = true; + track.isSelectedExclusively = true print("Debug: Current subtitle track index after setting: \(track.trackName)") } @@ -296,12 +309,11 @@ class VlcPlayerView: ExpoView { print("Error: Invalid subtitle URL") return } - let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: true) - - if result > 0 { - let internalName = "Track \(self.customSubtitles.count + 1)" - print("Subtitle added with result: \(result) \(internalName)") + let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false) + if result == 0 { + let internalName = "Track \(self.customSubtitles.count)" self.customSubtitles.append((internalName: internalName, originalName: name)) + print("Subtitle added with result: \(result) \(internalName)") } else { print("Failed to add subtitle") } @@ -313,30 +325,17 @@ class VlcPlayerView: ExpoView { } print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)") - let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in - if let customSubtitle = customSubtitles.first(where: { $0.internalName == track.trackName }) { - return ["name": customSubtitle.originalName, "index": index ] - } - else { - return ["name": track.trackName, "index": index ] + if let customSubtitle = customSubtitles.first(where: { + $0.internalName == track.trackName + }) { + return ["name": customSubtitle.originalName, "index": index] + } else { + return ["name": track.trackName, "index": index] } } - - print("Debug: Subtitle tracks: \(tracks)") - return tracks - } - - private func setSubtitleTrackByName(_ trackName: String) { - for track in self.vlc.player.textTracks { - if (track.trackName.starts(with: trackName)) { - print("Track Index setting to: \(track.trackName)") - track.isSelectedExclusively = true - return - } - } - - print("Track not found for name: \(trackName)") + print("Debug: Subtitle tracks: \(tracks)") + return tracks } @objc func stop(completion: (() -> Void)? = nil) { @@ -366,6 +365,19 @@ class VlcPlayerView: ExpoView { } + private func setInitialExternalSubtitles() { + if let externalSubtitles = self.externalSubtitles { + for subtitle in externalSubtitles { + if let subtitleName = subtitle["name"], + let subtitleURL = subtitle["DeliveryUrl"] + { + print("Setting external subtitle: \(subtitleName) \(subtitleURL)") + self.setSubtitleURL(subtitleURL, name: subtitleName) + } + } + } + } + private func performStop(completion: (() -> Void)? = nil) { // Stop the media player vlc.player.stop() @@ -387,18 +399,6 @@ class VlcPlayerView: ExpoView { let durationMs = self.vlc.player.media?.length.intValue ?? 0 print("Debug: Current time: \(currentTimeMs)") - if currentTimeMs >= 0 && currentTimeMs < durationMs { - if !self.isMediaReady { - self.isMediaReady = true - // Set external track subtitle when starting. - if let externalTrack = self.externalTrack { - if let name = externalTrack["name"], !name.isEmpty { - let deliveryUrl = externalTrack["DeliveryUrl"] ?? "" - self.setSubtitleURL(deliveryUrl, name: name) - } - } - } - } self.onVideoProgress?([ "currentTime": currentTimeMs, "duration": durationMs, @@ -414,7 +414,7 @@ class VlcPlayerView: ExpoView { "error": false, "isPlaying": player.isPlaying, "isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering, - "state": player.state.description + "state": player.state.description, ]) } diff --git a/modules/vlc-player/src/VlcPlayer.types.ts b/modules/vlc-player/src/VlcPlayer.types.ts index 91922a1b..e1c37797 100644 --- a/modules/vlc-player/src/VlcPlayer.types.ts +++ b/modules/vlc-player/src/VlcPlayer.types.ts @@ -39,7 +39,7 @@ export type VlcPlayerSource = { type?: string; isNetwork?: boolean; autoplay?: boolean; - externalTrack?: { name: string, DeliveryUrl: string }; + externalSubtitles: { name: string; DeliveryUrl: string }[]; initOptions?: any[]; mediaOptions?: { [key: string]: any }; startPosition?: number; diff --git a/utils/SubtitleHelper.ts b/utils/SubtitleHelper.ts deleted file mode 100644 index e060f271..00000000 --- a/utils/SubtitleHelper.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { TranscodedSubtitle } from "@/components/video-player/controls/types"; -import { TrackInfo } from "@/modules/vlc-player"; -import { MediaStream } from "@jellyfin/sdk/lib/generated-client"; -import { Platform } from "react-native"; - -const disableSubtitle = { - name: "Disable", - index: -1, - IsTextSubtitleStream: true, -} as TranscodedSubtitle; - -export class SubtitleHelper { - private mediaStreams: MediaStream[]; - - constructor(mediaStreams: MediaStream[]) { - this.mediaStreams = mediaStreams.filter((x) => x.Type === "Subtitle"); - } - - getSubtitles(): MediaStream[] { - return this.mediaStreams; - } - - getUniqueSubtitles(): MediaStream[] { - const uniqueSubs: MediaStream[] = []; - const seen = new Set(); - - this.mediaStreams.forEach((x) => { - if (!seen.has(x.DisplayTitle!)) { - seen.add(x.DisplayTitle!); - uniqueSubs.push(x); - } - }); - - return uniqueSubs; - } - - getCurrentSubtitle(subtitleIndex?: number): MediaStream | undefined { - return this.mediaStreams.find((x) => x.Index === subtitleIndex); - } - - getMostCommonSubtitleByName( - subtitleIndex: number | undefined - ): number | undefined { - if (subtitleIndex === undefined) -1; - const uniqueSubs = this.getUniqueSubtitles(); - const currentSub = this.getCurrentSubtitle(subtitleIndex); - - return uniqueSubs.find((x) => x.DisplayTitle === currentSub?.DisplayTitle) - ?.Index; - } - - getTextSubtitles(): MediaStream[] { - return this.mediaStreams.filter((x) => x.IsTextSubtitleStream); - } - - getImageSubtitles(): MediaStream[] { - return this.mediaStreams.filter((x) => !x.IsTextSubtitleStream); - } - - getEmbeddedTrackIndex(sourceSubtitleIndex: number): number { - if (Platform.OS === "android") { - const textSubs = this.getTextSubtitles(); - const matchingSubtitle = textSubs.find( - (sub) => sub.Index === sourceSubtitleIndex - ); - - if (!matchingSubtitle) return -1; - return textSubs.indexOf(matchingSubtitle); - } - - // Get unique text-based subtitles because react-native-video removes hls text tracks duplicates. (iOS) - const uniqueTextSubs = this.getUniqueTextBasedSubtitles(); - const matchingSubtitle = uniqueTextSubs.find( - (sub) => sub.Index === sourceSubtitleIndex - ); - - if (!matchingSubtitle) return -1; - return uniqueTextSubs.indexOf(matchingSubtitle); - } - - sortSubtitles( - textSubs: TranscodedSubtitle[], - allSubs: MediaStream[] - ): TranscodedSubtitle[] { - let textIndex = 0; // To track position in textSubtitles - // Merge text and image subtitles in the order of allSubs - const sortedSubtitles = allSubs.map((sub) => { - if (sub.IsTextSubtitleStream) { - if (textSubs.length === 0) return disableSubtitle; - const textSubtitle = textSubs[textIndex]; - if (!textSubtitle) return disableSubtitle; - textIndex++; - return textSubtitle; - } else { - return { - name: sub.DisplayTitle!, - index: sub.Index!, - IsTextSubtitleStream: sub.IsTextSubtitleStream, - } as TranscodedSubtitle; - } - }); - - return sortedSubtitles; - } - - getSortedSubtitles(subtitleTracks: TrackInfo[]): TranscodedSubtitle[] { - const textSubtitles = - subtitleTracks.map((s) => ({ - name: s.name, - index: s.index, - IsTextSubtitleStream: true, - })) || []; - - const sortedSubs = - Platform.OS === "android" - ? this.sortSubtitles(textSubtitles, this.mediaStreams) - : this.sortSubtitles(textSubtitles, this.getUniqueSubtitles()); - - return sortedSubs; - } - - getUniqueTextBasedSubtitles(): MediaStream[] { - return this.getUniqueSubtitles().filter((x) => x.IsTextSubtitleStream); - } - - // HLS stream indexes are not the same as the actual source indexes. - // This function aims to get the source subtitle index from the embedded track index. - getSourceSubtitleIndex = (embeddedTrackIndex: number): number => { - if (Platform.OS === "android") { - return this.getTextSubtitles()[embeddedTrackIndex]?.Index ?? -1; - } - return this.getUniqueTextBasedSubtitles()[embeddedTrackIndex]?.Index ?? -1; - }; -} diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 0250ebeb..de5f2e41 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -111,15 +111,6 @@ export const getStreamUrl = async ({ 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(); diff --git a/utils/profiles/native.js b/utils/profiles/native.js index 22e43159..72d4e3b6 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.js @@ -42,11 +42,9 @@ export default { Type: MediaTypes.Video, Context: "Streaming", Protocol: "hls", - Container: "ts", + Container: "fmp4", VideoCodec: "h264, hevc", - AudioCodec: "aac,mp3,ac3", - CopyTimestamps: false, - EnableSubtitlesInManifest: true, + AudioCodec: "aac,mp3,ac3,dts", }, { Type: MediaTypes.Audio, @@ -58,131 +56,81 @@ export default { }, ], SubtitleProfiles: [ - // Official foramts + // Official formats { Format: "vtt", Method: "Embed" }, - { Format: "vtt", Method: "Hls" }, { Format: "vtt", Method: "External" }, - { Format: "vtt", Method: "Encode" }, { Format: "webvtt", Method: "Embed" }, - { Format: "webvtt", Method: "Hls" }, { Format: "webvtt", Method: "External" }, - { Format: "webvtt", Method: "Encode" }, { Format: "srt", Method: "Embed" }, - { Format: "srt", Method: "Hls" }, { Format: "srt", Method: "External" }, - { Format: "srt", Method: "Encode" }, { Format: "subrip", Method: "Embed" }, - { Format: "subrip", Method: "Hls" }, { Format: "subrip", Method: "External" }, - { Format: "subrip", Method: "Encode" }, { Format: "ttml", Method: "Embed" }, - { Format: "ttml", Method: "Hls" }, { Format: "ttml", Method: "External" }, - { Format: "ttml", Method: "Encode" }, { Format: "dvbsub", Method: "Embed" }, - { Format: "dvbsub", Method: "Hls" }, - { Format: "dvbsub", Method: "External" }, { Format: "dvdsub", Method: "Encode" }, { Format: "ass", Method: "Embed" }, - { Format: "ass", Method: "Hls" }, { Format: "ass", Method: "External" }, - { Format: "ass", Method: "Encode" }, { Format: "idx", Method: "Embed" }, - { Format: "idx", Method: "Hls" }, - { Format: "idx", Method: "External" }, { Format: "idx", Method: "Encode" }, { Format: "pgs", Method: "Embed" }, - { Format: "pgs", Method: "Hls" }, - { Format: "pgs", Method: "External" }, { Format: "pgs", Method: "Encode" }, { Format: "pgssub", Method: "Embed" }, - { Format: "pgssub", Method: "Hls" }, - { Format: "pgssub", Method: "External" }, { Format: "pgssub", Method: "Encode" }, { Format: "ssa", Method: "Embed" }, - { Format: "ssa", Method: "Hls" }, { Format: "ssa", Method: "External" }, - { Format: "ssa", Method: "Encode" }, // Other formats { Format: "microdvd", Method: "Embed" }, - { Format: "microdvd", Method: "Hls" }, { Format: "microdvd", Method: "External" }, - { Format: "microdvd", Method: "Encode" }, { Format: "mov_text", Method: "Embed" }, - { Format: "mov_text", Method: "Hls" }, { Format: "mov_text", Method: "External" }, - { Format: "mov_text", Method: "Encode" }, { Format: "mpl2", Method: "Embed" }, - { Format: "mpl2", Method: "Hls" }, { Format: "mpl2", Method: "External" }, - { Format: "mpl2", Method: "Encode" }, { Format: "pjs", Method: "Embed" }, - { Format: "pjs", Method: "Hls" }, { Format: "pjs", Method: "External" }, - { Format: "pjs", Method: "Encode" }, { Format: "realtext", Method: "Embed" }, - { Format: "realtext", Method: "Hls" }, { Format: "realtext", Method: "External" }, - { Format: "realtext", Method: "Encode" }, { Format: "scc", Method: "Embed" }, - { Format: "scc", Method: "Hls" }, { Format: "scc", Method: "External" }, - { Format: "scc", Method: "Encode" }, { Format: "smi", Method: "Embed" }, - { Format: "smi", Method: "Hls" }, { Format: "smi", Method: "External" }, - { Format: "smi", Method: "Encode" }, { Format: "stl", Method: "Embed" }, - { Format: "stl", Method: "Hls" }, { Format: "stl", Method: "External" }, - { Format: "stl", Method: "Encode" }, { Format: "sub", Method: "Embed" }, - { Format: "sub", Method: "Hls" }, { Format: "sub", Method: "External" }, - { Format: "sub", Method: "Encode" }, { Format: "subviewer", Method: "Embed" }, - { Format: "subviewer", Method: "Hls" }, { Format: "subviewer", Method: "External" }, - { Format: "subviewer", Method: "Encode" }, { Format: "teletext", Method: "Embed" }, - { Format: "teletext", Method: "Hls" }, - { Format: "teletext", Method: "External" }, { Format: "teletext", Method: "Encode" }, { Format: "text", Method: "Embed" }, - { Format: "text", Method: "Hls" }, { Format: "text", Method: "External" }, - { Format: "text", Method: "Encode" }, { Format: "vplayer", Method: "Embed" }, - { Format: "vplayer", Method: "Hls" }, { Format: "vplayer", Method: "External" }, - { Format: "vplayer", Method: "Encode" }, { Format: "xsub", Method: "Embed" }, - { Format: "xsub", Method: "Hls" }, { Format: "xsub", Method: "External" }, - { Format: "xsub", Method: "Encode" }, ], }; diff --git a/utils/profiles/transcoding.js b/utils/profiles/transcoding.js deleted file mode 100644 index f45c498a..00000000 --- a/utils/profiles/transcoding.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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: "fmp4", - 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" }, - ], -};