mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Merge branch 'master' into feat/i18n
This commit is contained in:
21
utils/OrientationLockConverter.ts
Normal file
21
utils/OrientationLockConverter.ts
Normal 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
134
utils/SubtitleHelper.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
6
utils/atoms/orientation.ts
Normal file
6
utils/atoms/orientation.ts
Normal 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
|
||||
);
|
||||
67
utils/atoms/primaryColor.ts
Normal file
67
utils/atoms/primaryColor.ts
Normal 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);
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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
19
utils/bToMb.ts
Normal 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
23
utils/background-tasks.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
utils/collectionTypeToItemType.ts
Normal file
53
utils/collectionTypeToItemType.ts
Normal 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
19
utils/device.ts
Normal 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
33
utils/download.ts
Normal 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
87
utils/getItemImage.ts
Normal 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;
|
||||
};
|
||||
55
utils/hls/parseM3U8ForSubtitles.ts
Normal file
55
utils/hls/parseM3U8ForSubtitles.ts
Normal 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;
|
||||
}
|
||||
107
utils/jellyfin/getDefaultPlaySettings.ts
Normal file
107
utils/jellyfin/getDefaultPlaySettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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/${
|
||||
|
||||
@@ -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()}`;
|
||||
};
|
||||
|
||||
42
utils/jellyfin/image/getParentBackdropImageUrl.ts
Normal file
42
utils/jellyfin/image/getParentBackdropImageUrl.ts
Normal 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()}`;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
42
utils/jellyfin/image/getPrimaryParentImageUrl.ts
Normal file
42
utils/jellyfin/image/getPrimaryParentImageUrl.ts
Normal 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()}`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
61
utils/jellyfin/session/capabilities.ts
Normal file
61
utils/jellyfin/session/capabilities.ts
Normal 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
1
utils/jellyseerr
Submodule
Submodule utils/jellyseerr added at e69d160e25
47
utils/log.ts
47
utils/log.ts
@@ -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
88
utils/log.tsx
Normal 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
3
utils/mmkv.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
export const storage = new MMKV();
|
||||
239
utils/optimize-server.ts
Normal file
239
utils/optimize-server.ts
Normal 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
143
utils/profiles/android.js
Normal 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" },
|
||||
],
|
||||
};
|
||||
@@ -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
138
utils/profiles/download.js
Normal 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" },
|
||||
],
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -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
|
||||
]
|
||||
};
|
||||
@@ -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)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
86
utils/profiles/transcoding.js
Normal file
86
utils/profiles/transcoding.js
Normal 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
5
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// seconds to ticks util
|
||||
|
||||
export function secondsToTicks(seconds: number): number {
|
||||
return seconds * 10000000;
|
||||
}
|
||||
147
utils/streamRanker.ts
Normal file
147
utils/streamRanker.ts
Normal 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 };
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user