mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Merge branch 'pr/226' into feat/downloads-with-vlc
This commit is contained in:
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.20.1",
|
||||
"version": "0.21.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
@@ -20,7 +20,10 @@ import { writeToLog } from "@/utils/log";
|
||||
import native from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceType,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
@@ -32,6 +35,7 @@ import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Pressable, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import transcoding from "@/utils/profiles/transcoding";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
@@ -137,7 +141,7 @@ export default function page() {
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: native,
|
||||
deviceProfile: !bitrateValue ? native : transcoding,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
@@ -339,13 +343,50 @@ export default function page() {
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!stream || !item)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">No stream or item</Text>
|
||||
<Text className="text-white">Offline: {offline}</Text>
|
||||
</View>
|
||||
);
|
||||
if (!stream || !item) return null;
|
||||
|
||||
// Preselection of audio and subtitle tracks.
|
||||
|
||||
let initOptions = ["--sub-text-scale=60"];
|
||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||
|
||||
const allSubs =
|
||||
stream.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle") ||
|
||||
[];
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex
|
||||
);
|
||||
const allAudio =
|
||||
stream.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio"
|
||||
) || [];
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
// Direct playback CASE
|
||||
if (!bitrateValue) {
|
||||
// If Subtitle is embedded we can use the position to select it straight away.
|
||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
||||
// If Subtitle is external we need to pass the URL to the player.
|
||||
externalTrack = {
|
||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!chosenAudioTrack) throw new Error("No audio track found");
|
||||
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
} else {
|
||||
// Transcoded playback CASE
|
||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||
externalTrack = {
|
||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
||||
DeliveryUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -369,11 +410,8 @@ export default function page() {
|
||||
autoplay: true,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
initOptions: [
|
||||
"--sub-text-scale=60",
|
||||
`--sub-track=${subtitleIndex - 2}`, // This refers to the subtitle position index in the subtitles list.
|
||||
// `--audio-track=${audioIndex - 1}`, // This refers to the audio position index in the audio list.
|
||||
],
|
||||
externalTrack,
|
||||
initOptions,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onVideoProgress={onProgress}
|
||||
|
||||
@@ -63,6 +63,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
||||
setSelectedOptions(() => ({
|
||||
|
||||
@@ -1,751 +0,0 @@
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import {
|
||||
TrackInfo,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { VideoRef } from "react-native-video";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: React.MutableRefObject<VlcPlayerViewRef | VideoRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
isBuffering: boolean;
|
||||
showControls: boolean;
|
||||
ignoreSafeAreas?: boolean;
|
||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
offline?: boolean;
|
||||
isVideoLoaded?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
play: (() => Promise<void>) | (() => void);
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
stop?: (() => Promise<void>) | (() => void);
|
||||
isVlc?: boolean;
|
||||
}
|
||||
|
||||
export const Controls: React.FC<Props> = ({
|
||||
item,
|
||||
videoRef,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
progress,
|
||||
isBuffering,
|
||||
cacheProgress,
|
||||
showControls,
|
||||
setShowControls,
|
||||
ignoreSafeAreas,
|
||||
setIgnoreSafeAreas,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
stop,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
!offline && enableTrickplay
|
||||
);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||
|
||||
const wasPlayingRef = useRef(false);
|
||||
const lastProgressRef = useRef<number>(0);
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(previousItem, settings);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrate.toString(),
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/player?${queryParams}`);
|
||||
}, [previousItem, settings]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(nextItem, settings);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrate.toString(),
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/player?${queryParams}`);
|
||||
}, [nextItem, settings]);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||
const remaining = isVlc
|
||||
? maxValue - currentProgress
|
||||
: ticksToSeconds(maxValue - currentProgress);
|
||||
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
|
||||
// Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround.
|
||||
if (currentProgress === maxValue) {
|
||||
setShowControls(true);
|
||||
// Automatically play the next item if it exists
|
||||
goToNextItem();
|
||||
}
|
||||
},
|
||||
[goToNextItem, isVlc]
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
progress: progress.value,
|
||||
max: max.value,
|
||||
isSeeking: isSeeking.value,
|
||||
}),
|
||||
(result) => {
|
||||
// console.log("Progress changed", result);
|
||||
if (result.isSeeking === false) {
|
||||
runOnJS(updateTimes)(result.progress, result.max);
|
||||
}
|
||||
},
|
||||
[updateTimes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
progress.value = isVlc
|
||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = isVlc
|
||||
? ticksToMs(item.RunTimeTicks || 0)
|
||||
: item.RunTimeTicks || 0;
|
||||
}
|
||||
}, [item, isVlc]);
|
||||
|
||||
const toggleControls = () => setShowControls(!showControls);
|
||||
|
||||
const handleSliderComplete = useCallback(
|
||||
async (value: number) => {
|
||||
isSeeking.value = false;
|
||||
progress.value = value;
|
||||
|
||||
await seek(
|
||||
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
|
||||
);
|
||||
if (wasPlayingRef.current === true) play();
|
||||
},
|
||||
[isVlc]
|
||||
);
|
||||
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
|
||||
const handleSliderChange = (value: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
};
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (showControls === false) return;
|
||||
|
||||
wasPlayingRef.current = isPlaying;
|
||||
lastProgressRef.current = progress.value;
|
||||
|
||||
pause();
|
||||
isSeeking.value = true;
|
||||
}, [showControls, isPlaying]);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
||||
await seek(newTime);
|
||||
if (wasPlayingRef.current === true) play();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings?.forwardSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = progress.value;
|
||||
console.log(curr);
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? curr + secondsToMs(settings.forwardSkipTime)
|
||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||
await seek(Math.max(0, newTime));
|
||||
if (wasPlayingRef.current === true) play();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc]);
|
||||
|
||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||
setIgnoreSafeAreas((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const [selectedSubtitleTrack, setSelectedSubtitleTrack] = useState<
|
||||
MediaStream | undefined
|
||||
>(undefined);
|
||||
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
const subtitles = await getSubtitleTracks();
|
||||
console.log("Getting embeded subtitles...", subtitles);
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
if (getAudioTracks) {
|
||||
const audio = await getAudioTracks();
|
||||
setAudioTracks(audio);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
||||
|
||||
type EmbeddedSubtitle = {
|
||||
name: string;
|
||||
index: number;
|
||||
isExternal: boolean;
|
||||
};
|
||||
|
||||
type ExternalSubtitle = {
|
||||
name: string;
|
||||
index: number;
|
||||
isExternal: boolean;
|
||||
deliveryUrl: string;
|
||||
};
|
||||
|
||||
const allSubtitleTracks = useMemo(() => {
|
||||
const embeddedSubs =
|
||||
subtitleTracks
|
||||
?.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
deliveryUrl: undefined,
|
||||
}))
|
||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
||||
|
||||
console.log("embeddedSubs ~", embeddedSubs);
|
||||
|
||||
const externalSubs =
|
||||
mediaSource?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
||||
).map((s) => ({
|
||||
name: s.DisplayTitle! + " [External]",
|
||||
index: s.Index!,
|
||||
deliveryUrl: s.DeliveryUrl,
|
||||
})) || [];
|
||||
|
||||
// Combine embedded and unique external subs
|
||||
return [...embeddedSubs, ...externalSubs] as (
|
||||
| EmbeddedSubtitle
|
||||
| ExternalSubtitle
|
||||
)[];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 1000,
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
className="p-4"
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</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}
|
||||
>
|
||||
{allSubtitleTracks?.map((sub, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={`subtitle-item-${idx}`}
|
||||
onSelect={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL &&
|
||||
setSubtitleURL(
|
||||
api?.basePath + sub.deliveryUrl,
|
||||
sub.name
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Set sub url: ",
|
||||
api?.basePath + sub.deliveryUrl
|
||||
);
|
||||
} else {
|
||||
console.log("Set sub index: ", sub.index);
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
}
|
||||
|
||||
console.log("Subtitle: ", sub);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={`audio-item-${idx}`}
|
||||
onSelect={() => {
|
||||
setAudioTrack && setAudioTrack(track.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: 97,
|
||||
},
|
||||
]}
|
||||
className={`z-10 p-4
|
||||
${showSkipButton ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipIntro}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<Text className="text-white">Skip Intro</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 94,
|
||||
height: 70,
|
||||
}}
|
||||
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
||||
className={`z-10 p-4 ${
|
||||
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipCredit}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<Text className="text-white">Skip Credits</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
toggleControls();
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: showControls ? 0.5 : 0,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
></Pressable>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
pointerEvents="none"
|
||||
className={`flex flex-col items-center justify-center
|
||||
${isBuffering ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||
>
|
||||
{Platform.OS !== "ios" && (
|
||||
<TouchableOpacity
|
||||
onPress={toggleIgnoreSafeAreas}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons
|
||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
if (stop) await stop();
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
left: 0,
|
||||
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-col p-4 `}
|
||||
>
|
||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||
<Text className="font-bold">{item?.Name}</Text>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4">
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !previousItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToPreviousItem}
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<Ionicons
|
||||
name="refresh-outline"
|
||||
size={26}
|
||||
color="white"
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay(progress.value);
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={30}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !nextItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToNextItem}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className={`flex flex-col w-full shrink`}>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#000",
|
||||
heartbeatColor: "#999",
|
||||
}}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
}
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
marginLeft: -tileWidth / 4,
|
||||
marginTop: -tileHeight / 4 - 60,
|
||||
zIndex: 10,
|
||||
}}
|
||||
className=" bg-neutral-800 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
color: "white",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
padding: 5,
|
||||
borderRadius: 5,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${
|
||||
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
||||
}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
sliderHeight={10}
|
||||
thumbWidth={0}
|
||||
progress={progress}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
617
components/video-player/controls/Controls.tsx
Normal file
617
components/video-player/controls/Controls.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import {
|
||||
TrackInfo,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { VideoRef } from "react-native-video";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownView from "./DropdownView";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: React.MutableRefObject<VlcPlayerViewRef | VideoRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
isBuffering: boolean;
|
||||
showControls: boolean;
|
||||
ignoreSafeAreas?: boolean;
|
||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
offline?: boolean;
|
||||
isVideoLoaded?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
play: (() => Promise<void>) | (() => void);
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
stop?: (() => Promise<void>) | (() => void);
|
||||
isVlc?: boolean;
|
||||
}
|
||||
|
||||
export const Controls: React.FC<Props> = ({
|
||||
item,
|
||||
videoRef,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
progress,
|
||||
isBuffering,
|
||||
cacheProgress,
|
||||
showControls,
|
||||
setShowControls,
|
||||
ignoreSafeAreas,
|
||||
setIgnoreSafeAreas,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
stop,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setPlaySettings, playSettings } = usePlaySettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const windowDimensions = Dimensions.get("window");
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
!offline && enableTrickplay
|
||||
);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||
|
||||
const wasPlayingRef = useRef(false);
|
||||
const lastProgressRef = useRef<number>(0);
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(previousItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: previousItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrate.toString(),
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/player?${queryParams}`);
|
||||
}, [previousItem, settings]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(nextItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: nextItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrate.toString(),
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/player?${queryParams}`);
|
||||
}, [nextItem, settings]);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||
const remaining = isVlc
|
||||
? maxValue - currentProgress
|
||||
: ticksToSeconds(maxValue - currentProgress);
|
||||
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
|
||||
// Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround.
|
||||
if (currentProgress === maxValue) {
|
||||
setShowControls(true);
|
||||
// Automatically play the next item if it exists
|
||||
goToNextItem();
|
||||
}
|
||||
},
|
||||
[goToNextItem, isVlc]
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
progress: progress.value,
|
||||
max: max.value,
|
||||
isSeeking: isSeeking.value,
|
||||
}),
|
||||
(result) => {
|
||||
// console.log("Progress changed", result);
|
||||
if (result.isSeeking === false) {
|
||||
runOnJS(updateTimes)(result.progress, result.max);
|
||||
}
|
||||
},
|
||||
[updateTimes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
progress.value = isVlc
|
||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = isVlc
|
||||
? ticksToMs(item.RunTimeTicks || 0)
|
||||
: item.RunTimeTicks || 0;
|
||||
}
|
||||
}, [item, isVlc]);
|
||||
|
||||
const toggleControls = () => setShowControls(!showControls);
|
||||
|
||||
const handleSliderComplete = useCallback(
|
||||
async (value: number) => {
|
||||
isSeeking.value = false;
|
||||
progress.value = value;
|
||||
|
||||
await seek(
|
||||
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
|
||||
);
|
||||
if (wasPlayingRef.current === true) play();
|
||||
},
|
||||
[isVlc]
|
||||
);
|
||||
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
|
||||
const handleSliderChange = (value: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
};
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (showControls === false) return;
|
||||
|
||||
wasPlayingRef.current = isPlaying;
|
||||
lastProgressRef.current = progress.value;
|
||||
|
||||
pause();
|
||||
isSeeking.value = true;
|
||||
}, [showControls, isPlaying]);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
||||
await seek(newTime);
|
||||
if (wasPlayingRef.current === true) play();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings?.forwardSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = progress.value;
|
||||
console.log(curr);
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? curr + secondsToMs(settings.forwardSkipTime)
|
||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||
await seek(Math.max(0, newTime));
|
||||
if (wasPlayingRef.current === true) play();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc]);
|
||||
|
||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||
setIgnoreSafeAreas((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
mediaSource={mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<DropdownView showControls={showControls} />
|
||||
</VideoProvider>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: 97,
|
||||
},
|
||||
]}
|
||||
className={`z-10 p-4
|
||||
${showSkipButton ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipIntro}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<Text className="text-white">Skip Intro</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 94,
|
||||
height: 70,
|
||||
}}
|
||||
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
||||
className={`z-10 p-4 ${
|
||||
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipCredit}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<Text className="text-white">Skip Credits</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
toggleControls();
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: showControls ? 0.5 : 0,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
></Pressable>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
pointerEvents="none"
|
||||
className={`flex flex-col items-center justify-center
|
||||
${isBuffering ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||
>
|
||||
{Platform.OS !== "ios" && (
|
||||
<TouchableOpacity
|
||||
onPress={toggleIgnoreSafeAreas}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons
|
||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
if (stop) await stop();
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
left: 0,
|
||||
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
||||
opacity: showControls ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-col p-4 `}
|
||||
>
|
||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||
<Text className="font-bold">{item?.Name}</Text>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4">
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !previousItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToPreviousItem}
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<Ionicons
|
||||
name="refresh-outline"
|
||||
size={26}
|
||||
color="white"
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay(progress.value);
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={30}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !nextItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToNextItem}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className={`flex flex-col w-full shrink`}>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#000",
|
||||
heartbeatColor: "#999",
|
||||
}}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
}
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
marginLeft: -tileWidth / 4,
|
||||
marginTop: -tileHeight / 4 - 60,
|
||||
zIndex: 10,
|
||||
}}
|
||||
className=" bg-neutral-800 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
color: "white",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
padding: 5,
|
||||
borderRadius: 5,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${
|
||||
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
||||
}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
sliderHeight={10}
|
||||
thumbWidth={0}
|
||||
progress={progress}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ControlProvider>
|
||||
);
|
||||
};
|
||||
266
components/video-player/controls/DropdownView.tsx
Normal file
266
components/video-player/controls/DropdownView.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { View, TouchableOpacity } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { useControlContext } from "./contexts/ControlContext";
|
||||
import { useVideoContext } from "./contexts/VideoContext";
|
||||
import {
|
||||
EmbeddedSubtitle,
|
||||
ExternalSubtitle,
|
||||
TranscodedSubtitle,
|
||||
} from "./types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
|
||||
interface DropdownViewProps {
|
||||
showControls: boolean;
|
||||
}
|
||||
|
||||
const DropdownView: React.FC<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,
|
||||
audioTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
} = videoContext;
|
||||
|
||||
const allSubtitleTracksForDirectPlay = useMemo(() => {
|
||||
if (mediaSource?.TranscodingUrl) return null;
|
||||
const embeddedSubs =
|
||||
subtitleTracks
|
||||
?.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
deliveryUrl: undefined,
|
||||
}))
|
||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
||||
|
||||
const externalSubs =
|
||||
mediaSource?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
||||
).map((s) => ({
|
||||
name: s.DisplayTitle! + " [External]",
|
||||
index: s.Index!,
|
||||
deliveryUrl: s.DeliveryUrl,
|
||||
})) || [];
|
||||
|
||||
// Combine embedded and unique external subs
|
||||
return [...embeddedSubs, ...externalSubs] as (
|
||||
| EmbeddedSubtitle
|
||||
| ExternalSubtitle
|
||||
)[];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||
|
||||
// const audioForTranscodingStream = mediaSource?.MediaStreams?.filter(
|
||||
// (x) => x.Type === "Audio"
|
||||
// ).map((x) => ({
|
||||
// name: x.DisplayTitle!,
|
||||
// index: x.Index!,
|
||||
// }));
|
||||
|
||||
// Only used for transcoding streams.
|
||||
const {
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
audioIndex,
|
||||
bitrateValue,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
||||
const isOnTextSubtitle =
|
||||
mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Index === parseInt(subtitleIndexStr) && x.IsTextSubtitleStream
|
||||
) || subtitleIndexStr === "-1";
|
||||
|
||||
// TODO: Add support for text sorting subtitles renaming.
|
||||
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
||||
if (isOnTextSubtitle) {
|
||||
const textSubtitles =
|
||||
subtitleTracks?.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
IsTextSubtitleStream: true,
|
||||
})) || [];
|
||||
|
||||
const imageSubtitles = allSubs
|
||||
.filter((x) => !x.IsTextSubtitleStream)
|
||||
.map(
|
||||
(x) =>
|
||||
({
|
||||
name: x.DisplayTitle!,
|
||||
index: x.Index!,
|
||||
IsTextSubtitleStream: x.IsTextSubtitleStream,
|
||||
} as TranscodedSubtitle)
|
||||
);
|
||||
return [...textSubtitles, ...imageSubtitles];
|
||||
}
|
||||
|
||||
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
||||
name: x.DisplayTitle!,
|
||||
index: x.Index!,
|
||||
IsTextSubtitleStream: x.IsTextSubtitleStream!,
|
||||
}));
|
||||
|
||||
return [
|
||||
{ name: 'Disable', index: -1, IsTextSubtitleStream: true } as TranscodedSubtitle,
|
||||
...transcodedSubtitle
|
||||
];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||
|
||||
const ChangeTranscodingSubtitle = useCallback(
|
||||
(subtitleIndex: number) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue,
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/player?${queryParams}`);
|
||||
},
|
||||
[mediaSource]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 1000,
|
||||
opacity: showControls ? 1 : 0,
|
||||
}}
|
||||
className="p-4"
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</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}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl &&
|
||||
allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={`subtitle-item-${idx}`}
|
||||
onSelect={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL &&
|
||||
setSubtitleURL(
|
||||
api?.basePath + sub.deliveryUrl,
|
||||
sub.name
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Set external subtitle: ",
|
||||
api?.basePath + sub.deliveryUrl
|
||||
);
|
||||
} else {
|
||||
console.log("Set sub index: ", sub.index);
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
}
|
||||
|
||||
console.log("Subtitle: ", sub);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
{mediaSource?.TranscodingUrl &&
|
||||
allSubtitleTracksForTranscodingStream?.map(
|
||||
(sub, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={`subtitle-item-${idx}`}
|
||||
onSelect={() => {
|
||||
if (subtitleIndexStr === sub.index.toString()) return;
|
||||
|
||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
return;
|
||||
}
|
||||
ChangeTranscodingSubtitle(sub.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle
|
||||
key={`subtitle-item-title-${idx}`}
|
||||
>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
)}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={`audio-item-${idx}`}
|
||||
onSelect={() => {
|
||||
setAudioTrack && setAudioTrack(track.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownView;
|
||||
144
components/video-player/controls/SliderScrubbter.tsx
Normal file
144
components/video-player/controls/SliderScrubbter.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useTrickplay } from '@/hooks/useTrickplay';
|
||||
import { formatTimeString, msToTicks, ticksToSeconds } from '@/utils/time';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { Image } from "expo-image";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { SharedValue, useSharedValue } from 'react-native-reanimated';
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
interface SliderScrubberProps {
|
||||
cacheProgress: SharedValue<number>;
|
||||
handleSliderStart: () => void;
|
||||
handleSliderComplete: (value: number) => void;
|
||||
progress: SharedValue<number>;
|
||||
min: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
const SliderScrubber: React.FC<SliderScrubberProps> = ({
|
||||
cacheProgress,
|
||||
handleSliderStart,
|
||||
handleSliderComplete,
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
currentTime,
|
||||
remainingTime,
|
||||
item,
|
||||
}) => {
|
||||
|
||||
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
);
|
||||
|
||||
const handleSliderChange = (value: number) => {
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`flex flex-col w-full shrink`}>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#000",
|
||||
heartbeatColor: "#999",
|
||||
}}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
}
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
marginLeft: -tileWidth / 4,
|
||||
marginTop: -tileHeight / 4 - 60,
|
||||
zIndex: 10,
|
||||
}}
|
||||
className=" bg-neutral-800 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
color: "white",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
padding: 5,
|
||||
borderRadius: 5,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${
|
||||
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
||||
}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
sliderHeight={10}
|
||||
thumbWidth={0}
|
||||
progress={progress}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SliderScrubber;
|
||||
34
components/video-player/controls/contexts/ControlContext.tsx
Normal file
34
components/video-player/controls/contexts/ControlContext.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { TrackInfo } from '@/modules/vlc-player';
|
||||
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface ControlContextProps {
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
const ControlContext = createContext<ControlContextProps | undefined>(undefined);
|
||||
|
||||
interface ControlProviderProps {
|
||||
children: ReactNode;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item, mediaSource, isVideoLoaded }) => {
|
||||
return (
|
||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||
{children}
|
||||
</ControlContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useControlContext = () => {
|
||||
const context = useContext(ControlContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useControlContext must be used within a ControlProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
62
components/video-player/controls/contexts/VideoContext.tsx
Normal file
62
components/video-player/controls/contexts/VideoContext.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { TrackInfo } from '@/modules/vlc-player';
|
||||
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
|
||||
import { useControlContext } from './ControlContext';
|
||||
|
||||
interface VideoContextProps {
|
||||
audioTracks: TrackInfo[] | null;
|
||||
subtitleTracks: TrackInfo[] | null;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
}
|
||||
|
||||
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||
|
||||
interface VideoProviderProps {
|
||||
children: ReactNode;
|
||||
getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
|
||||
getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
}
|
||||
|
||||
export const VideoProvider: React.FC<VideoProviderProps> = ({ children, getSubtitleTracks, getAudioTracks, setSubtitleTrack, setSubtitleURL, setAudioTrack }) => {
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
const subtitles = await getSubtitleTracks();
|
||||
console.log("Getting embeded subtitles...", subtitles);
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
if (getAudioTracks) {
|
||||
const audio = await getAudioTracks();
|
||||
setAudioTracks(audio);
|
||||
}
|
||||
};
|
||||
fetchTracks();
|
||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider value={{ audioTracks, subtitleTracks, setSubtitleTrack, setSubtitleURL, setAudioTrack }}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVideoContext = () => {
|
||||
const context = useContext(VideoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useVideoContext must be used within a VideoProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
20
components/video-player/controls/types.ts
Normal file
20
components/video-player/controls/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
type EmbeddedSubtitle = {
|
||||
name: string;
|
||||
index: number;
|
||||
isExternal: boolean;
|
||||
};
|
||||
|
||||
type ExternalSubtitle = {
|
||||
name: string;
|
||||
index: number;
|
||||
isExternal: boolean;
|
||||
deliveryUrl: string;
|
||||
};
|
||||
|
||||
type TranscodedSubtitle = {
|
||||
name: string;
|
||||
index: number;
|
||||
IsTextSubtitleStream: boolean;
|
||||
}
|
||||
|
||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.20.1",
|
||||
"channel": "0.21.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.20.1",
|
||||
"channel": "0.21.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -32,6 +32,8 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var startPosition: Int? = 0
|
||||
private var isTranscodedStream: Boolean = false
|
||||
private var isMediaReady: Boolean = false
|
||||
private var externalTrack: Map<String, String>? = null
|
||||
|
||||
init {
|
||||
setupView()
|
||||
}
|
||||
@@ -50,6 +52,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
||||
externalTrack = source["externalTrack"] as? Map<String, String>
|
||||
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
|
||||
|
||||
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
|
||||
@@ -145,14 +148,26 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
mediaPlayer?.setSpuTrack(trackIndex)
|
||||
}
|
||||
|
||||
// fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
||||
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
// }
|
||||
// }
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
||||
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
|
||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
}
|
||||
|
||||
// Debug statement to print the result
|
||||
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||
println("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
@@ -224,6 +239,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
}
|
||||
|
||||
// Only used for HLS transcoded streams
|
||||
private fun setSubtitleTrackByName(trackName: String) {
|
||||
val track = mediaPlayer?.getSpuTracks()?.firstOrNull { it.name.startsWith(trackName) }
|
||||
val trackIndex = track?.id ?: -1
|
||||
println("Track Index setting to: $trackIndex")
|
||||
if (trackIndex != -1) {
|
||||
setSubtitleTrack(trackIndex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateVideoProgress() {
|
||||
val player = mediaPlayer ?: return
|
||||
@@ -235,7 +260,20 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
// Handle when VLC starts at cloest earliest segment skip to the start time, for transcoded streams.
|
||||
if (player.isPlaying && !isMediaReady) {
|
||||
isMediaReady = true
|
||||
if (isTranscodedStream) {
|
||||
externalTrack?.let {
|
||||
val name = it["name"]
|
||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
||||
if (!name.isNullOrEmpty()) {
|
||||
if (!isTranscodedStream) {
|
||||
setSubtitleURL(deliveryUrl, name)
|
||||
}
|
||||
else {
|
||||
setSubtitleTrackByName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTranscodedStream && startPosition != 0) {
|
||||
seekTo((startPosition ?: 0) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class VlcPlayerView: ExpoView {
|
||||
private var startPosition: Int32 = 0
|
||||
private var isTranscodedStream: Bool = false
|
||||
private var isMediaReady: Bool = false
|
||||
private var externalTrack: [String: String]?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -105,6 +106,7 @@ class VlcPlayerView: ExpoView {
|
||||
guard let self = self else { return }
|
||||
|
||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||
startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
initOptions.append("--start-time=\(startPosition)")
|
||||
@@ -313,6 +315,25 @@ class VlcPlayerView: ExpoView {
|
||||
return tracks
|
||||
}
|
||||
|
||||
private func setSubtitleTrackByName(_ trackName: String) {
|
||||
guard let mediaPlayer = self.mediaPlayer else { return }
|
||||
|
||||
// Get the subtitle tracks and their indexes
|
||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
||||
{
|
||||
for (index, name) in zip(indexes, names) {
|
||||
if name.starts(with: trackName) {
|
||||
let trackIndex = index.intValue
|
||||
print("Track Index setting to: \(trackIndex)")
|
||||
setSubtitleTrack(trackIndex)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
print("Track not found for name: \(trackName)")
|
||||
}
|
||||
|
||||
// @objc func getSubtitleTracks(
|
||||
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
|
||||
// ) {
|
||||
@@ -563,7 +584,8 @@ extension VlcPlayerView: VLCMediaPlayerDelegate {
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
]
|
||||
|
||||
// Playing and not transcoding, we can let it in no HLS issue.
|
||||
// We should also mark it as playing when the media is ready.
|
||||
// Fix HLS issue.
|
||||
if player.isPlaying && (!self.isTranscodedStream || self.isMediaReady) {
|
||||
stateInfo["isPlaying"] = true
|
||||
@@ -629,9 +651,36 @@ extension VlcPlayerView: VLCMediaPlayerDelegate {
|
||||
// Handle when VLC starts at cloest earliest segment skip to the start time, for transcoded streams.
|
||||
if player.isPlaying && !self.isMediaReady {
|
||||
self.isMediaReady = true
|
||||
if self.isTranscodedStream {
|
||||
self.seekTo(self.startPosition * 1000)
|
||||
if let externalTrack = self.externalTrack {
|
||||
if let name = externalTrack["name"] as? String, !name.isEmpty {
|
||||
let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? ""
|
||||
if !self.isTranscodedStream {
|
||||
self.setSubtitleURL(deliveryUrl, name: name)
|
||||
} else {
|
||||
self.setSubtitleTrackByName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HLS bug.
|
||||
if self.isTranscodedStream {
|
||||
if self.startPosition > 0 {
|
||||
print("Seeking to start position: \(self.startPosition)")
|
||||
self.seekTo(self.startPosition * 1000)
|
||||
} else {
|
||||
var stateInfo: [String: Any] = [
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
"isPlaying": true,
|
||||
"isBuffering": false,
|
||||
"state": "Playing",
|
||||
]
|
||||
self.onVideoStateChange?(stateInfo)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
|
||||
@@ -33,6 +33,7 @@ export type VlcPlayerSource = {
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
externalTrack?: { name: string, DeliveryUrl: string };
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
startPosition?: number;
|
||||
|
||||
@@ -52,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.20.1" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.21.0" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
})
|
||||
);
|
||||
@@ -86,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.20.1"`,
|
||||
}, DeviceId="${deviceId}", Version="0.21.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -109,12 +109,27 @@ export const getStreamUrl = async ({
|
||||
|
||||
if (item.MediaType === "Video") {
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
|
||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||
|
||||
// If there is no subtitle stream index, add it to the URL.
|
||||
if (subtitleStreamIndex == -1) {
|
||||
urlObj.searchParams.set("SubtitleMethod", "Hls");
|
||||
}
|
||||
|
||||
// Add 'SubtitleMethod=Hls' if it doesn't exist
|
||||
if (!urlObj.searchParams.has("SubtitleMethod")) {
|
||||
urlObj.searchParams.append("SubtitleMethod", "Hls");
|
||||
}
|
||||
// Get the updated URL
|
||||
const transcodeUrl = urlObj.toString();
|
||||
|
||||
console.log(
|
||||
"Video has transcoding URL:",
|
||||
`${api.basePath}${mediaSource.TranscodingUrl}`
|
||||
`${transcodeUrl}`
|
||||
);
|
||||
return {
|
||||
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||
url: transcodeUrl,
|
||||
sessionId: sessionId,
|
||||
mediaSource,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import MediaTypes from "../../constants/MediaTypes";
|
||||
export default {
|
||||
Name: "1. Vlc Player",
|
||||
MaxStaticBitrate: 20_000_000,
|
||||
MaxStreamingBitrate: 12_000_000,
|
||||
MaxStreamingBitrate: 20_000_000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
|
||||
88
utils/profiles/transcoding.js
Normal file
88
utils/profiles/transcoding.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
|
||||
export default {
|
||||
Name: "Vlc Player for HLS streams.",
|
||||
MaxStaticBitrate: 20_000_000,
|
||||
MaxStreamingBitrate: 12_000_000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
CopyTimestamps: false,
|
||||
EnableSubtitlesInManifest: true,
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Text based subtitles must use HLS.
|
||||
{ Format: "ass", Method: "Hls" },
|
||||
{ Format: "microdvd", Method: "Hls" },
|
||||
{ Format: "mov_text", Method: "Hls" },
|
||||
{ Format: "mpl2", Method: "Hls" },
|
||||
{ Format: "pjs", Method: "Hls" },
|
||||
{ Format: "realtext", Method: "Hls" },
|
||||
{ Format: "scc", Method: "Hls" },
|
||||
{ Format: "smi", Method: "Hls" },
|
||||
{ Format: "srt", Method: "Hls" },
|
||||
{ Format: "ssa", Method: "Hls" },
|
||||
{ Format: "stl", Method: "Hls" },
|
||||
{ Format: "sub", Method: "Hls" },
|
||||
{ Format: "subrip", Method: "Hls" },
|
||||
{ Format: "subviewer", Method: "Hls" },
|
||||
{ Format: "teletext", Method: "Hls" },
|
||||
{ Format: "text", Method: "Hls" },
|
||||
{ Format: "ttml", Method: "Hls" },
|
||||
{ Format: "vplayer", Method: "Hls" },
|
||||
{ Format: "vtt", Method: "Hls" },
|
||||
{ Format: "webvtt", Method: "Hls" },
|
||||
|
||||
|
||||
// Image based subs use encode.
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user