Merge branch 'master' into feat/i18n

This commit is contained in:
Simon Caron
2024-12-30 16:45:41 -05:00
270 changed files with 21674 additions and 6063 deletions

View File

@@ -0,0 +1,21 @@
import { Orientation, OrientationLock } from "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;
case Orientation.UNKNOWN:
default:
return OrientationLock.DEFAULT;
}
}
export default orientationToOrientationLock;

134
utils/SubtitleHelper.ts Normal file
View File

@@ -0,0 +1,134 @@
import { TranscodedSubtitle } from "@/components/video-player/controls/types";
import { TrackInfo } from "@/modules/vlc-player";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
const disableSubtitle = {
name: "Disable",
index: -1,
IsTextSubtitleStream: true,
} as TranscodedSubtitle;
export class SubtitleHelper {
private mediaStreams: MediaStream[];
constructor(mediaStreams: MediaStream[]) {
this.mediaStreams = mediaStreams.filter((x) => x.Type === "Subtitle");
}
getSubtitles(): MediaStream[] {
return this.mediaStreams;
}
getUniqueSubtitles(): MediaStream[] {
const uniqueSubs: MediaStream[] = [];
const seen = new Set<string>();
this.mediaStreams.forEach((x) => {
if (!seen.has(x.DisplayTitle!)) {
seen.add(x.DisplayTitle!);
uniqueSubs.push(x);
}
});
return uniqueSubs;
}
getCurrentSubtitle(subtitleIndex?: number): MediaStream | undefined {
return this.mediaStreams.find((x) => x.Index === subtitleIndex);
}
getMostCommonSubtitleByName(
subtitleIndex: number | undefined
): number | undefined {
if (subtitleIndex === undefined) -1;
const uniqueSubs = this.getUniqueSubtitles();
const currentSub = this.getCurrentSubtitle(subtitleIndex);
return uniqueSubs.find((x) => x.DisplayTitle === currentSub?.DisplayTitle)
?.Index;
}
getTextSubtitles(): MediaStream[] {
return this.mediaStreams.filter((x) => x.IsTextSubtitleStream);
}
getImageSubtitles(): MediaStream[] {
return this.mediaStreams.filter((x) => !x.IsTextSubtitleStream);
}
getEmbeddedTrackIndex(sourceSubtitleIndex: number): number {
if (Platform.OS === "android") {
const textSubs = this.getTextSubtitles();
const matchingSubtitle = textSubs.find(
(sub) => sub.Index === sourceSubtitleIndex
);
if (!matchingSubtitle) return -1;
return textSubs.indexOf(matchingSubtitle);
}
// Get unique text-based subtitles because react-native-video removes hls text tracks duplicates. (iOS)
const uniqueTextSubs = this.getUniqueTextBasedSubtitles();
const matchingSubtitle = uniqueTextSubs.find(
(sub) => sub.Index === sourceSubtitleIndex
);
if (!matchingSubtitle) return -1;
return uniqueTextSubs.indexOf(matchingSubtitle);
}
sortSubtitles(
textSubs: TranscodedSubtitle[],
allSubs: MediaStream[]
): TranscodedSubtitle[] {
let textIndex = 0; // To track position in textSubtitles
// Merge text and image subtitles in the order of allSubs
const sortedSubtitles = allSubs.map((sub) => {
if (sub.IsTextSubtitleStream) {
if (textSubs.length === 0) return disableSubtitle;
const textSubtitle = textSubs[textIndex];
if (!textSubtitle) return disableSubtitle;
textIndex++;
return textSubtitle;
} else {
return {
name: sub.DisplayTitle!,
index: sub.Index!,
IsTextSubtitleStream: sub.IsTextSubtitleStream,
} as TranscodedSubtitle;
}
});
return sortedSubtitles;
}
getSortedSubtitles(subtitleTracks: TrackInfo[]): TranscodedSubtitle[] {
const textSubtitles =
subtitleTracks.map((s) => ({
name: s.name,
index: s.index,
IsTextSubtitleStream: true,
})) || [];
const sortedSubs =
Platform.OS === "android"
? this.sortSubtitles(textSubtitles, this.mediaStreams)
: this.sortSubtitles(textSubtitles, this.getUniqueSubtitles());
return sortedSubs;
}
getUniqueTextBasedSubtitles(): MediaStream[] {
return this.getUniqueSubtitles().filter((x) => x.IsTextSubtitleStream);
}
// HLS stream indexes are not the same as the actual source indexes.
// This function aims to get the source subtitle index from the embedded track index.
getSourceSubtitleIndex = (embeddedTrackIndex: number): number => {
if (Platform.OS === "android") {
return this.getTextSubtitles()[embeddedTrackIndex]?.Index ?? -1;
}
return this.getUniqueTextBasedSubtitles()[embeddedTrackIndex]?.Index ?? -1;
};
}

View File

@@ -1,11 +0,0 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom } from "jotai";
export type ProcessItem = {
item: BaseItemDto;
progress: number;
speed?: number;
startTime?: Date;
};
export const runningProcesses = atom<ProcessItem | null>(null);

View File

@@ -1,47 +1,128 @@
import {
ItemFilter,
ItemSortBy,
NameGuidPair,
SortOrder,
} from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { storage } from "../mmkv";
export enum SortByOption {
Default = "Default",
SortName = "SortName",
CommunityRating = "CommunityRating",
CriticRating = "CriticRating",
DateCreated = "DateCreated",
DatePlayed = "DatePlayed",
PlayCount = "PlayCount",
ProductionYear = "ProductionYear",
Runtime = "Runtime",
OfficialRating = "OfficialRating",
PremiereDate = "PremiereDate",
StartDate = "StartDate",
IsUnplayed = "IsUnplayed",
IsPlayed = "IsPlayed",
AirTime = "AirTime",
Studio = "Studio",
IsFavoriteOrLiked = "IsFavoriteOrLiked",
Random = "Random",
}
export enum SortOrderOption {
Ascending = "Ascending",
Descending = "Descending",
}
export const sortOptions: {
key: ItemSortBy;
key: SortByOption;
value: string;
}[] = [
{ key: "SortName", value: "Name" },
{ key: "CommunityRating", value: "Community Rating" },
{ key: "CriticRating", value: "Critics Rating" },
{ key: "DateLastContentAdded", value: "Content Added" },
{ key: "DatePlayed", value: "Date Played" },
{ key: "PlayCount", value: "Play Count" },
{ key: "ProductionYear", value: "Production Year" },
{ key: "Runtime", value: "Runtime" },
{ key: "OfficialRating", value: "Official Rating" },
{ key: "PremiereDate", value: "Premiere Date" },
{ key: "StartDate", value: "Start Date" },
{ key: "IsUnplayed", value: "Is Unplayed" },
{ key: "IsPlayed", value: "Is Played" },
{ key: "VideoBitRate", value: "Video Bit Rate" },
{ key: "AirTime", value: "Air Time" },
{ key: "Studio", value: "Studio" },
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
{ key: "Random", value: "Random" },
{ key: SortByOption.Default, value: "Default" },
{ key: SortByOption.SortName, value: "Name" },
{ key: SortByOption.CommunityRating, value: "Community Rating" },
{ key: SortByOption.CriticRating, value: "Critics Rating" },
{ key: SortByOption.DateCreated, value: "Date Added" },
{ key: SortByOption.DatePlayed, value: "Date Played" },
{ key: SortByOption.PlayCount, value: "Play Count" },
{ key: SortByOption.ProductionYear, value: "Production Year" },
{ key: SortByOption.Runtime, value: "Runtime" },
{ key: SortByOption.OfficialRating, value: "Official Rating" },
{ key: SortByOption.PremiereDate, value: "Premiere Date" },
{ key: SortByOption.StartDate, value: "Start Date" },
{ key: SortByOption.IsUnplayed, value: "Is Unplayed" },
{ key: SortByOption.IsPlayed, value: "Is Played" },
{ key: SortByOption.AirTime, value: "Air Time" },
{ key: SortByOption.Studio, value: "Studio" },
{ key: SortByOption.IsFavoriteOrLiked, value: "Is Favorite Or Liked" },
{ key: SortByOption.Random, value: "Random" },
];
export const sortOrderOptions: {
key: SortOrder;
key: SortOrderOption;
value: string;
}[] = [
{ key: "Ascending", value: "Ascending" },
{ key: "Descending", value: "Descending" },
{ key: SortOrderOption.Ascending, value: "Ascending" },
{ key: SortOrderOption.Descending, value: "Descending" },
];
export const genreFilterAtom = atom<string[]>([]);
export const tagsFilterAtom = atom<string[]>([]);
export const yearFilterAtom = atom<string[]>([]);
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
sortOrderOptions[0],
export const sortByAtom = atom<SortByOption[]>([SortByOption.Default]);
export const sortOrderAtom = atom<SortOrderOption[]>([
SortOrderOption.Ascending,
]);
export interface SortPreference {
[libraryId: string]: SortByOption;
}
export interface SortOrderPreference {
[libraryId: string]: SortOrderOption;
}
const defaultSortPreference: SortPreference = {};
const defaultSortOrderPreference: SortOrderPreference = {};
export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
"sortByPreference",
defaultSortPreference,
{
getItem: (key) => {
const value = storage.getString(key);
return value ? JSON.parse(value) : null;
},
setItem: (key, value) => {
storage.set(key, JSON.stringify(value));
},
removeItem: (key) => {
storage.delete(key);
},
}
);
export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
"sortOrderPreference",
defaultSortOrderPreference,
{
getItem: (key) => {
const value = storage.getString(key);
return value ? JSON.parse(value) : null;
},
setItem: (key, value) => {
storage.set(key, JSON.stringify(value));
},
removeItem: (key) => {
storage.delete(key);
},
}
);
export const getSortByPreference = (
libraryId: string,
preferences: SortPreference
) => {
return preferences?.[libraryId] || null;
};
export const getSortOrderPreference = (
libraryId: string,
preferences: SortOrderPreference
) => {
return preferences?.[libraryId] || null;
};

