diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index dd86a357..00000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: '❌ bug'
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Smartphone (please complete the following information):**
- - Device: [e.g. iPhone15Pro]
- - OS: [e.g. iOS18]
- - Version [e.g. 0.3.1]
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..b65bc98f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,55 @@
+name: Bug report
+description: Create a report to help us improve
+title: '[Bug]: '
+labels:
+ - ['❌ bug']
+projects:
+ - ['fredrikburmester/5']
+assignees:
+ - fredrikburmester
+
+body:
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Also tell us, what did you expect to happen?
+ placeholder: A clear and concise description of what the bug is.
+ validations:
+ required: true
+
+ - type: textarea
+ id: repro
+ attributes:
+ label: Reproduction steps
+ description: "How do you trigger this bug? Please walk us through it step by step."
+ placeholder: |
+ 1.
+ 2.
+ 3.
+ ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: device
+ attributes:
+ label: Which device and operating system are you using?
+ description: e.g. iPhone 15, iOS 18.1.1
+ validations:
+ required: true
+
+ - type: textarea
+ id: version
+ attributes:
+ label: Which version of the app are you using?
+ description: e.g. 0.20.1
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ **Screenshots**
+ If applicable, please add screenshots to help explain your problem.
+ You can drag and drop images here or paste them directly into the comment box.
diff --git a/app.json b/app.json
index 2dfe334a..0f1c9ac2 100644
--- a/app.json
+++ b/app.json
@@ -41,7 +41,8 @@
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
- "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
+ "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
+ "android.permission.WRITE_SETTINGS"
]
},
"plugins": [
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 019236c0..e1c88490 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -458,21 +458,6 @@ export default function page() {
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
-
-
-
-
{videoRef.current && (
{
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
-
-
-
>
) : (
No video source...
diff --git a/bun.lockb b/bun.lockb
index 3a854c55..971e5d45 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
index 9a418357..556ae8c7 100644
--- a/components/downloads/ActiveDownloads.tsx
+++ b/components/downloads/ActiveDownloads.tsx
@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => {
Active downloads
{processes?.map((p) => (
-
+
))}
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
} else {
- FFmpegKit.cancel();
+ FFmpegKit.cancel(Number(id));
setProcesses((prev) => prev.filter((p) => p.id !== id));
}
},
diff --git a/components/inputs/Stepper.tsx b/components/inputs/Stepper.tsx
new file mode 100644
index 00000000..eb5032cf
--- /dev/null
+++ b/components/inputs/Stepper.tsx
@@ -0,0 +1,44 @@
+import {TouchableOpacity, View} from "react-native";
+import {Text} from "@/components/common/Text";
+
+interface StepperProps {
+ value: number,
+ step: number,
+ min: number,
+ max: number,
+ onUpdate: (value: number) => void,
+ appendValue?: string,
+}
+
+export const Stepper: React.FC = ({
+ value,
+ step,
+ min,
+ max,
+ onUpdate,
+ appendValue
+}) => {
+ return (
+
+ onUpdate(Math.max(min, value - step))}
+ className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
+ >
+ -
+
+
+ {value}{appendValue}
+
+ onUpdate(Math.min(max, value + step))}
+ >
+ +
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx
index a2229c4f..0390e14d 100644
--- a/components/settings/SettingToggles.tsx
+++ b/components/settings/SettingToggles.tsx
@@ -4,7 +4,7 @@ import {
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
-import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
+import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
@@ -17,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
+import {useEffect, useState} from "react";
import {
Linking,
Switch,
@@ -32,6 +32,7 @@ import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles";
+import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
@@ -483,7 +484,44 @@ export const SettingToggles: React.FC = ({ ...props }) => {
-
+
+
+ Remux max download
+
+ This is the total media you want to be able to download at the same time.
+
+
+ updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
+ />
+
+
Auto download
diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx
new file mode 100644
index 00000000..33fe0e0f
--- /dev/null
+++ b/components/video-player/controls/BrightnessSlider.tsx
@@ -0,0 +1,67 @@
+import React, { useEffect } from "react";
+import { View, StyleSheet } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import { Slider } from "react-native-awesome-slider";
+import * as Brightness from "expo-brightness";
+import { Ionicons } from "@expo/vector-icons";
+import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
+
+const BrightnessSlider = () => {
+ const brightness = useSharedValue(50);
+ const min = useSharedValue(0);
+ const max = useSharedValue(100);
+
+ useEffect(() => {
+ const fetchInitialBrightness = async () => {
+ const initialBrightness = await Brightness.getBrightnessAsync();
+ brightness.value = initialBrightness * 100;
+ };
+ fetchInitialBrightness();
+ }, [brightness]);
+
+ const handleValueChange = async (value: number) => {
+ brightness.value = value;
+ await Brightness.setBrightnessAsync(value / 100);
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ sliderContainer: {
+ width: 150,
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+});
+
+export default BrightnessSlider;
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index e61579de..a143fa0f 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -50,6 +50,7 @@ import { VideoProvider } from "./contexts/VideoContext";
import * as Haptics from "expo-haptics";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
+import BrightnessSlider from "./BrightnessSlider";
interface Props {
item: BaseItemDto;
@@ -324,220 +325,303 @@ export const Controls: React.FC = ({
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
-
-
- {!mediaSource?.TranscodingUrl ? (
-
- ) : (
-
- )}
-
+ {!mediaSource?.TranscodingUrl ? (
+
+ ) : (
+
+ )}
+
-
+
-
- Skip Intro
-
-
+ Skip Intro
+
+
-
+
+ Skip Credits
+
+
+
+ {
+ toggleControls();
+ }}
+ style={{
+ position: "absolute",
+ width: Dimensions.get("window").width,
+ height: Dimensions.get("window").height,
+ }}
+ >
+
+
+ top: 0,
+ right: 0,
+ opacity: showControls ? 1 : 0,
+ },
+ ]}
+ pointerEvents={showControls ? "auto" : "none"}
+ className={`flex flex-row items-center space-x-2 z-10 p-4 `}
+ >
+ {previousItem && (
- Skip Credits
-
-
-
- {
- toggleControls();
- }}
- style={{
- position: "absolute",
- width: Dimensions.get("window").width,
- height: Dimensions.get("window").height,
- }}
- >
-
-
- {Platform.OS !== "ios" && (
-
-
-
- )}
- {
- if (stop) await stop();
- router.back();
- }}
+ onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
-
+
-
+ )}
-
-
- {item?.Name}
- {item?.Type === "Episode" && (
- {item.SeriesName}
- )}
- {item?.Type === "Movie" && (
- {item?.ProductionYear}
- )}
- {item?.Type === "Audio" && (
- {item?.Album}
- )}
-
-
-
-
-
-
-
-
+
+ )}
+
+ {mediaSource?.TranscodingUrl && (
+
+
+
+ )}
+ {
+ if (stop) await stop();
+ router.back();
+ }}
+ className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {settings?.rewindSkipTime}
+
+
+
+
+ {
+ togglePlay(progress.value);
+ }}
+ >
+ {!isBuffering ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {settings?.forwardSkipTime}
+
+
+
+
+
+
+
+ {item?.Name}
+ {item?.Type === "Episode" && (
+ {item.SeriesName}
+ )}
+ {item?.Type === "Movie" && (
+ {item?.ProductionYear}
+ )}
+ {item?.Type === "Audio" && (
+ {item?.Album}
+ )}
+
+
+
+ (
+
-
- {
- togglePlay(progress.value);
- }}
- >
-
-
-
-
-
-
-
-
-
-
- {
- if (!trickPlayUrl || !trickplayInfo) {
- return null;
- }
- const { x, y, url } = trickPlayUrl;
-
- const tileWidth = 150;
- const tileHeight = 150 / trickplayInfo.aspectRatio!;
- return (
+ )}
+ cache={cacheProgress}
+ onSlidingStart={handleSliderStart}
+ onSlidingComplete={handleSliderComplete}
+ onValueChange={handleSliderChange}
+ containerStyle={{
+ borderRadius: 100,
+ }}
+ renderBubble={() => {
+ if (!trickPlayUrl || !trickplayInfo) {
+ return null;
+ }
+ const { x, y, url } = trickPlayUrl;
+ const tileWidth = 150;
+ const tileHeight = 150 / trickplayInfo.aspectRatio!;
+ return (
+
@@ -552,48 +636,44 @@ export const Controls: React.FC = ({
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
+ resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
-
- {`${time.hours > 0 ? `${time.hours}:` : ""}${
- time.minutes < 10 ? `0${time.minutes}` : time.minutes
- }:${
- time.seconds < 10 ? `0${time.seconds}` : time.seconds
- }`}
-
- );
- }}
- sliderHeight={10}
- thumbWidth={0}
- progress={progress}
- minimumValue={min}
- maximumValue={max}
- />
-
-
- {formatTimeString(currentTime, isVlc ? "ms" : "s")}
-
-
- -{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
-
-
+
+ {`${time.hours > 0 ? `${time.hours}:` : ""}${
+ time.minutes < 10 ? `0${time.minutes}` : time.minutes
+ }:${
+ time.seconds < 10 ? `0${time.seconds}` : time.seconds
+ }`}
+
+
+ );
+ }}
+ sliderHeight={10}
+ thumbWidth={0}
+ progress={progress}
+ minimumValue={min}
+ maximumValue={max}
+ />
+
+
+ {formatTimeString(currentTime, isVlc ? "ms" : "s")}
+
+
+ -{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
+
-
+
);
};
diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts
index dfbec4da..b722c3e6 100644
--- a/hooks/useRemuxHlsToMp4.ts
+++ b/hooks/useRemuxHlsToMp4.ts
@@ -1,7 +1,7 @@
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
-import { writeToLog } from "@/utils/log";
+import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log";
import {
BaseItemDto,
MediaSourceInfo,
@@ -9,12 +9,34 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
-import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
-import { useAtomValue } from "jotai";
-import { useCallback } from "react";
+import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native";
+import {useAtomValue} from "jotai";
+import {useCallback} from "react";
import { toast } from "sonner-native";
import useImageStorage from "./useImageStorage";
import useDownloadHelper from "@/utils/download";
+import {Api} from "@jellyfin/sdk";
+import {useSettings} from "@/utils/atoms/settings";
+import {JobStatus} from "@/utils/optimize-server";
+
+const createFFmpegCommand = (url: string, output: string) => [
+ "-y", // overwrite output files without asking
+ "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
+
+ // region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
+ "-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
+ "-multiple_requests 1", // http
+ "-tcp_nodelay 1", // http
+ // endregion ffmpeg protocol commands
+
+ "-fflags +genpts", // format flags
+ `-i ${url}`, // infile
+ "-map 0:v -map 0:a", // select all streams for video & audio
+ "-c copy", // streamcopy, preventing transcoding
+ "-bufsize 25M", // amount of data processed before calculating current bitrate
+ "-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
+ output
+]
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -25,11 +47,105 @@ import useDownloadHelper from "@/utils/download";
*/
export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
- const queryClient = useQueryClient();
- const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
- const { saveImage } = useImageStorage();
- const { saveSeriesPrimaryImage } = useDownloadHelper();
+ const queryClient = useQueryClient();
+
+ const [settings] = useSettings();
+ const {saveImage} = useImageStorage();
+ const {saveSeriesPrimaryImage} = useDownloadHelper();
+ const {saveDownloadedItemInfo, setProcesses, processes} = useDownload();
+
+ const onSaveAssets = async (api: Api, item: BaseItemDto) => {
+ await saveSeriesPrimaryImage(item);
+ const itemImage = getItemImage({
+ item,
+ api,
+ variant: "Primary",
+ quality: 90,
+ width: 500,
+ });
+
+ await saveImage(item.Id, itemImage?.uri);
+ }
+
+ const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => {
+ try {
+ let endTime;
+ const returnCode = await session.getReturnCode();
+ const startTime = new Date();
+
+ if (returnCode.isValueSuccess()) {
+ endTime = new Date();
+ const stat = await session.getLastReceivedStatistics();
+ await queryClient.invalidateQueries({queryKey: ["downloadedItems"]});
+
+ saveDownloadedItemInfo(item, stat.getSize());
+ writeInfoLog(
+ `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name},
+ start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
+ duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
+ .replace(/^ +/g, '')
+ )
+ toast.success("Download completed");
+ } else if (returnCode.isValueError()) {
+ endTime = new Date();
+ const allLogs = session.getAllLogsAsString();
+ writeErrorLog(
+ `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
+ start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
+ duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s. All logs: ${allLogs}`
+ .replace(/^ +/g, '')
+ )
+ } else if (returnCode.isValueCancel()) {
+ endTime = new Date();
+ writeInfoLog(
+ `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
+ start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
+ duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
+ .replace(/^ +/g, '')
+ )
+ }
+
+ setProcesses((prev) => {
+ return prev.filter((process) => process.itemId !== item.Id);
+ });
+ } catch (e) {
+ const error = e as Error;
+ writeErrorLog(
+ `useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name},
+ Error: ${error.message}, Stack: ${error.stack}`
+ .replace(/^ +/g, '')
+ );
+ }
+ }, [processes, setProcesses]);
+
+ const statisticsCallback = useCallback((statistics: Statistics, item: BaseItemDto) => {
+ const videoLength = (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
+ const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
+ const totalFrames = videoLength * fps;
+ const processedFrames = statistics.getVideoFrameNumber();
+ const speed = statistics.getSpeed();
+
+ const percentage =
+ totalFrames > 0
+ ? Math.floor((processedFrames / totalFrames) * 100)
+ : 0;
+
+ if (!item.Id) throw new Error("Item is undefined");
+ setProcesses((prev) => {
+ return prev.map((process) => {
+ if (process.itemId === item.Id) {
+ return {
+ ...process,
+ id: statistics.getSessionId().toString(),
+ progress: percentage,
+ speed: Math.max(speed, 0),
+ };
+ }
+ return process;
+ });
+ });
+ }, [setProcesses, completeCallback]);
const startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
@@ -37,16 +153,8 @@ export const useRemuxHlsToMp4 = () => {
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
- await saveSeriesPrimaryImage(item);
- const itemImage = getItemImage({
- item,
- api,
- variant: "Primary",
- quality: 90,
- width: 500,
- });
-
- await saveImage(item.Id, itemImage?.uri);
+ // First lets save any important assets we want to present to the user offline
+ await onSaveAssets(api, item);
toast.success(`Download started for ${item.Name}`, {
action: {
@@ -58,129 +166,34 @@ export const useRemuxHlsToMp4 = () => {
},
});
- const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
-
- writeToLog(
- "INFO",
- `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
- );
-
try {
- setProcesses((prev) => [
- ...prev,
- {
- id: "",
- deviceId: "",
- inputUrl: "",
- item: item,
- itemId: item.Id!,
- outputPath: "",
- progress: 0,
- status: "downloading",
- timestamp: new Date(),
- },
- ]);
+ const job: JobStatus = {
+ id: "",
+ deviceId: "",
+ inputUrl: url,
+ item: item,
+ itemId: item.Id!,
+ outputPath: output,
+ progress: 0,
+ status: "downloading",
+ timestamp: new Date(),
+ }
- FFmpegKitConfig.enableStatisticsCallback((statistics) => {
- const videoLength =
- (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
- const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
- const totalFrames = videoLength * fps;
- const processedFrames = statistics.getVideoFrameNumber();
- const speed = statistics.getSpeed();
+ writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
+ setProcesses((prev) => [...prev, job]);
- const percentage =
- totalFrames > 0
- ? Math.floor((processedFrames / totalFrames) * 100)
- : 0;
-
- if (!item.Id) throw new Error("Item is undefined");
- setProcesses((prev) => {
- return prev.map((process) => {
- if (process.itemId === item.Id) {
- return {
- ...process,
- progress: percentage,
- speed: Math.max(speed, 0),
- };
- }
- return process;
- });
- });
- });
-
- // Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
- await new Promise((resolve, reject) => {
- FFmpegKit.executeAsync(command, async (session) => {
- try {
- const returnCode = await session.getReturnCode();
- const startTime = new Date();
-
- let endTime;
- if (returnCode.isValueSuccess()) {
- endTime = new Date();
- writeToLog(
- "INFO",
- `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
- item.Name
- }, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
- (endTime.getTime() - startTime.getTime()) / 1000
- }s`
- );
- if (!item) throw new Error("Item is undefined");
- const stat = await session.getLastReceivedStatistics();
- await saveDownloadedItemInfo(item, stat.getSize());
- toast.success("Download completed");
- await queryClient.invalidateQueries({
- queryKey: ["downloadedItems"],
- });
- resolve();
- } else if (returnCode.isValueError()) {
- endTime = new Date();
- const allLogs = session.getAllLogsAsString();
- writeToLog(
- "ERROR",
- `useRemuxHlsToMp4 ~ remuxing failed for item: ${
- item.Name
- }, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
- (endTime.getTime() - startTime.getTime()) / 1000
- }s. All logs: ${allLogs}`
- );
- reject(new Error("Remuxing failed"));
- } else if (returnCode.isValueCancel()) {
- endTime = new Date();
- writeToLog(
- "INFO",
- `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${
- item.Name
- }, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
- (endTime.getTime() - startTime.getTime()) / 1000
- }s`
- );
- resolve();
- }
-
- setProcesses((prev) => {
- return prev.filter((process) => process.itemId !== item.Id);
- });
- } catch (e) {
- const error = e as Error;
- const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
- writeToLog(
- "ERROR",
- `useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
- );
- reject(error);
- }
- });
- });
+ await FFmpegKit.executeAsync(
+ createFFmpegCommand(url, output).join(" "),
+ session => completeCallback(session, item),
+ undefined,
+ s => statisticsCallback(s, item)
+ )
} catch (e) {
const error = e as Error;
- const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
console.error("Failed to remux:", error);
- writeToLog(
- "ERROR",
- `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}`
+ writeErrorLog(
+ `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
+ Error: ${error.message}, Stack: ${error.stack}`
);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
@@ -188,7 +201,7 @@ export const useRemuxHlsToMp4 = () => {
throw error; // Re-throw the error to propagate it to the caller
}
},
- []
+ [settings, processes, setProcesses, completeCallback, statisticsCallback]
);
const cancelRemuxing = useCallback(() => {
diff --git a/package.json b/package.json
index b71b26ff..cf2b2402 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"expo-asset": "~10.0.10",
"expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2",
+ "expo-brightness": "~12.0.1",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.29",
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 56072c07..48f7a536 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -30,7 +30,7 @@ import {
import axios from "axios";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
-import { useAtom } from "jotai";
+import {atom, useAtom} from "jotai";
import React, {
createContext,
useCallback,
@@ -56,6 +56,8 @@ export type DownloadedItem = {
size: number | undefined;
};
+export const processesAtom = atom([])
+
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
@@ -74,7 +76,7 @@ function useDownloadProvider() {
const {saveSeriesPrimaryImage} = useDownloadHelper();
const { saveImage } = useImageStorage();
- const [processes, setProcesses] = useState([]);
+ const [processes, setProcesses] = useAtom(processesAtom);
const authHeader = useMemo(() => {
return api?.accessToken;
diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts
index 8ec21e65..a6f967bb 100644
--- a/utils/atoms/queue.ts
+++ b/utils/atoms/queue.ts
@@ -1,6 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
+import {JobStatus} from "@/utils/optimize-server";
+import {processesAtom} from "@/providers/DownloadProvider";
+import {useSettings} from "@/utils/atoms/settings";
export interface Job {
id: string;
@@ -49,11 +52,13 @@ export const queueActions = {
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [running, setRunning] = useAtom(runningAtom);
+ const [processes] = useAtom(processesAtom);
+ const [settings] = useSettings();
useEffect(() => {
- if (queue.length > 0 && !running) {
+ if (queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setRunning);
}
- }, [queue, running, setQueue, setRunning]);
+ }, [processes, queue, running, setQueue, setRunning]);
};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index c7218d60..74fe8c9a 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -76,6 +76,7 @@ export type Settings = {
autoDownload: boolean;
showCustomMenuLinks: boolean;
subtitleSize: number;
+ remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max?
};
const loadSettings = (): Settings => {
@@ -107,6 +108,7 @@ const loadSettings = (): Settings => {
autoDownload: false,
showCustomMenuLinks: false,
subtitleSize: 60,
+ remuxConcurrentLimit: 1,
};
try {
diff --git a/utils/log.tsx b/utils/log.tsx
index 5658ec3d..7c432406 100644
--- a/utils/log.tsx
+++ b/utils/log.tsx
@@ -55,6 +55,9 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
storage.set("logs", JSON.stringify(recentLogs));
};
+export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
+export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
+
export const readFromLog = (): LogEntry[] => {
const logs = storage.getString("logs");
return logs ? JSON.parse(logs) : [];