Compare commits

...

5 Commits

Author SHA1 Message Date
Alex Kim
017bd4d074 Fixed file paths in the controls directory 2025-02-16 14:06:30 +11:00
herrrta
8b3141dfc6 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 15:16:25 -05:00
herrrta
d83ecb881b fix: Android PiP support fully working
- fixed black screen on re-entering
- ensured screen stays alive when video is playing
- PiP button states now reflect media status
2025-02-15 15:11:57 -05:00
Fredrik Burmester
4c14c08b35 fix: move from react-native-video -> VLC for transcoded streams (#529)
Co-authored-by: Alex Kim <alexkim5682@gmail.com>
2025-02-16 07:10:36 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
25 changed files with 641 additions and 1529 deletions

View File

@@ -36,15 +36,6 @@ export default function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack> </Stack>
</> </>
); );

View File

@@ -27,7 +27,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router"; import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, {
useCallback, useCallback,
@@ -36,17 +36,12 @@ import React, {
useState, useState,
useEffect, useEffect,
} from "react"; } from "react";
import { import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
Alert,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() { export default function page() {
console.log("Direct Player"); console.log("Direct Player");
@@ -54,6 +49,7 @@ export default function page() {
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
@@ -127,57 +123,80 @@ export default function page() {
staleTime: 0, staleTime: 0,
}); });
const { const [stream, setStream] = useState<{
data: stream, mediaSource: MediaSourceInfo;
isLoading: isLoadingStreamUrl, url: string;
isError: isErrorStreamUrl, sessionId: string | undefined;
} = useQuery({ } | null>(null);
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], const [isLoadingStream, setIsLoadingStream] = useState(true);
queryFn: async () => { const [isErrorStream, setIsErrorStream] = useState(false);
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const url = await getDownloadedFileUrl(data.item.Id!); useEffect(() => {
const fetchStream = async () => {
setIsLoadingStream(true);
setIsErrorStream(false);
if (item) try {
return { if (offline && !Platform.isTV) {
mediaSource: data.mediaSource, const data = await getDownloadedItem.getDownloadedItem(itemId);
url, if (!data?.mediaSource) {
sessionId: undefined, 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({ fetchStream();
api, }, [itemId, mediaSourceId]);
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,
});
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
if (!api) return; if (!api) return;
@@ -197,9 +216,7 @@ export default function page() {
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()), positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying, isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
} }
@@ -237,21 +254,6 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [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( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
@@ -293,8 +295,8 @@ export default function page() {
const onPipStarted = useCallback((e: PipStartedPayload) => { const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent; const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted) setIsPipStarted(pipStarted);
}, []) }, []);
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
@@ -325,91 +327,69 @@ export default function page() {
: 0; : 0;
}, [item]); }, [item]);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
// Handle app going to the background
if (nextAppState.match(/inactive|background/)) {
_setShowControls(false)
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState, isPipStarted, isPlaying]);
// Preselection of audio and subtitle tracks. // Preselection of audio and subtitle tracks.
if (!settings) return null; if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; 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 = const allAudio =
stream?.mediaSource.MediaStreams?.filter( stream?.mediaSource.MediaStreams?.filter(
(audio: { Type: string }) => audio.Type === "Audio" (audio) => audio.Type === "Audio"
) || []; ) || [];
const chosenAudioTrack = allAudio.find( const allSubs =
(audio: { Index: number | undefined }) => audio.Index === audioIndex 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 const notTranscoding = !stream?.mediaSource.TranscodingUrl;
if (!bitrateValue) { if (
// If Subtitle is embedded we can use the position to select it straight away. chosenSubtitleTrack &&
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) { (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`); ) {
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) { const finalIndex = notTranscoding
// If Subtitle is external we need to pass the URL to the player. ? allSubs.indexOf(chosenSubtitleTrack)
externalTrack = { : textSubs.indexOf(chosenSubtitleTrack);
name: chosenSubtitleTrack.DisplayTitle || "", initOptions.push(`--sub-track=${finalIndex}`);
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`, }
};
}
if (chosenAudioTrack) if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
} }
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || isLoadingStreamUrl || !stream) if (!item || isLoadingItem || !stream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader /> <Loader />
</View> </View>
); );
if (isErrorItem || isErrorStreamUrl) if (isErrorItem || isErrorStream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text> <Text className="text-white">{t("player.error")}</Text>
</View> </View>
); );
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
<View <View
@@ -427,11 +407,11 @@ export default function page() {
<VlcPlayerView <VlcPlayerView
ref={videoRef} ref={videoRef}
source={{ source={{
uri: stream.url, uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: true,
startPosition, startPosition,
externalTrack, externalSubtitles,
initOptions, initOptions,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
@@ -453,7 +433,7 @@ export default function page() {
}} }}
/> />
</View> </View>
{videoRef.current && ( {videoRef.current && !isPipStarted && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}

View File

@@ -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<VideoRef | null>(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<TrackInfo[]>([]);
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 (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
{videoSource ? (
<>
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
</>
) : (
<Text>{t("player.no_video_source")}</Text>
)}
</View>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
stop={stop}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) => {
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,
});
}}
/>
)}
</View>
);
};
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;