View File

@@ -0,0 +1,6 @@
import * as ScreenOrientation from "expo-screen-orientation";
import { atom } from "jotai";
export const orientationAtom = atom<number>(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);

View File

@@ -0,0 +1,67 @@
import { atom, useAtom } from "jotai";
interface ThemeColors {
primary: string;
text: string;
}
export const calculateTextColor = (backgroundColor: string): string => {
// Convert hex to RGB
const r = parseInt(backgroundColor.slice(1, 3), 16);
const g = parseInt(backgroundColor.slice(3, 5), 16);
const b = parseInt(backgroundColor.slice(5, 7), 16);
// Calculate perceived brightness
// Using the formula: (R * 299 + G * 587 + B * 114) / 1000
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
// Calculate contrast ratio with white and black
const contrastWithWhite = calculateContrastRatio([255, 255, 255], [r, g, b]);
const contrastWithBlack = calculateContrastRatio([0, 0, 0], [r, g, b]);
// Use black text if the background is bright and has good contrast with black
if (brightness > 180 && contrastWithBlack >= 4.5) {
return "#000000";
}
// Otherwise, use white text
return "#FFFFFF";
};
// Helper function to calculate contrast ratio
const calculateContrastRatio = (rgb1: number[], rgb2: number[]): number => {
const l1 = calculateRelativeLuminance(rgb1);
const l2 = calculateRelativeLuminance(rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
};
// Helper function to calculate relative luminance
const calculateRelativeLuminance = (rgb: number[]): number => {
const [r, g, b] = rgb.map((c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
export const isCloseToBlack = (color: string): boolean => {
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
// Check if the color is very dark (close to black)
return r < 20 && g < 20 && b < 20;
};
export const adjustToNearBlack = (color: string): string => {
return "#313131"; // A very dark gray, almost black
};
export const itemThemeColorAtom = atom<ThemeColors>({
primary: "#FFFFFF",
text: "#000000",
});
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);

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;
@@ -8,33 +11,41 @@ export interface Job {
execute: () => void | Promise<void>;
}
export const runningAtom = atom<boolean>(false);
export const queueAtom = atom<Job[]>([]);
export const isProcessingAtom = atom(false);
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
const updatedQueue = [...queue, job];
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
const updatedQueue = [...queue, ...job];
console.info("Enqueueing job", job, updatedQueue);
setQueue(updatedQueue);
},
processJob: async (
queue: Job[],
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
setProcessing: (processing: boolean) => void
) => {
const [job, ...rest] = queue;
setQueue(rest);
console.info("Processing job", job);
setProcessing(true);
await job.execute();
// Allow job to execute so that it gets added as a processes first BEFORE updating new queue
try {
await job.execute();
} finally {
setQueue(rest);
}
console.info("Job done", job);
setProcessing(false);
},
clear: (
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
setProcessing: (processing: boolean) => void
) => {
setQueue([]);
setProcessing(false);
@@ -43,12 +54,14 @@ export const queueActions = {
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
const [running, setRunning] = useAtom(runningAtom);
const [processes] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings();
useEffect(() => {
if (queue.length > 0 && !isProcessing) {
if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setProcessing);
queueActions.processJob(queue, setQueue, setRunning);
}
}, [queue, isProcessing, setQueue, setProcessing]);
}, [processes, queue, running, setQueue, setRunning]);
};

View File

