mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-03 20:48:26 +01:00
Merge branch 'master' into feat/i18n
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user