View File

@@ -757,7 +757,7 @@
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
@@ -1059,7 +1059,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.100", "", {}, "sha512-u1z9VuzDXV86X2r3vAns0/5ojfXBue9o0+JDUDBKYqGLjxLkSqsSUoPU/6kW0gx76V44frHaf6Zo+QF74TQCMg=="], "electron-to-chromium": ["electron-to-chromium@1.5.101", "", {}, "sha512-L0ISiQrP/56Acgu4/i/kfPwWSgrzYZUnQrC0+QPFuhqlLP1Ir7qzPPDVS9BcKIyWTRU8+o6CC8dKw38tSWhYIA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1089,6 +1089,8 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1207,8 +1209,6 @@
"fast-loops": ["fast-loops@1.1.4", "", {}, "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg=="], "fast-loops": ["fast-loops@1.1.4", "", {}, "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg=="],
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
"fast-xml-parser": ["fast-xml-parser@4.5.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w=="], "fast-xml-parser": ["fast-xml-parser@4.5.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w=="],
"fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="], "fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="],
@@ -1251,7 +1251,7 @@
"foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="], "foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="], "form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
"freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="], "freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="],
@@ -1911,7 +1911,7 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.3", "", { "dependencies": { "process": "^0.11.10", "readable-stream": "^4.7.0" } }, "sha512-In3boYjBnbGVrLuuRu/Ath/H6h1jgk30nAsk/71tCare1dTVoe1oMBGRn5LGf0n3c1BcHwwAqpraxX4AUAP5KA=="], "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -1977,7 +1977,7 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], "send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="],
"serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="],
@@ -2293,7 +2293,7 @@
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"@expo/cli/form-data": ["form-data@3.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ=="], "@expo/cli/form-data": ["form-data@3.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.35" } }, "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w=="],
"@expo/cli/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "@expo/cli/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
@@ -2455,8 +2455,6 @@
"expo-build-properties/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "expo-build-properties/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"expo-dev-launcher/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"expo-modules-autolinking/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "expo-modules-autolinking/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -2607,10 +2605,10 @@
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
"simple-plist/bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], "simple-plist/bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
"simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
@@ -2805,6 +2803,12 @@
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -2883,6 +2887,8 @@
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],

View File

@@ -16,7 +16,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { import {
@@ -118,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => { const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo); return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]); }, [loadingLogo, logoUrl]);
const [isTranscoding, setIsTranscoding] = useState(false);
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
useState<number | undefined>(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; if (!selectedOptions) return null;
return ( return (
@@ -239,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(

View File

@@ -73,11 +73,7 @@ export const PlayButton: React.FC<Props> = ({
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) { router.push(`/player/direct-player?${q}`);
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
}, },
[router] [router]
); );

View File

@@ -58,11 +58,7 @@ export const PlayButton: React.FC<Props> = ({
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) { router.push(`/player/direct-player?${q}`);
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
}, },
[router] [router]
); );

View File

