From 10bfa9506027d59b13cb1ba7ed6d271b78282749 Mon Sep 17 00:00:00 2001 From: Ahmed Sbai <30757139+sbaiahmed1@users.noreply.github.com> Date: Sat, 15 Mar 2025 09:21:24 +0100 Subject: [PATCH 1/3] fix: update textContentType for username input to oneTimeCode (#587) --- app/(auth)/(tabs)/(home)/_layout.tsx | 10 ++++---- app/login.tsx | 19 ++++++++------- components/AddToFavorites.tsx | 9 +++---- components/common/ItemImage.tsx | 11 ++++----- .../video-player/controls/AudioSlider.tsx | 14 +++++------ providers/WebSocketProvider.tsx | 24 +++++++++++-------- utils/jellyfin/session/capabilities.ts | 5 ---- 7 files changed, 46 insertions(+), 46 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 6c111607..dee024fd 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -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: () => ( @@ -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 ( { }} > - { 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")} { + onServerSelect={async (server) => { setServerURL(server.address); if (server.serverName) { setServerName(server.serverName); } - handleConnect(server.address); + await handleConnect(server.address); }} /> { - handleConnect(s.address); + onServerSelect={async (s) => { + await handleConnect(s.address); }} /> diff --git a/components/AddToFavorites.tsx b/components/AddToFavorites.tsx index 945e9988..b190dced 100644 --- a/components/AddToFavorites.tsx +++ b/components/AddToFavorites.tsx @@ -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 = ({ item, ...props }) => { + const { isFavorite, toggleFavorite } = useFavorite(item); + return ( void; } -export const ItemImage: React.FC = ({ +export const ItemImage: FC = ({ item, variant = "Primary", quality = 90, @@ -53,7 +52,7 @@ export const ItemImage: React.FC = ({ if (!source?.uri) return ( void; } const AudioSlider: React.FC = ({ setVisibility }) => { - if (Platform.isTV) return; + if (Platform.isTV) { + return; + } const volume = useSharedValue(50); // Explicitly type as number const min = useSharedValue(0); // Explicitly type as number const max = useSharedValue(100); // Explicitly type as number - const timeoutRef = useRef(null); // Use a ref to store the timeout ID + const timeoutRef = useRef(null); // Use a ref to store the timeout ID useEffect(() => { const fetchInitialVolume = async () => { @@ -50,7 +50,7 @@ const AudioSlider: React.FC = ({ setVisibility }) => { }; useEffect(() => { - const volumeListener = VolumeManager.addVolumeListener((result) => { + const volumeListener = VolumeManager.addVolumeListener((result: VolumeResult) => { volume.value = result.volume * 100; setVisibility(true); diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index 1311459f..b61c1967 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -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(null); export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { - const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); const [ws, setWs] = useState(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({ diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index 99ef5cb1..c0f3b295 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -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 => { if (!api || !itemId || !sessionId) { throw new Error("Missing parameters for marking item as not played"); From cbcb160bdd94cc10be7c1451547da5d7b45ce390 Mon Sep 17 00:00:00 2001 From: Danylo Kozhushko <32894068+ozgreat@users.noreply.github.com> Date: Sat, 15 Mar 2025 09:21:52 +0100 Subject: [PATCH 2/3] feat: Added Ukrainian translation (#593) --- i18n.ts | 3 + translations/ua.json | 475 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 translations/ua.json diff --git a/i18n.ts b/i18n.ts index 2480e384..7f35e575 100644 --- a/i18n.ts +++ b/i18n.ts @@ -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 }, }, diff --git a/translations/ua.json b/translations/ua.json new file mode 100644 index 00000000..d2a40d71 --- /dev/null +++ b/translations/ua.json @@ -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": "Улюблене" + } +} From 9b0ba285b39a20023b01a17e185534a91adc1876 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Mon, 10 Mar 2025 01:05:51 -0400 Subject: [PATCH 3/3] feat: Ability to consume webhook notifications and forward to clients #595 - forward expo device tokens to users plugin instance - added android notification icon --- app.json | 9 ++++- app/_layout.tsx | 65 +++++++++++++++++++++++++++++---- assets/images/notification.png | Bin 0 -> 22765 bytes augmentations/api.ts | 17 ++++++++- providers/JellyfinProvider.tsx | 5 +++ 5 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 assets/images/notification.png diff --git a/app.json b/app.json index 8867d0cc..ba4584d5 100644 --- a/app.json +++ b/app.json @@ -120,6 +120,13 @@ "image": "./assets/images/StreamyFinFinal.png", "imageWidth": 100 } + ], + [ + "expo-notifications", + { + "icon": "./assets/images/notification.png", + "color": "#9333EA" + } ] ], "experiments": { @@ -133,7 +140,7 @@ "projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68" } }, - "owner": "fredrikburmester", + "owner": "streamyfin", "runtimeVersion": { "policy": "appVersion" }, diff --git a/app/_layout.tsx b/app/_layout.tsx index 8dfe0786..2d1ab164 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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(); + const notificationListener = useRef(); + const responseListener = useRef(); + 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(() => { diff --git a/assets/images/notification.png b/assets/images/notification.png new file mode 100644 index 0000000000000000000000000000000000000000..b50e56aed4af6f931f3cacc7b9be59276c83cf41 GIT binary patch literal 22765 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 ): Promise>; + delete( + url: string, + config?: AxiosRequestConfig + ): Promise>; getStreamyfinPluginConfig(): Promise>; } } @@ -32,9 +36,18 @@ Api.prototype.post = function ( data: D, config: AxiosRequestConfig ): Promise> { - return this.axiosInstance.post(`${this.basePath}${url}`, { + return this.axiosInstance.post(`${this.basePath}${url}`, data, { + ...(config || {}), + headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader }, + }); +}; + +Api.prototype.delete = function ( + url: string, + config: AxiosRequestConfig +): Promise> { + return this.axiosInstance.delete(`${this.basePath}${url}`, { ...(config || {}), - data, headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader }, }); }; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 37b52346..1256d2ae 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -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);