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) : [];