@@ -4,40 +4,31 @@ import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
onChange: (value: number) => void; onChange: (value: number) => void;
selected?: number | undefined; selected?: number | undefined;
isTranscoding?: boolean;
} }
export const SubtitleTrackSelector: React.FC<Props> = ({ export const SubtitleTrackSelector: React.FC<Props> = ({
source, source,
onChange, onChange,
selected, selected,
isTranscoding,
...props ...props
}) => { }) => {
if (Platform.isTV) return null; if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo( const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected), () => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected] [subtitleStreams, selected]
); );
if (subtitleStreams.length === 0) return null; if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -24,7 +24,7 @@ import {
ticksToMs, ticksToMs,
ticksToSeconds, ticksToSeconds,
} from "@/utils/time"; } from "@/utils/time";
import {Ionicons, MaterialIcons} from "@expo/vector-icons"; import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -35,7 +35,12 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react"; 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 { Slider } from "react-native-awesome-slider";
import { import {
runOnJS, runOnJS,
@@ -49,13 +54,12 @@ import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext"; import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext"; import { VideoProvider } from "./contexts/VideoContext";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList"; import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay"; import { VideoTouchOverlay } from "./VideoTouchOverlay";
import DropdownView from "./dropdown/DropdownView";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
@@ -214,7 +218,7 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
stop() stop();
if (!bitrateValue) { if (!bitrateValue) {
// @ts-expect-error // @ts-expect-error
@@ -254,7 +258,7 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
stop() stop();
if (!bitrateValue) { if (!bitrateValue) {
// @ts-expect-error // @ts-expect-error
@@ -419,7 +423,7 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
stop() stop();
if (!bitrateValue) { if (!bitrateValue) {
// @ts-expect-error // @ts-expect-error
@@ -508,7 +512,7 @@ export const Controls: React.FC<Props> = ({
}, [trickPlayUrl, trickplayInfo, time]); }, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => { const onClose = async () => {
stop() stop();
lightHapticFeedback(); lightHapticFeedback();
await ScreenOrientation.lockAsync( await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP ScreenOrientation.OrientationLock.PORTRAIT_UP
@@ -559,19 +563,13 @@ export const Controls: React.FC<Props> = ({
setSubtitleTrack={setSubtitleTrack} setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL} setSubtitleURL={setSubtitleURL}
> >
{!mediaSource?.TranscodingUrl ? ( <DropdownView showControls={showControls} />
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider> </VideoProvider>
</View> </View>
<View className="flex flex-row items-center space-x-2 "> <View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && ( {!Platform.isTV && (
<TouchableOpacity <TouchableOpacity onPress={startPictureInPicture}>
onPress={startPictureInPicture}
>
<MaterialIcons <MaterialIcons
name="picture-in-picture" name="picture-in-picture"
size={24} size={24}

View File

@@ -9,12 +9,15 @@ import React, {
useState, useState,
ReactNode, ReactNode,
useEffect, useEffect,
useMemo,
} from "react"; } from "react";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router";
interface VideoContextProps { interface VideoContextProps {
audioTracks: TrackInfo[] | null; audioTracks: Track[] | null;
subtitleTracks: TrackInfo[] | null; subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -45,30 +48,155 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleURL, setSubtitleURL,
setAudioTrack, setAudioTrack,
}) => { }) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null); const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>( const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
null
);
const ControlContext = useControlContext(); const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded; 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(() => { useEffect(() => {
const fetchTracks = async () => { const fetchTracks = async () => {
if ( if (getSubtitleTracks) {
getSubtitleTracks && const subtitleData = await getSubtitleTracks();
(subtitleTracks === null || subtitleTracks.length === 0)
) { let textSubIndex = 0;
const subtitles = await getSubtitleTracks(); const subtitles: Track[] = allSubs?.map((sub) => {
console.log("Getting embeded subtitles...", subtitles); // 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); setSubtitleTracks(subtitles);
} }
if ( if (
getAudioTracks && getAudioTracks &&
(audioTracks === null || audioTracks.length === 0) (audioTracks === null || audioTracks.length === 0)
) { ) {
const audio = await getAudioTracks(); const audioData = await getAudioTracks();
setAudioTracks(audio); 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(); fetchTracks();

View File

@@ -1,67 +1,21 @@
import React, { useMemo, useState } from "react"; import React from "react";
import { View, TouchableOpacity, Platform } from "react-native"; import { TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
import { EmbeddedSubtitle, ExternalSubtitle } from "../types"; import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { router, useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps { interface DropdownViewProps {
showControls: boolean; showControls: boolean;
offline?: boolean; // used to disable external subs for downloads offline?: boolean; // used to disable external subs for downloads
} }
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({ const DropdownView: React.FC<DropdownViewProps> = ({
showControls, showControls,
offline = false, 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 videoContext = useVideoContext();
const { const { subtitleTracks, audioTracks } = videoContext;
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 { subtitleIndex, audioIndex } = useLocalSearchParams<{ const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string; itemId: string;
@@ -98,21 +52,11 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
loop={true} loop={true}
sideOffset={10} sideOffset={10}
> >
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => ( {subtitleTracks?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`} key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()} value={subtitleIndex === sub.index.toString()}
onValueChange={() => { onValueChange={() => sub.setTrack()}
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(),
});
}}
> >
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}> <DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name} {sub.name}
@@ -136,12 +80,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key={`audio-item-${idx}`} key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()} value={audioIndex === track.index.toString()}
onValueChange={() => { onValueChange={() => track.setTrack()}
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
> >
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}> <DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name} {track.name}
@@ -155,4 +94,4 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
); );
}; };
export default DropdownViewDirect; export default DropdownView;

View File

@@ -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<DropdownViewProps> = ({ 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 (
<View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</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}
>
{allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.CheckboxItem
value={
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
}
key={`subtitle-item-${idx}`}
onValueChange={() => {
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);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
)}
</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}
>
{allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
if (audioIndex === track.index.toString()) return;
router.setParams({
audioIndex: track.index.toString(),
});
ChangeTranscodingAudio(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownView;

View File

@@ -13,7 +13,14 @@ type ExternalSubtitle = {
type TranscodedSubtitle = { type TranscodedSubtitle = {
name: string; name: string;
index: number; index: number;
deliveryUrl: string;
IsTextSubtitleStream: boolean; IsTextSubtitleStream: boolean;
}; };
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle }; type Track = {
name: string;
index: number;
setTrack: () => void;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };

View File

@@ -0,0 +1,38 @@
package expo.modules.vlcplayer
import expo.modules.core.interfaces.ReactActivityLifecycleListener
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
// https://docs.expo.dev/modules/android-lifecycle-listeners/
object VLCManager: ReactActivityLifecycleListener {
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
// listeners.forEach {
// it.onCreate(activity, savedInstanceState)
// }
// }
//
// override fun onResume(activity: Activity?) {
// listeners.forEach {
// it.onResume(activity)
// }
// }
//
// override fun onPause(activity: Activity?) {
// listeners.forEach {
// it.onPause(activity)
// }
// }
//
// override fun onUserLeaveHint(activity: Activity?) {
// listeners.forEach {
// it.onUserLeaveHint(activity)
// }
// }
//
// override fun onDestroy(activity: Activity?) {
// listeners.forEach {
// it.onDestroy(activity)
// }
// }
}

View File

@@ -1,5 +1,6 @@
package expo.modules.vlcplayer package expo.modules.vlcplayer
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
@@ -7,6 +8,18 @@ class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() = ModuleDefinition {
Name("VlcPlayer") Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) { View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> -> Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source) view.setSource(source)

View File

@@ -1,6 +1,7 @@
package expo.modules.vlcplayer package expo.modules.vlcplayer
import android.R import android.R
import android.app.Activity
import android.app.PendingIntent import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.FLAG_UPDATE_CURRENT
@@ -14,13 +15,20 @@ import android.content.IntentFilter
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.View
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.ComponentActivity import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.content.ContextCompat import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import expo.modules.core.interfaces.ReactActivityLifecycleListener
import expo.modules.core.logging.LogHandlers
import expo.modules.core.logging.Logger
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
@@ -31,7 +39,8 @@ import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener { class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION" private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION" private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION" private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
@@ -43,6 +52,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var lastReportedState: Int? = null private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null private var media : Media? = null
private var timeLeft: Long? = null
private val onVideoProgress by EventDispatcher() private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher() private val onVideoStateChange by EventDispatcher()
@@ -64,53 +74,87 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
private val currentActivity get() = context.findActivity() private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf() private val actions: MutableList<RemoteAction> = mutableListOf()
private val remoteActionFilter = IntentFilter()
private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() { private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) { when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> if (isPaused) play() else pause() PIP_PLAY_PAUSE_ACTION -> {
if (isPaused) play() else pause()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.setPictureInPictureParams(getPipParams()!!)
}
}
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000) PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000) PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
} }
} }
} }
init { private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
setupView() if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { log.debug("Exiting PiP")
setupPipActions() timeLeft = mediaPlayer?.time
currentActivity.apply { pause()
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener { info -> // Setting the media after reattaching the view allows for a fast video view render
onPipStarted(mapOf( if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
"pipStarted" to info.isInPictureInPictureMode mediaPlayer?.attachViews(videoLayout, null, false, false)
)) mediaPlayer?.media = media
} mediaPlayer?.play()
timeLeft?.let { mediaPlayer?.time = it }
mediaPlayer?.pause()
} }
} }
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
init {
VLCManager.listeners.add(this)
setupView()
setupPiP()
} }
private fun setupView() { private fun setupView() {
Log.d("VlcPlayerView", "Setting up view") log.debug("Setting up view")
setBackgroundColor(android.graphics.Color.WHITE) setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply { videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
} }
videoLayout.keepScreenOn = true
addView(videoLayout) addView(videoLayout)
Log.d("VlcPlayerView", "View setup complete") log.debug("View setup complete")
}
private fun setupPiP() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentActivity.registerReceiver(
actionReceiver,
remoteActionFilter,
Context.RECEIVER_NOT_EXPORTED
)
}
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener(pipChangeListener)
}
}
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() { private fun setupPipActions() {
val remoteActionFilter = IntentFilter() actions.clear()
val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
actions.addAll( actions.addAll(
listOf( listOf(
RemoteAction( RemoteAction(
@@ -125,12 +169,13 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
) )
), ),
RemoteAction( RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_play), if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
else Icon.createWithResource(context, R.drawable.ic_media_pause),
"Play", "Play",
"Play Video", "Play Video",
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, context,
0, if (isPaused) 0 else 1,
playPauseIntent, playPauseIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
) )
@@ -148,13 +193,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
) )
) )
) )
ContextCompat.registerReceiver(
context,
actionReceiver,
remoteActionFilter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
} }
private fun getPipParams(): PictureInPictureParams? { private fun getPipParams(): PictureInPictureParams? {
@@ -171,7 +209,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
fun setSource(source: Map<String, Any>) { fun setSource(source: Map<String, Any>) {
log.debug("setting source $source")
if (hasSource) { if (hasSource) {
log.debug("Source already set. Resuming")
mediaPlayer?.attachViews(videoLayout, null, false, false) mediaPlayer?.attachViews(videoLayout, null, false, false)
play() play()
return return
@@ -196,12 +236,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.attachViews(videoLayout, null, false, false) mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this) mediaPlayer?.setEventListener(this)
Log.d("VlcPlayerView", "Loading network file: $uri") log.debug("Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri)) media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media mediaPlayer?.media = media
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions") log.debug("Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions) // media.addOptions(mediaOptions)
// Apply subtitle options // Apply subtitle options
@@ -218,7 +258,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
hasSource = true hasSource = true
if (autoplay) { if (autoplay) {
Log.d("VlcPlayerView", "Playing...") log.debug("Playing...")
play() play()
} }
} }
@@ -268,9 +308,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
fun getAudioTracks(): List<Map<String, Any>>? { fun getAudioTracks(): List<Map<String, Any>>? {
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
val trackDescriptions = mediaPlayer?.audioTracks ?: return null val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription -> return trackDescriptions.map { trackDescription ->
@@ -294,19 +332,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
// Debug statement to print the result // Debug statement to print the result
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks") log.debug("Subtitle Tracks: $subtitleTracks")
return subtitleTracks return subtitleTracks
} }
fun setSubtitleURL(subtitleURL: String, name: String) { fun setSubtitleURL(subtitleURL: String, name: String) {
println("Setting subtitle URL: $subtitleURL, name: $name") log.debug("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true) mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
println("onDetachedFromWindow") log.debug("onDetachedFromWindow")
super.onDetachedFromWindow() super.onDetachedFromWindow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
currentActivity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
}
currentActivity.unregisterReceiver(actionReceiver)
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
VLCManager.listeners.clear()
mediaPlayer?.stop() mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
@@ -319,6 +370,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
override fun onEvent(event: MediaPlayer.Event) { override fun onEvent(event: MediaPlayer.Event) {
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
when (event.type) { when (event.type) {
MediaPlayer.Event.Playing, MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused, MediaPlayer.Event.Paused,
@@ -340,35 +392,27 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"target" to "null", // Replace with actual target if needed "target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(), "currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0), "duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false "error" to false,
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
) )
// Todo: make enum - string to prevent this when statement from becoming exhaustive
when (currentState) { when (currentState) {
MediaPlayer.Event.Playing -> { MediaPlayer.Event.Playing ->
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing" stateInfo["state"] = "Playing"
} MediaPlayer.Event.Paused ->
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused" stateInfo["state"] = "Paused"
} MediaPlayer.Event.Buffering ->
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering" stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> { MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error" stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo); onVideoLoadEnd(stateInfo);
} }
MediaPlayer.Event.Opening -> { MediaPlayer.Event.Opening ->
Log.d("VlcPlayerView", "player.state ~ opening")
stateInfo["state"] = "Opening" stateInfo["state"] = "Opening"
}
} }
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) { if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying lastReportedIsPlaying = player.isPlaying
@@ -400,6 +444,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
)); ));
} }
} }
override fun onPause(activity: Activity?) {
log.debug("Pausing activity...")
}
override fun onResume(activity: Activity?) {
log.debug("Resuming activity...")
if (isPaused) play()
}
} }
internal fun Context.findActivity(): androidx.activity.ComponentActivity { internal fun Context.findActivity(): androidx.activity.ComponentActivity {

View File

@@ -1,7 +1,8 @@
{ {
"platforms": ["ios", "tvos", "android", "web"], "platforms": ["ios", "tvos", "android", "web"],
"ios": { "ios": {
"modules": ["VlcPlayerModule"] "modules": ["VlcPlayerModule"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
}, },
"android": { "android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"] "modules": ["expo.modules.vlcplayer.VlcPlayerModule"]

View File

@@ -0,0 +1,32 @@
import ExpoModulesCore
protocol SimpleAppLifecycleListener {
func applicationDidEnterBackground() -> Void
func applicationDidEnterForeground() -> Void
}
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
public func applicationDidBecomeActive(_ application: UIApplication) {
// The app has become active.
}
public func applicationWillResignActive(_ application: UIApplication) {
// The app is about to become inactive.
}
public func applicationDidEnterBackground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterBackground()
}
}
public func applicationWillEnterForeground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterForeground()
}
}
public func applicationWillTerminate(_ application: UIApplication) {
// The app is about to terminate.
}
}

View File

@@ -0,0 +1,4 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -1,34 +1,35 @@
import ExpoModulesCore import ExpoModulesCore
import VLCKit
import UIKit import UIKit
import VLCKit
import os
public class VLCPlayerView: UIView { public class VLCPlayerView: UIView {
func setupView(parent: UIView) { func setupView(parent: UIView) {
self.backgroundColor = .black self.backgroundColor = .black
self.translatesAutoresizingMaskIntoConstraints = false self.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor), self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor), self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
self.topAnchor.constraint(equalTo: parent.topAnchor), self.topAnchor.constraint(equalTo: parent.topAnchor),
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor), self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
]) ])
} }
public override func layoutSubviews() { public override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
for subview in subviews { for subview in subviews {
subview.frame = bounds subview.frame = bounds
} }
} }
} }
class VLCPlayerWrapper: NSObject { class VLCPlayerWrapper: NSObject {
private var lastProgressCall = Date().timeIntervalSince1970 private var lastProgressCall = Date().timeIntervalSince1970
public var player: VLCMediaPlayer = VLCMediaPlayer() public var player: VLCMediaPlayer = VLCMediaPlayer()
private var updatePlayerState: (() -> ())? private var updatePlayerState: (() -> Void)?
private var updateVideoProgress: (() -> ())? private var updateVideoProgress: (() -> Void)?
private var playerView: VLCPlayerView = VLCPlayerView() private var playerView: VLCPlayerView = VLCPlayerView()
public weak var pipController: VLCPictureInPictureWindowControlling? public weak var pipController: VLCPictureInPictureWindowControlling?
@@ -41,8 +42,8 @@ class VLCPlayerWrapper: NSObject {
public func setup( public func setup(
parent: UIView, parent: UIView,
updatePlayerState: (() -> ())?, updatePlayerState: (() -> Void)?,
updateVideoProgress: (() -> ())? updateVideoProgress: (() -> Void)?
) { ) {
self.updatePlayerState = updatePlayerState self.updatePlayerState = updatePlayerState
self.updateVideoProgress = updateVideoProgress self.updateVideoProgress = updateVideoProgress
@@ -52,9 +53,9 @@ class VLCPlayerWrapper: NSObject {
playerView.setupView(parent: parent) playerView.setupView(parent: parent)
} }
public func getPlayerView() -> UIView { public func getPlayerView() -> UIView {
return playerView return playerView
} }
} }
// MARK: - VLCPictureInPictureDrawable // MARK: - VLCPictureInPictureDrawable
@@ -63,7 +64,8 @@ extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
return self return self
} }
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! { public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
{
return { [weak self] controller in return { [weak self] controller in
self?.pipController = controller self?.pipController = controller
} }
@@ -88,7 +90,7 @@ extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
player.pause() player.pause()
} }
func seek(by offset: Int64, completion: @escaping () -> ()) { func seek(by offset: Int64, completion: @escaping () -> Void) {
player.jump(withOffset: Int32(offset), completion: completion) player.jump(withOffset: Int32(offset), completion: completion)
} }
@@ -115,20 +117,24 @@ extension VLCPlayerWrapper: VLCDrawable {
// MARK: - VLCMediaPlayerDelegate // MARK: - VLCMediaPlayerDelegate
extension VLCPlayerWrapper: VLCMediaPlayerDelegate { extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) { func mediaPlayerTimeChanged(_ aNotification: Notification) {
let timeNow = Date().timeIntervalSince1970 DispatchQueue.main.async { [weak self] in
if timeNow - lastProgressCall >= 1 { guard let self = self else { return }
lastProgressCall = timeNow let timeNow = Date().timeIntervalSince1970
updateVideoProgress?() if timeNow - self.lastProgressCall >= 1 {
self.lastProgressCall = timeNow
self.updateVideoProgress?()
}
} }
} }
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) { 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 } guard let pipController = self.pipController else { return }
DispatchQueue.main.async(execute: {
pipController.invalidatePlaybackState() pipController.invalidatePlaybackState()
}) }
} }
} }
@@ -137,16 +143,17 @@ extension VLCPlayerWrapper: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed // Implement VLCMediaDelegate methods if needed
} }
class VlcPlayerView: ExpoView { class VlcPlayerView: ExpoView {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper() private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false private var isPaused: Bool = false
private var customSubtitles: [(internalName: String, originalName: String)] = [] private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0 private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]? private var externalTrack: [String: String]?
private var isStopping: Bool = false // Define isStopping here private var isStopping: Bool = false // Define isStopping here
private var externalSubtitles: [[String: String]]?
var hasSource = false var hasSource = false
// MARK: - Initialization // MARK: - Initialization
@@ -154,6 +161,7 @@ class VlcPlayerView: ExpoView {
super.init(appContext: appContext) super.init(appContext: appContext)
setupVLC() setupVLC()
setupNotifications() setupNotifications()
VLCManager.shared.listeners.append(self)
} }
// MARK: - Setup // MARK: - Setup
@@ -185,7 +193,7 @@ class VlcPlayerView: ExpoView {
@objc func play() { @objc func play() {
self.vlc.player.play() self.vlc.player.play()
self.isPaused = false self.isPaused = false
print("Play") logger.debug("Play")
} }
@objc func pause() { @objc func pause() {
@@ -200,7 +208,7 @@ class VlcPlayerView: ExpoView {
} }
if let duration = vlc.player.media?.length.intValue { if let duration = vlc.player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)") logger.debug("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end // If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time let seekTime = time > duration ? duration - 1000 : time
@@ -214,11 +222,12 @@ class VlcPlayerView: ExpoView {
} }
} }
} else { } else {
print("Error: Unable to retrieve video duration") logger.error("Unable to retrieve video duration")
} }
} }
@objc func setSource(_ source: [String: Any]) { @objc func setSource(_ source: [String: Any]) {
logger.debug("Setting source...")
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.hasSource { if self.hasSource {
@@ -229,14 +238,16 @@ class VlcPlayerView: ExpoView {
self.externalTrack = source["externalTrack"] as? [String: String] self.externalTrack = source["externalTrack"] as? [String: String]
let initOptions: [String] = source["initOptions"] as? [String] ?? [] let initOptions: [String] = source["initOptions"] as? [String] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0 self.startPosition = source["startPosition"] as? Int32 ?? 0
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
for item in initOptions { for item in initOptions {
let option = item.components(separatedBy: "=") 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 { guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI") logger.error("Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"]) self.onVideoError?(["error": "Invalid or empty URI"])
return return
} }
@@ -248,10 +259,10 @@ class VlcPlayerView: ExpoView {
let media: VLCMedia! let media: VLCMedia!
if isNetwork { if isNetwork {
print("Loading network file: \(uri)") logger.debug("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!) media = VLCMedia(url: URL(string: uri)!)
} else { } else {
print("Loading local file: \(uri)") logger.debug("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) { if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url) media = VLCMedia(url: url)
} else { } else {
@@ -259,14 +270,14 @@ class VlcPlayerView: ExpoView {
} }
} }
print("Debug: Media options: \(mediaOptions)") logger.debug("Media options: \(mediaOptions)")
media.addOptions(mediaOptions) media.addOptions(mediaOptions)
self.vlc.player.media = media self.vlc.player.media = media
self.setInitialExternalSubtitles()
self.hasSource = true self.hasSource = true
if autoplay { if autoplay {
print("Playing...") logger.info("Playing...")
self.play() self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
} }
@@ -274,36 +285,43 @@ class VlcPlayerView: ExpoView {
} }
@objc func setAudioTrack(_ trackIndex: Int) { @objc func setAudioTrack(_ trackIndex: Int) {
print("Setting audio track: \(trackIndex)")
let track = self.vlc.player.audioTracks[trackIndex] let track = self.vlc.player.audioTracks[trackIndex]
track.isSelectedExclusively = true; track.isSelectedExclusively = true
} }
@objc func getAudioTracks() -> [[String: Any]]? { @objc func getAudioTracks() -> [[String: Any]]? {
return vlc.player.audioTracks.enumerated().map { return vlc.player.audioTracks.enumerated().map {
return ["name": $1.trackName, "index": $0 ] return ["name": $1.trackName, "index": $0]
} }
} }
@objc func setSubtitleTrack(_ trackIndex: Int) { @objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)") logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
if trackIndex == -1 {
logger.debug("Disabling all subtitles")
for track in self.vlc.player.textTracks {
track.isSelected = false
}
return
}
let track = self.vlc.player.textTracks[trackIndex] let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true; track.isSelectedExclusively = true;
print("Debug: Current subtitle track index after setting: \(track.trackName)") logger.debug("Current subtitle track index after setting: \(track.trackName)")
} }
@objc func setSubtitleURL(_ subtitleURL: String, name: String) { @objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL") logger.error("Invalid subtitle URL")
return return
} }
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: true) let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
if result == 0 {
if result > 0 { let internalName = "Track \(self.customSubtitles.count)"
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name)) self.customSubtitles.append((internalName: internalName, originalName: name))
logger.debug("Subtitle added with result: \(result) \(internalName)")
} else { } else {
print("Failed to add subtitle") logger.debug("Failed to add subtitle")
} }
} }
@@ -312,34 +330,24 @@ class VlcPlayerView: ExpoView {
return nil return nil
} }
print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)") logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
if let customSubtitle = customSubtitles.first(where: { $0.internalName == track.trackName }) { if let customSubtitle = customSubtitles.first(where: {
return ["name": customSubtitle.originalName, "index": index ] $0.internalName == track.trackName
} }) {
else { return ["name": customSubtitle.originalName, "index": index]
return ["name": track.trackName, "index": index ] } else {
return ["name": track.trackName, "index": index]
} }
} }
print("Debug: Subtitle tracks: \(tracks)") logger.debug("Subtitle tracks: \(tracks)")
return 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)")
}
@objc func stop(completion: (() -> Void)? = nil) { @objc func stop(completion: (() -> Void)? = nil) {
logger.debug("Stopping media...")
guard !isStopping else { guard !isStopping else {
completion?() completion?()
return return
@@ -366,6 +374,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) { private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player // Stop the media player
vlc.player.stop() vlc.player.stop()
@@ -386,19 +407,7 @@ class VlcPlayerView: ExpoView {
let currentTimeMs = self.vlc.player.time.intValue let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0 let durationMs = self.vlc.player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)") logger.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?([ self.onVideoProgress?([
"currentTime": currentTimeMs, "currentTime": currentTimeMs,
"duration": durationMs, "duration": durationMs,
@@ -414,7 +423,7 @@ class VlcPlayerView: ExpoView {
"error": false, "error": false,
"isPlaying": player.isPlaying, "isPlaying": player.isPlaying,
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering, "isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
"state": player.state.description "state": player.state.description,
]) ])
} }
@@ -430,7 +439,24 @@ class VlcPlayerView: ExpoView {
// MARK: - Deinitialization // MARK: - Deinitialization
deinit { deinit {
logger.debug("Deinitialization")
performStop() performStop()
VLCManager.shared.listeners.removeAll()
}
}
// MARK: - SimpleAppLifecycleListener
extension VlcPlayerView: SimpleAppLifecycleListener {
func applicationDidEnterBackground() {
logger.debug("Entering background")
}
func applicationDidEnterForeground() {
logger.debug("Entering foreground")
if !self.vlc.getPlayerView().isDescendant(of: self) {
logger.debug("Player view is missing. Adding back as subview")
self.addSubview(self.vlc.getPlayerView())
}
} }
} }

View File

@@ -39,7 +39,7 @@ export type VlcPlayerSource = {
type?: string; type?: string;
isNetwork?: boolean; isNetwork?: boolean;
autoplay?: boolean; autoplay?: boolean;
externalTrack?: { name: string, DeliveryUrl: string }; externalSubtitles: { name: string; DeliveryUrl: string }[];
initOptions?: any[]; initOptions?: any[];
mediaOptions?: { [key: string]: any }; mediaOptions?: { [key: string]: any };
startPosition?: number; startPosition?: number;

View File

@@ -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<string>();
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;
};
}

View File

@@ -111,15 +111,6 @@ export const getStreamUrl = async ({
if (mediaSource?.TranscodingUrl) { if (mediaSource?.TranscodingUrl) {
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object 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 // Get the updated URL
const transcodeUrl = urlObj.toString(); const transcodeUrl = urlObj.toString();

View File

@@ -42,11 +42,9 @@ export default {
Type: MediaTypes.Video, Type: MediaTypes.Video,
Context: "Streaming", Context: "Streaming",
Protocol: "hls", Protocol: "hls",
Container: "ts", Container: "fmp4",
VideoCodec: "h264, hevc", VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3", AudioCodec: "aac,mp3,ac3,dts",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
}, },
{ {
Type: MediaTypes.Audio, Type: MediaTypes.Audio,
@@ -58,131 +56,81 @@ export default {
}, },
], ],
SubtitleProfiles: [ SubtitleProfiles: [
// Official foramts // Official formats
{ Format: "vtt", Method: "Embed" }, { Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "Hls" },
{ Format: "vtt", Method: "External" }, { Format: "vtt", Method: "External" },
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Embed" }, { Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "Hls" },
{ Format: "webvtt", Method: "External" }, { Format: "webvtt", Method: "External" },
{ Format: "webvtt", Method: "Encode" },
{ Format: "srt", Method: "Embed" }, { Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "Hls" },
{ Format: "srt", Method: "External" }, { Format: "srt", Method: "External" },
{ Format: "srt", Method: "Encode" },
{ Format: "subrip", Method: "Embed" }, { Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "Hls" },
{ Format: "subrip", Method: "External" }, { Format: "subrip", Method: "External" },
{ Format: "subrip", Method: "Encode" },
{ Format: "ttml", Method: "Embed" }, { Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "Hls" },
{ Format: "ttml", Method: "External" }, { Format: "ttml", Method: "External" },
{ Format: "ttml", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" }, { Format: "dvbsub", Method: "Embed" },
{ Format: "dvbsub", Method: "Hls" },
{ Format: "dvbsub", Method: "External" },
{ Format: "dvdsub", Method: "Encode" }, { Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" }, { Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "Hls" },
{ Format: "ass", Method: "External" }, { Format: "ass", Method: "External" },
{ Format: "ass", Method: "Encode" },
{ Format: "idx", Method: "Embed" }, { Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Hls" },
{ Format: "idx", Method: "External" },
{ Format: "idx", Method: "Encode" }, { Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" }, { Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Hls" },
{ Format: "pgs", Method: "External" },
{ Format: "pgs", Method: "Encode" }, { Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" }, { Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Hls" },
{ Format: "pgssub", Method: "External" },
{ Format: "pgssub", Method: "Encode" }, { Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" }, { Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "Hls" },
{ Format: "ssa", Method: "External" }, { Format: "ssa", Method: "External" },
{ Format: "ssa", Method: "Encode" },
// Other formats // Other formats
{ Format: "microdvd", Method: "Embed" }, { Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "Hls" },
{ Format: "microdvd", Method: "External" }, { Format: "microdvd", Method: "External" },
{ Format: "microdvd", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" }, { Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "Hls" },
{ Format: "mov_text", Method: "External" }, { Format: "mov_text", Method: "External" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" }, { Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "Hls" },
{ Format: "mpl2", Method: "External" }, { Format: "mpl2", Method: "External" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" }, { Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "Hls" },
{ Format: "pjs", Method: "External" }, { Format: "pjs", Method: "External" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" }, { Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "Hls" },
{ Format: "realtext", Method: "External" }, { Format: "realtext", Method: "External" },
{ Format: "realtext", Method: "Encode" },
{ Format: "scc", Method: "Embed" }, { Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "Hls" },
{ Format: "scc", Method: "External" }, { Format: "scc", Method: "External" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" }, { Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "Hls" },
{ Format: "smi", Method: "External" }, { Format: "smi", Method: "External" },
{ Format: "smi", Method: "Encode" },
{ Format: "stl", Method: "Embed" }, { Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "Hls" },
{ Format: "stl", Method: "External" }, { Format: "stl", Method: "External" },
{ Format: "stl", Method: "Encode" },
{ Format: "sub", Method: "Embed" }, { Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "Hls" },
{ Format: "sub", Method: "External" }, { Format: "sub", Method: "External" },
{ Format: "sub", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" }, { Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "Hls" },
{ Format: "subviewer", Method: "External" }, { Format: "subviewer", Method: "External" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "teletext", Method: "Embed" }, { Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Hls" },
{ Format: "teletext", Method: "External" },
{ Format: "teletext", Method: "Encode" }, { Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" }, { Format: "text", Method: "Embed" },
{ Format: "text", Method: "Hls" },
{ Format: "text", Method: "External" }, { Format: "text", Method: "External" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" }, { Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "Hls" },
{ Format: "vplayer", Method: "External" }, { Format: "vplayer", Method: "External" },
{ Format: "vplayer", Method: "Encode" },
{ Format: "xsub", Method: "Embed" }, { Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "Hls" },
{ Format: "xsub", Method: "External" }, { Format: "xsub", Method: "External" },
{ Format: "xsub", Method: "Encode" },
], ],
}; };

View File

@@ -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" },
],
};