mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-28 08:40:27 +01:00
wip
This commit is contained in:
@@ -2,6 +2,7 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/Controls";
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
@@ -11,12 +12,12 @@ import {
|
|||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import android from "@/utils/profiles/android";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -29,13 +30,7 @@ import * as Haptics from "expo-haptics";
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { Alert, Pressable, View } from "react-native";
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -43,8 +38,6 @@ export default function page() {
|
|||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
const windowDimensions = useWindowDimensions();
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
@@ -56,20 +49,26 @@ export default function page() {
|
|||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
const { getDownloadedItem } = useDownload();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
subtitleIndex: subtitleIndexStr,
|
subtitleIndex: subtitleIndexStr,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
|
offline: offlineStr,
|
||||||
} = useLocalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
|
offline: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr
|
||||||
@@ -84,6 +83,12 @@ export default function page() {
|
|||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
|
if (offline) {
|
||||||
|
const item = await getDownloadedItem(itemId);
|
||||||
|
if (item) return item.item;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
const res = await getUserLibraryApi(api).getItem({
|
||||||
itemId,
|
itemId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
@@ -110,6 +115,21 @@ export default function page() {
|
|||||||
],
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
|
if (offline) {
|
||||||
|
const item = await getDownloadedItem(itemId);
|
||||||
|
if (!item?.mediaSource) return null;
|
||||||
|
|
||||||
|
const url = await getDownloadedFileUrl(item.item.Id!);
|
||||||
|
|
||||||
|
if (item)
|
||||||
|
return {
|
||||||
|
mediaSource: item.mediaSource,
|
||||||
|
url,
|
||||||
|
sessionId: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -134,7 +154,7 @@ export default function page() {
|
|||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!itemId && !!api && !!item,
|
enabled: !!itemId && !!api && !!item && !offline,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,33 +166,38 @@ export default function page() {
|
|||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
if (!offline) {
|
||||||
itemId: item?.Id!,
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
itemId: item?.Id!,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
positionTicks: msToTicks(ms),
|
mediaSourceId: mediaSourceId,
|
||||||
isPaused: true,
|
positionTicks: msToTicks(ms),
|
||||||
playMethod: stream.url?.includes("m3u8")
|
isPaused: true,
|
||||||
? "Transcode"
|
playMethod: stream.url?.includes("m3u8")
|
||||||
: "DirectStream",
|
? "Transcode"
|
||||||
playSessionId: stream.sessionId,
|
: "DirectStream",
|
||||||
});
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Actually marked as paused");
|
console.log("Actually marked as paused");
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
if (!offline) {
|
||||||
itemId: item?.Id!,
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
itemId: item?.Id!,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
positionTicks: msToTicks(ms),
|
mediaSourceId: mediaSourceId,
|
||||||
isPaused: false,
|
positionTicks: msToTicks(ms),
|
||||||
playMethod: stream?.url.includes("m3u8")
|
isPaused: false,
|
||||||
? "Transcode"
|
playMethod: stream?.url.includes("m3u8")
|
||||||
: "DirectStream",
|
? "Transcode"
|
||||||
playSessionId: stream.sessionId,
|
: "DirectStream",
|
||||||
});
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -184,6 +209,7 @@ export default function page() {
|
|||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
|
offline,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -202,19 +228,20 @@ export default function page() {
|
|||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
|
if (offline) return;
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
const currentTimeInTicks = msToTicks(progress.value);
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: currentTimeInTicks,
|
positionTicks: currentTimeInTicks,
|
||||||
playSessionId: stream?.sessionId!,
|
playSessionId: stream?.sessionId!,
|
||||||
});
|
});
|
||||||
};
|
}, [api, item, mediaSourceId, stream]);
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
if (!api || !stream) return;
|
if (!api || !stream) return;
|
||||||
|
if (offline) return;
|
||||||
await getPlaystateApi(api).onPlaybackStart({
|
await getPlaystateApi(api).onPlaybackStart({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
@@ -223,7 +250,7 @@ export default function page() {
|
|||||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
||||||
});
|
});
|
||||||
};
|
}, [api, item, mediaSourceId, stream]);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
@@ -238,6 +265,9 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress.value = currentTime;
|
progress.value = currentTime;
|
||||||
|
|
||||||
|
if (offline) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
const currentTimeInTicks = msToTicks(currentTime);
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
@@ -262,9 +292,10 @@ export default function page() {
|
|||||||
pauseVideo: pause,
|
pauseVideo: pause,
|
||||||
playVideo: play,
|
playVideo: play,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
|
offline: offline,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
|
||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
@@ -283,7 +314,15 @@ export default function page() {
|
|||||||
} else if (isBuffering) {
|
} else if (isBuffering) {
|
||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const startPosition = useMemo(() => {
|
||||||
|
if (offline) return 0;
|
||||||
|
|
||||||
|
return item?.UserData?.PlaybackPositionTicks
|
||||||
|
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||||
|
: 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
if (isLoadingItem || isLoadingStreamUrl)
|
||||||
return (
|
return (
|
||||||
@@ -301,11 +340,6 @@ export default function page() {
|
|||||||
|
|
||||||
if (!stream || !item) return null;
|
if (!stream || !item) return null;
|
||||||
|
|
||||||
console.log("AudioIndex", audioIndex);
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useMemo } from "react";
|
|||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
value: number | undefined;
|
value: number | undefined;
|
||||||
height?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
export const BITRATES: Bitrate[] = [
|
||||||
@@ -27,17 +26,14 @@ export const BITRATES: Bitrate[] = [
|
|||||||
{
|
{
|
||||||
key: "2 Mb/s",
|
key: "2 Mb/s",
|
||||||
value: 2000000,
|
value: 2000000,
|
||||||
height: 720,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "500 Kb/s",
|
key: "500 Kb/s",
|
||||||
value: 500000,
|
value: 500000,
|
||||||
height: 480,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "250 Kb/s",
|
key: "250 Kb/s",
|
||||||
value: 250000,
|
value: 250000,
|
||||||
height: 480,
|
|
||||||
},
|
},
|
||||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { Loader } from "./Loader";
|
|||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -42,7 +43,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { processes, startBackgroundDownload } = useDownload();
|
const { processes, startBackgroundDownload } = useDownload();
|
||||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
const { startRemuxing } = useRemuxHlsToMp4();
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
MediaSourceInfo | undefined | null
|
MediaSourceInfo | undefined | null
|
||||||
@@ -98,73 +99,33 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.axiosInstance.post(
|
const res = await getStreamUrl({
|
||||||
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
api,
|
||||||
{
|
item,
|
||||||
DeviceProfile: native,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
UserId: user.Id,
|
userId: user?.Id,
|
||||||
MaxStreamingBitrate: maxBitrate.value,
|
audioStreamIndex: selectedAudioStream,
|
||||||
StartTimeTicks: 0,
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
EnableTranscoding: maxBitrate.value ? true : undefined,
|
mediaSourceId: selectedMediaSource.Id,
|
||||||
AutoOpenLiveStream: true,
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
AllowVideoStreamCopy: maxBitrate.value ? false : true,
|
deviceProfile: native,
|
||||||
MediaSourceId: selectedMediaSource?.Id,
|
});
|
||||||
AudioStreamIndex: selectedAudioStream,
|
|
||||||
SubtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let url: string | undefined = undefined;
|
if (!res) return null;
|
||||||
let fileExtension: string | undefined | null = "mp4";
|
|
||||||
|
|
||||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
const { mediaSource, url } = res;
|
||||||
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mediaSource) {
|
if (!url || !mediaSource) throw new Error("No url");
|
||||||
throw new Error("No media source");
|
if (!mediaSource.TranscodingContainer) throw new Error("No file extension");
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay) {
|
|
||||||
if (item.MediaType === "Video") {
|
|
||||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
|
||||||
} else if (item.MediaType === "Audio") {
|
|
||||||
console.log("Using direct stream for audio!");
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
UserId: user.Id,
|
|
||||||
DeviceId: api.deviceInfo.id,
|
|
||||||
MaxStreamingBitrate: "140000000",
|
|
||||||
Container:
|
|
||||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
|
||||||
TranscodingContainer: "mp4",
|
|
||||||
TranscodingProtocol: "hls",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
api_key: api.accessToken,
|
|
||||||
StartTimeTicks: "0",
|
|
||||||
EnableRedirection: "true",
|
|
||||||
EnableRemoteMedia: "false",
|
|
||||||
});
|
|
||||||
url = `${api.basePath}/Audio/${
|
|
||||||
item.Id
|
|
||||||
}/universal?${searchParams.toString()}`;
|
|
||||||
}
|
|
||||||
} else if (mediaSource.TranscodingUrl) {
|
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
|
||||||
fileExtension = mediaSource.TranscodingContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url) throw new Error("No url");
|
|
||||||
if (!fileExtension) throw new Error("No file extension");
|
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
if (settings?.downloadMethod === "optimized") {
|
||||||
return await startBackgroundDownload(url, item, fileExtension);
|
return await startBackgroundDownload(
|
||||||
|
url,
|
||||||
|
item,
|
||||||
|
mediaSource.TranscodingContainer
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return await startRemuxing(url);
|
return await startRemuxing(item, url, mediaSource);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
@@ -203,7 +164,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const process = useMemo(() => {
|
const process = useMemo(() => {
|
||||||
if (!processes) return null;
|
if (!processes) return null;
|
||||||
|
|
||||||
return processes.find((process) => process?.item?.Id === item.Id);
|
return processes.find((process) => process?.item?.item.Id === item.Id);
|
||||||
}, [processes, item.Id]);
|
}, [processes, item.Id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -211,7 +172,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{process && process?.item.Id === item.Id ? (
|
{process && process?.item.item.Id === item.Id ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/downloads");
|
router.push("/downloads");
|
||||||
|
|||||||
@@ -93,18 +93,20 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
const eta = (p: JobStatus) => {
|
const eta = (p: JobStatus) => {
|
||||||
if (!p.speed || !p.progress) return null;
|
if (!p.speed || !p.progress) return null;
|
||||||
|
|
||||||
const length = p?.item?.RunTimeTicks || 0;
|
const length = p?.item?.item.RunTimeTicks || 0;
|
||||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||||
return formatTimeString(timeLeft, "tick");
|
return formatTimeString(timeLeft, "tick");
|
||||||
};
|
};
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(process.item.Id!);
|
return storage.getString(process.item.item.Id!);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/page?id=${process.item.item.Id}`)
|
||||||
|
}
|
||||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -138,10 +140,12 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className="shrink mb-1">
|
<View className="shrink mb-1">
|
||||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
<Text className="text-xs opacity-50">{process.item.item.Type}</Text>
|
||||||
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
<Text className="font-semibold shrink">
|
||||||
|
{process.item.item.Name}
|
||||||
|
</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
{process.item.ProductionYear}
|
{process.item.item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||||
{process.progress === 0 ? (
|
{process.progress === 0 ? (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -26,7 +26,7 @@ interface EpisodeCardProps {
|
|||||||
*/
|
*/
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
@@ -28,7 +28,7 @@ interface MovieCardProps {
|
|||||||
*/
|
*/
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
|
|||||||
@@ -116,9 +116,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { setPlaySettings, playSettings } = usePlaySettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const windowDimensions = Dimensions.get("window");
|
|
||||||
|
|
||||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||||
@@ -157,14 +155,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
getDefaultPlaySettings(previousItem, settings);
|
getDefaultPlaySettings(previousItem, settings);
|
||||||
|
|
||||||
setPlaySettings({
|
|
||||||
item: previousItem,
|
|
||||||
bitrate,
|
|
||||||
mediaSource,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
@@ -183,14 +173,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
getDefaultPlaySettings(nextItem, settings);
|
getDefaultPlaySettings(nextItem, settings);
|
||||||
|
|
||||||
setPlaySettings({
|
|
||||||
item: nextItem,
|
|
||||||
bitrate,
|
|
||||||
mediaSource,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
@@ -374,6 +356,8 @@ export const Controls: React.FC<Props> = ({
|
|||||||
}))
|
}))
|
||||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
||||||
|
|
||||||
|
console.log("embeddedSubs ~", embeddedSubs);
|
||||||
|
|
||||||
const externalSubs =
|
const externalSubs =
|
||||||
mediaSource?.MediaStreams?.filter(
|
mediaSource?.MediaStreams?.filter(
|
||||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
||||||
|
|||||||
@@ -1,51 +1,55 @@
|
|||||||
// hooks/useFileOpener.ts
|
|
||||||
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
export const useFileOpener = () => {
|
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
||||||
|
const directory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
throw new Error("Document directory is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
throw new Error("Item ID is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await FileSystem.readDirectoryAsync(directory);
|
||||||
|
const path = itemId!;
|
||||||
|
const matchingFile = files.find((file) => file.startsWith(path));
|
||||||
|
|
||||||
|
if (!matchingFile) {
|
||||||
|
throw new Error(`No file found for item ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${directory}${matchingFile}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDownloadedFileOpener = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
const openFile = useCallback(async (item: BaseItemDto) => {
|
const openFile = useCallback(
|
||||||
const directory = FileSystem.documentDirectory;
|
async (item: BaseItemDto) => {
|
||||||
|
try {
|
||||||
|
const url = await getDownloadedFileUrl(item.Id!);
|
||||||
|
|
||||||
if (!directory) {
|
setOfflineSettings({
|
||||||
throw new Error("Document directory is not available");
|
item,
|
||||||
}
|
});
|
||||||
|
setPlayUrl(url);
|
||||||
|
|
||||||
if (!item.Id) {
|
// @ts-expect-error
|
||||||
throw new Error("Item ID is not available");
|
router.push("/player?offline=true&itemId=" + item.Id);
|
||||||
}
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
try {
|
console.error("Error opening file:", error);
|
||||||
const files = await FileSystem.readDirectoryAsync(directory);
|
|
||||||
const path = item.Id!;
|
|
||||||
const matchingFile = files.find((file) => file.startsWith(path));
|
|
||||||
|
|
||||||
if (!matchingFile) {
|
|
||||||
throw new Error(`No file found for item ${path}`);
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const url = `${directory}${matchingFile}`;
|
[setOfflineSettings, setPlayUrl, router]
|
||||||
|
);
|
||||||
setOfflineSettings({
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
setPlayUrl(url);
|
|
||||||
|
|
||||||
if (Platform.OS === "ios") router.push("/offline-vlc-player");
|
|
||||||
else router.push("/offline-player");
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
|
||||||
console.error("Error opening file:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { openFile };
|
return { openFile };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { useAtom, useAtomValue } from "jotai";
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -21,22 +24,16 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
* @param item - The BaseItemDto object representing the media item
|
* @param item - The BaseItemDto object representing the media item
|
||||||
* @returns An object with remuxing-related functions
|
* @returns An object with remuxing-related functions
|
||||||
*/
|
*/
|
||||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
export const useRemuxHlsToMp4 = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
if (!item.Id || !item.Name) {
|
|
||||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
|
||||||
throw new Error("Item must have an Id and Name");
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
|
||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (url: string) => {
|
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||||
|
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||||
if (!api) throw new Error("API is not defined");
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
|
|
||||||
@@ -74,13 +71,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
id: "",
|
id: "",
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
inputUrl: "",
|
inputUrl: "",
|
||||||
item,
|
item: {
|
||||||
itemId: item.Id,
|
item,
|
||||||
|
mediaSource,
|
||||||
|
},
|
||||||
|
itemId: item.Id!,
|
||||||
outputPath: "",
|
outputPath: "",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
} as JobStatus,
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||||
@@ -119,7 +119,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
if (!item) throw new Error("Item is undefined");
|
if (!item) throw new Error("Item is undefined");
|
||||||
await saveDownloadedItemInfo(item);
|
await saveDownloadedItemInfo(item, mediaSource);
|
||||||
toast.success("Download completed");
|
toast.success("Download completed");
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
@@ -134,7 +134,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
"ERROR",
|
"ERROR",
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||||
);
|
);
|
||||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
reject(new Error("Remuxing failed"));
|
||||||
} else if (returnCode.isValueCancel()) {
|
} else if (returnCode.isValueCancel()) {
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
@@ -163,15 +163,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
throw error; // Re-throw the error to propagate it to the caller
|
throw error; // Re-throw the error to propagate it to the caller
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[output, item]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
const cancelRemuxing = useCallback(() => {
|
||||||
FFmpegKit.cancel();
|
FFmpegKit.cancel();
|
||||||
setProcesses((prev) => {
|
setProcesses([]);
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
}, []);
|
||||||
});
|
|
||||||
}, [item.Name]);
|
|
||||||
|
|
||||||
return { startRemuxing, cancelRemuxing };
|
return { startRemuxing, cancelRemuxing };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface UseWebSocketProps {
|
|||||||
pauseVideo: () => void;
|
pauseVideo: () => void;
|
||||||
playVideo: () => void;
|
playVideo: () => void;
|
||||||
stopPlayback: () => void;
|
stopPlayback: () => void;
|
||||||
|
offline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWebSocket = ({
|
export const useWebSocket = ({
|
||||||
@@ -22,6 +23,7 @@ export const useWebSocket = ({
|
|||||||
pauseVideo,
|
pauseVideo,
|
||||||
playVideo,
|
playVideo,
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
|
offline = false,
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
@@ -38,7 +40,7 @@ export const useWebSocket = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deviceId || !api?.accessToken) return;
|
if (offline || !deviceId || !api?.accessToken) return;
|
||||||
|
|
||||||
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
||||||
|
|
||||||
@@ -80,10 +82,10 @@ export const useWebSocket = ({
|
|||||||
}
|
}
|
||||||
newWebSocket.close();
|
newWebSocket.close();
|
||||||
};
|
};
|
||||||
}, [api, deviceId, user]);
|
}, [api, deviceId, user, offline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (offline || !ws) return;
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
@@ -106,7 +108,7 @@ export const useWebSocket = ({
|
|||||||
Alert.alert("Message from server: " + title, body);
|
Alert.alert("Message from server: " + title, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
|
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
|
||||||
|
|
||||||
return { isConnected };
|
return { isConnected };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
getAllJobsByDeviceId,
|
getAllJobsByDeviceId,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
} from "@/utils/optimize-server";
|
} from "@/utils/optimize-server";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
checkForExistingDownloads,
|
checkForExistingDownloads,
|
||||||
completeHandler,
|
completeHandler,
|
||||||
@@ -41,6 +44,11 @@ import * as Notifications from "expo-notifications";
|
|||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
|
|
||||||
|
export type DownloadedItem = {
|
||||||
|
item: Partial<BaseItemDto>;
|
||||||
|
mediaSource: MediaSourceInfo;
|
||||||
|
};
|
||||||
|
|
||||||
function onAppStateChange(status: AppStateStatus) {
|
function onAppStateChange(status: AppStateStatus) {
|
||||||
focusManager.setFocused(status === "active");
|
focusManager.setFocused(status === "active");
|
||||||
}
|
}
|
||||||
@@ -122,7 +130,7 @@ function useDownloadProvider() {
|
|||||||
if (settings.autoDownload) {
|
if (settings.autoDownload) {
|
||||||
startDownload(job);
|
startDownload(job);
|
||||||
} else {
|
} else {
|
||||||
toast.info(`${job.item.Name} is ready to be downloaded`, {
|
toast.info(`${job.item.item.Name} is ready to be downloaded`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Go to downloads",
|
label: "Go to downloads",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -133,8 +141,8 @@ function useDownloadProvider() {
|
|||||||
});
|
});
|
||||||
Notifications.scheduleNotificationAsync({
|
Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: job.item.Name,
|
title: job.item.item.Name,
|
||||||
body: `${job.item.Name} is ready to be downloaded`,
|
body: `${job.item.item.Name} is ready to be downloaded`,
|
||||||
data: {
|
data: {
|
||||||
url: `/downloads`,
|
url: `/downloads`,
|
||||||
},
|
},
|
||||||
@@ -182,7 +190,7 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const startDownload = useCallback(
|
const startDownload = useCallback(
|
||||||
async (process: JobStatus) => {
|
async (process: JobStatus) => {
|
||||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
if (!process?.item.item.Id || !authHeader) throw new Error("No item id");
|
||||||
|
|
||||||
setProcesses((prev) =>
|
setProcesses((prev) =>
|
||||||
prev.map((p) =>
|
prev.map((p) =>
|
||||||
@@ -205,7 +213,7 @@ function useDownloadProvider() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info(`Download started for ${process.item.Name}`, {
|
toast.info(`Download started for ${process.item.item.Name}`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Go to downloads",
|
label: "Go to downloads",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -220,7 +228,7 @@ function useDownloadProvider() {
|
|||||||
download({
|
download({
|
||||||
id: process.id,
|
id: process.id,
|
||||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||||
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
destination: `${baseDirectory}/${process.item.item.Id}.mp4`,
|
||||||
})
|
})
|
||||||
.begin(() => {
|
.begin(() => {
|
||||||
setProcesses((prev) =>
|
setProcesses((prev) =>
|
||||||
@@ -252,8 +260,11 @@ function useDownloadProvider() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.done(async () => {
|
.done(async () => {
|
||||||
await saveDownloadedItemInfo(process.item);
|
await saveDownloadedItemInfo(
|
||||||
toast.success(`Download completed for ${process.item.Name}`, {
|
process.item.item,
|
||||||
|
process.item.mediaSource
|
||||||
|
);
|
||||||
|
toast.success(`Download completed for ${process.item.item.Name}`, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
action: {
|
action: {
|
||||||
label: "Go to downloads",
|
label: "Go to downloads",
|
||||||
@@ -278,13 +289,15 @@ function useDownloadProvider() {
|
|||||||
if (error.errorCode === 404) {
|
if (error.errorCode === 404) {
|
||||||
errorMsg = "File not found on server";
|
errorMsg = "File not found on server";
|
||||||
}
|
}
|
||||||
toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
|
toast.error(
|
||||||
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
`Download failed for ${process.item.item.Name} - ${errorMsg}`
|
||||||
|
);
|
||||||
|
writeToLog("ERROR", `Download failed for ${process.item.item.Name}`, {
|
||||||
error,
|
error,
|
||||||
processDetails: {
|
processDetails: {
|
||||||
id: process.id,
|
id: process.id,
|
||||||
itemName: process.item.Name,
|
itemName: process.item.item.Name,
|
||||||
itemId: process.item.Id,
|
itemId: process.item.item.Id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.error("Error details:", {
|
console.error("Error details:", {
|
||||||
@@ -485,11 +498,28 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getAllDownloadedItems(): Promise<BaseItemDto[]> {
|
async function getDownloadedItem(
|
||||||
|
itemId: string
|
||||||
|
): Promise<DownloadedItem | null> {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
if (downloadedItems) {
|
if (downloadedItems) {
|
||||||
return JSON.parse(downloadedItems) as BaseItemDto[];
|
const items: DownloadedItem[] = JSON.parse(downloadedItems);
|
||||||
|
const item = items.find((i) => i.item.Id === itemId);
|
||||||
|
return item || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to retrieve item with ID ${itemId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllDownloadedItems(): Promise<DownloadedItem[]> {
|
||||||
|
try {
|
||||||
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
|
if (downloadedItems) {
|
||||||
|
return JSON.parse(downloadedItems) as DownloadedItem[];
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -499,25 +529,32 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
async function saveDownloadedItemInfo(
|
||||||
|
item: BaseItemDto,
|
||||||
|
mediaSource: MediaSourceInfo
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
let items: BaseItemDto[] = downloadedItems
|
let items: { item: BaseItemDto; mediaSource: MediaSourceInfo }[] =
|
||||||
? JSON.parse(downloadedItems)
|
downloadedItems ? JSON.parse(downloadedItems) : [];
|
||||||
: [];
|
|
||||||
|
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
||||||
|
const newItem = { item, mediaSource };
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
|
||||||
if (existingItemIndex !== -1) {
|
if (existingItemIndex !== -1) {
|
||||||
items[existingItemIndex] = item;
|
items[existingItemIndex] = newItem;
|
||||||
} else {
|
} else {
|
||||||
items.push(item);
|
items.push(newItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||||
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save downloaded item information:", error);
|
console.error(
|
||||||
|
"Failed to save downloaded item information with media source:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +568,7 @@ function useDownloadProvider() {
|
|||||||
removeProcess,
|
removeProcess,
|
||||||
setProcesses,
|
setProcesses,
|
||||||
startDownload,
|
startDownload,
|
||||||
|
getDownloadedItem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { writeToLog } from "./log";
|
import { writeToLog } from "./log";
|
||||||
|
import { DownloadedItem } from "@/providers/DownloadProvider";
|
||||||
|
|
||||||
interface IJobInput {
|
interface IJobInput {
|
||||||
deviceId?: string | null;
|
deviceId?: string | null;
|
||||||
@@ -23,7 +27,7 @@ export interface JobStatus {
|
|||||||
inputUrl: string;
|
inputUrl: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
item: Partial<BaseItemDto>;
|
item: DownloadedItem;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
base64Image?: string;
|
base64Image?: string;
|
||||||
|
|||||||
@@ -58,97 +58,128 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: [
|
SubtitleProfiles: [
|
||||||
{ Format: "ass", Method: "Embed" },
|
// Official foramts
|
||||||
{ Format: "ass", Method: "Hls" },
|
|
||||||
{ Format: "ass", Method: "External" },
|
|
||||||
{ Format: "ass", Method: "Encode" },
|
|
||||||
{ Format: "dvbsub", Method: "Embed" },
|
|
||||||
{ Format: "dvbsub", Method: "Hls" },
|
|
||||||
{ Format: "dvbsub", Method: "External" },
|
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
|
||||||
{ Format: "microdvd", Method: "Embed" },
|
|
||||||
{ Format: "microdvd", Method: "Hls" },
|
|
||||||
{ Format: "microdvd", Method: "External" },
|
|
||||||
{ Format: "microdvd", Method: "Encode" },
|
|
||||||
{ Format: "mov_text", Method: "Embed" },
|
|
||||||
{ Format: "mov_text", Method: "Hls" },
|
|
||||||
{ Format: "mov_text", Method: "External" },
|
|
||||||
{ Format: "mov_text", Method: "Encode" },
|
|
||||||
{ Format: "mpl2", Method: "Embed" },
|
|
||||||
{ Format: "mpl2", Method: "Hls" },
|
|
||||||
{ Format: "mpl2", Method: "External" },
|
|
||||||
{ Format: "mpl2", Method: "Encode" },
|
|
||||||
{ Format: "pgs", Method: "Embed" },
|
|
||||||
{ Format: "pgs", Method: "Hls" },
|
|
||||||
{ Format: "pgs", Method: "External" },
|
|
||||||
{ Format: "pgs", Method: "Encode" },
|
|
||||||
{ Format: "pgssub", Method: "Embed" },
|
|
||||||
{ Format: "pgssub", Method: "External" },
|
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
|
||||||
{ Format: "pjs", Method: "Embed" },
|
|
||||||
{ Format: "pjs", Method: "Hls" },
|
|
||||||
{ Format: "pjs", Method: "External" },
|
|
||||||
{ Format: "pjs", Method: "Encode" },
|
|
||||||
{ Format: "realtext", Method: "Embed" },
|
|
||||||
{ Format: "realtext", Method: "Hls" },
|
|
||||||
{ Format: "realtext", Method: "External" },
|
|
||||||
{ Format: "realtext", Method: "Encode" },
|
|
||||||
{ Format: "scc", Method: "Embed" },
|
|
||||||
{ Format: "scc", Method: "Hls" },
|
|
||||||
{ Format: "scc", Method: "External" },
|
|
||||||
{ Format: "scc", Method: "Encode" },
|
|
||||||
{ Format: "smi", Method: "Embed" },
|
|
||||||
{ Format: "smi", Method: "Hls" },
|
|
||||||
{ Format: "smi", Method: "External" },
|
|
||||||
{ Format: "smi", Method: "Encode" },
|
|
||||||
{ Format: "srt", Method: "Embed" },
|
|
||||||
{ Format: "srt", Method: "Hls" },
|
|
||||||
{ Format: "srt", Method: "External" },
|
|
||||||
{ Format: "srt", Method: "Encode" },
|
|
||||||
{ Format: "ssa", Method: "Embed" },
|
|
||||||
{ Format: "ssa", Method: "Hls" },
|
|
||||||
{ Format: "ssa", Method: "External" },
|
|
||||||
{ Format: "ssa", Method: "Encode" },
|
|
||||||
{ Format: "stl", Method: "Embed" },
|
|
||||||
{ Format: "stl", Method: "Hls" },
|
|
||||||
{ Format: "stl", Method: "External" },
|
|
||||||
{ Format: "stl", Method: "Encode" },
|
|
||||||
{ Format: "sub", Method: "Embed" },
|
|
||||||
{ Format: "sub", Method: "Hls" },
|
|
||||||
{ Format: "sub", Method: "External" },
|
|
||||||
{ Format: "sub", Method: "Encode" },
|
|
||||||
{ Format: "subrip", Method: "Embed" },
|
|
||||||
{ Format: "subrip", Method: "Hls" },
|
|
||||||
{ Format: "subrip", Method: "External" },
|
|
||||||
{ Format: "subrip", Method: "Encode" },
|
|
||||||
{ Format: "subviewer", Method: "Embed" },
|
|
||||||
{ Format: "subviewer", Method: "Hls" },
|
|
||||||
{ Format: "subviewer", Method: "External" },
|
|
||||||
{ Format: "subviewer", Method: "Encode" },
|
|
||||||
{ Format: "teletext", Method: "Embed" },
|
|
||||||
{ Format: "teletext", Method: "Hls" },
|
|
||||||
{ Format: "teletext", Method: "External" },
|
|
||||||
{ Format: "teletext", Method: "Encode" },
|
|
||||||
{ Format: "text", Method: "Embed" },
|
|
||||||
{ Format: "text", Method: "Hls" },
|
|
||||||
{ Format: "text", Method: "External" },
|
|
||||||
{ Format: "text", Method: "Encode" },
|
|
||||||
{ Format: "ttml", Method: "Embed" },
|
|
||||||
{ Format: "ttml", Method: "Hls" },
|
|
||||||
{ Format: "ttml", Method: "External" },
|
|
||||||
{ Format: "ttml", Method: "Encode" },
|
|
||||||
{ Format: "vplayer", Method: "Embed" },
|
|
||||||
{ Format: "vplayer", Method: "Hls" },
|
|
||||||
{ Format: "vplayer", Method: "External" },
|
|
||||||
{ Format: "vplayer", Method: "Encode" },
|
|
||||||
{ Format: "vtt", Method: "Embed" },
|
{ Format: "vtt", Method: "Embed" },
|
||||||
{ Format: "vtt", Method: "Hls" },
|
{ Format: "vtt", Method: "Hls" },
|
||||||
{ Format: "vtt", Method: "External" },
|
{ Format: "vtt", Method: "External" },
|
||||||
{ Format: "vtt", Method: "Encode" },
|
{ Format: "vtt", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "webvtt", Method: "Embed" },
|
{ Format: "webvtt", Method: "Embed" },
|
||||||
{ Format: "webvtt", Method: "Hls" },
|
{ Format: "webvtt", Method: "Hls" },
|
||||||
{ Format: "webvtt", Method: "External" },
|
{ Format: "webvtt", Method: "External" },
|
||||||
{ Format: "webvtt", Method: "Encode" },
|
{ Format: "webvtt", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "srt", Method: "Embed" },
|
||||||
|
{ Format: "srt", Method: "Hls" },
|
||||||
|
{ Format: "srt", Method: "External" },
|
||||||
|
{ Format: "srt", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "subrip", Method: "Embed" },
|
||||||
|
{ Format: "subrip", Method: "Hls" },
|
||||||
|
{ Format: "subrip", Method: "External" },
|
||||||
|
{ Format: "subrip", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "ttml", Method: "Embed" },
|
||||||
|
{ Format: "ttml", Method: "Hls" },
|
||||||
|
{ Format: "ttml", Method: "External" },
|
||||||
|
{ Format: "ttml", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "dvbsub", Method: "Embed" },
|
||||||
|
{ Format: "dvbsub", Method: "Hls" },
|
||||||
|
{ Format: "dvbsub", Method: "External" },
|
||||||
|
{ Format: "dvdsub", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "ass", Method: "Embed" },
|
||||||
|
{ Format: "ass", Method: "Hls" },
|
||||||
|
{ Format: "ass", Method: "External" },
|
||||||
|
{ Format: "ass", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "idx", Method: "Embed" },
|
||||||
|
{ Format: "idx", Method: "Hls" },
|
||||||
|
{ Format: "idx", Method: "External" },
|
||||||
|
{ Format: "idx", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "pgs", Method: "Embed" },
|
||||||
|
{ Format: "pgs", Method: "Hls" },
|
||||||
|
{ Format: "pgs", Method: "External" },
|
||||||
|
{ Format: "pgs", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "pgssub", Method: "Embed" },
|
||||||
|
{ Format: "pgssub", Method: "Hls" },
|
||||||
|
{ Format: "pgssub", Method: "External" },
|
||||||
|
{ Format: "pgssub", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "ssa", Method: "Embed" },
|
||||||
|
{ Format: "ssa", Method: "Hls" },
|
||||||
|
{ Format: "ssa", Method: "External" },
|
||||||
|
{ Format: "ssa", Method: "Encode" },
|
||||||
|
|
||||||
|
// Other formats
|
||||||
|
{ Format: "microdvd", Method: "Embed" },
|
||||||
|
{ Format: "microdvd", Method: "Hls" },
|
||||||
|
{ Format: "microdvd", Method: "External" },
|
||||||
|
{ Format: "microdvd", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "mov_text", Method: "Embed" },
|
||||||
|
{ Format: "mov_text", Method: "Hls" },
|
||||||
|
{ Format: "mov_text", Method: "External" },
|
||||||
|
{ Format: "mov_text", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "mpl2", Method: "Embed" },
|
||||||
|
{ Format: "mpl2", Method: "Hls" },
|
||||||
|
{ Format: "mpl2", Method: "External" },
|
||||||
|
{ Format: "mpl2", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "pjs", Method: "Embed" },
|
||||||
|
{ Format: "pjs", Method: "Hls" },
|
||||||
|
{ Format: "pjs", Method: "External" },
|
||||||
|
{ Format: "pjs", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "realtext", Method: "Embed" },
|
||||||
|
{ Format: "realtext", Method: "Hls" },
|
||||||
|
{ Format: "realtext", Method: "External" },
|
||||||
|
{ Format: "realtext", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "scc", Method: "Embed" },
|
||||||
|
{ Format: "scc", Method: "Hls" },
|
||||||
|
{ Format: "scc", Method: "External" },
|
||||||
|
{ Format: "scc", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "smi", Method: "Embed" },
|
||||||
|
{ Format: "smi", Method: "Hls" },
|
||||||
|
{ Format: "smi", Method: "External" },
|
||||||
|
{ Format: "smi", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "stl", Method: "Embed" },
|
||||||
|
{ Format: "stl", Method: "Hls" },
|
||||||
|
{ Format: "stl", Method: "External" },
|
||||||
|
{ Format: "stl", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "sub", Method: "Embed" },
|
||||||
|
{ Format: "sub", Method: "Hls" },
|
||||||
|
{ Format: "sub", Method: "External" },
|
||||||
|
{ Format: "sub", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "subviewer", Method: "Embed" },
|
||||||
|
{ Format: "subviewer", Method: "Hls" },
|
||||||
|
{ Format: "subviewer", Method: "External" },
|
||||||
|
{ Format: "subviewer", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "teletext", Method: "Embed" },
|
||||||
|
{ Format: "teletext", Method: "Hls" },
|
||||||
|
{ Format: "teletext", Method: "External" },
|
||||||
|
{ Format: "teletext", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "text", Method: "Embed" },
|
||||||
|
{ Format: "text", Method: "Hls" },
|
||||||
|
{ Format: "text", Method: "External" },
|
||||||
|
{ Format: "text", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "vplayer", Method: "Embed" },
|
||||||
|
{ Format: "vplayer", Method: "Hls" },
|
||||||
|
{ Format: "vplayer", Method: "External" },
|
||||||
|
{ Format: "vplayer", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "xsub", Method: "Embed" },
|
{ Format: "xsub", Method: "Embed" },
|
||||||
{ Format: "xsub", Method: "Hls" },
|
{ Format: "xsub", Method: "Hls" },
|
||||||
{ Format: "xsub", Method: "External" },
|
{ Format: "xsub", Method: "External" },
|
||||||
|
|||||||
Reference in New Issue
Block a user