This commit is contained in:
Fredrik Burmester
2024-10-18 22:27:26 +02:00
parent 6e669b2aa9
commit 39c49d4cdb
15 changed files with 381 additions and 159 deletions

View File

@@ -3,6 +3,7 @@ import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { TrackInfo } from "@/modules/vlc-player";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { import {
PlaybackType, PlaybackType,
@@ -12,6 +13,7 @@ import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { secondsToTicks } from "@/utils/secondsToTicks"; import { secondsToTicks } from "@/utils/secondsToTicks";
import { ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk"; import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
@@ -22,6 +24,7 @@ import { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import Video, { import Video, {
OnProgressData, OnProgressData,
SelectedTrack,
SelectedTrackType, SelectedTrackType,
VideoRef, VideoRef,
} from "react-native-video"; } from "react-native-video";
@@ -112,6 +115,13 @@ export default function page() {
reportPlaybackStopped(); reportPlaybackStopped();
}, [videoRef]); }, [videoRef]);
const seek = useCallback(
(ticks: number) => {
videoRef.current?.seek(ticksToSeconds(ticks));
},
[videoRef]
);
const reportPlaybackStopped = async () => { const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({ await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!, itemId: playSettings?.item?.Id!,
@@ -141,12 +151,20 @@ export default function page() {
if (isSeeking.value === true) return; if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return; if (isPlaybackStopped === true) return;
const ticks = data.currentTime * 10000000; console.log({
data,
isSeeking: isSeeking.value,
isPlaybackStopped,
});
progress.value = secondsToTicks(data.currentTime); const ticks = secondsToTicks(data.currentTime);
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration); cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0); setIsBuffering(data.playableDuration === 0);
console.log("progress.value", progress.value);
if (!playSettings?.item?.Id || data.currentTime === 0) return; if (!playSettings?.item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api).onPlaybackProgress({ await getPlaystateApi(api).onPlaybackProgress({
@@ -188,15 +206,11 @@ export default function page() {
stopPlayback: stop, stopPlayback: stop,
}); });
const selectedSubtitleTrack = useMemo(() => { const [selectedTextTrack, setSelectedTextTrack] = useState<
const a = playSettings?.mediaSource?.MediaStreams?.find( SelectedTrack | undefined
(s) => s.Index === playSettings.subtitleIndex >();
);
console.log(a);
return a;
}, [playSettings]);
const [hlsSubTracks, setHlsSubTracks] = useState< const [embededTextTracks, setEmbededTextTracks] = useState<
{ {
index: number; index: number;
language?: string | undefined; language?: string | undefined;
@@ -206,17 +220,12 @@ export default function page() {
}[] }[]
>([]); >([]);
const selectedTextTrack = useMemo(() => { const getSubtitleTracks = (): TrackInfo[] => {
for (let st of hlsSubTracks) { return embededTextTracks.map((t) => ({
if (st.title === selectedSubtitleTrack?.DisplayTitle) { name: t.title ?? "",
return { index: t.index,
type: SelectedTrackType.TITLE, }));
value: selectedSubtitleTrack?.DisplayTitle ?? "", };
};
}
}
return undefined;
}, [hlsSubTracks]);
return ( return (
<View <View
@@ -273,7 +282,7 @@ export default function page() {
}} }}
onTextTracks={(data) => { onTextTracks={(data) => {
console.log("onTextTracks ~", data); console.log("onTextTracks ~", data);
setHlsSubTracks(data.textTracks as any); setEmbededTextTracks(data.textTracks as any);
}} }}
selectedTextTrack={selectedTextTrack} selectedTextTrack={selectedTextTrack}
/> />
@@ -293,6 +302,16 @@ export default function page() {
setShowControls={setShowControls} setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) =>
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: i,
})
}
/> />
</View> </View>
); );
@@ -334,6 +353,9 @@ export function useVideoSource(
return { return {
uri: playUrl, uri: playUrl,
textTracks: {
},
isNetwork: true, isNetwork: true,
startPosition, startPosition,
headers: getAuthHeaders(api), headers: getAuthHeaders(api),

View File

@@ -1,3 +1,4 @@
import { Controls } from "@/components/video-player/Controls";
import { VlcControls } from "@/components/video-player/VlcControls"; import { VlcControls } from "@/components/video-player/VlcControls";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar"; import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
@@ -264,7 +265,7 @@ export default function page() {
</Pressable> </Pressable>
{videoRef.current && ( {videoRef.current && (
<VlcControls <Controls
mediaSource={mediaSource} mediaSource={mediaSource}
item={playSettings.item} item={playSettings.item}
videoRef={videoRef} videoRef={videoRef}
@@ -290,6 +291,7 @@ export default function page() {
setSubtitleURL={videoRef.current.setSubtitleURL} setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack} setAudioTrack={videoRef.current.setAudioTrack}
stop={videoRef.current.stop} stop={videoRef.current.stop}
isVlc
/> />
)} )}
</View> </View>

View File

@@ -84,7 +84,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
} }
if (Platform.OS === "ios") router.push("/vlc-player"); if (Platform.OS === "ios") router.push("/vlc-player");
else router.push("/play-video"); else router.push("/player");
return; return;
} }
@@ -125,7 +125,6 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
audioStreamIndex: playSettings?.audioIndex ?? 0, audioStreamIndex: playSettings?.audioIndex ?? 0,
subtitleStreamIndex: playSettings?.subtitleIndex ?? -1, subtitleStreamIndex: playSettings?.subtitleIndex ?? -1,
userId: user?.Id, userId: user?.Id,
forceDirectPlay: settings?.forceDirectPlay,
}); });
if (!data?.url) { if (!data?.url) {
@@ -206,7 +205,8 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
}); });
break; break;
case 1: case 1:
router.push("/vlc-player"); if (Platform.OS === "ios") router.push("/vlc-player");
else router.push("/player");
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;

