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:
Fredrik Burmester
2025-11-11 08:53:23 +01:00
committed by GitHub
parent 154788cf91
commit 485dc6eeac
181 changed files with 8422 additions and 4298 deletions

View File

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

View File

@@ -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);
},
},
);

View File

@@ -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,
);

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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 = {}) => {

View File

@@ -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
View 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;

View File

@@ -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);
};

View File

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