@@ -1,68 +1,171 @@
import { atom, useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
import { getLocales } from "expo-localization";
import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv";
import { Platform } from "react-native";
import {
CultureDto,
SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client";
type Settings = {
export type DownloadQuality = "original" | "high" | "low";
export type DownloadOption = {
label: string;
value: DownloadQuality;
};
export const ScreenOrientationEnum: Record<
ScreenOrientation.OrientationLock,
string
> = {
[ScreenOrientation.OrientationLock.DEFAULT]: "Default",
[ScreenOrientation.OrientationLock.ALL]: "All",
[ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up",
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down",
[ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right",
[ScreenOrientation.OrientationLock.OTHER]: "Other",
[ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown",
};
export const DownloadOptions: DownloadOption[] = [
{
label: "Original quality",
value: "original",
},
{
label: "High quality",
value: "high",
},
{
label: "Small file size",
value: "low",
},
];
export type LibraryOptions = {
display: "row" | "list";
cardStyle: "compact" | "detailed";
imageStyle: "poster" | "cover";
showTitles: boolean;
showStats: boolean;
};
export type DefaultLanguageOption = {
value: string;
label: string;
};
export type Settings = {
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
openFullScreenVideoPlayerByDefault?: boolean;
usePopularPlugin?: boolean;
deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
mediaListCollectionIds?: string[];
preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
libraryOptions: LibraryOptions;
defaultAudioLanguage: CultureDto | null;
playDefaultAudioTrack: boolean;
rememberAudioSelections: boolean;
defaultSubtitleLanguage: CultureDto | null;
subtitleMode: SubtitlePlaybackMode;
rememberSubtitleSelections: boolean;
showHomeTitles: boolean;
defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null;
downloadMethod: "optimized" | "remux";
autoDownload: boolean;
showCustomMenuLinks: boolean;
subtitleSize: number;
remuxConcurrentLimit: 1 | 2 | 3 | 4;
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
};
/**
*
* The settings atom is a Jotai atom that stores the user's settings.
* It is initialized with a default value of null, which indicates that the settings have not been loaded yet.
* The settings are loaded from AsyncStorage when the atom is read for the first time.
*
*/
const loadSettings = (): Settings => {
const defaultValues: Settings = {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
usePopularPlugin: false,
deviceProfile: "Expo",
mediaListCollectionIds: [],
preferedLanguage: getLocales()[0] || "en",
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
libraryOptions: {
display: "list",
cardStyle: "detailed",
imageStyle: "cover",
showTitles: true,
showStats: true,
},
defaultAudioLanguage: null,
playDefaultAudioTrack: true,
rememberAudioSelections: true,
defaultSubtitleLanguage: null,
subtitleMode: SubtitlePlaybackMode.Default,
rememberSubtitleSelections: true,
showHomeTitles: true,
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
forwardSkipTime: 30,
rewindSkipTime: 10,
optimizedVersionsServerUrl: null,
downloadMethod: "remux",
autoDownload: false,
showCustomMenuLinks: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1,
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
};
// Utility function to load settings from AsyncStorage
const loadSettings = async (): Promise<Settings> => {
const jsonValue = await AsyncStorage.getItem("settings");
return jsonValue != null
? JSON.parse(jsonValue)
: {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: false,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
preferedLanguage: getLocales()[0] || "en",
};
try {
const jsonValue = storage.getString("settings");
const loadedValues: Partial<Settings> =
jsonValue != null ? JSON.parse(jsonValue) : {};
return { ...defaultValues, ...loadedValues };
} catch (error) {
console.error("Failed to load settings:", error);
return defaultValues;
}
};
// Utility function to save settings to AsyncStorage
const saveSettings = async (settings: Settings) => {
const saveSettings = (settings: Settings) => {
const jsonValue = JSON.stringify(settings);
await AsyncStorage.setItem("settings", jsonValue);
storage.set("settings", jsonValue);
};
// Create an atom to store the settings in memory
const settingsAtom = atom<Settings | null>(null);
export const settingsAtom = atom<Settings | null>(null);
// A hook to manage settings, loading them on initial mount and providing a way to update them
export const useSettings = () => {
const [settings, setSettings] = useAtom(settingsAtom);
useEffect(() => {
if (settings === null) {
loadSettings().then(setSettings);
const loadedSettings = loadSettings();
setSettings(loadedSettings);
}
}, [settings, setSettings]);
const updateSettings = async (update: Partial<Settings>) => {
const updateSettings = (update: Partial<Settings>) => {
if (settings) {
const newSettings = { ...settings, ...update };
setSettings(newSettings);
await saveSettings(newSettings);
saveSettings(newSettings);
}
};

19
utils/bToMb.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Convert bits to megabits or gigabits
*
* Return nice looking string
* If under 1000Mb, return XXXMB, else return X.XGB
*/
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
if (!bits) return "0MB";
const megabits = bits / 1000000;
if (megabits < 1000) {
return Math.round(megabits) + "MB";
} else {
const gigabits = megabits / 1000;
return gigabits.toFixed(1) + "GB";
}
}

23
utils/background-tasks.ts Normal file
View File

@@ -0,0 +1,23 @@
import * as BackgroundFetch from "expo-background-fetch";
export const BACKGROUND_FETCH_TASK = "background-fetch";
export async function registerBackgroundFetchAsync() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
minimumInterval: 60 * 1, // 1 minutes
stopOnTerminate: false, // android only,
startOnBoot: false, // android only
});
} catch (error) {
console.log("Error registering background fetch task", error);
}
}
export async function unregisterBackgroundFetchAsync() {
try {
BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
} catch (error) {
console.log("Error unregistering background fetch task", error);
}
}

View File

@@ -0,0 +1,53 @@
import {
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client";
/**
* Converts a ColletionType to a BaseItemKind (also called ItemType)
*
* CollectionTypes
* readonly Unknown: "unknown";
readonly Movies: "movies";
readonly Tvshows: "tvshows";
readonly Music: "music";
readonly Musicvideos: "musicvideos";
readonly Trailers: "trailers";
readonly Homevideos: "homevideos";
readonly Boxsets: "boxsets";
readonly Books: "books";
readonly Photos: "photos";
readonly Livetv: "livetv";
readonly Playlists: "playlists";
readonly Folders: "folders";
*/
export const colletionTypeToItemType = (
collectionType?: CollectionType | null
): BaseItemKind | undefined => {
if (!collectionType) return undefined;
switch (collectionType) {
case CollectionType.Movies:
return BaseItemKind.Movie;
case CollectionType.Tvshows:
return BaseItemKind.Series;
case CollectionType.Homevideos:
return BaseItemKind.Video;
case CollectionType.Musicvideos:
return BaseItemKind.MusicVideo;
case CollectionType.Books:
return BaseItemKind.Book;
case CollectionType.Playlists:
return BaseItemKind.Playlist;
case CollectionType.Folders:
return BaseItemKind.Folder;
case CollectionType.Photos:
return BaseItemKind.Photo;
case CollectionType.Trailers:
return BaseItemKind.Trailer;
case CollectionType.Playlists:
return BaseItemKind.Playlist;
}
return undefined;
};

19
utils/device.ts Normal file
View File

@@ -0,0 +1,19 @@
import uuid from "react-native-uuid";
import { storage } from "./mmkv";
export const getOrSetDeviceId = () => {
let deviceId = storage.getString("deviceId");
if (!deviceId) {
deviceId = uuid.v4() as string;
storage.set("deviceId", deviceId);
}
return deviceId;
};
export const getDeviceId = () => {
let deviceId = storage.getString("deviceId");
return deviceId || null;
};

33
utils/download.ts Normal file
View File

@@ -0,0 +1,33 @@
import useImageStorage from "@/hooks/useImageStorage";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
const useDownloadHelper = () => {
const [api] = useAtom(apiAtom);
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}`);
}
};
return { saveSeriesPrimaryImage };
};
export default useDownloadHelper;

87
utils/getItemImage.ts Normal file
View File

@@ -0,0 +1,87 @@
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { ImageSource } from "expo-image";
interface Props {
item: BaseItemDto;
api: Api;
quality?: number;
width?: number;
variant?:
| "Primary"
| "Backdrop"
| "ParentBackdrop"
| "ParentLogo"
| "Logo"
| "AlbumPrimary"
| "SeriesPrimary"
| "Screenshot"
| "Thumb";
}
export const getItemImage = ({
item,
api,
variant = "Primary",
quality = 90,
width = 1000,
}: Props) => {
if (!api) return null;
let tag: string | null | undefined;
let blurhash: string | null | undefined;
let src: ImageSource | null = null;
switch (variant) {
case "Backdrop":
if (item.Type === "Episode") {
tag = item.ParentBackdropImageTags?.[0];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
src = {
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
}
tag = item.BackdropImageTags?.[0];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
case "Primary":
tag = item.ImageTags?.["Primary"];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Primary?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
case "Thumb":
tag = item.ImageTags?.["Thumb"];
if (!tag) break;
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}&width=${width}`,
blurhash,
};
break;
default:
tag = item.ImageTags?.["Primary"];
src = {
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`,
};
break;
}
if (!src?.uri) return null;
return src;
};

View File

@@ -0,0 +1,55 @@
import axios from "axios";
export interface SubtitleTrack {
index: number;
name: string;
uri: string;
language: string;
default: boolean;
forced: boolean;
autoSelect: boolean;
}
export async function parseM3U8ForSubtitles(
url: string
): Promise<SubtitleTrack[]> {
try {
const response = await axios.get(url, { responseType: "text" });
const lines = response.data.split(/\r?\n/);
const subtitleTracks: SubtitleTrack[] = [];
let index = 0;
lines.forEach((line: string) => {
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
const attributes = parseAttributes(line);
const track: SubtitleTrack = {
index: index++,
name: attributes["NAME"] || "",
uri: attributes["URI"] || "",
language: attributes["LANGUAGE"] || "",
default: attributes["DEFAULT"] === "YES",
forced: attributes["FORCED"] === "YES",
autoSelect: attributes["AUTOSELECT"] === "YES",
};
subtitleTracks.push(track);
}
});
return subtitleTracks;
} catch (error) {
console.error("Failed to fetch or parse the M3U8 file:", error);
throw error;
}
}
function parseAttributes(line: string): { [key: string]: string } {
const attributes: { [key: string]: string } = {};
const parts = line.split(",");
parts.forEach((part) => {
const [key, value] = part.split("=");
if (key && value) {
attributes[key.trim()] = value.replace(/"/g, "").trim();
}
});
return attributes;
}

View File

@@ -0,0 +1,107 @@
// utils/getDefaultPlaySettings.ts
import { BITRATES } from "@/components/BitrateSelector";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Settings, useSettings } from "../atoms/settings";
import {
AudioStreamRanker,
StreamRanker,
SubtitleStreamRanker,
} from "../streamRanker";
interface PlaySettings {
item: BaseItemDto;
bitrate: (typeof BITRATES)[0];
mediaSource?: MediaSourceInfo | null;
audioIndex?: number | undefined;
subtitleIndex?: number | undefined;
}
export interface previousIndexes {
audioIndex?: number;
subtitleIndex?: number;
}
interface TrackOptions {
DefaultAudioStreamIndex: number | undefined;
DefaultSubtitleStreamIndex: number | undefined;
}
// Used getting default values for the next player.
export function getDefaultPlaySettings(
item: BaseItemDto,
settings: Settings,
previousIndexes?: previousIndexes,
previousSource?: MediaSourceInfo
): PlaySettings {
if (item.Type === "Program") {
return {
item,
bitrate: BITRATES[0],
mediaSource: undefined,
audioIndex: undefined,
subtitleIndex: undefined,
};
}
// 1. Get first media source
const mediaSource = item.MediaSources?.[0];
// 2. Get default or preferred audio
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
)?.Index;
const firstAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Type === "Audio"
)?.Index;
// We prefer the previous track over the default track.
let trackOptions: TrackOptions = {
DefaultAudioStreamIndex: defaultAudioIndex ?? -1,
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
};
const mediaStreams = mediaSource?.MediaStreams ?? [];
if (settings?.rememberSubtitleSelections && previousIndexes) {
if (previousIndexes.subtitleIndex !== undefined && previousSource) {
const subtitleRanker = new SubtitleStreamRanker();
const ranker = new StreamRanker(subtitleRanker);
ranker.rankStream(
previousIndexes.subtitleIndex,
previousSource,
mediaStreams,
trackOptions
);
}
}
if (settings?.rememberAudioSelections && previousIndexes) {
if (previousIndexes.audioIndex !== undefined && previousSource) {
const audioRanker = new AudioStreamRanker();
const ranker = new StreamRanker(audioRanker);
ranker.rankStream(
previousIndexes.audioIndex,
previousSource,
mediaStreams,
trackOptions
);
}
}
// 4. Get default bitrate
const bitrate = BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity)
)[0];
return {
item,
bitrate,
mediaSource,
audioIndex: trackOptions.DefaultAudioStreamIndex,
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
};
}

View File

@@ -36,6 +36,10 @@ export const getBackdropUrl = ({
params.append("fillWidth", width.toString());
}
if (item.Type === "Episode") {
return getPrimaryImageUrl({ api, item, quality, width });
}
if (backdropImageTags) {
params.append("tag", backdropImageTags);
return `${api.basePath}/Items/${

View File

@@ -21,15 +21,29 @@ export const getLogoImageUrlById = ({
return null;
}
const imageTags = item.ImageTags?.["Logo"];
if (!imageTags) return null;
const params = new URLSearchParams();
params.append("tag", imageTags);
params.append("quality", "90");
params.append("fillHeight", height.toString());
if (item.Type === "Episode") {
const imageTag = item.ParentLogoImageTag;
const parentId = item.ParentLogoItemId;
if (!parentId || !imageTag) {
return null;
}
params.append("tag", imageTag);
return `${api.basePath}/Items/${parentId}/Images/Logo?${params.toString()}`;
}
const imageTag = item.ImageTags?.["Logo"];
if (!imageTag) return null;
params.append("tag", imageTag);
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
};

View File

@@ -0,0 +1,42 @@
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { isBaseItemDto } from "../jellyfin";
/**
* Retrieves the primary image URL for a given item.
*
* @param api - The Jellyfin API instance.
* @param item - The media item to retrieve the backdrop image URL for.
* @param quality - The desired image quality (default: 90).
*/
export const getParentBackdropImageUrl = ({
api,
item,
quality = 80,
width = 400,
}: {
api?: Api | null;
item?: BaseItemDto | null;
quality?: number | null;
width?: number | null;
}) => {
if (!item || !api) {
return null;
}
const parentId = item.ParentBackdropItemId;
const tag = item.ParentBackdropImageTags?.[0] || "";
const params = new URLSearchParams({
fillWidth: width ? String(width) : "500",
quality: quality ? String(quality) : "80",
tag: tag,
});
return `${
api?.basePath
}/Items/${parentId}/Images/Backdrop/0?${params.toString()}`;
};

View File

@@ -15,8 +15,8 @@ import { isBaseItemDto } from "../jellyfin";
export const getPrimaryImageUrl = ({
api,
item,
quality = 90,
width = 500,
quality = 80,
width = 400,
}: {
api?: Api | null;
item?: BaseItemDto | BaseItemPerson | null;

View File

@@ -0,0 +1,42 @@
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { isBaseItemDto } from "../jellyfin";
/**
* Retrieves the primary image URL for a given item.
*
* @param api - The Jellyfin API instance.
* @param item - The media item to retrieve the backdrop image URL for.
* @param quality - The desired image quality (default: 90).
*/
export const getPrimaryParentImageUrl = ({
api,
item,
quality = 80,
width = 400,
}: {
api?: Api | null;
item?: BaseItemDto | null;
quality?: number | null;
width?: number | null;
}) => {
if (!item || !api) {
return null;
}
const parentId = item.ParentId;
const primaryTag = item.ParentPrimaryImageTag?.[0];
const params = new URLSearchParams({
fillWidth: width ? String(width) : "500",
quality: quality ? String(quality) : "80",
tag: primaryTag || "",
});
return `${
api?.basePath
}/Items/${parentId}/Images/Primary?${params.toString()}`;
};

View File

@@ -1,19 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
export const getPlaybackInfo = async (
api?: Api | null | undefined,
itemId?: string | null | undefined,
userId?: string | null | undefined,
) => {
if (!api || !itemId || !userId) {
return null;
}
const a = await getMediaInfoApi(api).getPlaybackInfo({
itemId,
userId,
});
return a.data;
};

View File

@@ -1,10 +1,11 @@
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
MediaSourceInfo,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
export const getStreamUrl = async ({
api,
@@ -13,88 +14,183 @@ export const getStreamUrl = async ({
startTimeTicks = 0,
maxStreamingBitrate,
sessionData,
deviceProfile = ios,
deviceProfile = native,
audioStreamIndex = 0,
subtitleStreamIndex = 0,
forceDirectPlay = false,
subtitleStreamIndex = undefined,
mediaSourceId,
}: {
api: Api | null | undefined;
item: BaseItemDto | null | undefined;
userId: string | null | undefined;
startTimeTicks: number;
maxStreamingBitrate?: number;
sessionData: PlaybackInfoResponse;
deviceProfile: any;
sessionData?: PlaybackInfoResponse | null;
deviceProfile?: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
forceDirectPlay?: boolean;
}) => {
height?: number;
mediaSourceId?: string | null;
}): Promise<{
url: string | null;
sessionId: string | null;
mediaSource: MediaSourceInfo | undefined;
} | null> => {
if (!api || !userId || !item?.Id) {
return null;
}
const itemId = item.Id;
let mediaSource: MediaSourceInfo | undefined;
let sessionId: string | null | undefined;
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
UserId: userId,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: startTimeTicks,
EnableTranscoding: maxStreamingBitrate ? true : undefined,
AutoOpenLiveStream: true,
MediaSourceId: itemId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
AudioStreamIndex: audioStreamIndex,
SubtitleStreamIndex: subtitleStreamIndex,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
if (item.Type === "Program") {
console.log("Item is of type program...");
const res0 = await getMediaInfoApi(api).getPlaybackInfo(
{
userId,
itemId: item.ChannelId!,
},
},
);
{
method: "POST",
params: {
startTimeTicks: 0,
isPlayback: true,
autoOpenLiveStream: true,
maxStreamingBitrate,
audioStreamIndex,
},
data: {
deviceProfile,
},
}
);
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
sessionId = res0.data.PlaySessionId || null;
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
if (!mediaSource) {
throw new Error("No media source");
}
if (!sessionData.PlaySessionId) {
throw new Error("no PlaySessionId");
}
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData.PlaySessionId,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
if (transcodeUrl) {
return {
url: `${api.basePath}${transcodeUrl}`,
sessionId,
mediaSource: res0.data.MediaSources?.[0],
};
}
}
if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
return `${api.basePath}${mediaSource.TranscodingUrl}`;
} else {
throw new Error("No transcoding url");
const itemId = item.Id;
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
{
userId,
itemId: item.Id!,
},
{
method: "POST",
data: {
deviceProfile,
userId,
maxStreamingBitrate,
startTimeTicks,
autoOpenLiveStream: true,
mediaSourceId,
audioStreamIndex,
subtitleStreamIndex,
},
}
);
if (res2.status !== 200) {
console.error("Error getting playback info:", res2.status, res2.statusText);
}
sessionId = res2.data.PlaySessionId || null;
mediaSource = res2.data.MediaSources?.find(
(source: MediaSourceInfo) => source.Id === mediaSourceId
);
if (item.MediaType === "Video") {
if (mediaSource?.TranscodingUrl) {
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
// If there is no subtitle stream index, add it to the URL.
if (subtitleStreamIndex == -1) {
urlObj.searchParams.set("SubtitleMethod", "Hls");
}
// Add 'SubtitleMethod=Hls' if it doesn't exist
if (!urlObj.searchParams.has("SubtitleMethod")) {
urlObj.searchParams.append("SubtitleMethod", "Hls");
}
// Get the updated URL
const transcodeUrl = urlObj.toString();
console.log("Video has transcoding URL:", `${transcodeUrl}`);
return {
url: transcodeUrl,
sessionId: sessionId,
mediaSource,
};
}
if (mediaSource?.SupportsDirectPlay) {
const searchParams = new URLSearchParams({
playSessionId: sessionData?.PlaySessionId || "",
mediaSourceId: mediaSource?.Id || "",
static: "true",
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
audioStreamIndex: audioStreamIndex?.toString() || "",
deviceId: api.deviceInfo.id,
api_key: api.accessToken,
startTimeTicks: startTimeTicks.toString(),
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
userId: userId || "",
});
const directPlayUrl = `${
api.basePath
}/Videos/${itemId}/stream.mp4?${searchParams.toString()}`;
console.log("Video is being direct played:", directPlayUrl);
return {
url: directPlayUrl,
sessionId: sessionId,
mediaSource,
};
}
}
if (item.MediaType === "Audio") {
if (mediaSource?.TranscodingUrl) {
return {
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
sessionId,
mediaSource,
};
}
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData?.PlaySessionId || "",
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return {
url: `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`,
sessionId,
mediaSource,
};
}
throw new Error("Unsupported media type");
};

View File

@@ -1,12 +1,25 @@
import { Api } from "@jellyfin/sdk";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
import { Settings } from "@/utils/atoms/settings";
import {
getMediaInfoApi,
getPlaystateApi,
getSessionApi,
} from "@jellyfin/sdk/lib/utils/api";
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
interface ReportPlaybackProgressParams {
api: Api;
sessionId: string;
itemId: string;
positionTicks: number;
api?: Api | null;
sessionId?: string | null;
itemId?: string | null;
positionTicks?: number | null;
IsPaused?: boolean;
deviceProfile?: Settings["deviceProfile"];
}
/**
@@ -20,26 +33,39 @@ export const reportPlaybackProgress = async ({
sessionId,
itemId,
positionTicks,
IsPaused = false,
deviceProfile,
}: ReportPlaybackProgressParams): Promise<void> => {
console.info(
"Reporting playback progress:",
sessionId,
itemId,
positionTicks,
);
if (!api || !sessionId || !itemId || !positionTicks) {
return;
}
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
try {
await api.axiosInstance.post(
`${api.basePath}/Sessions/Playing/Progress`,
{
ItemId: itemId,
PlaySessionId: sessionId,
IsPaused: false,
PositionTicks: Math.round(positionTicks),
CanSeek: true,
MediaSourceId: itemId,
},
{ headers: getAuthHeaders(api) },
);
await getPlaystateApi(api).onPlaybackProgress({
itemId,
audioStreamIndex: 0,
subtitleStreamIndex: 0,
mediaSourceId: itemId,
positionTicks: Math.round(positionTicks),
isPaused: IsPaused,
isMuted: false,
playMethod: "Transcode",
});
// await api.axiosInstance.post(
// `${api.basePath}/Sessions/Playing/Progress`,
// {
// ItemId: itemId,
// PlaySessionId: sessionId,
// IsPaused,
// PositionTicks: Math.round(positionTicks),
// CanSeek: true,
// MediaSourceId: itemId,
// EventName: "timeupdate",
// },
// { headers: getAuthHeaders(api) }
// );
} catch (error) {
console.error(error);
}

View File

@@ -1,67 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
interface PlaybackStoppedParams {
api: Api | null | undefined;
sessionId: string | null | undefined;
itemId: string | null | undefined;
positionTicks: number | null | undefined;
}
/**
* Reports playback stopped event to the Jellyfin server.
*
* @param {PlaybackStoppedParams} params - The parameters for the report.
* @param {Api} params.api - The Jellyfin API instance.
* @param {string} params.sessionId - The session ID.
* @param {string} params.itemId - The item ID.
* @param {number} params.positionTicks - The playback position in ticks.
*/
export const reportPlaybackStopped = async ({
api,
sessionId,
itemId,
positionTicks,
}: PlaybackStoppedParams): Promise<void> => {
if (!positionTicks || positionTicks === 0) return;
if (!api) {
console.error("Missing api");
return;
}
if (!sessionId) {
console.error("Missing sessionId", sessionId);
return;
}
if (!itemId) {
console.error("Missing itemId");
return;
}
try {
const url = `${api.basePath}/PlayingItems/${itemId}`;
const params = {
playSessionId: sessionId,
positionTicks: Math.round(positionTicks),
mediaSourceId: itemId,
};
const headers = getAuthHeaders(api);
// Send DELETE request to report playback stopped
await api.axiosInstance.delete(url, { params, headers });
} catch (error) {
// Log the error with additional context
if (error instanceof AxiosError) {
console.error(
"Failed to report playback progress",
error.message,
error.response?.data,
);
} else {
console.error("Failed to report playback progress", error);
}
}
};

View File

@@ -0,0 +1,61 @@
import { Settings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { Api } from "@jellyfin/sdk";
import { AxiosError, AxiosResponse } from "axios";
import { useMemo } from "react";
import { getAuthHeaders } from "../jellyfin";
import iosFmp4 from "@/utils/profiles/iosFmp4";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
deviceProfile: Settings["deviceProfile"];
}
/**
* Marks a media item as not played for a specific user.
*
* @param params - The parameters for marking an item as not played
* @returns A promise that resolves to true if the operation was successful, false otherwise
*/
export const postCapabilities = async ({
api,
itemId,
sessionId,
deviceProfile,
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing parameters for marking item as not played");
}
try {
const d = api.axiosInstance.post(
api.basePath + "/Sessions/Capabilities/Full",
{
playableMediaTypes: ["Audio", "Video"],
supportedCommands: [
"PlayState",
"Play",
"ToggleFullscreen",
"DisplayMessage",
"Mute",
"Unmute",
"SetVolume",
"ToggleMute",
],
supportsMediaControl: true,
id: sessionId,
DeviceProfile: native,
},
{
headers: getAuthHeaders(api),
}
);
return d;
} catch (error: any | AxiosError) {
throw new Error("Failed to mark as not played");
}
};

1
utils/jellyseerr Submodule

Submodule utils/jellyseerr added at e69d160e25

View File

@@ -1,47 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
type LogLevel = "INFO" | "WARN" | "ERROR";
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
data?: any;
}
const asyncStorage = createJSONStorage(() => AsyncStorage);
const logsAtom = atomWithStorage("logs", [], asyncStorage);
export const writeToLog = async (
level: LogLevel,
message: string,
data?: any
) => {
const newEntry: LogEntry = {
timestamp: new Date().toISOString(),
level: level,
message: message,
data: data,
};
const currentLogs = await AsyncStorage.getItem("logs");
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
logs.push(newEntry);
const maxLogs = 100;
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
};
export const readFromLog = async (): Promise<LogEntry[]> => {
const logs = await AsyncStorage.getItem("logs");
return logs ? JSON.parse(logs) : [];
};
export const clearLogs = async () => {
await AsyncStorage.removeItem("logs");
};
export default logsAtom;

88
utils/log.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { storage } from "./mmkv";
import {useQuery} from "@tanstack/react-query";
import React, {createContext, useContext} from "react";
type LogLevel = "INFO" | "WARN" | "ERROR";
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
data?: any;
}
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),
}));
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(null);
const DownloadContext = createContext<ReturnType<
typeof useLogProvider
> | null>(null);
function useLogProvider() {
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
return {
logs
}
}
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const newEntry: LogEntry = {
timestamp: new Date().toISOString(),
level: level,
message: message,
data: data,
};
const currentLogs = storage.getString("logs");
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
logs.push(newEntry);
const maxLogs = 100;
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
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) : [];
};
export const clearLogs = () => {
storage.delete("logs");
};
export function useLog() {
const context = useContext(LogContext);
if (context === null) {
throw new Error("useLog must be used within a LogProvider");
}
return context;
}
export function LogProvider({children}: { children: React.ReactNode }) {
const provider = useLogProvider();
return (
<LogContext.Provider value={provider}>
{children}
</LogContext.Provider>
)
}
export default logsAtom;

