mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: sarendsen <coding-mosses0z@icloud.com> Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
154788cf91
commit
485dc6eeac
@@ -1,23 +0,0 @@
|
||||
import {
|
||||
Orientation,
|
||||
OrientationLock,
|
||||
} from "@/packages/expo-screen-orientation";
|
||||
|
||||
function orientationToOrientationLock(
|
||||
orientation: Orientation,
|
||||
): OrientationLock {
|
||||
switch (orientation) {
|
||||
case Orientation.PORTRAIT_UP:
|
||||
return OrientationLock.PORTRAIT_UP;
|
||||
case Orientation.PORTRAIT_DOWN:
|
||||
return OrientationLock.PORTRAIT_DOWN;
|
||||
case Orientation.LANDSCAPE_LEFT:
|
||||
return OrientationLock.LANDSCAPE_LEFT;
|
||||
case Orientation.LANDSCAPE_RIGHT:
|
||||
return OrientationLock.LANDSCAPE_RIGHT;
|
||||
default:
|
||||
return OrientationLock.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
export default orientationToOrientationLock;
|
||||
@@ -91,7 +91,7 @@ export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.delete(key);
|
||||
storage.remove(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -108,7 +108,7 @@ export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.delete(key);
|
||||
storage.remove(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
|
||||
export const orientationAtom = atom<number>(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
@@ -3,7 +3,6 @@ import { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { processesAtom } from "@/providers/DownloadProvider";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
@@ -52,21 +51,21 @@ export const queueActions = {
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_CONCURRENT_LIMIT = 2;
|
||||
|
||||
export const useJobProcessor = () => {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [running, setRunning] = useAtom(runningAtom);
|
||||
const [processes] = useAtom<JobStatus[]>(processesAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!running &&
|
||||
queue.length > 0 &&
|
||||
settings &&
|
||||
processes.length < settings?.remuxConcurrentLimit
|
||||
processes.length < DEFAULT_CONCURRENT_LIMIT
|
||||
) {
|
||||
console.info("Processing queue", queue);
|
||||
queueActions.processJob(queue, setQueue, setRunning);
|
||||
}
|
||||
}, [processes, queue, running, setQueue, setRunning, settings]);
|
||||
}, [processes, queue, running, setQueue, setRunning]);
|
||||
};
|
||||
|
||||
@@ -79,10 +79,6 @@ export type DefaultLanguageOption = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export enum DownloadMethod {
|
||||
Remux = "remux",
|
||||
}
|
||||
|
||||
export type Home = {
|
||||
sections: Array<HomeSection>;
|
||||
};
|
||||
@@ -142,8 +138,6 @@ export enum VideoPlayer {
|
||||
|
||||
export type Settings = {
|
||||
home?: Home | null;
|
||||
followDeviceOrientation?: boolean;
|
||||
forceLandscapeInVideoPlayer?: boolean;
|
||||
deviceProfile?: "Expo" | "Native" | "Old";
|
||||
mediaListCollectionIds?: string[];
|
||||
preferedLanguage?: string;
|
||||
@@ -163,12 +157,9 @@ export type Settings = {
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||
forwardSkipTime: number;
|
||||
rewindSkipTime: number;
|
||||
downloadMethod: DownloadMethod;
|
||||
autoDownload: boolean;
|
||||
showCustomMenuLinks: boolean;
|
||||
disableHapticFeedback: boolean;
|
||||
subtitleSize: number;
|
||||
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
||||
safeAreaInControlsEnabled: boolean;
|
||||
jellyseerrServerUrl?: string;
|
||||
hiddenLibraries?: string[];
|
||||
@@ -205,8 +196,6 @@ export type StreamyfinPluginConfig = {
|
||||
|
||||
export const defaultValues: Settings = {
|
||||
home: null,
|
||||
followDeviceOrientation: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
deviceProfile: "Expo",
|
||||
mediaListCollectionIds: [],
|
||||
preferedLanguage: undefined,
|
||||
@@ -232,12 +221,9 @@ export const defaultValues: Settings = {
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||
forwardSkipTime: 30,
|
||||
rewindSkipTime: 10,
|
||||
downloadMethod: DownloadMethod.Remux,
|
||||
autoDownload: false,
|
||||
showCustomMenuLinks: false,
|
||||
disableHapticFeedback: false,
|
||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||
remuxConcurrentLimit: 1,
|
||||
safeAreaInControlsEnabled: true,
|
||||
jellyseerrServerUrl: undefined,
|
||||
hiddenLibraries: [],
|
||||
@@ -257,7 +243,7 @@ export const defaultValues: Settings = {
|
||||
enableLeftSideBrightnessSwipe: true,
|
||||
enableRightSideVolumeSwipe: true,
|
||||
usePopularPlugin: true,
|
||||
showLargeHomeCarousel: true,
|
||||
showLargeHomeCarousel: false,
|
||||
};
|
||||
|
||||
const loadSettings = (): Partial<Settings> => {
|
||||
|
||||
@@ -10,20 +10,15 @@ const useDownloadHelper = () => {
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
|
||||
console.log(`Attempting to save primary image for item: ${item.Id}`);
|
||||
if (
|
||||
item.Type === "Episode" &&
|
||||
item.SeriesId &&
|
||||
!storage.getString(item.SeriesId)
|
||||
) {
|
||||
console.log(`Saving primary image for series: ${item.SeriesId}`);
|
||||
await saveImage(
|
||||
item.SeriesId,
|
||||
getPrimaryImageUrlById({ api, id: item.SeriesId }),
|
||||
);
|
||||
console.log(`Primary image saved for series: ${item.SeriesId}`);
|
||||
} else {
|
||||
console.log(`Skipping primary image save for item: ${item.Id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,3 +26,28 @@ const useDownloadHelper = () => {
|
||||
};
|
||||
|
||||
export default useDownloadHelper;
|
||||
|
||||
/**
|
||||
* Estimates the download file size based on bitrate and video duration.
|
||||
* Used when transcoding at lower bitrates where final size is unknown.
|
||||
* Adds 10% overhead to account for container and metadata.
|
||||
*
|
||||
* @param bitrateValue - The bitrate in bits per second
|
||||
* @param runTimeTicks - The video duration in ticks (1 tick = 100 nanoseconds)
|
||||
* @returns Estimated file size in bytes (with 10% overhead), or undefined if duration is invalid
|
||||
*/
|
||||
export function estimateDownloadSize(
|
||||
bitrateValue: number,
|
||||
runTimeTicks?: number | null,
|
||||
): number | undefined {
|
||||
if (!runTimeTicks || runTimeTicks <= 0) return undefined;
|
||||
|
||||
// Convert ticks to seconds (1 tick = 100 nanoseconds)
|
||||
const durationSeconds = runTimeTicks / 10000000;
|
||||
|
||||
// Calculate size in bytes: (bitrate * duration) / 8
|
||||
// Add 10% overhead for container and metadata
|
||||
const estimatedBytes = ((bitrateValue * durationSeconds) / 8) * 1.1;
|
||||
|
||||
return Math.floor(estimatedBytes);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface LogEntry {
|
||||
const mmkvStorage = createJSONStorage(() => ({
|
||||
getItem: (key: string) => storage.getString(key) || null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
removeItem: (key: string) => storage.remove(key),
|
||||
}));
|
||||
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
||||
|
||||
@@ -74,7 +74,7 @@ export const readFromLog = (): LogEntry[] => {
|
||||
};
|
||||
|
||||
export const clearLogs = () => {
|
||||
storage.delete("logs");
|
||||
storage.remove("logs");
|
||||
};
|
||||
|
||||
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import { createMMKV } from "react-native-mmkv";
|
||||
|
||||
// Create a single MMKV instance following the official documentation
|
||||
// https://github.com/mrousavy/react-native-mmkv
|
||||
export const storage = new MMKV();
|
||||
export const storage = createMMKV();
|
||||
|
||||
11
utils/profiles/native.d.ts
vendored
Normal file
11
utils/profiles/native.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export interface DeviceProfileOptions {
|
||||
transcode?: boolean;
|
||||
}
|
||||
|
||||
export function generateDeviceProfile(options?: DeviceProfileOptions): any;
|
||||
@@ -4,6 +4,20 @@ import React from "react";
|
||||
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
||||
|
||||
// New Jellyfin 10.11+ Media Segments API types
|
||||
interface MediaSegmentDto {
|
||||
Id: string;
|
||||
ItemId: string;
|
||||
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
StartTicks: number;
|
||||
EndTicks: number;
|
||||
}
|
||||
|
||||
interface MediaSegmentsResponse {
|
||||
Items: MediaSegmentDto[];
|
||||
}
|
||||
|
||||
// Legacy API types (for fallback)
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
HideSkipPromptAt: number;
|
||||
@@ -26,6 +40,8 @@ interface CreditTimestamps {
|
||||
};
|
||||
}
|
||||
|
||||
const TICKS_PER_SECOND = 10000000;
|
||||
|
||||
export const useSegments = (
|
||||
itemId: string,
|
||||
isOffline: boolean,
|
||||
@@ -65,7 +81,66 @@ export const getSegmentsForItem = (
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAndParseSegments = async (
|
||||
/**
|
||||
* Converts Jellyfin ticks to seconds
|
||||
*/
|
||||
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
|
||||
|
||||
/**
|
||||
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
|
||||
*/
|
||||
const fetchMediaSegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
} | null> => {
|
||||
try {
|
||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||
`${api.basePath}/MediaSegments/${itemId}`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
params: {
|
||||
includeSegmentTypes: ["Intro", "Outro"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const introSegments: MediaTimeSegment[] = [];
|
||||
const creditSegments: MediaTimeSegment[] = [];
|
||||
|
||||
response.data.Items.forEach((segment) => {
|
||||
const timeSegment: MediaTimeSegment = {
|
||||
startTime: ticksToSeconds(segment.StartTicks),
|
||||
endTime: ticksToSeconds(segment.EndTicks),
|
||||
text: segment.Type,
|
||||
};
|
||||
|
||||
switch (segment.Type) {
|
||||
case "Intro":
|
||||
introSegments.push(timeSegment);
|
||||
break;
|
||||
case "Outro":
|
||||
creditSegments.push(timeSegment);
|
||||
break;
|
||||
// Optionally handle other types like Recap, Commercial, Preview
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
} catch (_error) {
|
||||
// Return null to indicate we should try legacy endpoints
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches segments using legacy pre-10.11 endpoints
|
||||
*/
|
||||
const fetchLegacySegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
@@ -106,8 +181,25 @@ export const fetchAndParseSegments = async (
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch segments", error);
|
||||
console.error("Failed to fetch legacy segments", error);
|
||||
}
|
||||
|
||||
return { introSegments, creditSegments };
|
||||
};
|
||||
|
||||
export const fetchAndParseSegments = async (
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments: MediaTimeSegment[];
|
||||
creditSegments: MediaTimeSegment[];
|
||||
}> => {
|
||||
// Try new API first (Jellyfin 10.11+)
|
||||
const newSegments = await fetchMediaSegments(itemId, api);
|
||||
if (newSegments) {
|
||||
return newSegments;
|
||||
}
|
||||
|
||||
// Fallback to legacy endpoints
|
||||
return fetchLegacySegments(itemId, api);
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export const formatTimeString = (
|
||||
|
||||
export const secondsToTicks = (seconds?: number | undefined) => {
|
||||
if (!seconds) return 0;
|
||||
return seconds * 10000000;
|
||||
return Math.floor(seconds * 10000000);
|
||||
};
|
||||
|
||||
export const ticksToSeconds = (ticks?: number | undefined) => {
|
||||
@@ -82,7 +82,7 @@ export const ticksToSeconds = (ticks?: number | undefined) => {
|
||||
|
||||
export const msToTicks = (ms?: number | undefined) => {
|
||||
if (!ms) return 0;
|
||||
return ms * 10000;
|
||||
return Math.floor(ms * 10000);
|
||||
};
|
||||
|
||||
export const ticksToMs = (ticks?: number | undefined) => {
|
||||
|
||||
Reference in New Issue
Block a user