View File

@@ -103,7 +103,7 @@ export const SongsListItem: React.FC<Props> = ({
}); });
} else { } else {
console.log("Playing on device", data.url, item.Id); console.log("Playing on device", data.url, item.Id);
router.push("/play-music"); router.push("/music-player");
} }
}, []); }, []);

View File

@@ -10,6 +10,7 @@ import {
registerBackgroundFetchAsync, registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync, unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks"; } from "@/utils/background-tasks";
import { getStatistics } from "@/utils/optimize-server";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch"; import * as BackgroundFetch from "expo-background-fetch";
@@ -18,7 +19,6 @@ import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
ActivityIndicator,
Linking, Linking,
Switch, Switch,
TouchableOpacity, TouchableOpacity,
@@ -32,8 +32,6 @@ import { Input } from "../common/Input";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles"; import { MediaToggles } from "./MediaToggles";
import axios from "axios";
import { getStatistics } from "@/utils/optimize-server";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -248,22 +246,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root> </DropdownMenu.Root>
</View> </View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This
requries VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View className="flex flex-col"> <View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col"> <View className="flex flex-col">
@@ -334,22 +316,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
)} )}
</View> </View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Force direct play</Text>
<Text className="text-xs opacity-50 shrink">
This will always request direct play. This is good if you want
to try to stream movies you think the device supports.
</Text>
</View>
<Switch
value={settings.forceDirectPlay}
onValueChange={(value) =>
updateSettings({ forceDirectPlay: value })
}
/>
</View>
<View className="flex flex-col"> <View className="flex flex-col">
<View <View
className={` className={`

View File

@@ -2,16 +2,28 @@ import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import {
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { formatTimeString, ticksToSeconds } from "@/utils/time"; import {
formatTimeString,
secondsToMs,
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Dimensions, Dimensions,
Platform, Platform,
@@ -20,22 +32,23 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import Animated, { import {
runOnJS, runOnJS,
SharedValue, SharedValue,
useAnimatedReaction, useAnimatedReaction,
useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video"; import { VideoRef } from "react-native-video";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import * as DropdownMenu from "zeego/dropdown-menu";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
videoRef: React.MutableRefObject<VideoRef | null>; videoRef: React.MutableRefObject<VlcPlayerViewRef | VideoRef | null>;
isPlaying: boolean; isPlaying: boolean;
isSeeking: SharedValue<boolean>; isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>; cacheProgress: SharedValue<number>;
@@ -47,11 +60,27 @@ interface Props {
enableTrickplay?: boolean; enableTrickplay?: boolean;
togglePlay: (ticks: number) => void; togglePlay: (ticks: number) => void;
setShowControls: (shown: boolean) => void; setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: () => Promise<TrackInfo[] | null>;
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
stop?: (() => Promise<void>) | (() => void);
isVlc?: boolean;
} }
export const Controls: React.FC<Props> = ({ export const Controls: React.FC<Props> = ({
item, item,
videoRef, videoRef,
seek,
play,
pause,
togglePlay, togglePlay,
isPlaying, isPlaying,
isSeeking, isSeeking,
@@ -62,19 +91,29 @@ export const Controls: React.FC<Props> = ({
setShowControls, setShowControls,
ignoreSafeAreas, ignoreSafeAreas,
setIgnoreSafeAreas, setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
stop,
offline = false,
enableTrickplay = true, enableTrickplay = true,
isVlc = false,
}) => { }) => {
const [settings] = useSettings(); const [settings] = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { setPlaySettings } = usePlaySettings(); const { setPlaySettings, playSettings } = usePlaySettings();
const api = useAtomValue(apiAtom);
const windowDimensions = Dimensions.get("window"); const windowDimensions = Dimensions.get("window");
const { previousItem, nextItem } = useAdjacentItems({ item }); const { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item, item,
enableTrickplay !offline && enableTrickplay
); );
const [currentTime, setCurrentTime] = useState(0); // Seconds const [currentTime, setCurrentTime] = useState(0); // Seconds
@@ -84,23 +123,20 @@ export const Controls: React.FC<Props> = ({
const max = useSharedValue(item.RunTimeTicks || 0); const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false); const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const seek = (ticks: number) => {
videoRef.current?.seek(ticks);
};
const { showSkipButton, skipIntro } = useIntroSkipper( const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id, offline ? undefined : item.Id,
currentTime, currentTime,
seek, seek,
() => videoRef.current?.resume() play
); );
const { showSkipCreditButton, skipCredit } = useCreditSkipper( const { showSkipCreditButton, skipCredit } = useCreditSkipper(
item.Id, offline ? undefined : item.Id,
currentTime, currentTime,
seek, seek,
() => videoRef.current?.resume() play
); );
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
@@ -117,7 +153,8 @@ export const Controls: React.FC<Props> = ({
subtitleIndex, subtitleIndex,
}); });
router.replace("/play-video"); if (Platform.OS === "ios") router.replace("/vlc-player");
else router.replace("/player");
}, [previousItem, settings]); }, [previousItem, settings]);
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {
@@ -134,13 +171,16 @@ export const Controls: React.FC<Props> = ({
subtitleIndex, subtitleIndex,
}); });
router.replace("/play-video"); if (Platform.OS === "ios") router.replace("/vlc-player");
else router.replace("/player");
}, [nextItem, settings]); }, [nextItem, settings]);
const updateTimes = useCallback( const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => { (currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress); const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - currentProgress); const remaining = isVlc
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current); setCurrentTime(current);
setRemainingTime(remaining); setRemainingTime(remaining);
@@ -151,7 +191,7 @@ export const Controls: React.FC<Props> = ({
goToNextItem(); goToNextItem();
} }
}, },
[goToNextItem] [goToNextItem, isVlc]
); );
useAnimatedReaction( useAnimatedReaction(
@@ -170,19 +210,27 @@ export const Controls: React.FC<Props> = ({
useEffect(() => { useEffect(() => {
if (item) { if (item) {
progress.value = item?.UserData?.PlaybackPositionTicks || 0; progress.value = isVlc
max.value = item.RunTimeTicks || 0; ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
} }
}, [item]); }, [item, isVlc]);
const toggleControls = () => setShowControls(!showControls); const toggleControls = () => setShowControls(!showControls);
const handleSliderComplete = useCallback((value: number) => { const handleSliderComplete = useCallback(
progress.value = value; async (value: number) => {
isSeeking.value = false; isSeeking.value = false;
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000))); progress.value = value;
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, []); await seek(Math.max(0, Math.floor(isVlc ? value : value / 10000000)));
if (wasPlayingRef.current === true) play();
},
[isVlc]
);
const handleSliderChange = (value: number) => { const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value); calculateTrickplayUrl(value);
@@ -190,49 +238,124 @@ export const Controls: React.FC<Props> = ({
const handleSliderStart = useCallback(() => { const handleSliderStart = useCallback(() => {
if (showControls === false) return; if (showControls === false) return;
wasPlayingRef.current = isPlaying; wasPlayingRef.current = isPlaying;
videoRef.current?.pause(); lastProgressRef.current = progress.value;
pause();
isSeeking.value = true; isSeeking.value = true;
}, [showControls, isPlaying]); }, [showControls, isPlaying]);
const handleSkipBackward = useCallback(async () => { const handleSkipBackward = useCallback(async () => {
console.log("handleSkipBackward");
if (!settings?.rewindSkipTime) return; if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying; wasPlayingRef.current = isPlaying;
try { try {
const curr = await videoRef.current?.getCurrentPosition(); const curr = progress.value;
if (curr !== undefined) { if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); const newTime = isVlc
setTimeout(() => { ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
if (wasPlayingRef.current === true) videoRef.current?.resume(); : Math.max(0, curr - settings.rewindSkipTime * 10000000);
}, 10); await seek(newTime);
if (wasPlayingRef.current === true) play();
} }
} catch (error) { } catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error); writeToLog("ERROR", "Error seeking video backwards", error);
} }
}, [settings, isPlaying]); }, [settings, isPlaying, isVlc]);
const handleSkipForward = useCallback(async () => { const handleSkipForward = useCallback(async () => {
console.log("handleSkipForward");
if (!settings?.forwardSkipTime) return; if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying; wasPlayingRef.current = isPlaying;
try { try {
const curr = await videoRef.current?.getCurrentPosition(); const curr = progress.value;
if (curr !== undefined) { if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); const newTime = isVlc
setTimeout(() => { ? curr + secondsToMs(settings.forwardSkipTime)
if (wasPlayingRef.current === true) videoRef.current?.resume(); : curr + settings.forwardSkipTime * 10000000;
}, 10); await seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
} }
} catch (error) { } catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error); writeToLog("ERROR", "Error seeking video forwards", error);
} }
}, [settings, isPlaying]); }, [settings, isPlaying, isVlc]);
const toggleIgnoreSafeAreas = useCallback(() => { const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev); setIgnoreSafeAreas((prev) => !prev);
}, []); }, []);
const [selectedSubtitleTrack, setSelectedSubtitleTrack] = useState<
MediaStream | undefined
>(undefined);
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
useEffect(() => {
const fetchTracks = async () => {
if (getAudioTracks && getSubtitleTracks) {
const audio = await getAudioTracks();
const subtitles = await getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
}
};
fetchTracks();
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
type EmbeddedSubtitle = {
name: string;
index: number;
isExternal: false;
};
type ExternalSubtitle = {
name: string;
index: number;
isExternal: true;
deliveryUrl: string;
};
const allSubtitleTracks = useMemo(() => {
const embeddedSubs =
subtitleTracks?.map((s) => ({
name: s.name,
index: s.index,
isExternal: false,
deliveryUrl: undefined,
})) || [];
const externalSubs =
mediaSource?.MediaStreams?.filter(
(stream) =>
stream.Type === "Subtitle" &&
stream.IsExternal &&
!!stream.DeliveryUrl
).map((s) => ({
name: s.DisplayTitle!,
index: s.Index!,
isExternal: true,
deliveryUrl: s.DeliveryUrl,
})) || [];
// Create a Set of embedded subtitle names for quick lookup
const embeddedSubNames = new Set(embeddedSubs.map((sub) => sub.name));
// Filter out external subs that have the same name as embedded subs
const uniqueExternalSubs = externalSubs.filter(
(sub) => !embeddedSubNames.has(sub.name)
);
// Combine embedded and unique external subs
return [...embeddedSubs, ...uniqueExternalSubs] as (
| EmbeddedSubtitle
| ExternalSubtitle
)[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource]);
return ( return (
<View <View
style={[ style={[
@@ -245,6 +368,116 @@ export const Controls: React.FC<Props> = ({
}, },
]} ]}
> >
{/* <VideoDebugInfo playerRef={videoRef} /> */}
{setSubtitleURL && setSubtitleTrack && setAudioTrack && (
<View
style={{
position: "absolute",
top: insets.top,
left: insets.left,
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
pointerEvents={showControls ? "auto" : "none"}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons
name="ellipsis-horizontal"
size={24}
color={"white"}
/>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracks.length > 0
? allSubtitleTracks?.map((sub, idx: number) => (
<DropdownMenu.Item
key={`subtitle-item-${idx}`}
onSelect={() => {
console.log("Trying to set subtitle...");
if (sub.isExternal) {
console.log("Setting external sub:", sub);
setSubtitleURL(
api?.basePath + sub.deliveryUrl,
sub.name
);
return;
}
console.log("Setting sub with index:", sub.index);
setSubtitleTrack(sub.index);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle
key={`subtitle-item-title-${idx}`}
>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))
: null}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.length
? audioTracks?.map((a, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value="off"
onValueChange={() => {
setAudioTrack(a.index);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle
key={`audio-item-title-${idx}`}
>
{a.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))
: null}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
)}
<View <View
style={[ style={[
{ {
@@ -331,7 +564,7 @@ export const Controls: React.FC<Props> = ({
}, },
]} ]}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4`} className={`flex flex-row items-center space-x-2 z-10 p-4 `}
> >
<TouchableOpacity <TouchableOpacity
onPress={toggleIgnoreSafeAreas} onPress={toggleIgnoreSafeAreas}
@@ -344,7 +577,8 @@ export const Controls: React.FC<Props> = ({
/> />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={async () => {
if (stop) await stop();
router.back(); router.back();
}} }}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
@@ -489,10 +723,10 @@ export const Controls: React.FC<Props> = ({
/> />
<View className="flex flex-row items-center justify-between mt-0.5"> <View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400"> <Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)} {formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text> </Text>
<Text className="text-[12px] text-neutral-400"> <Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)} -{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
</Text> </Text>
</View> </View>
</View> </View>

View File

@@ -151,7 +151,8 @@ export const VlcControls: React.FC<Props> = ({
subtitleIndex, subtitleIndex,
}); });
router.replace("/play-video"); if (Platform.OS === "ios") router.replace("/vlc-player");
else router.replace("/player");
}, [previousItem, settings]); }, [previousItem, settings]);
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {
@@ -168,7 +169,8 @@ export const VlcControls: React.FC<Props> = ({
subtitleIndex, subtitleIndex,
}); });
router.replace("/play-video"); if (Platform.OS === "ios") router.replace("/vlc-player");
else router.replace("/player");
}, [nextItem, settings]); }, [nextItem, settings]);
const updateTimes = useCallback( const updateTimes = useCallback(
@@ -266,10 +268,6 @@ export const VlcControls: React.FC<Props> = ({
setIgnoreSafeAreas((prev) => !prev); setIgnoreSafeAreas((prev) => !prev);
}, []); }, []);
const [selectedSubtitleTrack, setSelectedSubtitleTrack] = useState<
MediaStream | undefined
>(undefined);
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null); const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>( const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null null

View File

@@ -1,14 +1,11 @@
import { import {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
TrackInfo, TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
import { useState, useEffect } from "react"; import React, { useEffect, useState } from "react";
import { View, TouchableOpacity, ViewProps } from "react-native"; import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "../common/Text";
import React from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "../common/Text";
interface Props extends ViewProps { interface Props extends ViewProps {
playerRef: React.RefObject<VlcPlayerViewRef>; playerRef: React.RefObject<VlcPlayerViewRef>;

View File

@@ -77,10 +77,10 @@ public class VlcPlayerModule: Module {
return view.getVideoCropGeometry() return view.getVideoCropGeometry()
} }
AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String) in AsyncFunction("setSubtitleURL") {
view.setSubtitleURL(url) (view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)
} }
} }
} }
} }

View File

@@ -11,6 +11,7 @@ class VlcPlayerView: ExpoView {
private var lastReportedState: VLCMediaPlayerState? private var lastReportedState: VLCMediaPlayerState?
private var lastReportedIsPlaying: Bool? private var lastReportedIsPlaying: Bool?
private var isMediaReady: Bool = false private var isMediaReady: Bool = false
private var customSubtitles: [(internalName: String, originalName: String)] = []
// MARK: - Initialization // MARK: - Initialization
@@ -198,14 +199,6 @@ class VlcPlayerView: ExpoView {
} }
} }
@objc func loadExternalSubtitle(_ subtitlePath: String) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.addPlaybackSlave(
URL(fileURLWithPath: subtitlePath), type: .subtitle, enforce: true)
}
}
@objc func setMuted(_ muted: Bool) { @objc func setMuted(_ muted: Bool) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.mediaPlayer?.audio?.isMuted = muted self.mediaPlayer?.audio?.isMuted = muted
@@ -296,7 +289,7 @@ class VlcPlayerView: ExpoView {
} }
} }
@objc func setSubtitleURL(_ subtitleURL: String) { @objc func setSubtitleURL(_ subtitleURL: String, name: String) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self, let url = URL(string: subtitleURL) else { guard let self = self, let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL") print("Error: Invalid subtitle URL")
@@ -305,7 +298,9 @@ class VlcPlayerView: ExpoView {
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true) let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result { if let result = result {
print("Subtitle added with result: \(result)") let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else { } else {
print("Failed to add subtitle") print("Failed to add subtitle")
} }
@@ -318,10 +313,7 @@ class VlcPlayerView: ExpoView {
} }
let count = mediaPlayer.numberOfSubtitlesTracks let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
print(
"Debug: Number of subtitle tracks: \(count)"
)
guard count > 0 else { guard count > 0 else {
return nil return nil
@@ -333,10 +325,15 @@ class VlcPlayerView: ExpoView {
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{ {
for (index, name) in zip(indexes, names) { for (index, name) in zip(indexes, names) {
tracks.append(["name": name, "index": index.intValue]) if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else {
tracks.append(["name": name, "index": index.intValue])
}
} }
} }
print("Debug: Subtitle tracks: \(tracks)")
return tracks return tracks
} }

View File

@@ -82,5 +82,5 @@ export interface VlcPlayerViewRef {
getChapters: () => Promise<ChapterInfo[] | null>; getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (geometry: string | null) => Promise<void>; setVideoCropGeometry: (geometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>; getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string) => Promise<void>; setSubtitleURL: (url: string, name: string) => Promise<void>;
} }

View File

@@ -78,8 +78,8 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const geometry = await nativeRef.current?.getVideoCropGeometry(); const geometry = await nativeRef.current?.getVideoCropGeometry();
return geometry ?? null; return geometry ?? null;
}, },
setSubtitleURL: async (url: string) => { setSubtitleURL: async (url: string, name: string) => {
await nativeRef.current?.setSubtitleURL(url); await nativeRef.current?.setSubtitleURL(url, name);
}, },
})); }));

View File

@@ -59,7 +59,6 @@ export type Settings = {
forceLandscapeInVideoPlayer?: boolean; forceLandscapeInVideoPlayer?: boolean;
usePopularPlugin?: boolean; usePopularPlugin?: boolean;
deviceProfile?: "Expo" | "Native" | "Old"; deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
mediaListCollectionIds?: string[]; mediaListCollectionIds?: string[];
searchEngine: "Marlin" | "Jellyfin"; searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string; marlinServerUrl?: string;
@@ -90,7 +89,6 @@ const loadSettings = async (): Promise<Settings> => {
forceLandscapeInVideoPlayer: false, forceLandscapeInVideoPlayer: false,
usePopularPlugin: false, usePopularPlugin: false,
deviceProfile: "Expo", deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [], mediaListCollectionIds: [],
searchEngine: "Jellyfin", searchEngine: "Jellyfin",
marlinServerUrl: "", marlinServerUrl: "",

View File

@@ -19,7 +19,6 @@ export const getStreamUrl = async ({
deviceProfile = native, deviceProfile = native,
audioStreamIndex = 0, audioStreamIndex = 0,
subtitleStreamIndex = undefined, subtitleStreamIndex = undefined,
forceDirectPlay = false,
mediaSourceId, mediaSourceId,
}: { }: {
api: Api | null | undefined; api: Api | null | undefined;
@@ -31,7 +30,6 @@ export const getStreamUrl = async ({
deviceProfile: any; deviceProfile: any;
audioStreamIndex?: number; audioStreamIndex?: number;
subtitleStreamIndex?: number; subtitleStreamIndex?: number;
forceDirectPlay?: boolean;
height?: number; height?: number;
mediaSourceId?: string | null; mediaSourceId?: string | null;
}): Promise<{ }): Promise<{
@@ -114,17 +112,17 @@ export const getStreamUrl = async ({
); );
if (item.MediaType === "Video") { if (item.MediaType === "Video") {
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) { if (mediaSource?.TranscodingUrl) {
return { return {
url: `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`, url: `${api.basePath}${mediaSource.TranscodingUrl}`,
sessionId: sessionId, sessionId: sessionId,
mediaSource, mediaSource,
}; };
} }
if (mediaSource?.TranscodingUrl) { if (mediaSource?.SupportsDirectPlay) {
return { return {
url: `${api.basePath}${mediaSource.TranscodingUrl}`, url: `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`,
sessionId: sessionId, sessionId: sessionId,
mediaSource, mediaSource,
}; };

View File

@@ -39,13 +39,23 @@ export const runtimeTicksToSeconds = (
// t: ms // t: ms
export const formatTimeString = ( export const formatTimeString = (
t: number | null | undefined, t: number | null | undefined,
tick = false unit: "s" | "ms" | "tick" = "ms"
): string => { ): string => {
if (t === null || t === undefined) return "0:00"; if (t === null || t === undefined) return "0:00";
let seconds = t / 1000; let seconds: number;
if (tick) { switch (unit) {
seconds = Math.floor(t / 10000000); // Convert ticks to seconds case "s":
seconds = Math.floor(t);
break;
case "ms":
seconds = Math.floor(t / 1000);
break;
case "tick":
seconds = Math.floor(t / 10000000);
break;
default:
seconds = Math.floor(t / 1000); // Default to ms if an invalid type is provided
} }
if (seconds < 0) return "0:00"; if (seconds < 0) return "0:00";