3
utils/mmkv.ts Normal file
View File

@@ -0,0 +1,3 @@
import { MMKV } from "react-native-mmkv";
export const storage = new MMKV();

239
utils/optimize-server.ts Normal file
View File

@@ -0,0 +1,239 @@
import { itemRouter } from "@/components/common/TouchableItemRouter";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import axios from "axios";
import { writeToLog } from "./log";
import { DownloadedItem } from "@/providers/DownloadProvider";
import { MMKV } from "react-native-mmkv";
interface IJobInput {
deviceId?: string | null;
authHeader?: string | null;
url?: string | null;
}
export interface JobStatus {
id: string;
status:
| "queued"
| "optimizing"
| "completed"
| "failed"
| "cancelled"
| "downloading";
progress: number;
outputPath: string;
inputUrl: string;
deviceId: string;
itemId: string;
item: BaseItemDto;
speed?: number;
timestamp: Date;
base64Image?: string;
}
/**
* Fetches all jobs for a specific device.
*
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
* @param {string} params.authHeader - The authorization header for the API request.
* @param {string} params.url - The base URL for the API endpoint.
*
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
*
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
*/
export async function getAllJobsByDeviceId({
deviceId,
authHeader,
url,
}: IJobInput): Promise<JobStatus[]> {
const statusResponse = await axios.get(`${url}all-jobs`, {
headers: {
Authorization: authHeader,
},
params: {
deviceId,
},
});
if (statusResponse.status !== 200) {
console.error(
statusResponse.status,
statusResponse.data,
statusResponse.statusText
);
throw new Error("Failed to fetch job status");
}
return statusResponse.data;
}
interface ICancelJob {
authHeader: string;
url: string;
id: string;
}
export async function cancelJobById({
authHeader,
url,
id,
}: ICancelJob): Promise<boolean> {
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
headers: {
Authorization: authHeader,
},
});
if (statusResponse.status !== 200) {
throw new Error("Failed to cancel process");
}
return true;
}
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
if (!deviceId) return false;
if (!authHeader) return false;
if (!url) return false;
try {
await getAllJobsByDeviceId({
deviceId,
authHeader,
url,
}).then((jobs) => {
jobs.forEach((job) => {
cancelJobById({
authHeader,
url,
id: job.id,
});
});
});
} catch (error) {
writeToLog("ERROR", "Failed to cancel all jobs", error);
console.error(error);
return false;
}
return true;
}
/**
* Fetches statistics for a specific device.
*
* @param {IJobInput} params - The parameters for the API request.
* @param {string} params.deviceId - The ID of the device to fetch statistics for.
* @param {string} params.authHeader - The authorization header for the API request.
* @param {string} params.url - The base URL for the API endpoint.
*
* @returns {Promise<any | null>} A promise that resolves to the statistics data or null if the request fails.
*
* @throws {Error} Throws an error if any required parameter is missing.
*/
export async function getStatistics({
authHeader,
url,
deviceId,
}: IJobInput): Promise<any | null> {
if (!deviceId || !authHeader || !url) {
return null;
}
try {
const statusResponse = await axios.get(`${url}statistics`, {
headers: {
Authorization: authHeader,
},
params: {
deviceId,
},
});
return statusResponse.data;
} catch (error) {
console.error("Failed to fetch statistics:", error);
return null;
}
}
/**
* Saves the download item info to disk - this data is used temporarily to fetch additional download information
* in combination with the optimize server. This is used to not have to send all item info to the optimize server.
*
* @param {BaseItemDto} item - The item to save.
* @param {MediaSourceInfo} mediaSource - The media source of the item.
* @param {string} url - The URL of the item.
* @return {boolean} A promise that resolves when the item info is saved.
*/
export function saveDownloadItemInfoToDiskTmp(
item: BaseItemDto,
mediaSource: MediaSourceInfo,
url: string
): boolean {
try {
const storage = new MMKV();
const downloadInfo = JSON.stringify({
item,
mediaSource,
url,
});
storage.set(`tmp_download_info_${item.Id}`, downloadInfo);
return true;
} catch (error) {
console.error("Failed to save download item info to disk:", error);
throw error;
}
}
/**
* Retrieves the download item info from disk.
*
* @param {string} itemId - The ID of the item to retrieve.
* @return {{
* item: BaseItemDto;
* mediaSource: MediaSourceInfo;
* url: string;
* } | null} The retrieved download item info or null if not found.
*/
export function getDownloadItemInfoFromDiskTmp(itemId: string): {
item: BaseItemDto;
mediaSource: MediaSourceInfo;
url: string;
} | null {
try {
const storage = new MMKV();
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
if (rawInfo) {
return JSON.parse(rawInfo);
}
return null;
} catch (error) {
console.error("Failed to retrieve download item info from disk:", error);
return null;
}
}
/**
* Deletes the download item info from disk.
*
* @param {string} itemId - The ID of the item to delete.
* @return {boolean} True if the item info was successfully deleted, false otherwise.
*/
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
try {
const storage = new MMKV();
storage.delete(`tmp_download_info_${itemId}`);
return true;
} catch (error) {
console.error("Failed to delete download item info from disk:", error);
return false;
}
}

