Merge branch 'master' into feature/subtitle-size-change

This commit is contained in:
Fredrik Burmester
2024-12-05 18:10:38 +01:00
committed by GitHub
17 changed files with 685 additions and 429 deletions

View File

@@ -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]

55
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -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.

View File

@@ -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": [

View File

@@ -458,21 +458,6 @@ export default function page() {
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</View>
{videoRef.current && (
<Controls

View File

@@ -467,20 +467,6 @@ const Player = () => {
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</>
) : (
<Text>No video source...</Text>

BIN
bun.lockb

Binary file not shown.

View File

@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
<Text className="text-lg font-bold mb-2">Active downloads</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.id} process={p} />
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
</View>
@@ -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));
}
},

View File

@@ -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<StepperProps> = ({
value,
step,
min,
max,
onUpdate,
appendValue
}) => {
return (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() => onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text
className={
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
}
>
{value}{appendValue}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() => onUpdate(Math.min(max, value + step))}
>
<Text>+</Text>
</TouchableOpacity>
</View>
)
}

View File

@@ -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,
@@ -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> = ({ ...props }) => {
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View
pointerEvents={
settings.downloadMethod === "remux" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "remux"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Remux max download</Text>
<Text className="text-xs opacity-50 shrink">
This is the total media you want to be able to download at the same time.
</Text>
</View>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Auto download</Text>
<Text className="text-xs opacity-50 shrink">

View File

@@ -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 (
<View style={styles.sliderContainer}>
<Slider
progress={brightness}
minimumValue={min}
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons
name="sunny"
size={20}
color="#FDFDFD"
style={{
marginLeft: 8,
}}
/>
</View>
);
};
const styles = StyleSheet.create({
sliderContainer: {
width: 150,
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
});
export default BrightnessSlider;

View File

@@ -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;
@@ -323,16 +324,6 @@ export const Controls: React.FC<Props> = ({
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
<SafeAreaView
style={{
flex: 1,
position: "absolute",
top: insets.top,
left: insets.left,
right: insets.right,
bottom: insets.bottom,
}}
>
<VideoProvider
getAudioTracks={getAudioTracks}
@@ -352,7 +343,7 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
bottom: 97,
bottom: 110,
},
]}
className={`z-10 p-4
@@ -370,7 +361,7 @@ export const Controls: React.FC<Props> = ({
<View
style={{
position: "absolute",
bottom: 94,
bottom: 110,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
@@ -409,7 +400,25 @@ export const Controls: React.FC<Props> = ({
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{Platform.OS !== "ios" && (
{previousItem && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
)}
{nextItem && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
@@ -432,6 +441,101 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
top: "50%", // Center vertically
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
paddingHorizontal: "28%", // Add some padding to the left and right
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
bottom: 30,
}}
>
<BrightnessSlider />
</View>
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name="refresh-outline"
size={50}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color="white"
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name="refresh-outline" size={50} color="white" />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
</View>
<View
style={[
{
@@ -458,50 +562,8 @@ export const Controls: React.FC<Props> = ({
)}
</View>
<View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800`}
className={`flex flex-col-reverse py-4 pb-2 px-4 rounded-2xl items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={goToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
@@ -509,9 +571,22 @@ export const Controls: React.FC<Props> = ({
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => (
<View
style={{
width: 18,
height: 18,
left: -2,
borderRadius: 10,
backgroundColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
/>
)}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
@@ -524,20 +599,29 @@ export const Controls: React.FC<Props> = ({
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
left: -57,
bottom: 15,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5, // Adjust the width of the outer container if needed
backgroundColor: "rgba(0, 0, 0, 0.6)", // Outer box background color (optional)
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5, // Optional border radius
}}
className=" bg-neutral-800 overflow-hidden"
>
@@ -552,19 +636,16 @@ export const Controls: React.FC<Props> = ({
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
position: "absolute",
bottom: 5,
left: 5,
color: "white",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 5,
borderRadius: 5,
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
@@ -593,7 +674,6 @@ export const Controls: React.FC<Props> = ({
</View>
</View>
</View>
</SafeAreaView>
</ControlProvider>
);
};

View File

@@ -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 {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,18 +47,15 @@ import useDownloadHelper from "@/utils/download";
*/
export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const {saveImage} = useImageStorage();
const {saveSeriesPrimaryImage} = useDownloadHelper();
const {saveDownloadedItemInfo, setProcesses, processes} = useDownload();
const startRemuxing = useCallback(
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");
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
@@ -47,43 +66,61 @@ export const useRemuxHlsToMp4 = () => {
});
await saveImage(item.Id, itemImage?.uri);
}
toast.success(`Download started for ${item.Name}`, {
action: {
label: "Go to download",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
});
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}`
);
const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => {
try {
setProcesses((prev) => [
...prev,
{
id: "",
deviceId: "",
inputUrl: "",
item: item,
itemId: item.Id!,
outputPath: "",
progress: 0,
status: "downloading",
timestamp: new Date(),
},
]);
let endTime;
const returnCode = await session.getReturnCode();
const startTime = new Date();
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
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();
@@ -100,6 +137,7 @@ export const useRemuxHlsToMp4 = () => {
if (process.itemId === item.Id) {
return {
...process,
id: statistics.getSessionId().toString(),
progress: percentage,
speed: Math.max(speed, 0),
};
@@ -107,80 +145,55 @@ export const useRemuxHlsToMp4 = () => {
return process;
});
});
}, [setProcesses, completeCallback]);
const startRemuxing = useCallback(
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");
// 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: {
label: "Go to download",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((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();
const job: JobStatus = {
id: "",
deviceId: "",
inputUrl: url,
item: item,
itemId: item.Id!,
outputPath: output,
progress: 0,
status: "downloading",
timestamp: new Date(),
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev) => [...prev, job]);
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}`;
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
);
reject(error);
}
});
});
} 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(() => {

View File

@@ -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",

View File

@@ -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<JobStatus[]>([])
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
@@ -74,7 +76,7 @@ function useDownloadProvider() {
const {saveSeriesPrimaryImage} = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useState<JobStatus[]>([]);
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const authHeader = useMemo(() => {
return api?.accessToken;

View File

@@ -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<JobStatus[]>(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]);
};

View File

@@ -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 {

View File

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