Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop

This commit is contained in:
Fredrik Burmester
2025-03-15 19:17:15 +01:00
14 changed files with 608 additions and 56 deletions

View File

@@ -119,6 +119,13 @@
"image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100
}
],
[
"expo-notifications",
{
"icon": "./assets/images/notification.png",
"color": "#9333EA"
}
]
],
"experiments": {

View File

@@ -3,7 +3,7 @@ import { Ionicons, Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
@@ -25,7 +25,7 @@ export default function IndexLayout() {
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
@@ -113,7 +113,7 @@ export default function IndexLayout() {
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
@@ -137,7 +137,7 @@ const SettingsButton = () => {
const SessionsButton = () => {
const router = useRouter();
const { sessions = [], _ } = useSessions({} as useSessionsProps);
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
<TouchableOpacity
@@ -146,7 +146,7 @@ const SessionsButton = () => {
}}
>
<View className="mr-4">
<Ionicons
<Ionicons
name="play-circle"
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}

View File

@@ -12,7 +12,7 @@ import {
BACKGROUND_FETCH_TASK_SESSIONS,
registerBackgroundFetchAsyncSessions,
} from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import {LogProvider, writeErrorLog, writeToLog} from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
@@ -30,7 +30,7 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import {useEffect, useRef, useState} from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
@@ -41,6 +41,9 @@ import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { store } from "@/utils/store";
import {EventSubscription} from "expo-modules-core";
import {ExpoPushToken} from "expo-notifications/build/Tokens.types";
import {Notification, NotificationResponse} from "expo-notifications/build/Notifications.types";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -258,6 +261,7 @@ const queryClient = new QueryClient({
function Layout() {
const [settings] = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
const segments = useSegments();
@@ -268,13 +272,58 @@ function Layout() {
if (!Platform.isTV) {
useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
const notificationListener = useRef<EventSubscription>();
const responseListener = useRef<EventSubscription>();
useEffect(() => {
checkAndRequestPermissions();
(async () => {
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
registerBackgroundFetchAsyncSessions();
}
})();
if (expoPushToken && api && user) {
api?.post("/Streamyfin/device", {
token: expoPushToken.data,
deviceId: getOrSetDeviceId(),
userId: user.Id
}).then(_ => console.log("Posted expo push token"))
.catch(_ => writeErrorLog("Failed to push expo push token to plugin"))
}
else console.log("No token available")
}, [api, expoPushToken, user]);
async function registerNotifications() {
if (Platform.OS === 'android') {
console.log("Setting android notification channel 'default'")
await Notifications?.setNotificationChannelAsync('default', {
name: 'default'
});
}
await checkAndRequestPermissions();
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
useEffect(() => {
registerNotifications()
notificationListener.current = Notifications?.addNotificationReceivedListener((notification: Notification) => {
console.log("Notification received while app running", notification);
});
responseListener.current = Notifications?.addNotificationResponseReceivedListener((response: NotificationResponse) => {
console.log("Notification interacted with", response);
});
return () => {
notificationListener.current &&
Notifications?.removeNotificationSubscription(notificationListener.current);
responseListener.current &&
Notifications?.removeNotificationSubscription(responseListener.current);
}
}, []);
useEffect(() => {

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -58,7 +58,7 @@ const Login: React.FC = () => {
useEffect(() => {
(async () => {
if (_apiUrl) {
setServer({
await setServer({
address: _apiUrl,
});
@@ -181,7 +181,7 @@ const Login: React.FC = () => {
return;
}
setServer({ address: url });
await setServer({ address: url });
}, []);
const handleQuickConnect = async () => {
@@ -237,11 +237,12 @@ const Login: React.FC = () => {
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType="oneTimeCode"
clearButtonMode="while-editing"
maxLength={500}
/>
@@ -324,17 +325,17 @@ const Login: React.FC = () => {
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={(server) => {
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
handleConnect(server.address);
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
/>
</View>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -13,6 +13,10 @@ declare module "@jellyfin/sdk" {
data: D,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
delete<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
}
}
@@ -32,9 +36,18 @@ Api.prototype.post = function <T, D = any>(
data: D,
config: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
...(config || {}),
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};
Api.prototype.delete = function <T, D = any>(
url: string,
config: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
...(config || {}),
data,
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};

View File

@@ -1,16 +1,17 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useFavorite } from "@/hooks/useFavorite";
import { View } from "react-native";
import {View, ViewProps} from "react-native";
import { RoundButton } from "@/components/RoundButton";
import {FC} from "react";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const AddToFavorites = ({ item, ...props }) => {
const { isFavorite, toggleFavorite, _} = useFavorite(item);
export const AddToFavorites:FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
return (
<View {...props}>
<RoundButton

View File

@@ -1,12 +1,11 @@
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, ImageProps, ImageSource } from "expo-image";
import { Image, ImageProps } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import {FC, useMemo} from "react";
import { View, ViewProps } from "react-native";
interface Props extends ImageProps {
item: BaseItemDto;
@@ -25,7 +24,7 @@ interface Props extends ImageProps {
onError?: () => void;
}
export const ItemImage: React.FC<Props> = ({
export const ItemImage: FC<Props> = ({
item,
variant = "Primary",
quality = 90,
@@ -53,7 +52,7 @@ export const ItemImage: React.FC<Props> = ({
if (!source?.uri)
return (
<View
{...props}
{...props as ViewProps}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
>
<Ionicons

View File

@@ -2,24 +2,24 @@ import React, { useEffect, useRef } from "react";
import { View, StyleSheet, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { Slider } from "react-native-awesome-slider";
// import { VolumeManager } from "react-native-volume-manager";
const VolumeManager = !Platform.isTV
? require("react-native-volume-manager")
: null;
const VolumeManager = Platform.isTV ? null : require("react-native-volume-manager");
import { Ionicons } from "@expo/vector-icons";
import { VolumeResult } from "react-native-volume-manager";
interface AudioSliderProps {
setVisibility: (show: boolean) => void;
}
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
if (Platform.isTV) return;
if (Platform.isTV) {
return;
}
const volume = useSharedValue<number>(50); // Explicitly type as number
const min = useSharedValue<number>(0); // Explicitly type as number
const max = useSharedValue<number>(100); // Explicitly type as number
const timeoutRef = useRef<NodeJS.Timeout | null>(null); // Use a ref to store the timeout ID
const timeoutRef = useRef<number | null>(null); // Use a ref to store the timeout ID
useEffect(() => {
const fetchInitialVolume = async () => {
@@ -50,7 +50,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
};
useEffect(() => {
const volumeListener = VolumeManager.addVolumeListener((result) => {
const volumeListener = VolumeManager.addVolumeListener((result: VolumeResult) => {
volume.value = result.volume * 100;
setVisibility(true);

View File

@@ -10,6 +10,7 @@ import ja from "./translations/ja.json";
import tr from "./translations/tr.json";
import nl from "./translations/nl.json";
import sv from "./translations/sv.json";
import ua from "./translations/ua.json"
import zhCN from './translations/zh-CN.json';
import zhTW from './translations/zh-TW.json';
import { getLocales } from "expo-localization";
@@ -24,6 +25,7 @@ export const APP_LANGUAGES = [
{ label: "Türkçe", value: "tr" },
{ label: "Nederlands", value: "nl" },
{ label: "Svenska", value: "sv" },
{ label: "Українська", value: "ua" },
{ label: "简体中文", value: "zh-CN" },
{ label: "繁體中文", value: "zh-TW" },
];
@@ -40,6 +42,7 @@ i18n.use(initReactI18next).init({
nl: { translation: nl },
sv: { translation: sv },
tr: { translation: tr },
ua: { translation: ua },
"zh-CN": { translation: zhCN },
"zh-TW": { translation: zhTW },
},

View File

@@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import uuid from "react-native-uuid";
import {writeErrorLog, writeInfoLog} from "@/utils/log";
interface Server {
address: string;
@@ -286,6 +287,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({
mutationFn: async () => {
api?.delete(`/Streamyfin/device/${deviceId}`)
.then(r => writeInfoLog("Deleted expo push token for device"))
.catch(e => writeErrorLog(`Failed to delete expo push token for device`))
storage.delete("token");
setUser(null);
setApi(null);

View File

@@ -7,16 +7,13 @@ import React, {
useMemo,
useCallback,
} from "react";
import { Alert, AppState, AppStateStatus } from "react-native";
import { AppState, AppStateStatus } from "react-native";
import { useAtomValue } from "jotai";
import { useQuery } from "@tanstack/react-query";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import native from "@/utils/profiles/native";
interface WebSocketProviderProps {
children: ReactNode;
@@ -30,7 +27,6 @@ interface WebSocketContextType {
const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
@@ -40,7 +36,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []);
const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken) return;
if (!deviceId || !api?.accessToken) {
return;
}
const protocol = api.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api.basePath
@@ -50,7 +48,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
let keepAliveInterval: number | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
@@ -67,14 +65,18 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
};
newWebSocket.onclose = () => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
setIsConnected(false);
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId]);
@@ -85,7 +87,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, [connectWebSocket]);
useEffect(() => {
if (!deviceId || !api || !api?.accessToken) return;
if (!deviceId || !api || !api?.accessToken) {
return;
}
const init = async () => {
await getSessionApi(api).postFullCapabilities({

475
translations/ua.json Normal file
View File

@@ -0,0 +1,475 @@
{
"login": {
"username_required": "Імʼя користувача необхідне",
"error_title": "Помилка",
"login_title": "Вхід",
"login_to_title": "Увійти в",
"username_placeholder": "Імʼя користувача",
"password_placeholder": "Пароль",
"login_button": "Вхід",
"quick_connect": "Швидке Зʼєднання",
"enter_code_to_login": "Введіть код {{code}} для входу",
"failed_to_initiate_quick_connect": "Не вдалося ініціалізувати Швидке Зʼєднання",
"got_it": "Готово",
"connection_failed": "Помилка зʼєднання",
"could_not_connect_to_server": "Неможливо підʼєднатися до серверу. Будь ласка перевірте URL і ваше зʼєднання з мережею",
"an_unexpected_error_occured": "Сталася несподівана помилка",
"change_server": "Змінити сервер",
"invalid_username_or_password": "Неправильні імʼя користувача або пароль",
"user_does_not_have_permission_to_log_in": "Користувач не маю дозволу на вхід",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер відповідає занадто довго, будь-ласка спробуйте пізніше",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "Відбулася помилка на стороні сервера",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?"
},
"server": {
"enter_url_to_jellyfin_server": "Введіть URL вашого Jellyfin сервера",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Підʼєднатися",
"previous_servers": "попередні сервери",
"clear_button": "Очистити",
"search_for_local_servers": "Пошук локальних серверів",
"searching": "Пошук...",
"servers": "Сервери"
},
"home": {
"no_internet": "Інтернет відсутній",
"no_items": "Пусто",
"no_internet_message": "Не хвилюйтеся, ви все ще можете переглядати\nзавантажений контент.",
"go_to_downloads": "Перейти в завантаження",
"oops": "Упс!",
"error_message": "Щось пішло не так.\nБудь ласка вийдіть і увійдіть знов.",
"continue_watching": "Продовжити перегляд",
"next_up": "Далі",
"recently_added_in": "Нещодавно додане до медіатеки {{libraryName}}",
"suggested_movies": "Рекомендовані Фільми",
"suggested_episodes": "Рекомендовані Епізоди",
"intro": {
"welcome_to_streamyfin": "Вітаємо у Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Вільний і open-source клієнт для Jellyfin.",
"features_title": "Функції",
"features_description": "Streamyfin має безліч функцій та інтегрується з широким спектром програмного забезпечення, яке ви можете знайти в меню налаштувань, зокрема:",
"jellyseerr_feature_description": "Підключіться до вашого екземпляру Jellyseerr і запитуватуйте фільми безпосередньо в застосунку.",
"downloads_feature_title": "Завантаження",
"downloads_feature_description": "Завантажуйте фільми і серіали для перегляду офлайн. Використовуйте або метод за замовчуванням або встановіть оптимізований сервер для завантаження файлів у фоні.",
"chromecast_feature_description": "Транслюйте фільми і серіали но ваші Chromecast прилади.",
"centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Налаштуйте параметри з централізованої локації на вашому сервері Jellyfin. Всі налаштування клієнтів для всіх користувачів будуть синхронізовані автоматично.",
"done_button": "Готово",
"go_to_settings_button": "Перейти до параметрів",
"read_more": "Прочитати більше"
},
"settings": {
"settings_title": "Параметри",
"log_out_button": "Вихід",
"user_info": {
"user_info_title": "Інформація користувача",
"user": "Користувач",
"server": "Сервер",
"token": "Токен",
"app_version": "Версія Застосунку"
},
"quick_connect": {
"quick_connect_title": "Швидке Зʼєднання",
"authorize_button": "Авторизуйте Швидке Зʼєднання",
"enter_the_quick_connect_code": "Введіть код для швидкого зʼєднання...",
"success": "Успіх",
"quick_connect_autorized": "Швидке Зʼєднання авторизовано",
"error": "Помилка",
"invalid_code": "Не правильний код",
"authorize": "Авторизувати"
},
"media_controls": {
"media_controls_title": "Керування Медія",
"forward_skip_length": "Тривалість перемотування вперед",
"rewind_length": "Довжина перемотування назад",
"seconds_unit": "с"
},
"audio": {
"audio_title": "Аудіо",
"set_audio_track": "Виставити аудіо доріжку як в попередньому епізоду",
"audio_language": "Мова аудіо",
"audio_hint": "Вибрати мову аудіо за замовчуванням.",
"none": "Ніяка",
"language": "Мова"
},
"subtitles": {
"subtitle_title": "Субтитри",
"subtitle_language": "Мова субтитрів",
"subtitle_mode": "Режим субтитрів",
"set_subtitle_track": "Виставити доріжку субтитрів як в попередньому епізоду",
"subtitle_size": "Розмір субтитрів",
"subtitle_hint": "Налаштуйте параметри субтитрів.",
"none": "Ніякі",
"language": "Мова",
"loading": "Завантаження",
"modes": {
"Default": "За замовчування",
"Smart": "Smart",
"Always": "Завжди",
"None": "Някий",
"OnlyForced": "Виключно Форсовані"
}
},
"other": {
"other_title": "Інші",
"follow_device_orientation": "Дотримуйтесь орієнтації пристрою",
"video_orientation": "Орієнтація відео",
"orientation": "Orientation",
"orientations": {
"DEFAULT": "За змовчуванням",
"ALL": "Всі",
"PORTRAIT": "Портретна",
"PORTRAIT_UP": "Портретна Догори",
"PORTRAIT_DOWN": "Портретна Донизу",
"LANDSCAPE": "Альбомна",
"LANDSCAPE_LEFT": "Альбомна Ліва",
"LANDSCAPE_RIGHT": "Альбомна Права",
"OTHER": "Інше",
"UNKNOWN": "Невідомо"
},
"safe_area_in_controls": "Безпечна зона в елементах керування",
"video_player": "Відео плеєр",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Показати посилання на користувацьке меню",
"hide_libraries": "Сховати медіатеки",
"select_liraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
"disable_haptic_feedback": "Вимкнути тактильний зворотний зв'язок",
"default_quality": "Якість за замовченням"
},
"downloads": {
"downloads_title": "Завантаження",
"download_method": "Метод завантаження",
"remux_max_download": "Remux max download",
"auto_download": "Авто-завантаження",
"optimized_versions_server": "Optimized versions server",
"save_button": "Зберегти",
"optimized_server": "Оптимізований Сервер",
"optimized": "Оптимізований",
"default": "За замовченням",
"optimized_version_hint": "Введіть URL-адресу сервера для оптимізації. URL-адреса має містити http або https і, за бажанням, порт.",
"read_more_about_optimized_server": "Дізнайтеся більше про сервер для оптимізації.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "Плагіни",
"jellyseerr": {
"jellyseerr_warning": "Ця інтеграція перебуває на початковій стадії. Очікуйте, що все зміниться.",
"server_url": "URL Сервера",
"server_url_hint": "Наприклад: http(s)://your-host.url\n(додайте порт якщо необхідно)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Пароль",
"password_placeholder": "Введіть Jellyfin пароль для користувача {{username}}",
"save_button": "Зберегти",
"clear_button": "Очистити",
"login_button": "Вхід",
"total_media_requests": "Загальна кількість медіа запитів",
"movie_quota_limit": "Дні квоти на фільми",
"movie_quota_days": "Дні квоти на фільми",
"tv_quota_limit": "Дні квоти на серіали",
"tv_quota_days": "Дні квоти на серіали",
"reset_jellyseerr_config_button": "Скинути конфігурацію Jellyseerr",
"unlimited": "Необмежене",
"plus_n_more": "+{{n}} ще",
"order_by": {
"DEFAULT": "За замовченням",
"VOTE_COUNT_AND_AVERAGE": "Кількість голосів і середнє",
"POPULARITY": "Популярність"
}
},
"marlin_search": {
"enable_marlin_search": "Увімкнути Marlin Search ",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Введіть URL-адресу сервера Marlin. Адреса повинна містити http або https і, за бажанням, порт.",
"read_more_about_marlin": "Дізнайтеся більше про Marlin.",
"save_button": "Зберегти",
"toasts": {
"saved": "Збережено"
}
}
},
"storage": {
"storage_title": "Сховище",
"app_usage": "Застосунок {{usedSpace}}%",
"device_usage": "Гаджет {{availableSpace}}%",
"size_used": "{{used}} з {{total}} використано",
"delete_all_downloaded_files": "Видалити усі завантаженні файли"
},
"intro": {
"show_intro": "Показати інтро",
"reset_intro": "Скинути інтро"
},
"logs": {
"logs_title": "Журнал",
"no_logs_available": "Нема доступних журналів",
"delete_all_logs": "Видалити усі журнали"
},
"languages": {
"title": "Мова",
"app_language": "Мова застосунку",
"app_language_description": "Виберіть мову застосунку.",
"system": "Системна"
},
"toasts": {
"error_deleting_files": "Помилка при видалені файлів",
"background_downloads_enabled": "Завантаження в фоні увімкнене",
"background_downloads_disabled": "Завантаження в фоні вимкнене",
"connected": "Зʼєднано",
"could_not_connect": "Неможливо зʼєднатися",
"invalid_url": "Неправльий URL"
}
},
"sessions": {
"title": "Сесії",
"no_active_sessions": "Нема активних сесій"
},
"downloads": {
"downloads_title": "Завантаження",
"tvseries": "ТБ-Серіали",
"movies": "Фільми",
"queue": "Черга",
"queue_hint": "Черга і завантаження буде втрачене при перезапуску застосунку",
"no_items_in_queue": "Нема елементів в черзі",
"no_downloaded_items": "Нема завантажених елементів",
"delete_all_movies_button": "Видалити всі Фільми",
"delete_all_tvseries_button": "Видалити всі ТБ-Серіали",
"delete_all_button": "Видалити Все",
"active_download": "Активне завантаження",
"no_active_downloads": "Нема активних завантажень",
"active_downloads": "Активні завантаження",
"new_app_version_requires_re_download": "Нова версія застосунку вимагає завантажити заново",
"new_app_version_requires_re_download_description": "Нове оновлення вимагає повторного завантаження вмісту. Будь ласка, видаліть весь завантажений вміст і повторіть спробу.",
"back": "Назад",
"delete": "Видалити",
"something_went_wrong": "Щось пішло не так",
"could_not_get_stream_url_from_jellyfin": "Не вдалося отримати URL-адресу потоку від Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Методи",
"toasts": {
"you_are_not_allowed_to_download_files": "Вам не дозволено завантажувати файли.",
"deleted_all_movies_successfully": "Видалення всіх фільмів було успішне!",
"failed_to_delete_all_movies": "Не вдалося видалити усі фільми",
"deleted_all_tvseries_successfully": "Успішно видалено всі серіали!",
"failed_to_delete_all_tvseries": "Не вдалося видалити всі телесеріали",
"download_cancelled": "Завантаження скасоване",
"could_not_cancel_download": "Неможливо скасувати завантаження",
"download_completed": "Завантаження завершено",
"download_started_for": "Почалося завантаження {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} вже завантажено",
"download_stated_for_item": "Почалося завантаження {{item}}",
"download_failed_for_item": "Не вдалося завантажити {{item}} - {{error}}",
"download_completed_for_item": "Завантаження завершено {{item}}",
"queued_item_for_optimization": "{{item}} в черзі на оптимізацію",
"failed_to_start_download_for_item": "Не вдалося почати завантаження {{item}}: {{message}}",
"server_responded_with_status_code": "Сервер відповів зі статусом {{statusCode}}",
"no_response_received_from_server": "Не отримано відповіді від сервера",
"error_setting_up_the_request": "Помилка налаштування запиту",
"failed_to_start_download_for_item_unexpected_error": "Не вдалося почати завантаження {{item}}: Несподівана помилка",
"all_files_folders_and_jobs_deleted_successfully": "Усі файли, папки та завдання успішно видалено",
"an_error_occured_while_deleting_files_and_jobs": "Виникла помилка під час видалення файлів і завдань",
"go_to_downloads": "Перейти до завантаження"
}
}
},
"search": {
"search_here": "Шукати тут...",
"search": "Шукати...",
"x_items": "{{count}} елементів",
"library": "Медіатека",
"discover": "Відкрийте для себе",
"no_results": "Без результатів",
"no_results_found_for": "Жодних результатів не знайдено для",
"movies": "Фільми",
"series": "Серіали",
"episodes": "Епізоди",
"collections": "Колекції",
"actors": "Актори",
"request_movies": "Запитати Фільми",
"request_series": "Запитати Серіали",
"recently_added": "Нещодавно Додане",
"recent_requests": "Нещодавні Запити",
"plex_watchlist": "Список перегляду Plex",
"trending": "У Тренді",
"popular_movies": "Популярні Фільми",
"movie_genres": "Жанри Кіно",
"upcoming_movies": "Майбутні Фільми",
"studios": "Студії",
"popular_tv": "Популярні Серіали",
"tv_genres": "Жанри Серіалів",
"upcoming_tv": "Майбутні Серіали",
"networks": "ТБ Канали",
"tmdb_movie_keyword": "TMDB Ключові слова Фільмів",
"tmdb_movie_genre": "TMDB Жанри Кіно",
"tmdb_tv_keyword": "TMDB ТБ Ключові слова",
"tmdb_tv_genre": "TMDB ТБ Жанри",
"tmdb_search": "TMDB Пошук",
"tmdb_studio": "TMDB Студії",
"tmdb_network": "TMDB ТБ Канали",
"tmdb_movie_streaming_services": "TMDB Стрімінгові Сервіси Фільмів",
"tmdb_tv_streaming_services": "TMDB Стрімінгові Сервіси Серіалів"
},
"library": {
"no_items_found": "Елементів не знайдено",
"no_results": "Без результатів",
"no_libraries_found": "Не знайдено медіатек",
"item_types": {
"movies": "фільми",
"series": "серіали",
"boxsets": "бокс-сети",
"items": "елементи"
},
"options": {
"display": "Показати",
"row": "Ряд",
"list": "Список",
"image_style": "Стиль зображення",
"poster": "Постер",
"cover": "Обкладинка",
"show_titles": "Показати заголовки",
"show_stats": "Показати статистику"
},
"filters": {
"genres": "Жанри",
"years": "Роки",
"sort_by": "Відсортувати за",
"sort_order": "Порядок сортування",
"asc": "За зростанням",
"desc": "За спаданням",
"tags": "Теги"
}
},
"favorites": {
"series": "Серіали",
"movies": "Фільми",
"episodes": "Епізоди",
"videos": "Відео",
"boxsets": "Бокс-сети",
"playlists": "Плейлісти"
},
"custom_links": {
"no_links": "Немає посилань"
},
"player": {
"error": "Помилка",
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнали в налаштуваннях.",
"client_error": "Помилка клієнту",
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
"message_from_server": "Повідомлення від серверу: {{message}}",
"video_has_finished_playing": "Відтворення відео завершено!",
"no_video_source": "Немає джерела відео...",
"next_episode": "Наступний Епізод",
"refresh_tracks": "Оновити доріжки",
"subtitle_tracks": "Доріжки Субтитрів:",
"audio_tracks": "Аудіо-доріжки:",
"playback_state": "Стан відтворення:",
"no_data_available": "Дані відсутні",
"index": "Індекс:"
},
"item_card": {
"next_up": "Далі",
"no_items_to_display": "Немає елементів для відображення",
"cast_and_crew": "Акторський склад та команда",
"series": "Серіали",
"seasons": "Сезони",
"season": "Сезон",
"no_episodes_for_this_season": "У цьому сезоні немає епізодів",
"overview": "Огляд",
"more_with": "Більше з {{name}}",
"similar_items": "Схожі елементи",
"no_similar_items_found": "Не знайдено схожих елементів",
"video": "Відео",
"more_details": "Більше деталей",
"quality": "Якість",
"audio": "Аудіо",
"subtitles": "Субтитри",
"show_more": "Показати більше",
"show_less": "Показати менше",
"appeared_in": "Зʼявлявся у",
"could_not_load_item": "Неможливо завантажити елемент",
"none": "Нічого",
"download": {
"download_season": "Завантажити Сезон",
"download_series": "Завантажити Серіал",
"download_episode": "Завантажити Епізод",
"download_movie": "Завантажити Фільм",
"download_x_item": "Завантажено {{item_count}} елементів",
"download_button": "Завантажити",
"using_optimized_server": "Використовуючи сервер оптимізації",
"using_default_method": "Використовуючи метод за замовченням"
}
},
"live_tv": {
"next": "Наступний",
"previous": "Попередній",
"live_tv": "Live TV",
"coming_soon": "Скоро",
"on_now": "Просто зараз",
"shows": "Серіали",
"movies": "Фільми",
"sports": "Спорт",
"for_kids": "Для дітей",
"news": "Новини"
},
"jellyseerr": {
"confirm": "Підтвердити",
"cancel": "Скасувати",
"yes": "Так",
"whats_wrong": "Щось сталося?",
"issue_type": "Тип проблеми",
"select_an_issue": "Виберіть проблему",
"types": "Типи",
"describe_the_issue": "(опціонально) Опишіть проблему...",
"submit_button": "Надіслати",
"report_issue_button": "Звіт про проблему",
"request_button": "Запити",
"are_you_sure_you_want_to_request_all_seasons": "Ви впевнені, що хочете запросити всі сезони?",
"failed_to_login": "Не вдалося увійти",
"cast": "Акторський склад",
"details": "Деталі",
"status": "Статус",
"original_title": "Оригінальна Назва",
"series_type": "Тип Серіалу",
"release_dates": "Дата Виходу",
"first_air_date": "Дата першого етеру",
"next_air_date": "Дата наступного етеру",
"revenue": "Збори",
"budget": "Бюджет",
"original_language": "Мова Оригіналу",
"production_country": "Країна Виробництва",
"studios": "Студії",
"network": "ТБ Канали",
"currently_streaming_on": "Наразі транслюється на",
"advanced": "Просунуте",
"request_as": "Запит Як",
"tags": "Теги",
"quality_profile": "Профіль якості",
"root_folder": "Корнева Тека",
"season_all": "Сезон (всі)",
"season_number": "Сезон {{season_number}}",
"number_episodes": "{{episode_number}} Епізодів",
"born": "Дата народження",
"appearances": "Зовнішній вигляд",
"toasts": {
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не відповідає мінімальним вимогам до версії! Будь ласка, оновіть версію принаймні до 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr завершився невдало. Спробуйте ще раз.",
"failed_to_test_jellyseerr_server_url": "Не вдалося перевірити URL-адресу сервера jellyseerr",
"issue_submitted": "Звіт про проблему відправлено",
"requested_item": "Запитано {{item}}!",
"you_dont_have_permission_to_request": "У вас нема дозволу на запит медіа!",
"something_went_wrong_requesting_media": "Щось пішло не так під час запиту медіа!"
}
},
"tabs": {
"home": "Головна",
"search": "Пошук",
"library": "Медіатека",
"custom_links": "Ваші Посилання",
"favorites": "Улюблене"
}
}

View File

@@ -1,12 +1,8 @@
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;
@@ -25,7 +21,6 @@ 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");