143
utils/profiles/android.js Normal file
View File

@@ -0,0 +1,143 @@
/**
* 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/.
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for Native video player
*/
export default {
Name: "1. Native iOS Video Profile",
MaxStaticBitrate: 100000000,
MaxStreamingBitrate: 120000000,
MusicStreamingTranscodingBitrate: 384000,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp",
VideoCodec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
AudioCodec: "aac,mp3,flac,alac,opus,vorbis,wma",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec: "mp3,aac,flac,alac,opus,vorbis,wma,pcm",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264",
AudioCodec: "aac,mp3,ac3",
MaxAudioChannels: "8",
MinSegments: "2",
BreakOnNonKeyFrames: true,
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
ResponseProfiles: [
{
Container: "mkv",
MimeType: "video/x-matroska",
Type: MediaTypes.Video,
},
{
Container: "mp4",
MimeType: "video/mp4",
Type: MediaTypes.Video,
},
],
SubtitleProfiles: [
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "External" },
{ Format: "srt", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "External" },
{ Format: "ass", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "External" },
{ Format: "ssa", Method: "Encode" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "External" },
{ Format: "sub", Method: "Encode" },
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "External" },
{ Format: "vtt", Method: "Encode" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "External" },
{ Format: "ttml", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "External" },
{ Format: "pgs", Method: "Encode" },
{ Format: "dvdsub", Method: "Embed" },
{ Format: "dvdsub", Method: "External" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvbsub", Method: "External" },
{ Format: "dvbsub", Method: "Encode" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "External" },
{ Format: "xsub", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "External" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "External" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "External" },
{ Format: "smi", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "External" },
{ Format: "teletext", Method: "Encode" },
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "External" },
{ Format: "microdvd", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "External" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "External" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "External" },
{ Format: "realtext", Method: "Encode" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "External" },
{ Format: "stl", Method: "Encode" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "External" },
{ Format: "subrip", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "External" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "External" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "External" },
{ Format: "vplayer", Method: "Encode" },
],
};

