diff --git a/app/(auth)/player/player.tsx b/app/(auth)/player/player.tsx
index 57b28db2..c86f2281 100644
--- a/app/(auth)/player/player.tsx
+++ b/app/(auth)/player/player.tsx
@@ -2,6 +2,7 @@ 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 { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
@@ -11,12 +12,12 @@ import {
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
+import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
-import android from "@/utils/profiles/android";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -29,13 +30,7 @@ import * as Haptics from "expo-haptics";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
-import {
- Alert,
- Platform,
- Pressable,
- useWindowDimensions,
- View,
-} from "react-native";
+import { Alert, Pressable, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
export default function page() {
@@ -43,8 +38,6 @@ export default function page() {
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
- const windowDimensions = useWindowDimensions();
-
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
@@ -56,20 +49,26 @@ export default function page() {
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
+ const { getDownloadedItem } = useDownload();
+
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
+ offline: offlineStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
+ offline: string;
}>();
+ const offline = offlineStr === "true";
+
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr
@@ -84,6 +83,12 @@ export default function page() {
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) return;
+
+ if (offline) {
+ const item = await getDownloadedItem(itemId);
+ if (item) return item.item;
+ }
+
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
@@ -110,6 +115,21 @@ export default function page() {
],
queryFn: async () => {
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({
api,
item,
@@ -134,7 +154,7 @@ export default function page() {
url,
};
},
- enabled: !!itemId && !!api && !!item,
+ enabled: !!itemId && !!api && !!item && !offline,
staleTime: 0,
});
@@ -146,33 +166,38 @@ export default function page() {
if (isPlaying) {
await videoRef.current?.pause();
- await getPlaystateApi(api).onPlaybackProgress({
- itemId: item?.Id!,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
- mediaSourceId: mediaSourceId,
- positionTicks: msToTicks(ms),
- isPaused: true,
- playMethod: stream.url?.includes("m3u8")
- ? "Transcode"
- : "DirectStream",
- playSessionId: stream.sessionId,
- });
+ if (!offline) {
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: msToTicks(ms),
+ isPaused: true,
+ playMethod: stream.url?.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream.sessionId,
+ });
+ }
+
console.log("Actually marked as paused");
} else {
videoRef.current?.play();
- await getPlaystateApi(api).onPlaybackProgress({
- itemId: item?.Id!,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
- mediaSourceId: mediaSourceId,
- positionTicks: msToTicks(ms),
- isPaused: false,
- playMethod: stream?.url.includes("m3u8")
- ? "Transcode"
- : "DirectStream",
- playSessionId: stream.sessionId,
- });
+ if (!offline) {
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: msToTicks(ms),
+ isPaused: false,
+ playMethod: stream?.url.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream.sessionId,
+ });
+ }
}
},
[
@@ -184,6 +209,7 @@ export default function page() {
audioIndex,
subtitleIndex,
mediaSourceId,
+ offline,
]
);
@@ -202,19 +228,20 @@ export default function page() {
reportPlaybackStopped();
}, [videoRef]);
- const reportPlaybackStopped = async () => {
+ const reportPlaybackStopped = useCallback(async () => {
+ if (offline) return;
const currentTimeInTicks = msToTicks(progress.value);
-
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!,
});
- };
+ }, [api, item, mediaSourceId, stream]);
- const reportPlaybackStart = async () => {
+ const reportPlaybackStart = useCallback(async () => {
if (!api || !stream) return;
+ if (offline) return;
await getPlaystateApi(api).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
@@ -223,7 +250,7 @@ export default function page() {
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
- };
+ }, [api, item, mediaSourceId, stream]);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
@@ -238,6 +265,9 @@ export default function page() {
}
progress.value = currentTime;
+
+ if (offline) return;
+
const currentTimeInTicks = msToTicks(currentTime);
await getPlaystateApi(api).onPlaybackProgress({
@@ -262,9 +292,10 @@ export default function page() {
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
+ offline: offline,
});
- const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
+ const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
@@ -283,7 +314,15 @@ export default function page() {
} else if (isBuffering) {
setIsBuffering(true);
}
- };
+ }, []);
+
+ const startPosition = useMemo(() => {
+ if (offline) return 0;
+
+ return item?.UserData?.PlaybackPositionTicks
+ ? ticksToSeconds(item.UserData.PlaybackPositionTicks)
+ : 0;
+ }, [item]);
if (isLoadingItem || isLoadingStreamUrl)
return (
@@ -301,11 +340,6 @@ export default function page() {
if (!stream || !item) return null;
- console.log("AudioIndex", audioIndex);
- const startPosition = item?.UserData?.PlaybackPositionTicks
- ? ticksToSeconds(item.UserData.PlaybackPositionTicks)
- : 0;
-
return (
(b.value || Infinity) - (a.value || Infinity));
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index e1f90a10..abe3e702 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -31,6 +31,7 @@ import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -42,7 +43,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
- const { startRemuxing } = useRemuxHlsToMp4(item);
+ const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
@@ -98,73 +99,33 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
);
}
- const response = await api.axiosInstance.post(
- `${api.basePath}/Items/${item.Id}/PlaybackInfo`,
- {
- DeviceProfile: native,
- UserId: user.Id,
- MaxStreamingBitrate: maxBitrate.value,
- StartTimeTicks: 0,
- EnableTranscoding: maxBitrate.value ? true : undefined,
- AutoOpenLiveStream: true,
- AllowVideoStreamCopy: maxBitrate.value ? false : true,
- MediaSourceId: selectedMediaSource?.Id,
- AudioStreamIndex: selectedAudioStream,
- SubtitleStreamIndex: selectedSubtitleStream,
- },
- {
- headers: {
- Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
- },
- }
- );
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user?.Id,
+ audioStreamIndex: selectedAudioStream,
+ maxStreamingBitrate: maxBitrate.value,
+ mediaSourceId: selectedMediaSource.Id,
+ subtitleStreamIndex: selectedSubtitleStream,
+ deviceProfile: native,
+ });
- let url: string | undefined = undefined;
- let fileExtension: string | undefined | null = "mp4";
+ if (!res) return null;
- const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
- (source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
- );
+ const { mediaSource, url } = res;
- if (!mediaSource) {
- throw new Error("No media source");
- }
-
- 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 (!url || !mediaSource) throw new Error("No url");
+ if (!mediaSource.TranscodingContainer) throw new Error("No file extension");
if (settings?.downloadMethod === "optimized") {
- return await startBackgroundDownload(url, item, fileExtension);
+ return await startBackgroundDownload(
+ url,
+ item,
+ mediaSource.TranscodingContainer
+ );
} else {
- return await startRemuxing(url);
+ return await startRemuxing(item, url, mediaSource);
}
}, [
api,
@@ -203,7 +164,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
const process = useMemo(() => {
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]);
return (
@@ -211,7 +172,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
- {process && process?.item.Id === item.Id ? (
+ {process && process?.item.item.Id === item.Id ? (
{
router.push("/downloads");
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
index 034f2f4b..05bc0efe 100644
--- a/components/downloads/ActiveDownloads.tsx
+++ b/components/downloads/ActiveDownloads.tsx
@@ -93,18 +93,20 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const eta = (p: JobStatus) => {
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;
return formatTimeString(timeLeft, "tick");
};
const base64Image = useMemo(() => {
- return storage.getString(process.item.Id!);
+ return storage.getString(process.item.item.Id!);
}, []);
return (
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"
{...props}
>
@@ -138,10 +140,12 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)}
- {process.item.Type}
- {process.item.Name}
+ {process.item.item.Type}
+
+ {process.item.item.Name}
+
- {process.item.ProductionYear}
+ {process.item.item.ProductionYear}
{process.progress === 0 ? (
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 3d95821c..80a50d3d 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -7,7 +7,7 @@ import {
useActionSheet,
} from "@expo/react-native-action-sheet";
-import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
+import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
@@ -26,7 +26,7 @@ interface EpisodeCardProps {
*/
export const EpisodeCard: React.FC = ({ item }) => {
const { deleteFile } = useDownload();
- const { openFile } = useFileOpener();
+ const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 54381c08..9cc52afd 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -10,7 +10,7 @@ import {
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "../common/Text";
-import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
+import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
@@ -28,7 +28,7 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC = ({ item }) => {
const { deleteFile } = useDownload();
- const { openFile } = useFileOpener();
+ const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const handleOpenFile = useCallback(() => {
diff --git a/components/video-player/Controls.tsx b/components/video-player/Controls.tsx
index e9a26f35..5b766dd0 100644
--- a/components/video-player/Controls.tsx
+++ b/components/video-player/Controls.tsx
@@ -116,9 +116,7 @@ export const Controls: React.FC = ({
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(
@@ -157,14 +155,6 @@ export const Controls: React.FC = ({
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() ?? "",
@@ -183,14 +173,6 @@ export const Controls: React.FC = ({
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() ?? "",
@@ -374,6 +356,8 @@ export const Controls: React.FC = ({
}))
.filter((sub) => !sub.name.endsWith("[External]")) || [];
+ console.log("embeddedSubs ~", embeddedSubs);
+
const externalSubs =
mediaSource?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts
index 67244d2c..791dd753 100644
--- a/hooks/useDownloadedFileOpener.ts
+++ b/hooks/useDownloadedFileOpener.ts
@@ -1,51 +1,55 @@
-// hooks/useFileOpener.ts
-
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { useCallback } from "react";
-import { Platform } from "react-native";
-export const useFileOpener = () => {
+export const getDownloadedFileUrl = async (itemId: string): Promise => {
+ 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 { setPlayUrl, setOfflineSettings } = usePlaySettings();
- const openFile = useCallback(async (item: BaseItemDto) => {
- const directory = FileSystem.documentDirectory;
+ const openFile = useCallback(
+ async (item: BaseItemDto) => {
+ try {
+ const url = await getDownloadedFileUrl(item.Id!);
- if (!directory) {
- throw new Error("Document directory is not available");
- }
+ setOfflineSettings({
+ item,
+ });
+ setPlayUrl(url);
- if (!item.Id) {
- throw new Error("Item ID is not available");
- }
-
- try {
- 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}`);
+ // @ts-expect-error
+ router.push("/player?offline=true&itemId=" + item.Id);
+ } catch (error) {
+ writeToLog("ERROR", "Error opening file", error);
+ console.error("Error opening file:", error);
}
-
- const url = `${directory}${matchingFile}`;
-
- 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);
- }
- }, []);
+ },
+ [setOfflineSettings, setPlayUrl, router]
+ );
return { openFile };
};
diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts
index db7c0a88..d38ae713 100644
--- a/hooks/useRemuxHlsToMp4.ts
+++ b/hooks/useRemuxHlsToMp4.ts
@@ -3,7 +3,10 @@ import { useAtom, useAtomValue } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
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 { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner-native";
@@ -21,22 +24,16 @@ import { apiAtom } from "@/providers/JellyfinProvider";
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
-export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
+export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
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(
- 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 (!item.Id) throw new Error("Item must have an Id");
@@ -74,13 +71,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
id: "",
deviceId: "",
inputUrl: "",
- item,
- itemId: item.Id,
+ item: {
+ item,
+ mediaSource,
+ },
+ itemId: item.Id!,
outputPath: "",
progress: 0,
status: "downloading",
timestamp: new Date(),
- } as JobStatus,
+ },
]);
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
@@ -119,7 +119,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
if (returnCode.isValueSuccess()) {
if (!item) throw new Error("Item is undefined");
- await saveDownloadedItemInfo(item);
+ await saveDownloadedItemInfo(item, mediaSource);
toast.success("Download completed");
writeToLog(
"INFO",
@@ -134,7 +134,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
"ERROR",
`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()) {
writeToLog(
"INFO",
@@ -163,15 +163,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
throw error; // Re-throw the error to propagate it to the caller
}
},
- [output, item]
+ []
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
- setProcesses((prev) => {
- return prev.filter((process) => process.itemId !== item.Id);
- });
- }, [item.Name]);
+ setProcesses([]);
+ }, []);
return { startRemuxing, cancelRemuxing };
};
diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts
index 92c647bb..473106d1 100644
--- a/hooks/useWebsockets.ts
+++ b/hooks/useWebsockets.ts
@@ -15,6 +15,7 @@ interface UseWebSocketProps {
pauseVideo: () => void;
playVideo: () => void;
stopPlayback: () => void;
+ offline?: boolean;
}
export const useWebSocket = ({
@@ -22,6 +23,7 @@ export const useWebSocket = ({
pauseVideo,
playVideo,
stopPlayback,
+ offline = false,
}: UseWebSocketProps) => {
const router = useRouter();
const user = useAtomValue(userAtom);
@@ -38,7 +40,7 @@ export const useWebSocket = ({
});
useEffect(() => {
- if (!deviceId || !api?.accessToken) return;
+ if (offline || !deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
@@ -80,10 +82,10 @@ export const useWebSocket = ({
}
newWebSocket.close();
};
- }, [api, deviceId, user]);
+ }, [api, deviceId, user, offline]);
useEffect(() => {
- if (!ws) return;
+ if (offline || !ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
@@ -106,7 +108,7 @@ export const useWebSocket = ({
Alert.alert("Message from server: " + title, body);
}
};
- }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
+ }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
return { isConnected };
};
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index b1286c81..49b6c3fe 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -7,7 +7,10 @@ import {
getAllJobsByDeviceId,
JobStatus,
} from "@/utils/optimize-server";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
import {
checkForExistingDownloads,
completeHandler,
@@ -41,6 +44,11 @@ import * as Notifications from "expo-notifications";
import { getItemImage } from "@/utils/getItemImage";
import useImageStorage from "@/hooks/useImageStorage";
+export type DownloadedItem = {
+ item: Partial;
+ mediaSource: MediaSourceInfo;
+};
+
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
@@ -122,7 +130,7 @@ function useDownloadProvider() {
if (settings.autoDownload) {
startDownload(job);
} else {
- toast.info(`${job.item.Name} is ready to be downloaded`, {
+ toast.info(`${job.item.item.Name} is ready to be downloaded`, {
action: {
label: "Go to downloads",
onClick: () => {
@@ -133,8 +141,8 @@ function useDownloadProvider() {
});
Notifications.scheduleNotificationAsync({
content: {
- title: job.item.Name,
- body: `${job.item.Name} is ready to be downloaded`,
+ title: job.item.item.Name,
+ body: `${job.item.item.Name} is ready to be downloaded`,
data: {
url: `/downloads`,
},
@@ -182,7 +190,7 @@ function useDownloadProvider() {
const startDownload = useCallback(
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) =>
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: {
label: "Go to downloads",
onClick: () => {
@@ -220,7 +228,7 @@ function useDownloadProvider() {
download({
id: process.id,
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
- destination: `${baseDirectory}/${process.item.Id}.mp4`,
+ destination: `${baseDirectory}/${process.item.item.Id}.mp4`,
})
.begin(() => {
setProcesses((prev) =>
@@ -252,8 +260,11 @@ function useDownloadProvider() {
);
})
.done(async () => {
- await saveDownloadedItemInfo(process.item);
- toast.success(`Download completed for ${process.item.Name}`, {
+ await saveDownloadedItemInfo(
+ process.item.item,
+ process.item.mediaSource
+ );
+ toast.success(`Download completed for ${process.item.item.Name}`, {
duration: 3000,
action: {
label: "Go to downloads",
@@ -278,13 +289,15 @@ function useDownloadProvider() {
if (error.errorCode === 404) {
errorMsg = "File not found on server";
}
- toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
- writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
+ toast.error(
+ `Download failed for ${process.item.item.Name} - ${errorMsg}`
+ );
+ writeToLog("ERROR", `Download failed for ${process.item.item.Name}`, {
error,
processDetails: {
id: process.id,
- itemName: process.item.Name,
- itemId: process.item.Id,
+ itemName: process.item.item.Name,
+ itemId: process.item.item.Id,
},
});
console.error("Error details:", {
@@ -485,11 +498,28 @@ function useDownloadProvider() {
}
};
- async function getAllDownloadedItems(): Promise {
+ async function getDownloadedItem(
+ itemId: string
+ ): Promise {
try {
const downloadedItems = await AsyncStorage.getItem("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 {
+ try {
+ const downloadedItems = await AsyncStorage.getItem("downloadedItems");
+ if (downloadedItems) {
+ return JSON.parse(downloadedItems) as DownloadedItem[];
} else {
return [];
}
@@ -499,25 +529,32 @@ function useDownloadProvider() {
}
}
- async function saveDownloadedItemInfo(item: BaseItemDto) {
+ async function saveDownloadedItemInfo(
+ item: BaseItemDto,
+ mediaSource: MediaSourceInfo
+ ) {
try {
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
- let items: BaseItemDto[] = downloadedItems
- ? JSON.parse(downloadedItems)
- : [];
+ let items: { item: BaseItemDto; mediaSource: MediaSourceInfo }[] =
+ 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) {
- items[existingItemIndex] = item;
+ items[existingItemIndex] = newItem;
} else {
- items.push(item);
+ items.push(newItem);
}
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
refetch();
} 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,
setProcesses,
startDownload,
+ getDownloadedItem,
};
}
diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts
index 244ed0da..54c0439c 100644
--- a/utils/optimize-server.ts
+++ b/utils/optimize-server.ts
@@ -1,7 +1,11 @@
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 { writeToLog } from "./log";
+import { DownloadedItem } from "@/providers/DownloadProvider";
interface IJobInput {
deviceId?: string | null;
@@ -23,7 +27,7 @@ export interface JobStatus {
inputUrl: string;
deviceId: string;
itemId: string;
- item: Partial;
+ item: DownloadedItem;
speed?: number;
timestamp: Date;
base64Image?: string;
diff --git a/utils/profiles/native.js b/utils/profiles/native.js
index 37f6773b..e54c8764 100644
--- a/utils/profiles/native.js
+++ b/utils/profiles/native.js
@@ -58,97 +58,128 @@ export default {
},
],
SubtitleProfiles: [
- { Format: "ass", Method: "Embed" },
- { 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" },
+ // Official foramts
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "Hls" },
{ Format: "vtt", Method: "External" },
{ Format: "vtt", Method: "Encode" },
+
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "Hls" },
{ Format: "webvtt", Method: "External" },
{ Format: "webvtt", Method: "Encode" },
+
+ { Format: "srt", Method: "Embed" },
+ { Format: "srt", Method: "Hls" },
+ { Format: "srt", Method: "External" },
+ { Format: "srt", Method: "Encode" },
+
+ { Format: "subrip", Method: "Embed" },
+ { Format: "subrip", Method: "Hls" },
+ { Format: "subrip", Method: "External" },
+ { Format: "subrip", Method: "Encode" },
+
+ { Format: "ttml", Method: "Embed" },
+ { Format: "ttml", Method: "Hls" },
+ { Format: "ttml", Method: "External" },
+ { Format: "ttml", Method: "Encode" },
+
+ { Format: "dvbsub", Method: "Embed" },
+ { Format: "dvbsub", Method: "Hls" },
+ { Format: "dvbsub", Method: "External" },
+ { Format: "dvdsub", Method: "Encode" },
+
+ { Format: "ass", Method: "Embed" },
+ { Format: "ass", Method: "Hls" },
+ { Format: "ass", Method: "External" },
+ { Format: "ass", Method: "Encode" },
+
+ { Format: "idx", Method: "Embed" },
+ { Format: "idx", Method: "Hls" },
+ { Format: "idx", Method: "External" },
+ { Format: "idx", Method: "Encode" },
+
+ { Format: "pgs", Method: "Embed" },
+ { Format: "pgs", Method: "Hls" },
+ { Format: "pgs", Method: "External" },
+ { Format: "pgs", Method: "Encode" },
+
+ { Format: "pgssub", Method: "Embed" },
+ { Format: "pgssub", Method: "Hls" },
+ { Format: "pgssub", Method: "External" },
+ { Format: "pgssub", Method: "Encode" },
+
+ { Format: "ssa", Method: "Embed" },
+ { Format: "ssa", Method: "Hls" },
+ { Format: "ssa", Method: "External" },
+ { Format: "ssa", Method: "Encode" },
+
+ // Other formats
+ { Format: "microdvd", Method: "Embed" },
+ { Format: "microdvd", Method: "Hls" },
+ { Format: "microdvd", Method: "External" },
+ { Format: "microdvd", Method: "Encode" },
+
+ { Format: "mov_text", Method: "Embed" },
+ { Format: "mov_text", Method: "Hls" },
+ { Format: "mov_text", Method: "External" },
+ { Format: "mov_text", Method: "Encode" },
+
+ { Format: "mpl2", Method: "Embed" },
+ { Format: "mpl2", Method: "Hls" },
+ { Format: "mpl2", Method: "External" },
+ { Format: "mpl2", Method: "Encode" },
+
+ { Format: "pjs", Method: "Embed" },
+ { Format: "pjs", Method: "Hls" },
+ { Format: "pjs", Method: "External" },
+ { Format: "pjs", Method: "Encode" },
+
+ { Format: "realtext", Method: "Embed" },
+ { Format: "realtext", Method: "Hls" },
+ { Format: "realtext", Method: "External" },
+ { Format: "realtext", Method: "Encode" },
+
+ { Format: "scc", Method: "Embed" },
+ { Format: "scc", Method: "Hls" },
+ { Format: "scc", Method: "External" },
+ { Format: "scc", Method: "Encode" },
+
+ { Format: "smi", Method: "Embed" },
+ { Format: "smi", Method: "Hls" },
+ { Format: "smi", Method: "External" },
+ { Format: "smi", Method: "Encode" },
+
+ { Format: "stl", Method: "Embed" },
+ { Format: "stl", Method: "Hls" },
+ { Format: "stl", Method: "External" },
+ { Format: "stl", Method: "Encode" },
+
+ { Format: "sub", Method: "Embed" },
+ { Format: "sub", Method: "Hls" },
+ { Format: "sub", Method: "External" },
+ { Format: "sub", Method: "Encode" },
+
+ { Format: "subviewer", Method: "Embed" },
+ { Format: "subviewer", Method: "Hls" },
+ { Format: "subviewer", Method: "External" },
+ { Format: "subviewer", Method: "Encode" },
+
+ { Format: "teletext", Method: "Embed" },
+ { Format: "teletext", Method: "Hls" },
+ { Format: "teletext", Method: "External" },
+ { Format: "teletext", Method: "Encode" },
+
+ { Format: "text", Method: "Embed" },
+ { Format: "text", Method: "Hls" },
+ { Format: "text", Method: "External" },
+ { Format: "text", Method: "Encode" },
+
+ { Format: "vplayer", Method: "Embed" },
+ { Format: "vplayer", Method: "Hls" },
+ { Format: "vplayer", Method: "External" },
+ { Format: "vplayer", Method: "Encode" },
+
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "Hls" },
{ Format: "xsub", Method: "External" },