View File

@@ -1,26 +1,25 @@
import {
DeviceProfile,
DlnaProfileType,
} from "@jellyfin/sdk/lib/generated-client/models";
const MediaTypes = {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
};
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
export const chromecastProfile: DeviceProfile = {
Name: "Chromecast Video Profile",
Id: "chromecast-001",
MaxStreamingBitrate: 4000000, // 4 Mbps
MaxStaticBitrate: 4000000, // 4 Mbps
MaxStreamingBitrate: 8000000, // 8 Mbps
MaxStaticBitrate: 8000000, // 8 Mbps
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
},
],
DirectPlayProfiles: [
{
Container: "mp4,webm",
Container: "mp4",
Type: "Video",
VideoCodec: "h264,vp8,vp9",
VideoCodec: "h264",
AudioCodec: "aac,mp3,opus,vorbis",
},
{
@@ -78,53 +77,14 @@ export const chromecastProfile: DeviceProfile = {
MaxAudioChannels: "2",
},
],
ContainerProfiles: [
{
Type: "Video",
Container: "mp4",
},
{
Type: "Video",
Container: "webm",
},
],
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "8",
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "41",
},
],
},
{
Type: "Video",
Codec: "vp9",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "10",
},
],
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "Hls",
Method: "Encode",
},
{
Format: "vtt",
Method: "External",
Method: "Encode",
},
],
};

138
utils/profiles/download.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* 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/.
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for Native video player
*/
export default {
Name: "1. Vlc Player",
MaxStaticBitrate: 20_000_000,
MaxStreamingBitrate: 20_000_000,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
// Official foramts
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "Encode" },
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "Encode" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "Encode" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "Encode" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "Encode" },
// Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "Encode" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "Encode" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "Encode" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "Encode" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "Encode" },
],
};

View File

@@ -1,180 +0,0 @@
/**
* 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/.
*/
import MediaTypes from '../../constants/MediaTypes';
import BaseProfile from './base';
/**
* Device profile for Expo Video player on iOS 10
*/
export default {
...BaseProfile,
Name: 'Expo iOS 10 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: MediaTypes.Video
},
...BaseProfile.CodecProfiles
],
DirectPlayProfiles: [
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4,m4v',
Type: MediaTypes.Video,
VideoCodec: 'h264,vc1'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mov',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
Container: 'mp3',
Type: MediaTypes.Audio
},
{
Container: 'aac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'alac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'wav',
Type: MediaTypes.Audio
}
],
TranscodingProfiles: [
{
AudioCodec: 'aac',
BreakOnNonKeyFrames: true,
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac,mp3',
BreakOnNonKeyFrames: true,
Container: 'ts',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4',
Context: 'Static',
Protocol: 'http',
Type: MediaTypes.Video,
VideoCodec: 'h264'
}
]
};

View File

@@ -1,49 +0,0 @@
/**
* 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/.
*/
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 11-12
*/
export default {
...iOSProfile,
Name: 'Expo iOS 12 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: 'Video'
},
...iOSProfile.CodecProfiles
]
};

View File

@@ -1,35 +0,0 @@
/**
* 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/.
*/
import MediaTypes from '../../constants/MediaTypes';
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 13+ with fMP4 support
*/
export default {
...iOSProfile,
Name: 'Expo iOS fMP4 Video Profile',
TranscodingProfiles: [
// Add all audio profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Audio),
// Add fMP4 profile
{
AudioCodec: 'aac,mp3,flac,alac',
BreakOnNonKeyFrames: true,
Container: 'mp4',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'hevc,h264'
},
// Add all video profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Video)
]
};

View File

@@ -9,276 +9,180 @@ import MediaTypes from "../../constants/MediaTypes";
* Device profile for Native video player
*/
export default {
Name: "1. Native iOS Video Profile",
MaxStaticBitrate: 100000000,
MaxStreamingBitrate: 120000000,
MusicStreamingTranscodingBitrate: 384000,
Name: "1. Vlc Player",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Codec: "h264",
Conditions: [
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsAnamorphic",
Value: "true",
},
{
Condition: "EqualsAny",
IsRequired: false,
Property: "VideoProfile",
Value: "high|main|baseline|constrained baseline",
},
{
Condition: "LessThanEqual",
IsRequired: false,
Property: "VideoLevel",
Value: "80",
},
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsInterlaced",
Value: "true",
},
],
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Codec: "hevc",
Conditions: [
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsAnamorphic",
Value: "true",
},
{
Condition: "EqualsAny",
IsRequired: false,
Property: "VideoProfile",
Value: "high|main|main 10",
},
{
Condition: "LessThanEqual",
IsRequired: false,
Property: "VideoLevel",
Value: "175",
},
{
Condition: "NotEquals",
IsRequired: false,
Property: "IsInterlaced",
Value: "true",
},
],
Type: MediaTypes.Video,
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
AudioCodec: "flac,alac,aac,eac3,ac3,opus",
Container: "mp4",
Type: MediaTypes.Video,
VideoCodec: "hevc,h264,mpeg4",
},
{
AudioCodec: "alac,aac,ac3",
Container: "m4v",
Type: MediaTypes.Video,
VideoCodec: "h264,mpeg4",
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"alac,aac,eac3,ac3,mp3,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le",
Container: "mov",
Type: MediaTypes.Video,
VideoCodec: "hevc,h264,mpeg4,mjpeg",
},
{
AttrudioCodec: "aac,eac3,ac3,mp3",
Container: "mpegts",
Type: MediaTypes.Video,
VideoCodec: "h264",
},
{
AttrudioCodec: "aac,amr_nb",
Container: "3gp,3g2",
Type: MediaTypes.Video,
VideoCodec: "h264,mpeg4",
},
{
AttrudioCodec: "pcm_s16le,pcm_mulaw",
Container: "avi",
Type: MediaTypes.Video,
VideoCodec: "mjpeg",
},
{
Container: "mp3",
Type: MediaTypes.Audio,
},
{
Container: "aac",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "m4a",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "m4b",
Type: MediaTypes.Audio,
},
{
Container: "flac",
Type: MediaTypes.Audio,
},
{
Container: "alac",
Type: MediaTypes.Audio,
},
{
AudioCodec: "alac",
Container: "m4a",
Type: MediaTypes.Audio,
},
{
AudioCodec: "alac",
Container: "m4b",
Type: MediaTypes.Audio,
},
{
Container: "wav",
Type: MediaTypes.Audio,
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
AudioCodec: "flac,alac,aac,eac3,ac3,opus",
BreakOnNonKeyFrames: true,
Container: "mp4",
Context: "streaming",
MaxAudioChannels: "8",
MinSegments: 2,
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Type: "video",
VideoCodec: "hevc,h264,mpeg4",
},
{
AudioCodec: "aac",
BreakOnNonKeyFrames: true,
Container: "aac",
Context: "Streaming",
MaxAudioChannels: "6",
MinSegments: "2",
Protocol: "hls",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "aac",
Context: "Streaming",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "mp3",
Container: "mp3",
Context: "Streaming",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "wav",
Container: "wav",
Context: "Streaming",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "mp3",
Container: "mp3",
Context: "Static",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac",
Container: "aac",
Context: "Static",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "wav",
Container: "wav",
Context: "Static",
MaxAudioChannels: "6",
Protocol: "http",
Type: MediaTypes.Audio,
},
{
AudioCodec: "aac,mp3",
BreakOnNonKeyFrames: true,
Container: "ts",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
MaxAudioChannels: "6",
MinSegments: "2",
Protocol: "hls",
Type: MediaTypes.Video,
VideoCodec: "h264",
},
{
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
Container: "mp4",
Context: "Static",
Protocol: "http",
Type: MediaTypes.Video,
VideoCodec: "h264",
},
],
ResponseProfiles: [
{
Container: "m4v",
MimeType: "video/mp4",
Type: MediaTypes.Video,
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
{
Format: "pgssub",
Method: "encode",
},
{
Format: "dvdsub",
Method: "encode",
},
{
Format: "dvbsub",
Method: "encode",
},
{
Format: "xsub",
Method: "encode",
},
{
Format: "vtt",
Method: "hls",
},
{
Format: "ttml",
Method: "embed",
},
{
Format: "cc_dec",
Method: "embed",
},
// Official foramts
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "Hls" },
{ Format: "vtt", Method: "External" },
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "Hls" },
{ Format: "webvtt", Method: "External" },
{ Format: "webvtt", Method: "Encode" },
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "Hls" },
{ Format: "srt", Method: "External" },
{ Format: "srt", Method: "Encode" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "Hls" },
{ Format: "subrip", Method: "External" },
{ Format: "subrip", Method: "Encode" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "Hls" },
{ Format: "ttml", Method: "External" },
{ Format: "ttml", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvbsub", Method: "Hls" },
{ Format: "dvbsub", Method: "External" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "Hls" },
{ Format: "ass", Method: "External" },
{ Format: "ass", Method: "Encode" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Hls" },
{ Format: "idx", Method: "External" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Hls" },
{ Format: "pgs", Method: "External" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Hls" },
{ Format: "pgssub", Method: "External" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "Hls" },
{ Format: "ssa", Method: "External" },
{ Format: "ssa", Method: "Encode" },
// Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "Hls" },
{ Format: "microdvd", Method: "External" },
{ Format: "microdvd", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "Hls" },
{ Format: "mov_text", Method: "External" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "Hls" },
{ Format: "mpl2", Method: "External" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "Hls" },
{ Format: "pjs", Method: "External" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "Hls" },
{ Format: "realtext", Method: "External" },
{ Format: "realtext", Method: "Encode" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "Hls" },
{ Format: "scc", Method: "External" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "Hls" },
{ Format: "smi", Method: "External" },
{ Format: "smi", Method: "Encode" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "Hls" },
{ Format: "stl", Method: "External" },
{ Format: "stl", Method: "Encode" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "Hls" },
{ Format: "sub", Method: "External" },
{ Format: "sub", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "Hls" },
{ Format: "subviewer", Method: "External" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Hls" },
{ Format: "teletext", Method: "External" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "Hls" },
{ Format: "text", Method: "External" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "Hls" },
{ Format: "vplayer", Method: "External" },
{ Format: "vplayer", Method: "Encode" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "Hls" },
{ Format: "xsub", Method: "External" },
{ Format: "xsub", Method: "Encode" },
],
};

View File

@@ -1,259 +0,0 @@
/**
* 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/.
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for old phones (aka does not support HEVC)
*
* This file is a modified version of the original file.
*
* Link to original: https://github.com/jellyfin/jellyfin-expo/blob/e7b7e736a8602c94612917ef02de22f87c7c28f2/utils/profiles/ios.js#L4
*/
export default {
MaxStreamingBitrate: 3000000,
MaxStaticBitrate: 3000000,
MusicStreamingTranscodingBitrate: 256000,
DirectPlayProfiles: [
{
Container: "mp4,m4v",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mkv",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mov",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mp3",
Type: "Audio",
},
{
Container: "aac",
Type: "Audio",
},
{
Container: "m4a",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "m4b",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "hls",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
],
TranscodingProfiles: [
{
Container: "mp4",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp4",
Type: "Video",
AudioCodec: "aac,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
{
Container: "ts",
Type: "Video",
AudioCodec: "aac,mp3,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
],
ContainerProfiles: [],
CodecProfiles: [
{
Type: "VideoAudio",
Codec: "aac",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "VideoAudio",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "NotEquals",
Property: "IsAnamorphic",
Value: "true",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoProfile",
Value: "high|main|baseline|constrained baseline",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoRangeType",
Value: "SDR",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "52",
IsRequired: false,
},
{
Condition: "NotEquals",
Property: "IsInterlaced",
Value: "true",
IsRequired: false,
},
],
},
{
Type: "Video",
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "65",
IsRequired: false,
},
],
},
],
SubtitleProfiles: [
{
Method: "Encode",
},
],
};

View File

@@ -0,0 +1,86 @@
/**
* 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/.
*/
import MediaTypes from "../../constants/MediaTypes";
export default {
Name: "Vlc Player for HLS streams.",
MaxStaticBitrate: 20_000_000,
MaxStreamingBitrate: 12_000_000,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "fmp4",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
// Text based subtitles must use HLS.
{ Format: "ass", Method: "Hls" },
{ Format: "microdvd", Method: "Hls" },
{ Format: "mov_text", Method: "Hls" },
{ Format: "mpl2", Method: "Hls" },
{ Format: "pjs", Method: "Hls" },
{ Format: "realtext", Method: "Hls" },
{ Format: "scc", Method: "Hls" },
{ Format: "smi", Method: "Hls" },
{ Format: "srt", Method: "Hls" },
{ Format: "ssa", Method: "Hls" },
{ Format: "stl", Method: "Hls" },
{ Format: "sub", Method: "Hls" },
{ Format: "subrip", Method: "Hls" },
{ Format: "subviewer", Method: "Hls" },
{ Format: "teletext", Method: "Hls" },
{ Format: "text", Method: "Hls" },
{ Format: "ttml", Method: "Hls" },
{ Format: "vplayer", Method: "Hls" },
{ Format: "vtt", Method: "Hls" },
{ Format: "webvtt", Method: "Hls" },
// Image based subs use encode.
{ Format: "dvdsub", Method: "Encode" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "xsub", Method: "Encode" },
],
};

5
utils/secondsToTicks.ts Normal file
View File

@@ -0,0 +1,5 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}

147
utils/streamRanker.ts Normal file
View File

@@ -0,0 +1,147 @@
import {
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
abstract class StreamRankerStrategy {
abstract streamType: string;
abstract rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void;
protected rank(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void {
if (prevIndex == -1) {
console.debug(`AutoSet Subtitle - No Stream Set`);
trackOptions[`Default${this.streamType}StreamIndex`] = -1;
return;
}
if (!prevSource.MediaStreams || !mediaStreams) {
console.debug(`AutoSet ${this.streamType} - No MediaStreams`);
return;
}
let bestStreamIndex = null;
let bestStreamScore = 0;
const prevStream = prevSource.MediaStreams[prevIndex];
if (!prevStream) {
console.debug(`AutoSet ${this.streamType} - No prevStream`);
return;
}
console.debug(
`AutoSet ${this.streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`
);
let prevRelIndex = 0;
for (const stream of prevSource.MediaStreams) {
if (stream.Type != this.streamType) continue;
if (stream.Index == prevIndex) break;
prevRelIndex += 1;
}
let newRelIndex = 0;
for (const stream of mediaStreams) {
if (stream.Type != this.streamType) continue;
let score = 0;
if (prevStream.Codec == stream.Codec) score += 1;
if (prevRelIndex == newRelIndex) score += 1;
if (
prevStream.DisplayTitle &&
prevStream.DisplayTitle == stream.DisplayTitle
)
score += 2;
if (
prevStream.Language &&
prevStream.Language != "und" &&
prevStream.Language == stream.Language
)
score += 2;
console.debug(
`AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`
);
if (score > bestStreamScore && score >= 3) {
bestStreamScore = score;
bestStreamIndex = stream.Index;
}
newRelIndex += 1;
}
if (bestStreamIndex != null) {
console.debug(
`AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`
);
trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex;
} else {
console.debug(
`AutoSet ${this.streamType} - Threshold not met. Using default.`
);
}
}
}
class SubtitleStreamRanker extends StreamRankerStrategy {
streamType = "Subtitle";
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void {
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
class AudioStreamRanker extends StreamRankerStrategy {
streamType = "Audio";
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void {
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
class StreamRanker {
private strategy: StreamRankerStrategy;
constructor(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
) {
this.strategy.rankStream(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };

View File

@@ -6,7 +6,7 @@
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
*/
export const runtimeTicksToMinutes = (
ticks: number | null | undefined,
ticks: number | null | undefined
): string => {
if (!ticks) return "0h 0m";
@@ -16,11 +16,12 @@ export const runtimeTicksToMinutes = (
const hours = Math.floor(ticks / ticksPerHour);
const minutes = Math.floor((ticks % ticksPerHour) / ticksPerMinute);
return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
else return `${minutes}m`;
};
export const runtimeTicksToSeconds = (
ticks: number | null | undefined,
ticks: number | null | undefined
): string => {
if (!ticks) return "0h 0m";
@@ -34,3 +35,68 @@ export const runtimeTicksToSeconds = (
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
else return `${minutes}m ${seconds}s`;
};
// t: ms
export const formatTimeString = (
t: number | null | undefined,
unit: "s" | "ms" | "tick" = "ms"
): string => {
if (t === null || t === undefined) return "0:00";
let seconds: number;
switch (unit) {
case "s":
seconds = Math.floor(t);
break;
case "ms":
seconds = Math.floor(t / 1000);
break;
case "tick":
seconds = Math.floor(t / 10000000);
break;
default:
seconds = Math.floor(t / 1000); // Default to ms if an invalid type is provided
}
if (seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${remainingSeconds}s`;
} else {
return `${minutes}m ${remainingSeconds}s`;
}
};
export const secondsToTicks = (seconds?: number | undefined) => {
if (!seconds) return 0;
return seconds * 10000000;
};
export const ticksToSeconds = (ticks?: number | undefined) => {
if (!ticks) return 0;
return Math.floor(ticks / 10000000);
};
export const msToTicks = (ms?: number | undefined) => {
if (!ms) return 0;
return ms * 10000;
};
export const ticksToMs = (ticks?: number | undefined) => {
if (!ticks) return 0;
return Math.floor(ticks / 10000);
};
export const secondsToMs = (seconds?: number | undefined) => {
if (!seconds) return 0;
return Math.floor(seconds * 1000);
};
export const msToSeconds = (ms?: number | undefined) => {
if (!ms) return 0;
return Math.floor(ms / 1000);
};