Compare commits

..

44 Commits

Author SHA1 Message Date
Gauvain
933c6a6588 Merge remote-tracking branch 'origin/develop' into fix/ui-and-bugs
# Conflicts:
#	components/TrackSheet.tsx
2026-07-15 00:10:17 +02:00
Gauvain
743a8a0ce5 Merge branch 'develop' into fix/ui-and-bugs
Resolve TechnicalInfoOverlay buffer line: keep the i18n buffer_seconds key and
append the mpv demuxer-cache caps from develop (#1765).
Fix TV tab _layout index-segment comparison after develop's route-name union change.
2026-06-29 01:57:41 +02:00
Gauvain
1e7f5a4ef8 fix(library): host filters in a single shared sheet to stop stacked sheets
Each FilterButton owned its own bottom-sheet modal, so tapping two
filter buttons quickly could present two sheets at once. Add a
FilterSheetProvider that hosts one shared sheet per screen; buttons just
swap its content via openFilter(). A first-wins guard in the provider
(one source of truth, reset on the single close path) ignores a second
tap while a sheet is opening. The generic FilterSheet is untouched, so
the bitrate/source/track selectors and provider-less screens (logs,
discover) keep their standalone sheet. Also drop the header deps that
remounted the whole filter bar on every fetch.
2026-06-28 01:24:05 +02:00
Gauvain
8a781f2462 feat(appearance): episode images for Next Up & Continue Watching
- Fix black episode thumbnails in the Next Up / Continue Watching rows:
  build the parent Thumb URL from the matched ParentThumbItemId +
  ParentThumbImageTag pair, instead of pairing ParentBackdropItemId with
  the thumb tag (different parent -> 404 -> black). Fixed on mobile
  (ContinueWatchingPoster) and TV (TVPosterCard, TVHeroCarousel).
- Add a "Use episode images for Next Up & Continue Watching" setting
  (default off = series image, matching Jellyfin), wired into the home
  rows on mobile and TV.
- Add helper descriptions under the Appearance settings rows.
2026-06-28 01:24:04 +02:00
Gauvain
4a0dc44251 fix(home): defer first-launch intro until the save-account sheet is dismissed
On first login with "save account" checked, the intro sheet's 1s timer
fired while the post-login save-account bottom sheet was still up and
stole its presentation (both are gorhom modals), closing it. Gate the
intro on pendingAccountSaveAtom so it only schedules once no account
save is pending: before the sheet opens, or after it is dismissed.
2026-06-28 01:24:03 +02:00
Gauvain
a3dc08c323 Merge branch 'develop' into fix/ui-and-bugs 2026-06-27 23:21:50 +02:00
Gauvain
bd5b95c835 fix(library): per-library filter memory + reset-to-top, reset on app close
Genre/year/tag filters lived in global atoms with no per-library memory and
no reset on library switch, so a selection in one library (e.g. Anime) bled
into another (e.g. Shows). Sort/order/filterBy were already per-library but
persisted across app restarts.

Move all per-library filter state to in-memory preferences — genres/years/tags
get their own, and sort/order/filterBy stop using atomWithStorage: each library
remembers its own filters for the session and everything resets when the app is
fully closed. The library mount effect restores the active library's selection,
the shared useFilterReset clears it, and collections open with a clean slate.

Also fix the grid not returning to the top on a filter/sort change or reset:
it now pins to the top instantly and re-pins once the filtered fetch settles
(FlashList was restoring the previous offset as the new content grew);
pagination is left untouched.
2026-06-25 00:32:59 +02:00
Gauvain
8a37ec46bf fix(library): reset (X) also clears sort & order, via a shared hook
The mobile ResetFiltersButton and the TV filter header each had their own
active-state and reset logic, and both ignored sort & order — so the reset
chip never appeared for a changed sort and reset never restored it.

Centralise both in useFilterReset(libraryId): the chip now reflects a
non-default sort, and reset restores SortName/Ascending and clears the
per-library persisted sort/order/filter preferences so the reset sticks.
2026-06-24 23:14:55 +02:00
Gauvain
b70f644fbc fix(logs): scroll long entries on Android, full-row expand & copy to clipboard
- the code-block ScrollView is nested in the page ScrollView, so Android
  never scrolled it (the outer view captured the gesture); add
  nestedScrollEnabled so the debug payload scrolls
- move the "more info" hint inside the toggle so tapping anywhere on the
  collapsed row expands it
- make the JSON payload selectable
- add a Copy button (expo-clipboard, guarded for builds without the native
  module, mobile only) copying level + timestamp + message + data
2026-06-24 23:14:47 +02:00
Gauvain
d5ef69b612 fix(settings): apply the plugin (admin) default when a setting is unlocked
A setting that was always locked then unlocked showed the app's hardcoded
default instead of the plugin's configured default: the unlocked branch
treated the persisted app default as a meaningful user value, so the plugin
value was effectively dead code for any key with a non-empty app default.

Keep the user's value only when it diverges from the app default; otherwise
the plugin (admin) default wins over the hardcoded app default. Shared via
useSettings, so mobile and TV are both fixed.
2026-06-24 22:53:48 +02:00
Gauvain
2ac664de5a fix(settings): tidy spacing, uniform Android row heights & toggle layout
- spacing: top padding + inter-group gaps so section titles, descriptions
  and buttons no longer touch (playback buffer, audio/subtitles, music,
  marlin, kefin, streamystats, storage, hide-libraries)
- ListGroup: skip empty section titles
- SettingSwitch: cap the native Android switch in a fixed centered box so
  toggle rows match the other rows and stay put when toggled; iOS unchanged
- Appearance: move "Hide libraries" to the end of the list
2026-06-24 22:22:49 +02:00
Alex
91ea791663 Merge branch 'develop' into fix/ui-and-bugs 2026-06-23 18:24:32 +10:00
Gauvain
e1f7968352 fix(navigation): dismiss the keyboard when using the header back button
Leaving a screen with the keyboard up (e.g. the Jellyseerr login) left it
lingering over the previous screen. Dismiss it before navigating back.
2026-06-16 23:42:31 +02:00
Gauvain
f8e0baa0c0 refactor(kefin-tweaks): use the standard ListItem/disabledByAdmin pattern
Rebuild the KefinTweaks toggle on ListGroup + ListItem like the other
plugin settings: drop the bespoke card, the red label, the custom switch
colours, and the `t("Watchlist On")`/`t("Watchlist Off")` literal-as-key
strings. The admin lock now shows via the ListItem subtitle instead of
wrapping the whole screen in DisabledSetting.
2026-06-16 23:42:25 +02:00
Gauvain
9ed49e040e fix(jellyseerr): lock only the server URL, keep the password editable
The page greyed out the whole settings screen when the admin locked the
server URL, so the user couldn't even type their password to sign in to
the pinned server. Now only the URL field is disabled (greyed + "Disabled
by admin"); the password input stays editable.
2026-06-16 23:42:20 +02:00
Gauvain
e2acc40c49 refactor(settings): move "refresh from server" to the plugins index
The button pulls the centralised Streamyfin plugin settings for every
plugin, so it belongs on the plugins list page rather than buried inside
the Streamystats screen.
2026-06-16 23:42:12 +02:00
Gauvain
3d84b558fb fix(marlin-search): show the admin-lock notice via the ListItem subtitle
The "Disabled by admin" notice was rendered by DisabledSetting inside the
rounded, overflow-hidden card, which clipped the text. Switch to the same
pattern as the Streamystats settings: plain ListItems with the
`disabledByAdmin` prop, so the notice renders as the row subtitle and the
URL/toggle disable per-field.
2026-06-16 23:27:52 +02:00
Gauvain
8270c4c30c feat(quick-connect): add a paste button to the authorize code input
Codes often arrive over SMS/another app; add a "Paste code" button under
the PIN input that reads the clipboard (expo-clipboard, probed optionally)
and keeps the 6 digits.
2026-06-16 23:25:16 +02:00
Gauvain
4d9fc1d615 fix(library): scroll the grid back to the top when filters change
Resetting or changing a filter/sort refetches from page 1, but the
FlashList kept its previous offset, leaving the user stranded mid-list.
Hold a FlashListRef and scrollToOffset(0) whenever the active
filters/sort change.
2026-06-16 23:25:06 +02:00
Gauvain
3478f7a040 Merge branch 'develop' into fix/ui-and-bugs 2026-06-16 21:54:56 +02:00
Gauvain
32adb5c43a fix(ui): unify the header back icon and fix its Android alignment
Use the Settings chevron-left for HeaderBackButton everywhere (was
Ionicons arrow-back) so every back button matches. On Android, replace
the `rounded-full p-2` (which pushed both the arrow and the title too far
right) with a 16px right margin, like the Settings back button.
2026-06-16 21:50:00 +02:00
Gauvain
b6da8c0784 fix(settings): align the Settings/Plugins back chevron with the edge
Drop the `pl-0.5` that pushed the back chevron slightly to the right on
the Settings and Plugins headers, matching the other back buttons.
2026-06-16 21:49:57 +02:00
Gauvain
fc53a7d760 fix(logs): keep the filter bar below the header on iOS
The logs page rendered its filter bar in a plain View with no top inset,
so under the transparent iOS header it sat behind the header and the
filter buttons weren't tappable. Use a root ScrollView with
contentInsetAdjustmentBehavior='automatic' (like the sibling settings
pages) and make the filter bar a sticky header, so iOS insets the content
below the header and the bar stays pinned while logs scroll.
2026-06-16 21:49:35 +02:00
Gauvain
3035a9019c Merge branch 'develop' into fix/ui-and-bugs 2026-06-16 21:03:12 +02:00
Gauvain
182a2cf1e6 Merge remote-tracking branch 'origin/develop' into fix/ui-and-bugs
# Conflicts:
#	bun.lock
#	package.json
2026-06-16 20:59:00 +02:00
Gauvain
6195db2a83 Merge branch 'develop' into fix/ui-and-bugs 2026-06-15 01:36:00 +02:00
Gauvino
599096f883 fix(review): address second CodeRabbit pass
- streamystats: derive toggle enablement from the same effective URL the
  input renders (locked admin URL no longer disables every switch)
- FilterSheet: use the deep-equality rule for toggling that rendering
  already uses — option objects are recreated across renders
- DownloadCard: take t from useTranslation so badge labels re-render on
  language change
- fileOperations: count trickplay bytes in the storage total, matching
  the per-item size model
- PendingAccountSaveModal: warn instead of silently swallowing a failed
  account save
2026-06-12 16:23:08 +02:00
Gauvino
3247bf709c fix(review): address CodeRabbit feedback
- swap direct i18next t imports for the useTranslation hook so the four
  touched components re-render on language change
- localize the buffer seconds unit via a buffer_seconds key instead of a
  literal trailing s
- reword the useAppRouter guard comment to match its real scope
2026-06-12 15:00:49 +02:00
Gauvain
2af252d639 Merge branch 'develop' into fix/ui-and-bugs 2026-06-12 11:03:08 +02:00
Gauvain
1636523d48 fix(login): ask how to protect a saved account after the login succeeds
The protection picker used to show before the login attempt, so a wrong
password still walked the user through choosing a PIN/password for an
account that never logged in - and a Quick Connect login could not save
the account at all.

Login flows now only flag the intent (pendingAccountSaveAtom); the
picker is a global PendingAccountSaveModal mounted at the root, shown
once the session is authorized - the login screen unmounts on success,
so it cannot host the modal itself. Works identically for the password
and Quick Connect flows; the credential is saved from the live session
token (saveCurrentAccount). Cancelling saves nothing, and a logout
before answering drops the intent.
2026-06-11 00:42:59 +02:00
Gauvain
855957707a fix(notifications): drop deprecated handler flags and payload logging
- shouldShowAlert is deprecated in expo-notifications: specify
  shouldShowBanner and shouldShowList instead (same behavior).
- The foreground listener logged the entire notification object, which
  touches the deprecated dataString getter (another deprecation warning)
  and dumps noisy payloads into the console - log only the title.
2026-06-11 00:42:59 +02:00
Gauvain
4bad8ae054 fix(filters): keep the search input responsive on large option lists
Typing in the filter-sheet search re-filtered and re-rendered up to 100
option rows per keystroke. On large lists (2000+ tags) that blocked the
JS thread long enough for the controlled TextInput to snap back to a
stale value - letters were dropped and deleted text reappeared.

Defer the search value (useDeferredValue) so the keystroke render stays
cheap and the filtering/list update runs after, and memoize the row
elements so urgent renders don't rebuild them.
2026-06-11 00:42:58 +02:00
Gauvain
16188ac2a3 fix(login): show the Quick Connect code in an auto-dismissing sheet
The code was shown in a native Alert, which has no programmatic
dismiss: after another device authorized the code and polling logged
the user in, the alert stayed open on top of the app.

Replace it with an in-app bottom sheet that closes itself once the
session is authorized. Dismissing only hides the code - polling
continues so login still completes if the code is authorized
afterwards; polling stops when leaving the login screen (parity with
TVLogin). The code can be tapped to copy (expo-clipboard, probed via
requireOptionalNativeModule so builds without the native module just
no-op).
2026-06-11 00:42:57 +02:00
Gauvain
d12d62863e fix(filters): present the filter sheet from the press handler
On the new architecture with Reanimated 4, BottomSheetModal.present()
called from a useEffect after a state update silently no-ops: the press
registered, open flipped to true, the effect called present() on a
valid ref - and nothing mounted (no onChange, nothing in the native
tree). Sheets that present() directly inside their press handler
(downloads, account picker) kept working, which is what pinned it down.

FilterSheet now takes a modalRef and the opener presents imperatively
from the gesture handler. The [open] effect only handles closing, and
never dismisses a modal that was never presented. The sheet also opens
immediately with a loader while options load, instead of the old
data-loaded press gate that left the button silently dead.

This restores genre/year/tag/sort filters in libraries and collections,
and the same pattern is applied to the bitrate/media-source/track
sheets that share FilterSheet.
2026-06-11 00:42:56 +02:00
Gauvain
7eb65ba430 fix(auth): clear the session on any 401 via a response interceptor
When the server revokes the token (device/session deleted), a 401 can
surface from any authenticated request. Nothing cleaned it up: the dead
token stayed in storage, every reload re-fired authenticated calls (401
spam, uncaught rejections) and the app lingered half-authenticated.

A response interceptor on the authenticated api clears the session once
on the first 401 so the app drops cleanly to the login screen. It only
attaches when api.accessToken is set, so a wrong-password 401 on the
login screen is never treated as session expiry. Saved credentials are
kept for quick re-login.
2026-06-11 00:42:55 +02:00
Gauvain
43d469f398 fix(auth): stop the offline splash hang and soften handled auth logs
On a cold start without network, startup awaited getCurrentUser on an
axios instance with no timeout, so the splash hung for the full OS TCP
timeout (75-120 s). Render from the cached user immediately and run the
token validation/refresh in the background; setInitialLoaded moves to a
finally so every path dismisses the splash.

Handled failures (quick-login with a revoked token, background
validation while offline) now log as warnings, and the background path
logs only status/message - axios errors carry the Authorization header.
2026-06-11 00:42:55 +02:00
Gauvain
d397233991 fix(filters): memoize useFilterOptions and drop debug logging
The hook returned a fresh array on every render (and console.logged
it). The unstable identity cascaded into list-header re-creation and,
under heavy re-rendering, tripped React maximum-update-depth.
2026-06-11 00:42:54 +02:00
Gauvain
7f020120b3 fix(settings): enforce admin-locked settings at write time
updateSettings persisted any key into user storage, including ones the
admin locked via the Streamyfin plugin. The read memo already overrides
locked keys at runtime, but the write still landed in storage and
several settings screens never disable their controls, so locked
settings appeared changeable. Strip locked keys before persisting.
2026-06-11 00:42:53 +02:00
Gauvain
aec3444829 fix(nav): drop duplicate pushes from rapid taps
Tapping an item twice before the pushed screen rendered stacked the
screen twice. A push blurs the source screen synchronously in the
navigation state, so a second tap sees an unfocused screen and is
dropped (focus-based guard, no timers).
2026-06-11 00:42:52 +02:00
Gauvain
24f9c38098 fix(downloads): key the series poster cache read on SeriesId
The cached base64 poster was read in a useMemo with empty deps, so
recycled list cells kept showing the first-rendered series poster.
2026-06-11 00:42:51 +02:00
Gauvain
1dd1940334 fix(downloads): compute storage usage from live file sizes
The storage bar showed 0.00% because calculateTotalDownloadedSize
summed the stored videoFileSize, which is 0 for items downloaded before
the size was recorded (or when fileInfo.size was undefined). Stat the
file on disk and fall back to the stored value.
2026-06-11 00:42:51 +02:00
Gauvain
1e537bc11e fix(downloads): confirm before deleting all downloaded files
The "Delete all downloaded files" row wiped everything on a single tap.
Ask for confirmation first (destructive action, cannot be undone).
2026-06-10 22:29:51 +02:00
Gauvain
b163c2abb4 fix(settings): restore plugin list order
Jellyseerr / Streamystats / Marlin Search rows were reordered by
mistake; put them back in the original order.
2026-06-10 22:29:48 +02:00
Gauvain
0d47c8d43a feat(i18n): localize hardcoded UI strings and fix misspelled keys
Move remaining hardcoded English strings (player menus, technical-info
overlay, music/now-playing, live TV, TV search badges, MPV subtitle
settings, accessibility labels, not-found screen, session picker) to
en.json, and correct misspelled keys (occured -> occurred, autorized ->
authorized, liraries -> libraries, jellyseer -> jellyseerr) along with
their usages.
2026-06-10 22:29:16 +02:00
111 changed files with 2730 additions and 1647 deletions

View File

@@ -73,7 +73,6 @@ export default function IndexLayout() {
headerLeft: () => ( headerLeft: () => (
<Pressable <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
@@ -158,7 +157,6 @@ export default function IndexLayout() {
headerLeft: () => ( headerLeft: () => (
<Pressable <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />

View File

@@ -645,7 +645,7 @@ export default function SettingsTV() {
formatValue={(v) => `${v.toFixed(1)}x`} formatValue={(v) => `${v.toFixed(1)}x`}
/> />
<TVSettingsStepper <TVSettingsStepper
label='Vertical Margin' label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => { onDecrease={() => {
const newValue = Math.max( const newValue = Math.max(
@@ -663,11 +663,11 @@ export default function SettingsTV() {
}} }}
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Horizontal Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_x")}
value={alignXLabel} value={alignXLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Horizontal Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_x"),
options: alignXOptions, options: alignXOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -677,11 +677,11 @@ export default function SettingsTV() {
} }
/> />
<TVSettingsOptionButton <TVSettingsOptionButton
label='Vertical Alignment' label={t("home.settings.subtitles.mpv_subtitle_align_y")}
value={alignYLabel} value={alignYLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: "Vertical Alignment", title: t("home.settings.subtitles.mpv_subtitle_align_y"),
options: alignYOptions, options: alignYOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ updateSettings({
@@ -854,6 +854,13 @@ export default function SettingsTV() {
updateSettings({ mergeNextUpAndContinueWatching: value }) updateSettings({ mergeNextUpAndContinueWatching: value })
} }
/> />
<TVSettingsToggle
label={t("home.settings.appearance.use_episode_images_next_up")}
value={settings.useEpisodeImagesForNextUp}
onToggle={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
<TVSettingsToggle <TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")} label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop} value={settings.showHomeBackdrop}

View File

@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, Switch, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true} disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4' className='px-4 pt-4'
> >
<ListGroup title={t("home.settings.other.hide_libraries")}> <ListGroup title={t("home.settings.other.hide_libraries")}>
{data?.map((view) => ( {data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}> <ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch <SettingSwitch
value={settings.hiddenLibraries?.includes(view.Id!) || false} value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => { onValueChange={(value) => {
updateSettings({ updateSettings({

View File

@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native"; import { View } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
<ListGroup> <ListGroup>
{data?.map((view) => ( {data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}> <ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch <SettingSwitch
value={settings.hiddenLibraries?.includes(view.Id!) || false} value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => { onValueChange={(value) => {
updateSettings({ updateSettings({

View File

@@ -1,4 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { File, Paths } from "expo-file-system"; import { File, Paths } from "expo-file-system";
import { requireOptionalNativeModule } from "expo-modules-core";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import type * as SharingType from "expo-sharing"; import type * as SharingType from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
@@ -6,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible"; import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -72,6 +75,25 @@ export default function Page() {
} }
}, [filteredLogs, Sharing]); }, [filteredLogs, Sharing]);
const copyLog = useCallback(
async (log: NonNullable<typeof logs>[number]) => {
// Skip on builds that don't ship the expo-clipboard native module
// (probe returns null instead of throwing); same guard as Quick Connect.
if (!requireOptionalNativeModule("ExpoClipboard")) return;
const Clipboard = await import("expo-clipboard");
const text = [
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
log.message,
log.data ? JSON.stringify(log.data, null, 2) : null,
]
.filter(Boolean)
.join("\n");
await Clipboard.setStringAsync(text);
toast.success(t("home.settings.logs.copied"));
},
[logs, t],
);
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -88,8 +110,15 @@ export default function Page() {
}, [share, loading]); }, [share, loading]);
return ( return (
<View className='flex-1'> <ScrollView
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> // Like the sibling settings pages, let iOS auto-inset the content below the
// transparent header (no manual header-height math). The filter bar is a
// sticky header so it stays pinned just under the header while logs scroll.
contentInsetAdjustmentBehavior='automatic'
stickyHeaderIndices={[0]}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
<FilterButton <FilterButton
id={orderFilterId} id={orderFilterId}
queryKey='log' queryKey='log'
@@ -112,67 +141,77 @@ export default function Page() {
multiple={true} multiple={true}
/> />
</View> </View>
<ScrollView <View className='flex flex-col space-y-2 px-4'>
className='pb-4 px-4' {filteredLogs?.map((log, index) => (
contentContainerStyle={{ paddingBottom: insets.bottom }} <View className='bg-neutral-900 rounded-xl p-3' key={index}>
> <TouchableOpacity
<View className='flex flex-col space-y-2'> disabled={!log.data}
{filteredLogs?.map((log, index) => ( onPress={() =>
<View className='bg-neutral-900 rounded-xl p-3' key={index}> setState((v) => ({
<TouchableOpacity ...v,
disabled={!log.data} [log.timestamp]: !v[log.timestamp],
onPress={() => }))
setState((v) => ({ }
...v, >
[log.timestamp]: !v[log.timestamp], <View className='flex flex-row justify-between'>
})) <Text
} className={`mb-1
>
<View className='flex flex-row justify-between'>
<Text
className={`mb-1
${log.level === "INFO" && "text-blue-500"} ${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"} ${log.level === "ERROR" && "text-red-500"}
${log.level === "DEBUG" && "text-purple-500"} ${log.level === "DEBUG" && "text-purple-500"}
`} `}
> >
{log.level} {log.level}
</Text>
<Text className='text-xs'>
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text selectable className='text-xs'>
{log.message}
</Text> </Text>
</TouchableOpacity>
{log.data && ( <Text className='text-xs'>
<> {new Date(log.timestamp).toLocaleString()}
{!state[log.timestamp] && ( </Text>
<Text className='text-xs mt-0.5'> </View>
{t("home.settings.logs.click_for_more_info")} <Text className='text-xs'>{log.message}</Text>
</Text> {/* Keep the whole collapsed row tappable: the hint lives inside
)} the toggle so tapping it expands too. */}
<Collapsible collapsed={!state[log.timestamp]}> {log.data && !state[log.timestamp] && (
<View className='mt-2 flex flex-col space-y-2'> <Text className='text-xs mt-0.5'>
<ScrollView className='rounded-xl' style={codeBlockStyle}> {t("home.settings.logs.click_for_more_info")}
<Text>{JSON.stringify(log.data, null, 2)}</Text> </Text>
</ScrollView>
</View>
</Collapsible>
</>
)} )}
</View> </TouchableOpacity>
))}
{filteredLogs?.length === 0 && ( {log.data && (
<Text className='opacity-50'> <Collapsible collapsed={!state[log.timestamp]}>
{t("home.settings.logs.no_logs_available")} <View className='mt-2 flex flex-col space-y-2'>
</Text> <ScrollView
)} className='rounded-xl'
</View> style={codeBlockStyle}
</ScrollView> nestedScrollEnabled
</View> >
{/* Only the raw payload is selectable (per request); the
header/message stay tap-to-toggle. */}
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>
{!Platform.isTV && (
<TouchableOpacity
onPress={() => copyLog(log)}
className='flex flex-row items-center self-end px-2 py-1'
>
<Ionicons name='copy-outline' size={16} color='white' />
<Text className='text-xs ml-1'>
{t("home.settings.logs.copy")}
</Text>
</TouchableOpacity>
)}
</View>
</Collapsible>
)}
</View>
))}
{filteredLogs?.length === 0 && (
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
</Text>
)}
</View>
</ScrollView>
); );
} }

View File

@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
title={t("home.settings.music.prefer_downloaded")} title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked} disabled={pluginSettings?.preferLocalAudio?.locked}
> >
<Switch <SettingSwitch
value={settings.preferLocalAudio} value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked} disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
title={t("home.settings.music.lookahead_enabled")} title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked} disabled={pluginSettings?.audioLookaheadEnabled?.locked}
> >
<Switch <SettingSwitch
value={settings.audioLookaheadEnabled} value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked} disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
})} })}
/> />
</ListGroup> </ListGroup>
<ListGroup> <ListGroup className='mt-4'>
<ListItem <ListItem
textColor='red' textColor='red'
onPress={onDeleteDownloadedSongsClicked} onPress={onDeleteDownloadedSongsClicked}

View File

@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: insets.bottom,
}} }}
> >
<View <View
className='p-4 flex flex-col' className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<View className='mb-4'> <View>
<MediaProvider> <MediaProvider>
<MediaToggles className='mb-4' /> <MediaToggles className='mb-4' />
<GestureControls className='mb-4' /> <GestureControls className='mb-4' />

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function JellyseerrPluginPage() { export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='p-4'>
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings /> <JellyseerrSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() { export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='px-4 pt-4'>
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<KefinTweaksSettings /> <KefinTweaksSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,35 +1,35 @@
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Linking, Linking,
ScrollView, ScrollView,
Switch,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function MarlinSearchPage() { export default function MarlinSearchPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
const hasStreamystats = !!pluginSettings?.streamyStatsServerUrl?.value;
const onSave = (val: string) => { const onSave = (val: string) => {
updateSettings({ updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1), marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
@@ -41,15 +41,8 @@ export default function MarlinSearchPage() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search"); Linking.openURL("https://github.com/fredrikburmester/marlin-search");
}; };
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => { useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) { if (!marlinUrlLocked) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'> <TouchableOpacity onPress={() => onSave(value)} className='px-2'>
@@ -60,7 +53,7 @@ export default function MarlinSearchPage() {
), ),
}); });
} }
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]); }, [navigation, value, marlinUrlLocked, t]);
if (!settings) return null; if (!settings) return null;
@@ -72,52 +65,39 @@ export default function MarlinSearchPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting disabled={disabled} className='px-4'> <View className='px-4 pt-4'>
<ListGroup> <ListGroup>
<DisabledSetting {/* disabledByAdmin renders the "Disabled by admin" notice as the row's
disabled={ subtitle (same pattern as the Streamystats settings) — no clipping. */}
pluginSettings?.searchEngine?.locked === true || <ListItem
!!pluginSettings?.streamyStatsServerUrl?.value title={t(
} "home.settings.plugins.marlin_search.enable_marlin_search",
showText={!pluginSettings?.marlinServerUrl?.locked} )}
disabledByAdmin={searchEngineLocked}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
> >
<ListItem <SettingSwitch
title={t( value={settings.searchEngine === "Marlin"}
"home.settings.plugins.marlin_search.enable_marlin_search", disabled={searchEngineLocked || hasStreamystats}
)} onValueChange={(val) => {
onPress={() => { updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
> />
<Switch </ListItem>
value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<DisabledSetting <ListGroup className='mt-2'>
disabled={pluginSettings?.marlinServerUrl?.locked === true} <ListItem
showText={!pluginSettings?.searchEngine?.locked} title={t("home.settings.plugins.marlin_search.url")}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4' disabledByAdmin={marlinUrlLocked}
>
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
> >
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput <TextInput
editable={settings.searchEngine === "Marlin"} editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
className='text-white' className='text-white text-right flex-1'
placeholder={t( placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder", "home.settings.plugins.marlin_search.server_url_placeholder",
)} )}
@@ -128,15 +108,16 @@ export default function MarlinSearchPage() {
textContentType='URL' textContentType='URL'
onChangeText={(text) => setValue(text)} onChangeText={(text) => setValue(text)}
/> />
</View> </ListItem>
</DisabledSetting> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}> <Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")} {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text> </Text>
</Text> </Text>
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,9 +1,21 @@
import { Platform, ScrollView, View } from "react-native"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { PluginSettings } from "@/components/settings/PluginSettings"; import { PluginSettings } from "@/components/settings/PluginSettings";
import { useSettings } from "@/utils/atoms/settings";
export default function PluginsPage() { export default function PluginsPage() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { refreshStreamyfinPluginSettings } = useSettings();
const handleRefreshFromServer = useCallback(async () => {
await refreshStreamyfinPluginSettings();
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
return ( return (
<ScrollView <ScrollView
@@ -18,6 +30,17 @@ export default function PluginsPage() {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<PluginSettings /> <PluginSettings />
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
so it lives on the plugins index rather than inside Streamystats. */}
<TouchableOpacity
onPress={handleRefreshFromServer}
className='py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
import { import {
Linking, Linking,
ScrollView, ScrollView,
Switch,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { const { settings, updateSettings, pluginSettings } = useSettings();
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields // Local state for all editable fields
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
); );
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true; const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url; const searchLocked = pluginSettings?.searchEngine?.locked === true;
const movieRecsLocked =
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
const seriesRecsLocked =
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
const promotedWatchlistsLocked =
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
const hideWatchlistsTabLocked =
pluginSettings?.hideWatchlistsTab?.locked === true;
// The input renders the locked admin URL; enablement must follow the same
// effective value or every toggle stays disabled until local state syncs.
const effectiveUrl = isUrlLocked
? (settings?.streamyStatsServerUrl ?? "")
: url;
const isStreamystatsEnabled = !!effectiveUrl;
const onSave = useCallback(() => { const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url; const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
Linking.openURL("https://github.com/fredrikburmester/streamystats"); Linking.openURL("https://github.com/fredrikburmester/streamystats");
}; };
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
if (newUrl) {
setUseForSearch(true);
}
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
if (!settings) return null; if (!settings) return null;
return ( return (
@@ -134,7 +132,7 @@ export default function StreamystatsPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='px-4'> <View className='px-4 pt-4'>
<ListGroup className='flex-1'> <ListGroup className='flex-1'>
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.url")} title={t("home.settings.plugins.streamystats.url")}
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
placeholder={t( placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder", "home.settings.plugins.streamystats.server_url_placeholder",
)} )}
value={url} value={effectiveUrl}
keyboardType='url' keyboardType='url'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
> >
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.enable_search")} title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true} disabledByAdmin={searchLocked}
> >
<Switch {/* Locked controls show the live admin value and can't be toggled —
value={useForSearch} local form state would let the switch flip while the write guard
disabled={!isStreamystatsEnabled} drops the change. */}
<SettingSwitch
value={
searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch} onValueChange={setUseForSearch}
/> />
</ListItem> </ListItem>
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
title={t( title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations", "home.settings.plugins.streamystats.enable_movie_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={movieRecsLocked}
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
> >
<Switch <SettingSwitch
value={movieRecs} value={
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
onValueChange={setMovieRecs} onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || movieRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_series_recommendations", "home.settings.plugins.streamystats.enable_series_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={seriesRecsLocked}
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
> >
<Switch <SettingSwitch
value={seriesRecs} value={
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
onValueChange={setSeriesRecs} onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || seriesRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists", "home.settings.plugins.streamystats.enable_promoted_watchlists",
)} )}
disabledByAdmin={ disabledByAdmin={promotedWatchlistsLocked}
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
> >
<Switch <SettingSwitch
value={promotedWatchlists} value={
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
onValueChange={setPromotedWatchlists} onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")} title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true} disabledByAdmin={hideWatchlistsTabLocked}
> >
<Switch <SettingSwitch
value={hideWatchlistsTab} value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab} onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
{t("home.settings.plugins.streamystats.home_sections_hint")} {t("home.settings.plugins.streamystats.home_sections_hint")}
</Text> </Text>
<TouchableOpacity
onPress={handleRefreshFromServer}
className='mt-6 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
{/* Disable button - only show if URL is not locked and Streamystats is enabled */} {/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && ( {!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity <TouchableOpacity

View File

@@ -23,6 +23,7 @@ import {
TouchableItemRouter, TouchableItemRouter,
} from "@/components/common/TouchableItemRouter"; } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -134,6 +135,12 @@ const page: React.FC = () => {
useEffect(() => { useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" }); navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]); setSortOrder([SortOrderOption.Ascending]);
// Collections open with a clean filter slate: the genre/year/tag atoms are
// global, so without this the previously viewed library's selection bleeds
// in (libraries now keep their own per-library memory).
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
if (!collection) return; if (!collection) return;
@@ -204,40 +211,39 @@ const page: React.FC = () => {
], ],
); );
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
useInfiniteQuery({ queryKey: [
queryKey: [ "collection-items",
"collection-items", collectionId,
collectionId, selectedGenres,
selectedGenres, selectedYears,
selectedYears, selectedTags,
selectedTags, sortBy,
sortBy, sortOrder,
sortOrder, ],
], queryFn: fetchItems,
queryFn: fetchItems, getNextPageParam: (lastPage, pages) => {
getNextPageParam: (lastPage, pages) => { if (
if ( !lastPage?.Items ||
!lastPage?.Items || !lastPage?.TotalRecordCount ||
!lastPage?.TotalRecordCount || lastPage?.TotalRecordCount === 0
lastPage?.TotalRecordCount === 0 )
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined; return undefined;
},
initialPageParam: 0, const totalItems = lastPage.TotalRecordCount;
enabled: !!api && !!user?.Id && !!collection, const accumulatedItems = pages.reduce(
}); (acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => { const flatData = useMemo(() => {
return ( return (
@@ -326,7 +332,7 @@ const page: React.FC = () => {
data={[ data={[
{ {
key: "reset", key: "reset",
component: <ResetFiltersButton />, component: <ResetFiltersButton libraryId={collectionId} />,
}, },
{ {
key: "genre", key: "genre",
@@ -466,7 +472,6 @@ const page: React.FC = () => {
setSortBy, setSortBy,
sortOrder, sortOrder,
setSortOrder, setSortOrder,
isFetching,
], ],
); );
@@ -633,43 +638,45 @@ const page: React.FC = () => {
// Mobile return // Mobile return
if (!Platform.isTV) { if (!Platform.isTV) {
return ( return (
<FlashList <FilterSheetProvider>
ListEmptyComponent={ <FlashList
<View className='flex flex-col items-center justify-center h-full'> ListEmptyComponent={
<Text className='font-bold text-xl text-neutral-500'> <View className='flex flex-col items-center justify-center h-full'>
{t("search.no_results")} <Text className='font-bold text-xl text-neutral-500'>
</Text> {t("search.no_results")}
</View> </Text>
} </View>
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
} }
}} extraData={[
onEndReachedThreshold={0.5} selectedGenres,
ListHeaderComponent={ListHeaderComponent} selectedYears,
contentContainerStyle={{ paddingBottom: 24 }} selectedTags,
ItemSeparatorComponent={() => ( sortBy,
<View sortOrder,
style={{ ]}
width: 10, contentInsetAdjustmentBehavior='automatic'
height: 10, data={flatData}
}} renderItem={renderItem}
/> keyExtractor={keyExtractor}
)} numColumns={nrOfCols}
/> onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
); );
} }

View File

@@ -9,7 +9,7 @@ import {
getItemsApi, getItemsApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { import {
@@ -35,6 +35,7 @@ import {
TouchableItemRouter, TouchableItemRouter,
} from "@/components/common/TouchableItemRouter"; } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -44,6 +45,7 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useFilterReset } from "@/hooks/useFilterReset";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -55,7 +57,9 @@ import {
FilterByPreferenceAtom, FilterByPreferenceAtom,
filterByAtom, filterByAtom,
genreFilterAtom, genreFilterAtom,
genrePreferenceAtom,
getFilterByPreference, getFilterByPreference,
getMultiFilterPreference,
getSortByPreference, getSortByPreference,
getSortOrderPreference, getSortOrderPreference,
SortByOption, SortByOption,
@@ -66,11 +70,12 @@ import {
sortOrderAtom, sortOrderAtom,
sortOrderOptions, sortOrderOptions,
sortOrderPreferenceAtom, sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom, tagsFilterAtom,
useFilterOptions, useFilterOptions,
yearFilterAtom, yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -108,6 +113,9 @@ const Page = () => {
const [sortOrderPreference, setOrderByPreference] = useAtom( const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom, sortOrderPreferenceAtom,
); );
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
const { orientation } = useOrientation(); const { orientation } = useOrientation();
@@ -205,6 +213,13 @@ const Page = () => {
const fp = getFilterByPreference(libraryId, filterByPreference); const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []); _setFilterBy(fp ? [fp] : []);
} }
// Genres / years / tags: per-library saved preference (no URL params), so
// switching libraries restores each library's own selection instead of
// bleeding the previous one.
setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
}, [ }, [
libraryId, libraryId,
sortOrderPreference, sortOrderPreference,
@@ -213,6 +228,12 @@ const Page = () => {
_setSortBy, _setSortBy,
filterByPreference, filterByPreference,
_setFilterBy, _setFilterBy,
genrePreference,
yearPreference,
tagPreference,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
searchParams.sortBy, searchParams.sortBy,
searchParams.sortOrder, searchParams.sortOrder,
searchParams.filterBy, searchParams.filterBy,
@@ -257,6 +278,32 @@ const Page = () => {
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy], [libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
); );
// Genres / years / tags: save the per-library memory then update the active
// atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
const setGenres = useCallback(
(genres: string[]) => {
setGenrePreference({ ...genrePreference, [libraryId]: genres });
setSelectedGenres(genres);
},
[libraryId, genrePreference, setGenrePreference, setSelectedGenres],
);
const setYears = useCallback(
(years: string[]) => {
setYearPreference({ ...yearPreference, [libraryId]: years });
setSelectedYears(years);
},
[libraryId, yearPreference, setYearPreference, setSelectedYears],
);
const setTags = useCallback(
(tags: string[]) => {
setTagPreference({ ...tagPreference, [libraryId]: tags });
setSelectedTags(tags);
},
[libraryId, tagPreference, setTagPreference, setSelectedTags],
);
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) { if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile // TV uses flexWrap, so nrOfCols is just for mobile
@@ -415,6 +462,29 @@ const Page = () => {
); );
}, [data]); }, [data]);
const flashListRef = useRef<FlashListRef<BaseItemDto>>(null);
// Jump the grid to the top when the filters/sort change (incl. reset).
const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
const pendingScrollTopRef = useRef(false);
// Instant feedback: pin to the top the moment the filters change, without
// waiting for the new fetch — and flag a re-pin for once it settles.
useEffect(() => {
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
pendingScrollTopRef.current = true;
}, [filterSignature]);
// Safety net: FlashList can restore the previous offset as the filtered list
// grows, so re-pin once the fetch settles. Pagination keeps the same
// signature, so it never re-pins.
useEffect(() => {
if (pendingScrollTopRef.current && !isFetching) {
pendingScrollTopRef.current = false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
}
}, [isFetching, flatData]);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => ( ({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter <TouchableItemRouter
@@ -530,7 +600,6 @@ const Page = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions(); const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<FlatList <FlatList
@@ -545,7 +614,7 @@ const Page = () => {
data={[ data={[
{ {
key: "reset", key: "reset",
component: <ResetFiltersButton />, component: <ResetFiltersButton libraryId={libraryId} />,
}, },
{ {
key: "genre", key: "genre",
@@ -564,7 +633,7 @@ const Page = () => {
}); });
return response.data.Genres || []; return response.data.Genres || [];
}} }}
set={setSelectedGenres} set={setGenres}
values={selectedGenres} values={selectedGenres}
title={t("library.filters.genres")} title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
@@ -591,7 +660,7 @@ const Page = () => {
}); });
return response.data.Years || []; return response.data.Years || [];
}} }}
set={setSelectedYears} set={setYears}
values={selectedYears} values={selectedYears}
title={t("library.filters.years")} title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
@@ -616,7 +685,7 @@ const Page = () => {
}); });
return response.data.Tags || []; return response.data.Tags || [];
}} }}
set={setSelectedTags} set={setTags}
values={selectedTags} values={selectedTags}
title={t("library.filters.tags")} title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
@@ -696,35 +765,23 @@ const Page = () => {
api, api,
user?.Id, user?.Id,
selectedGenres, selectedGenres,
setSelectedGenres, setGenres,
selectedYears, selectedYears,
setSelectedYears, setYears,
selectedTags, selectedTags,
setSelectedTags, setTags,
sortBy, sortBy,
setSortBy, setSortBy,
sortOrder, sortOrder,
setSortOrder, setSortOrder,
isFetching,
filterBy, filterBy,
setFilter, setFilter,
settings,
], ],
); );
// TV Filter bar header // Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
const hasActiveFilters = // sort/order can't be forgotten on one path (it used to be reset on neither).
selectedGenres.length > 0 || const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
_setFilterBy([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
// TV Filter options - with "All" option for clearable filters // TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo( const tvGenreFilterOptions = useMemo(
@@ -818,15 +875,15 @@ const Page = () => {
options: tvGenreFilterOptions, options: tvGenreFilterOptions,
onSelect: (value: string) => { onSelect: (value: string) => {
if (value === "__all__") { if (value === "__all__") {
setSelectedGenres([]); setGenres([]);
} else if (selectedGenres.includes(value)) { } else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value)); setGenres(selectedGenres.filter((g) => g !== value));
} else { } else {
setSelectedGenres([...selectedGenres, value]); setGenres([...selectedGenres, value]);
} }
}, },
}); });
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]); }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
const handleShowYearFilter = useCallback(() => { const handleShowYearFilter = useCallback(() => {
showOptions({ showOptions({
@@ -834,15 +891,15 @@ const Page = () => {
options: tvYearFilterOptions, options: tvYearFilterOptions,
onSelect: (value: string) => { onSelect: (value: string) => {
if (value === "__all__") { if (value === "__all__") {
setSelectedYears([]); setYears([]);
} else if (selectedYears.includes(value)) { } else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value)); setYears(selectedYears.filter((y) => y !== value));
} else { } else {
setSelectedYears([...selectedYears, value]); setYears([...selectedYears, value]);
} }
}, },
}); });
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]); }, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
const handleShowTagFilter = useCallback(() => { const handleShowTagFilter = useCallback(() => {
showOptions({ showOptions({
@@ -850,15 +907,15 @@ const Page = () => {
options: tvTagFilterOptions, options: tvTagFilterOptions,
onSelect: (value: string) => { onSelect: (value: string) => {
if (value === "__all__") { if (value === "__all__") {
setSelectedTags([]); setTags([]);
} else if (selectedTags.includes(value)) { } else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value)); setTags(selectedTags.filter((tag) => tag !== value));
} else { } else {
setSelectedTags([...selectedTags, value]); setTags([...selectedTags, value]);
} }
}, },
}); });
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]); }, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
const handleShowSortByFilter = useCallback(() => { const handleShowSortByFilter = useCallback(() => {
showOptions({ showOptions({
@@ -906,42 +963,45 @@ const Page = () => {
// Mobile return // Mobile return
if (!Platform.isTV) { if (!Platform.isTV) {
return ( return (
<FlashList <FilterSheetProvider>
key={orientation} <FlashList
ListEmptyComponent={ ref={flashListRef}
<View className='flex flex-col items-center justify-center h-full'> key={orientation}
<Text className='font-bold text-xl text-neutral-500'> ListEmptyComponent={
{t("library.no_results")} <View className='flex flex-col items-center justify-center h-full'>
</Text> <Text className='font-bold text-xl text-neutral-500'>
</View> {t("library.no_results")}
} </Text>
contentInsetAdjustmentBehavior='automatic' </View>
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
} }
}} contentInsetAdjustmentBehavior='automatic'
onEndReachedThreshold={1} data={flatData}
ListHeaderComponent={ListHeaderComponent} renderItem={renderItem}
contentContainerStyle={{ extraData={[orientation, nrOfCols]}
paddingBottom: 24, keyExtractor={keyExtractor}
paddingLeft: insets.left, numColumns={nrOfCols}
paddingRight: insets.right, onEndReached={() => {
}} if (hasNextPage) {
ItemSeparatorComponent={() => ( fetchNextPage();
<View }
style={{ }}
width: 10, onEndReachedThreshold={1}
height: 10, ListHeaderComponent={ListHeaderComponent}
}} contentContainerStyle={{
/> paddingBottom: 24,
)} paddingLeft: insets.left,
/> paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
); );
} }

View File

@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
return ( return (
<View className='flex-1 justify-center items-center bg-black px-6'> <View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'> <Text className='text-neutral-500 text-center'>
Missing music library id. {t("music.missing_library_id")}
</Text> </Text>
</View> </View>
); );

View File

@@ -51,7 +51,8 @@ function TVTabLayout() {
const currentTab = segments.find(isTabRoute); const currentTab = segments.find(isTabRoute);
const lastSegment = segments[segments.length - 1] ?? ""; const lastSegment = segments[segments.length - 1] ?? "";
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index"; const atTabRoot =
isTabRoute(lastSegment) || (lastSegment as string) === "index";
const tabs: TVNavBarTab[] = useMemo( const tabs: TVNavBarTab[] = useMemo(
() => () =>

View File

@@ -14,6 +14,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue"; type ViewMode = "player" | "queue";
export default function NowPlayingScreen() { export default function NowPlayingScreen() {
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const router = useRouter(); const router = useRouter();
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
paddingBottom: Platform.OS === "android" ? insets.bottom : 0, paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}} }}
> >
<Text className='text-neutral-500'>No track playing</Text> <Text className='text-neutral-500'>
{t("music.no_track_playing")}
</Text>
</View> </View>
</BottomSheetModalProvider> </BottomSheetModalProvider>
); );
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
: "text-neutral-500" : "text-neutral-500"
} }
> >
Now Playing {t("music.now_playing")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
onRemoveFromQueue, onRemoveFromQueue,
onReorderQueue, onReorderQueue,
}) => { }) => {
const { t } = useTranslation();
const renderQueueItem = useCallback( const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => { ({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0; const index = getIndex() ?? 0;
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
ListHeaderComponent={ ListHeaderComponent={
<View className='px-4 py-2'> <View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'> <Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 ? "Playing from queue" : "Up next"} {history.length > 0
? t("music.playing_from_queue")
: t("music.up_next")}
</Text> </Text>
</View> </View>
} }
ListEmptyComponent={ ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'> <View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>Queue is empty</Text> <Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
</View> </View>
} }
/> />

View File

@@ -7,7 +7,6 @@ import {
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { import {
getMediaInfoApi,
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
@@ -311,37 +310,6 @@ export default function DirectPlayerPage() {
// Ref to store the stream fetch function for refreshing subtitle tracks // Ref to store the stream fetch function for refreshing subtitle tracks
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null); const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
// Live TV opens a server-side live stream via autoOpenLiveStream. If it is
// never closed, Jellyfin's M3U tuner limit fills up and every channel then
// fails with "simultaneous stream limit has been reached". reportPlaybackStopped
// handles a clean stop, but a channel switch (the player re-fetches in place)
// or an unmount after an error bypass it, so track the open live stream and
// release it on those paths too.
const apiRef = useRef(api);
useEffect(() => {
apiRef.current = api;
}, [api]);
const releaseLiveStream = useCallback(
(liveStreamId: string | null) => {
if (!liveStreamId || !apiRef.current || offline) return;
// Best effort: a failed close must not break teardown, and the slot is
// also freed by the server's reap as a backstop.
getMediaInfoApi(apiRef.current)
.closeLiveStream({ liveStreamId })
.catch(() => {});
},
[offline],
);
// The effect cleanup releases the live stream both when it changes (channel
// switch, which re-runs the effect) and when the player unmounts, so no
// manual previous-id tracking is needed.
useEffect(() => {
const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null;
return () => releaseLiveStream(liveStreamId);
}, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]);
useEffect(() => { useEffect(() => {
const fetchStreamData = async (): Promise<Stream | null> => { const fetchStreamData = async (): Promise<Stream | null> => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
@@ -477,12 +445,6 @@ export default function DirectPlayerPage() {
MediaSourceId: mediaSourceId, MediaSourceId: mediaSourceId,
PositionTicks: currentTimeInTicks, PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId, PlaySessionId: stream.sessionId,
// Release the server-side live stream (and its tuner slot) on stop.
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
// the stop report carries its LiveStreamId; without it the stream leaks
// and Live TV eventually fails for everyone with "M3U simultaneous
// stream limit has been reached". Undefined for non-live items (no-op).
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
}, },
}); });
}, [api, item, mediaSourceId, stream, progress, offline]); }, [api, item, mediaSourceId, stream, progress, offline]);

View File

@@ -192,6 +192,7 @@ const SubtitleResultCard = React.forwardRef<
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { >(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -328,7 +329,7 @@ const SubtitleResultCard = React.forwardRef<
]} ]}
> >
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}> <Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
Hash Match {t("player.hash_match")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,17 +1,20 @@
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
export default function NotFoundScreen() { export default function NotFoundScreen() {
const { t } = useTranslation();
return ( return (
<> <>
<Stack.Screen options={{ title: "Oops!" }} /> <Stack.Screen options={{ title: t("home.oops") }} />
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type='title'>This screen doesn't exist.</ThemedText> <ThemedText type='title'>{t("not_found.title")}</ThemedText>
<Link href={"/home"} style={styles.link}> <Link href={"/home"} style={styles.link}>
<ThemedText type='link'>Go to home screen!</ThemedText> <ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </>

View File

@@ -11,6 +11,7 @@ import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -551,6 +552,7 @@ function Layout() {
closeButton closeButton
/> />
{!Platform.isTV && <GlobalModal />} {!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider> </ThemeProvider>
</IntroSheetProvider> </IntroSheetProvider>
</BottomSheetModalProvider> </BottomSheetModalProvider>

View File

@@ -31,6 +31,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.18", "expo-build-properties": "~56.0.18",
"expo-camera": "~56.0.8", "expo-camera": "~56.0.8",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.18", "expo-constants": "~56.0.18",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.20", "expo-dev-client": "~56.0.20",
@@ -1001,6 +1002,8 @@
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="], "expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="], "expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react"; import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
const isTv = Platform.isTV; const isTv = Platform.isTV;
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between' className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)} onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
> >
<Text numberOfLines={1}> <Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key} {BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
<FilterSheet <FilterSheet
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")} title={t("item_card.quality")}
data={sorted} data={sorted}
values={selected ? [selected] : []} values={selected ? [selected] : []}

View File

@@ -63,8 +63,9 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
} }
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
// useEpisodePoster in deps so flipping the prop re-computes the URL live. // useEpisodePoster in deps so flipping the setting re-computes the URL live
}, [api, item, useEpisodePoster]); // (no app restart needed).
}, [item, useEpisodePoster]);
if (!url) if (!url)
return <View className='aspect-video border border-neutral-800 w-44' />; return <View className='aspect-video border border-neutral-800 w-44' />;

View File

@@ -1,8 +1,9 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
const isTv = Platform.isTV; const isTv = Platform.isTV;
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => { const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC<Props> = ({
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text> <Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity <TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center' className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => setOpen(true)} onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
> >
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
<FilterSheet <FilterSheet
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.video")} title={t("item_card.video")}
data={item.MediaSources || []} data={item.MediaSources || []}
values={selected ? [selected] : []} values={selected ? [selected] : []}

View File

@@ -0,0 +1,45 @@
import { useAtom, useAtomValue } from "jotai";
import type React from "react";
import { useEffect } from "react";
import { Platform } from "react-native";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import {
pendingAccountSaveAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
/**
* Post-login save-account prompt. Login flows (password or Quick Connect)
* only flag the intent via pendingAccountSaveAtom; the protection picker
* shows here, AFTER the session is authorized — the login screen itself
* unmounts as soon as the user is set, so it can't host the modal.
*/
export const PendingAccountSaveModal: React.FC = () => {
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
const user = useAtomValue(userAtom);
const { saveCurrentAccount } = useJellyfin();
// A logout before answering drops the intent — it must not resurface on
// the next (possibly different) login.
useEffect(() => {
if (!user && pending) setPending(null);
}, [user, pending, setPending]);
if (Platform.isTV) return null;
return (
<SaveAccountModal
visible={!!pending && !!user}
username={user?.Name ?? ""}
onClose={() => setPending(null)}
onSave={(securityType, pinCode) => {
const serverName = pending?.serverName;
setPending(null);
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
(error) => console.warn("Failed to save account:", error),
);
}}
/>
);
};

View File

@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
expoUIConfig, expoUIConfig,
bottomSheetConfig, bottomSheetConfig,
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { t } = useTranslation();
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// Handle controlled open state for Android // Handle controlled open state for Android
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
return ( return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>} {trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative flex-1"} className={"relative flex-1"}
> >

View File

@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
colors, colors,
...props ...props
}: Props) => { }: Props) => {
const { t } = useTranslation();
const [globalColorAtom] = useAtom(itemThemeColorAtom); const [globalColorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityLabel='Play button' accessibilityLabel={t("accessibility.play_button")}
accessibilityHint='Tap to play the media' accessibilityHint={t("accessibility.play_hint")}
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props} {...props}

View File

@@ -6,6 +6,7 @@ import {
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
FlatList, FlatList,
Modal, Modal,
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { sessions, isLoading } = useAllSessions({} as useSessionsProps); const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
const { t } = useTranslation();
const handlePlayInSession = async (sessionId: string) => { const handlePlayInSession = async (sessionId: string) => {
if (!api || !item.Id) return; if (!api || !item.Id) return;
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
<View style={styles.centeredView}> <View style={styles.centeredView}>
<View style={styles.modalView}> <View style={styles.modalView}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Select Session</Text> <Text style={styles.modalTitle}>
{t("home.sessions.select_session")}
</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}> <TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
</TouchableOpacity> </TouchableOpacity>
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</View> </View>
) : !sessions || sessions.length === 0 ? ( ) : !sessions || sessions.length === 0 ? (
<Text style={styles.noSessionsText}> <Text style={styles.noSessionsText}>
No active sessions found {t("home.sessions.no_active_sessions")}
</Text> </Text>
) : ( ) : (
<FlatList <FlatList
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</Text> </Text>
{session.NowPlayingItem && ( {session.NowPlayingItem && (
<Text style={styles.nowPlaying} numberOfLines={1}> <Text style={styles.nowPlaying} numberOfLines={1}>
Now playing:{" "} {t("home.sessions.now_playing")}{" "}
{session.NowPlayingItem.SeriesName {session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :` ? `${session.NowPlayingItem.SeriesName} :`
: ""} : ""}

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur"; import { BlurView, type BlurViewProps } from "expo-blur";
import { Platform } from "react-native"; import { Keyboard, Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler"; import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
@@ -16,30 +16,37 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
// Dismiss the keyboard before navigating — otherwise it lingers over the
// previous screen (e.g. leaving the Jellyseerr login while typing).
const handleBack = () => {
Keyboard.dismiss();
router.back();
};
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<Pressable <Pressable
onPress={() => router.back()} onPress={handleBack}
className='flex items-center justify-center w-9 h-9' className='flex items-center justify-center w-9 h-9'
{...pressableProps} {...pressableProps}
> >
<Ionicons name='arrow-back' size={24} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</Pressable> </Pressable>
); );
} }
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<Pressable onPress={() => router.back()} {...pressableProps}> <Pressable onPress={handleBack} {...pressableProps}>
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
className='overflow-hidden rounded-full p-2' className='overflow-hidden rounded-full p-2'
> >
<Ionicons <Feather
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='arrow-back' name='chevron-left'
size={24} size={28}
color='white' color='white'
/> />
</BlurView> </BlurView>
@@ -48,14 +55,17 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<Pressable <Pressable
onPress={() => router.back()} onPress={handleBack}
className=' rounded-full p-2' // Match the Settings page back button: chevron flush to the edge with a
// 16px gap before the title (the old `p-2` pushed both arrow and title
// too far right). drop-shadow keeps it readable over images.
style={{ marginRight: 16 }}
{...pressableProps} {...pressableProps}
> >
<Ionicons <Feather
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='arrow-back' name='chevron-left'
size={24} size={28}
color='white' color='white'
/> />
</Pressable> </Pressable>

View File

@@ -0,0 +1,40 @@
import type React from "react";
import { Platform, Switch, type SwitchProps, View } from "react-native";
/**
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
* FIXED-SIZE box (overflow hidden) and center it:
* - the fixed height caps the row height (compact, uniform rows),
* - the fixed width + centering keep the switch in the exact same spot in the
* on/off states (a non-fixed wrapper let its width fluctuate between states,
* which shifted the switch sideways on toggle).
* iOS renders the switch untouched.
*
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
*/
const BOX_W = 40;
const BOX_H = 30;
const SCALE = 0.9;
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
if (Platform.OS !== "android") return <Switch {...props} />;
return (
<View
style={{
width: BOX_W,
height: BOX_H,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Switch
{...props}
style={[props.style, { transform: [{ scale: SCALE }] }]}
/>
</View>
);
};

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
TouchableOpacity, TouchableOpacity,
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
} }
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { t } = useTranslation();
const { cancelDownload } = useDownload(); const { cancelDownload } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{isTranscoding && ( {isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'> <View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'>Transcoding</Text> <Text className='text-xs text-purple-400'>
{t("home.downloads.transcoding")}
</Text>
</View> </View>
)} )}

View File

@@ -1,9 +1,11 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons"; import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useCallback, useRef, useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet"; import { FilterSheet } from "./FilterSheet";
import { useFilterSheet } from "./FilterSheetProvider";
interface FilterButtonProps<T> extends ViewProps { interface FilterButtonProps<T> extends ViewProps {
id: string; id: string;
@@ -33,22 +35,63 @@ export const FilterButton = <T,>({
icon = "filter", icon = "filter",
...props ...props
}: FilterButtonProps<T>) => { }: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false); // When a FilterSheetProvider is present (library / collections), all buttons
// share one sheet so two can never stack. Outside a provider (e.g. logs,
// discover), fall back to this button's own standalone sheet.
const shared = useFilterSheet();
const { data: filters } = useQuery<T[]>({ const { data: filters, isLoading } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id], queryKey: ["filters", title, queryKey, id],
queryFn, queryFn,
staleTime: 0, staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey, enabled: !!id && !!queryFn && !!queryKey,
}); });
// Standalone-mode state (unused in shared mode).
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const onButtonPress = useCallback(() => {
if (shared) {
shared.openFilter({
key: `${id}:${queryKey}`,
id,
queryKey,
queryFn,
title,
values: values as unknown[],
set: set as (value: unknown[]) => void,
renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
searchFilter: searchFilter as
| ((item: unknown, query: string) => boolean)
| undefined,
disableSearch,
multiple,
});
return;
}
// present() must run from the press handler: from an effect after a state
// update it silently no-ops on the new architecture and the sheet never
// appears.
setOpen(true);
sheetModalRef.current?.present();
}, [
shared,
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch,
multiple,
]);
return ( return (
<> <>
<TouchableOpacity <TouchableOpacity onPress={onButtonPress}>
onPress={() => {
filters?.length && setOpen(true);
}}
>
<View <View
className={` className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1 px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
@@ -85,18 +128,22 @@ export const FilterButton = <T,>({
)} )}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<FilterSheet<T> {!shared && (
title={title} <FilterSheet<T>
open={open} title={title}
setOpen={setOpen} open={open}
data={filters} setOpen={setOpen}
values={values} modalRef={sheetModalRef}
set={set} loading={isLoading}
renderItemLabel={renderItemLabel} data={filters}
searchFilter={searchFilter} values={values}
disableSearch={disableSearch} set={set}
multiple={multiple} renderItemLabel={renderItemLabel}
/> searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
)}
</> </>
); );
}; };

View File

@@ -7,7 +7,14 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
StyleSheet, StyleSheet,
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { Loader } from "../Loader";
interface Props<T> extends ViewProps { interface Props<T> extends ViewProps {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
/**
* Modal ref the opener must use to present() the sheet from inside its
* press handler. On the new architecture with Reanimated 4, present()
* called from an effect after a state update silently no-ops — the sheet
* mounts nothing. Presenting straight from the gesture handler works.
*/
modalRef: React.RefObject<BottomSheetModal | null>;
data?: T[] | null; data?: T[] | null;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[]; values: T[];
set: (value: T[]) => void; set: (value: T[]) => void;
title: string; title: string;
@@ -66,16 +83,18 @@ const LIMIT = 100;
export const FilterSheet = <T,>({ export const FilterSheet = <T,>({
values, values,
data: _data, data: _data,
loading = false,
open, open,
set, set,
setOpen, setOpen,
modalRef,
title, title,
searchFilter, searchFilter,
renderItemLabel, renderItemLabel,
disableSearch = false, disableSearch = false,
multiple = false, multiple = false,
}: Props<T>) => { }: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = modalRef;
const snapPoints = useMemo(() => ["85%"], []); const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0); const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
// Filtering and re-rendering the option list on every keystroke blocks the
// JS thread on large lists (2000+ tags); the controlled input then snaps the
// native text back to a stale value (lost/reappearing letters). Deferring the
// value keeps the keystroke render cheap and runs the list update after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState<boolean>(false); const [showSearch, setShowSearch] = useState<boolean>(false);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!search) return _data; if (!deferredSearch) return _data;
const results = []; const results = [];
for (let i = 0; i < (_data?.length || 0); i++) { for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], search)) { if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]); results.push(_data[i]);
} }
} }
return results.slice(0, 100); return results.slice(0, 100);
}, [search, _data, searchFilter]); }, [deferredSearch, _data, searchFilter]);
useEffect(() => { useEffect(() => {
if (!data || data.length === 0 || disableSearch) return; if (!data || data.length === 0 || disableSearch) return;
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
setData(newData); setData(newData);
}, [offset, _data]); }, [offset, _data]);
// Opening is imperative (see the modalRef prop); this effect only closes.
// It also never calls dismiss() on a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (open) bottomSheetModalRef.current?.present(); if (!open && wasPresentedRef.current) {
else bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
}
}, [open]); }, [open]);
const handleSheetChanges = useCallback((index: number) => { const handleSheetChanges = useCallback((index: number) => {
if (index === -1) { if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false); setOpen(false);
} }
}, []); }, []);
const renderData = useMemo(() => { const renderData = useMemo(() => {
if (search.length > 0 && showSearch) return filteredData; if (deferredSearch.length > 0 && showSearch) return filteredData;
return data; return data;
}, [search, filteredData, data]); }, [deferredSearch, showSearch, filteredData, data]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -154,6 +185,54 @@ export const FilterSheet = <T,>({
[], [],
); );
// Memoized so typing in the search input (urgent render with an unchanged
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
// Match the deep-equality rule used to render the selected
// state below — option objects are recreated across renders,
// so reference checks would re-add an already selected item.
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!isSelected) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
)),
[renderData, values, multiple, set, setOpen, renderItemLabel],
);
return ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
}} }}
> >
<Text className='font-bold text-2xl'>{title}</Text> <Text className='font-bold text-2xl'>{title}</Text>
<Text className='mb-2 text-neutral-500'> {loading ? (
{t("search.x_items", { count: _data?.length })} <View className='my-8 flex items-center justify-center'>
</Text> <Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && ( {showSearch && (
<Input <Input
placeholder={t("search.search")} placeholder={t("search.search")}
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
}} }}
className='mb-4 flex flex-col rounded-xl overflow-hidden' className='mb-4 flex flex-col rounded-xl overflow-hidden'
> >
{renderData?.map((item, index) => ( {renderedRows}
<View key={index}>
<TouchableOpacity
onPress={() => {
if (multiple) {
if (!values.includes(item)) set(values.concat(item));
else set(values.filter((v) => v !== item));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
))}
</View> </View>
{data.length < (_data?.length || 0) && ( {data.length < (_data?.length || 0) && (
<Button <Button

View File

@@ -0,0 +1,74 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet";
interface FilterSheetContextType {
openFilter: (config: FilterConfig) => void;
}
const FilterSheetContext = createContext<FilterSheetContextType | null>(null);
/**
* Returns the shared-sheet controller, or null when rendered outside a
* FilterSheetProvider — FilterButton then falls back to its own standalone
* sheet (used by screens that don't host a provider, e.g. logs / discover).
*/
export const useFilterSheet = (): FilterSheetContextType | null =>
useContext(FilterSheetContext);
/**
* Hosts the single shared filter sheet for a screen. Every FilterButton under
* it calls openFilter() to show its options in that one sheet — so two sheets
* can never stack regardless of how fast the buttons are tapped. present() runs
* synchronously from the button's press handler (the modal is always mounted).
*/
export const FilterSheetProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const modalRef = useRef<BottomSheetModal | null>(null);
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<FilterConfig | null>(null);
// First-wins guard. With a single shared sheet there is exactly one source of
// truth (this ref) reset on the one close path — so unlike a per-button guard
// it can't get stuck on remounts or multiple instances. A second tap during
// the first sheet's open animation is ignored; the first tapped filter wins.
const openRef = useRef(false);
const openFilter = useCallback((next: FilterConfig) => {
if (openRef.current) return;
openRef.current = true;
setConfig(next);
setOpen(true);
modalRef.current?.present();
}, []);
// Single close path for every dismissal (select / swipe / backdrop) — frees
// the guard reliably.
const closeSheet = useCallback(() => {
openRef.current = false;
setOpen(false);
}, []);
const value = useMemo(() => ({ openFilter }), [openFilter]);
return (
<FilterSheetContext.Provider value={value}>
{children}
<SharedFilterSheet
modalRef={modalRef}
open={open}
setOpen={closeSheet}
config={config}
/>
</FilterSheetContext.Provider>
);
};

View File

@@ -1,38 +1,24 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { import { useFilterReset } from "@/hooks/useFilterReset";
filterByAtom,
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
interface Props extends TouchableOpacityProps {} interface Props extends TouchableOpacityProps {
libraryId: string;
}
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => { export const ResetFiltersButton: React.FC<Props> = ({
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); libraryId,
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); ...props
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); }) => {
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom); const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
if ( if (!hasActiveFilters) {
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
selectedYears.length === 0 &&
selectedFilters.length === 0
) {
return null; return null;
} }
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={resetAllFilters}
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
setSelectedFilters([]);
}}
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1' className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props} {...props}
> >

View File

@@ -0,0 +1,286 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { isEqual } from "lodash";
import type React from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
/**
* Config for the filter currently shown by the shared sheet. Generics are erased
* at the FilterButton → provider boundary, so item-typed callbacks use `any`.
*/
export interface FilterConfig {
/** Stable identity — changing it remounts the content with fresh state. */
key: string;
id: string;
queryKey: string;
queryFn: (params: any) => Promise<any>;
title: string;
values: any[];
set: (value: any[]) => void;
renderItemLabel: (item: any) => React.ReactNode;
searchFilter?: (item: any, query: string) => boolean;
disableSearch?: boolean;
multiple?: boolean;
}
const LIMIT = 100;
interface SharedFilterSheetProps {
modalRef: React.RefObject<BottomSheetModal | null>;
open: boolean;
setOpen: (open: boolean) => void;
config: FilterConfig | null;
}
/**
* The single shared filter sheet — one BottomSheetModal hosted by
* FilterSheetProvider for a whole screen; FilterButtons only swap its `config`.
* Because only one modal ever exists, rapid taps across buttons can never stack
* two sheets, so no guard/timer is needed. The modal shell stays mounted with a
* stable ref (present() can run synchronously from the tapping button); the
* inner content is keyed by the active filter so its pagination/search reset
* cleanly between filters.
*/
export const SharedFilterSheet: React.FC<SharedFilterSheetProps> = ({
modalRef,
open,
setOpen,
config,
}) => {
const snapPoints = useMemo(() => ["85%"], []);
const insets = useSafeAreaInsets();
// Opening is imperative (the provider calls present()); this effect only
// closes, and never dismisses a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => {
if (!open && wasPresentedRef.current) {
modalRef.current?.dismiss();
}
}, [open, modalRef]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false);
}
},
[setOpen],
);
const requestClose = useCallback(() => setOpen(false), [setOpen]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={modalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
>
<BottomSheetScrollView style={{ flex: 1 }}>
<View
className='mt-2 mb-8'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{config && (
<SharedFilterSheetContent
key={config.key}
config={config}
onRequestClose={requestClose}
/>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
};
interface SharedFilterSheetContentProps {
config: FilterConfig;
onRequestClose: () => void;
}
const SharedFilterSheetContent: React.FC<SharedFilterSheetContentProps> = ({
config,
onRequestClose,
}) => {
const {
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch = false,
multiple = false,
} = config;
const { t } = useTranslation();
// The options query lives here (deduped with the FilterButton's own query via
// the shared React Query key), so the list stays live after the sheet opens.
const { data: _data, isLoading: loading } = useQuery<any[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
const [data, setData] = useState<any[]>([]);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState("");
// Filtering on every keystroke blocks the JS thread on large lists; defer the
// value so the keystroke render stays cheap and the list update runs after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState(false);
const filteredData = useMemo(() => {
if (!deferredSearch) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
if (data.length > 15) {
setShowSearch(true);
}
}, [data, disableSearch]);
// Loads data in batches of LIMIT from offset (efficient "load more").
useEffect(() => {
if (!_data || _data.length === 0) return;
const newData = [...data];
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
const item = _data[i];
// Option objects are recreated across renders → dedupe by value.
const exists = newData.some((existingItem) =>
isEqual(existingItem, item),
);
if (!exists) {
newData.push(item);
}
}
setData(newData);
}, [offset, _data]);
const renderData = useMemo(() => {
if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
}, [deferredSearch, showSearch, filteredData, data]);
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => onRequestClose(), 250);
} else if (!isSelected) {
set([item]);
setTimeout(() => onRequestClose(), 250);
}
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{ height: StyleSheet.hairlineWidth }}
className='h-1 divide-neutral-700'
/>
</View>
)),
[renderData, values, multiple, set, renderItemLabel, onRequestClose],
);
return (
<>
<Text className='font-bold text-2xl'>{title}</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && (
<Input
placeholder={t("search.search")}
className='my-2 border-neutral-800 border'
value={search}
onChangeText={setSearch}
returnKeyType='done'
/>
)}
<View
style={{ borderRadius: 20, overflow: "hidden" }}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderedRows}
</View>
{data.length < (_data?.length || 0) && (
<Button onPress={() => setOffset(offset + LIMIT)}>Load more</Button>
)}
</>
);
};

View File

@@ -39,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import {
apiAtom,
pendingAccountSaveAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -89,6 +93,9 @@ const HomeMobile = () => {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set()); const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet(); const { showIntro } = useIntroSheet();
// Gate the intro so it can't steal presentation from the post-login
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
// Fallback refresh for newly added content when returning to the home screen // Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event). // (primary path is the LibraryChanged WebSocket event).
@@ -97,7 +104,9 @@ const HomeMobile = () => {
// Show intro modal on first launch // Show intro modal on first launch
useEffect(() => { useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro"); const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) { // Defer while the save-account sheet is up; this effect re-runs and schedules
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
if (!hasShownIntro && !pendingAccountSave) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
showIntro(); showIntro();
}, 1000); }, 1000);
@@ -106,7 +115,7 @@ const HomeMobile = () => {
clearTimeout(timer); clearTimeout(timer);
}; };
} }
}, [showIntro]); }, [showIntro, pendingAccountSave]);
useEffect(() => { useEffect(() => {
if (isConnected && !prevIsConnected.current) { if (isConnected && !prevIsConnected.current) {

View File

@@ -15,6 +15,7 @@ import {
import { SectionHeader } from "@/components/common/SectionHeader"; import { SectionHeader } from "@/components/common/SectionHeader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { useSettings } from "@/utils/atoms/settings";
import { Colors } from "../../constants/Colors"; import { Colors } from "../../constants/Colors";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [isSuccess, onLoaded]); }, [isSuccess, onLoaded]);
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates) // Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
const allItems = useMemo(() => { const allItems = useMemo(() => {
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
`} `}
> >
{item.Type === "Episode" && orientation === "horizontal" && ( {item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)} )}
{item.Type === "Episode" && orientation === "vertical" && ( {item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />

View File

@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize"; import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow // Extra padding to accommodate scale animation (1.05x) and glow shadow
@@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
const allItems = useMemo(() => { const allItems = useMemo(() => {
const items = data?.pages.flat() ?? []; const items = data?.pages.flat() ?? [];
@@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hasTVPreferredFocus={isFirstItem} hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)} onFocus={() => handleItemFocus(item)}
width={itemWidth} width={itemWidth}
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
/> />
</View> </View>
); );
@@ -243,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showItemActions, showItemActions,
handleItemFocus, handleItemFocus,
ITEM_GAP, ITEM_GAP,
settings?.useEpisodeImagesForNextUp,
], ],
); );

View File

@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { useInView } from "@/hooks/useInView"; import { useInView } from "@/hooks/useInView";
import { useSettings } from "@/utils/atoms/settings";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
// Show skeleton if loading OR if lazy loading is enabled and not in view yet // Show skeleton if loading OR if lazy loading is enabled and not in view yet
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView); const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
`} `}
> >
{item.Type === "Episode" && orientation === "horizontal" && ( {item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)} )}
{item.Type === "Episode" && orientation === "vertical" && ( {item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />

View File

@@ -23,9 +23,11 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'> {title ? (
{title} <Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
</Text> {title}
</Text>
) : null}
<View <View
style={[]} style={[]}
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900' className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { PropsWithChildren, ReactNode } from "react"; import type { PropsWithChildren, ReactNode } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -34,12 +34,17 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
}) => { }) => {
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle; const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
const isDisabled = disabled || disabledByAdmin; const isDisabled = disabled || disabledByAdmin;
// Keep the row floor uniform; Android trims padding slightly (its native
// controls sit taller). Switch height is capped via SettingSwitch so toggle
// rows match non-toggle rows.
const rowSizing =
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
if (onPress) if (onPress)
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={isDisabled} disabled={isDisabled}
onPress={onPress} onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`} className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...(viewProps as any)} {...(viewProps as any)}
> >
<ListItemContent <ListItemContent
@@ -58,7 +63,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
); );
return ( return (
<View <View
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`} className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...viewProps} {...viewProps}
> >
<ListItemContent <ListItemContent

View File

@@ -1,5 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
disabled = false, disabled = false,
refSetter, refSetter,
}) => { }) => {
const { t } = useTranslation();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({ const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
scaleAmount: 1, scaleAmount: 1,
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
<Text <Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]} style={[styles.liveBadgeText, { fontSize: typography.callout }]}
> >
LIVE {t("player.live")}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
marginBottom: 24, marginBottom: 24,
}} }}
> >
Live TV {t("live_tv.title")}
</Text> </Text>
{/* Tab Bar */} {/* Tab Bar */}

View File

@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import {
import type { apiAtom,
AccountSecurityType, pendingAccountSaveAtom,
SavedServer, useJellyfin,
} from "@/utils/secureCredentials"; userAtom,
} from "@/providers/JellyfinProvider";
import type { SavedServer } from "@/utils/secureCredentials";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
@@ -37,14 +39,17 @@ export const Login: React.FC = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const user = useAtomValue(userAtom);
const { const {
setServer, setServer,
login, login,
removeServer, removeServer,
initiateQuickConnect, initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential, loginWithSavedCredential,
loginWithPassword, loginWithPassword,
} = useJellyfin(); } = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -64,13 +69,43 @@ export const Login: React.FC = () => {
password: _password || "", password: _password || "",
}); });
// Save account state // Quick Connect code shown in the in-app sheet while polling for authorization
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
// Close the code sheet as soon as the session is authorized — the native
// Alert used before had no programmatic dismiss and stayed open after login.
// A Quick Connect login with "save account" on flags the post-login save:
// the protection picker shows globally once the session exists (this screen
// unmounts on login, so it can't host the modal).
useEffect(() => {
if (user) {
if (quickConnectCode && saveAccount) {
setPendingAccountSave({ serverName });
}
setQuickConnectCode(null);
}
}, [user]);
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
useEffect(() => {
return () => {
stopQuickConnectPolling();
};
}, [stopQuickConnectPolling]);
// Going back to server selection keeps this component mounted (same screen,
// different state), so the unmount cleanup above doesn't run. Without this a
// code authorized after leaving would silently log the user in later.
useEffect(() => {
if (!api?.basePath) {
stopQuickConnectPolling();
setQuickConnectCode(null);
}
}, [api?.basePath, stopQuickConnectPolling]);
// Save account state — only the intent lives here; the protection picker is
// the global PendingAccountSaveModal, shown after the login succeeds.
const [saveAccount, setSaveAccount] = useState(false); const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
// Handle URL params for server connection // Handle URL params for server connection
useEffect(() => { useEffect(() => {
@@ -117,29 +152,22 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return; if (!result.success) return;
if (saveAccount) { const ok = await performLogin(credentials.username, credentials.password);
setPendingLogin({ // The protection picker shows AFTER a successful login (global modal) —
username: credentials.username, // never for a failed one.
password: credentials.password, if (ok && saveAccount) {
}); setPendingAccountSave({ serverName });
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
} }
}; };
const performLogin = async ( const performLogin = async (
username: string, username: string,
password: string, password: string,
options?: { ): Promise<boolean> => {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true); setLoading(true);
try { try {
await login(username, password, serverName, options); await login(username, password, serverName);
return true;
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
@@ -149,23 +177,9 @@ export const Login: React.FC = () => {
t("login.an_unexpected_error_occurred"), t("login.an_unexpected_error_occurred"),
); );
} }
return false;
} finally { } finally {
setLoading(false); setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
} }
}; };
@@ -259,15 +273,7 @@ export const Login: React.FC = () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert( setQuickConnectCode(code);
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
} }
} catch (_error) { } catch (_error) {
Alert.alert( Alert.alert(
@@ -402,7 +408,7 @@ export const Login: React.FC = () => {
{t("server.enter_url_to_jellyfin_server")} {t("server.enter_url_to_jellyfin_server")}
</Text> </Text>
<Input <Input
aria-label='Server URL' aria-label={t("server.server_url")}
placeholder={t("server.server_url_placeholder")} placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL} onChangeText={setServerURL}
value={serverURL} value={serverURL}
@@ -444,14 +450,11 @@ export const Login: React.FC = () => {
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
<SaveAccountModal {/* Dismissing only hides the code — polling continues so the login still
visible={showSaveModal} completes if the code is authorized from another device afterwards. */}
onClose={() => { <QuickConnectCodeModal
setShowSaveModal(false); code={quickConnectCode}
setPendingLogin(null); onClose={() => setQuickConnectCode(null)}
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/> />
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -0,0 +1,137 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { requireOptionalNativeModule } from "expo-modules-core";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Text } from "../common/Text";
interface Props {
/** The Quick Connect code to display, or null when hidden. */
code: string | null;
onClose: () => void;
}
/**
* Shows the Quick Connect code while the app polls for authorization.
* In-app sheet instead of a native Alert so it can dismiss itself once the
* session is authorized — a native alert has no programmatic dismiss and
* lingers over the app after login completes.
*/
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["50%"], []);
const isPresentedRef = useRef(false);
// Keep the last code around so the dismiss animation doesn't flash empty
// when the parent clears the code to close the sheet.
const lastCodeRef = useRef<string | null>(null);
if (code) lastCodeRef.current = code;
useEffect(() => {
if (code) {
bottomSheetModalRef.current?.present();
} else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [code]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const copyCode = useCallback(async () => {
const value = code ?? lastCodeRef.current;
if (!value) return;
// Builds that don't ship the expo-clipboard native module yet: probe with
// requireOptionalNativeModule (returns null instead of throwing/logging)
// and skip — importing the JS wrapper there would error out.
if (!requireOptionalNativeModule("ExpoClipboard")) return;
const Clipboard = await import("expo-clipboard");
await Clipboard.setStringAsync(value);
toast.success(t("login.code_copied"));
}, [code, t]);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
>
<BottomSheetView
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
}}
>
<View className='flex-1'>
<Text className='font-bold text-2xl text-neutral-100'>
{t("login.quick_connect")}
</Text>
<TouchableOpacity
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
onPress={copyCode}
>
<Text
className='text-center font-bold text-5xl text-neutral-100'
style={{ letterSpacing: 10 }}
>
{code ?? lastCodeRef.current}
</Text>
<Ionicons
name='copy-outline'
size={22}
color='white'
style={{ opacity: 0.4, marginLeft: 16 }}
/>
</TouchableOpacity>
<Text className='mt-2 text-neutral-500 text-center text-xs'>
{t("login.tap_code_to_copy")}
</Text>
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
{t("login.quick_connect_instructions")}
</Text>
<Button className='mt-auto' color='purple' onPress={onClose}>
{t("login.got_it")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native"; import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
showDiscover, showDiscover,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
if (!showDiscover) { if (!showDiscover) {
return null; return null;
} }
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
}} }}
> >
<TVSearchTabBadge <TVSearchTabBadge
label='Library' label={t("search.library")}
isSelected={searchType === "Library"} isSelected={searchType === "Library"}
onPress={() => setSearchType("Library")} onPress={() => setSearchType("Library")}
disabled={disabled} disabled={disabled}
/> />
<TVSearchTabBadge <TVSearchTabBadge
label='Discover' label={t("search.discover")}
isSelected={searchType === "Discover"} isSelected={searchType === "Discover"}
onPress={() => setSearchType("Discover")} onPress={() => setSearchType("Discover")}
disabled={disabled} disabled={disabled}

View File

@@ -1,7 +1,8 @@
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Switch } from "react-native"; import { Linking } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -27,6 +28,7 @@ export const AppearanceSettings: React.FC = () => {
<ListGroup title={t("home.settings.appearance.title")} className=''> <ListGroup title={t("home.settings.appearance.title")} className=''>
<ListItem <ListItem
title={t("home.settings.other.show_custom_menu_links")} title={t("home.settings.other.show_custom_menu_links")}
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
disabled={pluginSettings?.showCustomMenuLinks?.locked} disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() => onPress={() =>
Linking.openURL( Linking.openURL(
@@ -34,7 +36,7 @@ export const AppearanceSettings: React.FC = () => {
) )
} }
> >
<Switch <SettingSwitch
value={settings.showCustomMenuLinks} value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked} disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -44,31 +46,51 @@ export const AppearanceSettings: React.FC = () => {
</ListItem> </ListItem>
<ListItem <ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")} title={t("home.settings.appearance.merge_next_up_continue_watching")}
subtitle={t(
"home.settings.appearance.merge_next_up_continue_watching_hint",
)}
> >
<Switch <SettingSwitch
value={settings.mergeNextUpAndContinueWatching} value={settings.mergeNextUpAndContinueWatching}
onValueChange={(value) => onValueChange={(value) =>
updateSettings({ mergeNextUpAndContinueWatching: value }) updateSettings({ mergeNextUpAndContinueWatching: value })
} }
/> />
</ListItem> </ListItem>
<ListItem
title={t("home.settings.appearance.use_episode_images_next_up")}
subtitle={t(
"home.settings.appearance.use_episode_images_next_up_hint",
)}
>
<SettingSwitch
value={settings.useEpisodeImagesForNextUp}
onValueChange={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.hide_remote_session_button")}
subtitle={t(
"home.settings.appearance.hide_remote_session_button_hint",
)}
>
<SettingSwitch
value={settings.hideRemoteSessionButton}
onValueChange={(value) =>
updateSettings({ hideRemoteSessionButton: value })
}
/>
</ListItem>
<ListItem <ListItem
onPress={() => onPress={() =>
router.push("/settings/appearance/hide-libraries/page") router.push("/settings/appearance/hide-libraries/page")
} }
title={t("home.settings.other.hide_libraries")} title={t("home.settings.other.hide_libraries")}
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
showArrow showArrow
/> />
<ListItem
title={t("home.settings.appearance.hide_remote_session_button")}
>
<Switch
value={settings.hideRemoteSessionButton}
onValueChange={(value) =>
updateSettings({ hideRemoteSessionButton: value })
}
/>
</ListItem>
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native"; import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler"; import { SettingSwitch } from "@/components/common/SettingSwitch";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
title={t("home.settings.audio.set_audio_track")} title={t("home.settings.audio.set_audio_track")}
disabled={pluginSettings?.rememberAudioSelections?.locked} disabled={pluginSettings?.rememberAudioSelections?.locked}
> >
<Switch <SettingSwitch
value={settings.rememberAudioSelections} value={settings.rememberAudioSelections}
disabled={pluginSettings?.rememberAudioSelections?.locked} disabled={pluginSettings?.rememberAudioSelections?.locked}
onValueChange={(value) => onValueChange={(value) =>

View File

@@ -1,4 +1,5 @@
import { Switch, View } from "react-native"; import { View } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
@@ -9,7 +10,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
<View {...props}> <View {...props}>
<ListGroup title={"Chromecast"}> <ListGroup title={"Chromecast"}>
<ListItem title={"Enable H265 for Chromecast"}> <ListItem title={"Enable H265 for Chromecast"}>
<Switch <SettingSwitch
value={settings.enableH265ForChromecast} value={settings.enableH265ForChromecast}
onValueChange={(enableH265ForChromecast) => onValueChange={(enableH265ForChromecast) =>
updateSettings({ enableH265ForChromecast }) updateSettings({ enableH265ForChromecast })

View File

@@ -2,7 +2,7 @@ import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import { Switch } from "react-native"; import { SettingSwitch } from "@/components/common/SettingSwitch";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)} )}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked} disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
> >
<Switch <SettingSwitch
value={settings.enableHorizontalSwipeSkip} value={settings.enableHorizontalSwipeSkip}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked} disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
onValueChange={(enableHorizontalSwipeSkip) => onValueChange={(enableHorizontalSwipeSkip) =>
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)} )}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked} disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
> >
<Switch <SettingSwitch
value={settings.enableLeftSideBrightnessSwipe} value={settings.enableLeftSideBrightnessSwipe}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked} disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
onValueChange={(enableLeftSideBrightnessSwipe) => onValueChange={(enableLeftSideBrightnessSwipe) =>
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)} )}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked} disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
> >
<Switch <SettingSwitch
value={settings.enableRightSideVolumeSwipe} value={settings.enableRightSideVolumeSwipe}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked} disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
onValueChange={(enableRightSideVolumeSwipe) => onValueChange={(enableRightSideVolumeSwipe) =>
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)} )}
disabled={pluginSettings?.hideVolumeSlider?.locked} disabled={pluginSettings?.hideVolumeSlider?.locked}
> >
<Switch <SettingSwitch
value={settings.hideVolumeSlider} value={settings.hideVolumeSlider}
disabled={pluginSettings?.hideVolumeSlider?.locked} disabled={pluginSettings?.hideVolumeSlider?.locked}
onValueChange={(hideVolumeSlider) => onValueChange={(hideVolumeSlider) =>
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
)} )}
disabled={pluginSettings?.hideBrightnessSlider?.locked} disabled={pluginSettings?.hideBrightnessSlider?.locked}
> >
<Switch <SettingSwitch
value={settings.hideBrightnessSlider} value={settings.hideBrightnessSlider}
disabled={pluginSettings?.hideBrightnessSlider?.locked} disabled={pluginSettings?.hideBrightnessSlider?.locked}
onValueChange={(hideBrightnessSlider) => onValueChange={(hideBrightnessSlider) =>

View File

@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
// Only the server URL is admin-lockable — the password stays editable so
// the user can still sign in to the admin-pinned Jellyseerr server.
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
const [jellyseerrPassword, setJellyseerrPassword] = useState< const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined string | undefined
@@ -115,30 +118,41 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='font-bold mb-1'> <View style={{ opacity: urlLocked ? 0.5 : 1 }}>
{t("home.settings.plugins.jellyseerr.server_url")} <Text className='font-bold mb-1'>
</Text> {t("home.settings.plugins.jellyseerr.server_url")}
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text> </Text>
</View> <View className='flex flex-col shrink mb-2'>
<Input <Text className='text-xs text-gray-600'>
className='border border-neutral-800 mb-2' {t("home.settings.plugins.jellyseerr.server_url_hint")}
placeholder={t( </Text>
"home.settings.plugins.jellyseerr.server_url_placeholder", </View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
value={
urlLocked
? settings?.jellyseerrServerUrl
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
/>
{urlLocked && (
<Text className='text-xs text-red-600 mb-2'>
Disabled by admin
</Text>
)} )}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl} </View>
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
/>
<View> <View>
<Text className='font-bold mb-2'> <Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")} {t("home.settings.plugins.jellyseerr.password")}

View File

@@ -1,33 +1,28 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, Text, View } from "react-native"; import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const KefinTweaksSettings = () => { export const KefinTweaksSettings = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const isEnabled = settings?.useKefinTweaks ?? false; const isEnabled = settings?.useKefinTweaks ?? false;
const locked = pluginSettings?.useKefinTweaks?.locked === true;
return ( return (
<View className=''> <ListGroup>
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <ListItem
<Text className='text-xs text-red-600 mb-2'> title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")} disabledByAdmin={locked}
</Text> >
<SettingSwitch
<View className='flex flex-row items-center justify-between mt-2'> value={isEnabled}
<Text className='text-white'> disabled={locked}
{isEnabled ? t("Watchlist On") : t("Watchlist Off")} onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
</Text> />
</ListItem>
<Switch </ListGroup>
value={isEnabled}
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
trackColor={{ false: "#555", true: "purple" }}
thumbColor={isEnabled ? "#fff" : "#ccc"}
/>
</View>
</View>
</View>
); );
}; };

View File

@@ -2,8 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useWifiSSID } from "@/hooks/useWifiSSID"; import { useWifiSSID } from "@/hooks/useWifiSSID";
import { useServerUrl } from "@/providers/ServerUrlProvider"; import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
@@ -147,7 +148,10 @@ export function LocalNetworkSettings(): React.ReactElement | null {
title={t("home.settings.network.auto_switch_enabled")} title={t("home.settings.network.auto_switch_enabled")}
subtitle={t("home.settings.network.auto_switch_description")} subtitle={t("home.settings.network.auto_switch_description")}
> >
<Switch value={config.enabled} onValueChange={handleToggleEnabled} /> <SettingSwitch
value={config.enabled}
onValueChange={handleToggleEnabled}
/>
</ListItem> </ListItem>
</ListGroup> </ListGroup>

View File

@@ -1,6 +1,8 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, Switch, View, type ViewProps } from "react-native"; import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Stepper } from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
@@ -17,20 +19,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const media = useMedia(); const media = useMedia();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const { t } = useTranslation();
const alignXOptions: AlignX[] = ["left", "center", "right"]; const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"]; const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = { const alignXLabels: Record<AlignX, string> = {
left: "Left", left: t("home.settings.subtitles.align.left"),
center: "Center", center: t("home.settings.subtitles.align.center"),
right: "Right", right: t("home.settings.subtitles.align.right"),
}; };
const alignYLabels: Record<AlignY, string> = { const alignYLabels: Record<AlignY, string> = {
top: "Top", top: t("home.settings.subtitles.align.top"),
center: "Center", center: t("home.settings.subtitles.align.center"),
bottom: "Bottom", bottom: t("home.settings.subtitles.align.bottom"),
}; };
const alignXOptionGroups = useMemo(() => { const alignXOptionGroups = useMemo(() => {
@@ -60,16 +63,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
title='MPV Subtitle Settings' title={t("home.settings.subtitles.mpv_settings_title")}
description={ description={
<Text className='text-[#8E8D91] text-xs'> <Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player {t("home.settings.subtitles.mpv_settings_description")}
</Text> </Text>
} }
> >
{!isTv && ( {!isTv && (
<> <>
<ListItem title='Vertical Margin'> <ListItem
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
>
<Stepper <Stepper
value={settings.mpvSubtitleMarginY ?? 0} value={settings.mpvSubtitleMarginY ?? 0}
step={5} step={5}
@@ -81,7 +86,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</ListItem> </ListItem>
<ListItem title='Horizontal Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
<PlatformDropdown <PlatformDropdown
groups={alignXOptionGroups} groups={alignXOptionGroups}
trigger={ trigger={
@@ -96,11 +101,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Horizontal Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_x")}
/> />
</ListItem> </ListItem>
<ListItem title='Vertical Alignment'> <ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
<PlatformDropdown <PlatformDropdown
groups={alignYOptionGroups} groups={alignYOptionGroups}
trigger={ trigger={
@@ -115,14 +120,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
} }
title='Vertical Alignment' title={t("home.settings.subtitles.mpv_subtitle_align_y")}
/> />
</ListItem> </ListItem>
</> </>
)} )}
<ListItem title='Opaque Background'> <ListItem title={t("home.settings.subtitles.opaque_background")}>
<Switch <SettingSwitch
value={settings.mpvSubtitleBackgroundEnabled ?? false} value={settings.mpvSubtitleBackgroundEnabled ?? false}
onValueChange={(value) => onValueChange={(value) =>
updateSettings({ mpvSubtitleBackgroundEnabled: value }) updateSettings({ mpvSubtitleBackgroundEnabled: value })
@@ -131,7 +136,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
{settings.mpvSubtitleBackgroundEnabled && ( {settings.mpvSubtitleBackgroundEnabled && (
<ListItem title='Background Opacity'> <ListItem title={t("home.settings.subtitles.background_opacity")}>
<Stepper <Stepper
value={settings.mpvSubtitleBackgroundOpacity ?? 75} value={settings.mpvSubtitleBackgroundOpacity ?? 75}
step={5} step={5}

View File

@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Switch, View } from "react-native"; import { Linking, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
@@ -132,7 +133,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.safe_area_in_controls")} title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
> >
<Switch <SettingSwitch
value={settings.safeAreaInControlsEnabled} value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -150,7 +151,7 @@ export const OtherSettings: React.FC = () => {
) )
} }
> >
<Switch <SettingSwitch
value={settings.showCustomMenuLinks} value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked} disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -188,7 +189,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.disable_haptic_feedback")} title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}
> >
<Switch <SettingSwitch
value={settings.disableHapticFeedback} value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) => onValueChange={(disableHapticFeedback) =>

View File

@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native"; import { View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
@@ -115,7 +116,7 @@ export const PlaybackControlsSettings: React.FC = () => {
return ( return (
<DisabledSetting disabled={disabled}> <DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className=''> <ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
<ListItem <ListItem
title={t("home.settings.other.video_orientation")} title={t("home.settings.other.video_orientation")}
disabled={pluginSettings?.defaultVideoOrientation?.locked} disabled={pluginSettings?.defaultVideoOrientation?.locked}
@@ -146,7 +147,7 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.safe_area_in_controls")} title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
> >
<Switch <SettingSwitch
value={settings.safeAreaInControlsEnabled} value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) => onValueChange={(value) =>
@@ -205,7 +206,7 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.disable_haptic_feedback")} title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}
> >
<Switch <SettingSwitch
value={settings.disableHapticFeedback} value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) => onValueChange={(disableHapticFeedback) =>
@@ -218,7 +219,7 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.auto_play_next_episode")} title={t("home.settings.other.auto_play_next_episode")}
disabled={pluginSettings?.autoPlayNextEpisode?.locked} disabled={pluginSettings?.autoPlayNextEpisode?.locked}
> >
<Switch <SettingSwitch
value={settings.autoPlayNextEpisode} value={settings.autoPlayNextEpisode}
disabled={pluginSettings?.autoPlayNextEpisode?.locked} disabled={pluginSettings?.autoPlayNextEpisode?.locked}
onValueChange={(autoPlayNextEpisode) => onValueChange={(autoPlayNextEpisode) =>

View File

@@ -20,12 +20,7 @@ export const PluginSettings = () => {
> >
<ListItem <ListItem
onPress={() => router.push("/settings/plugins/jellyseerr/page")} onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title={"Jellyseerr"} title='Jellyseerr'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow showArrow
/> />
<ListItem <ListItem
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
title='Streamystats' title='Streamystats'
showArrow showArrow
/> />
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow
/>
<ListItem <ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")} onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks' title='KefinTweaks'

View File

@@ -1,3 +1,4 @@
import { Feather } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
type BottomSheetBackdropProps, type BottomSheetBackdropProps,
@@ -5,11 +6,13 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { requireOptionalNativeModule } from "expo-modules-core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, Platform, View, type ViewProps } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button"; import { Button } from "../Button";
@@ -79,6 +82,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
} }
}, [api, user, quickConnectCode]); }, [api, user, quickConnectCode]);
const pasteCode = useCallback(async () => {
// Builds without the expo-clipboard native module: probe first (no-op).
if (!requireOptionalNativeModule("ExpoClipboard")) return;
const Clipboard = await import("expo-clipboard");
const text = await Clipboard.getStringAsync();
const digits = (text || "").replace(/\D/g, "").slice(0, 6);
if (digits) setQuickConnectCode(digits);
}, []);
if (isTv) return null; if (isTv) return null;
return ( return (
@@ -130,6 +142,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
style={{ paddingHorizontal: 16 }} style={{ paddingHorizontal: 16 }}
autoFocus autoFocus
/> />
<Pressable
onPress={pasteCode}
className='flex-row items-center justify-center self-center'
>
<Feather name='clipboard' size={15} color='#a3a3a3' />
<Text className='text-neutral-400 ml-2'>
{t("home.settings.quick_connect.paste_code")}
</Text>
</Pressable>
</View> </View>
</View> </View>
<Button <Button

View File

@@ -125,7 +125,7 @@ export const StorageSettings = () => {
</View> </View>
</View> </View>
{!Platform.isTV && ( {!Platform.isTV && (
<ListGroup> <ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
<ListItem <ListItem
textColor='red' textColor='red'
onPress={onDeleteClicked} onPress={onDeleteClicked}

View File

@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native"; import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Stepper } from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -98,6 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
className='mb-4'
title={t("home.settings.subtitles.subtitle_title")} title={t("home.settings.subtitles.subtitle_title")}
description={ description={
<Text className='text-[#8E8D91] text-xs'> <Text className='text-[#8E8D91] text-xs'>
@@ -152,7 +153,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
title={t("home.settings.subtitles.set_subtitle_track")} title={t("home.settings.subtitles.set_subtitle_track")}
disabled={pluginSettings?.rememberSubtitleSelections?.locked} disabled={pluginSettings?.rememberSubtitleSelections?.locked}
> >
<Switch <SettingSwitch
value={settings.rememberSubtitleSelections} value={settings.rememberSubtitleSelections}
disabled={pluginSettings?.rememberSubtitleSelections?.locked} disabled={pluginSettings?.rememberSubtitleSelections?.locked}
onValueChange={(value) => onValueChange={(value) =>

View File

@@ -3,6 +3,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useMemo, useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
Animated, Animated,
Easing, Easing,
@@ -72,6 +73,9 @@ export interface TVPosterCardProps {
/** Custom image URL getter - if not provided, uses smart URL logic */ /** Custom image URL getter - if not provided, uses smart URL logic */
imageUrlGetter?: (item: BaseItemDto) => string | undefined; imageUrlGetter?: (item: BaseItemDto) => string | undefined;
/** For horizontal episodes, prefer the episode's own image over the series thumb */
preferEpisodeImage?: boolean;
} }
/** /**
@@ -108,7 +112,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
glowColor = "white", glowColor = "white",
scaleAmount = 1.05, scaleAmount = 1.05,
imageUrlGetter, imageUrlGetter,
preferEpisodeImage = false,
}) => { }) => {
const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes(); const posterSizes = useScaledTVPosterSizes();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
@@ -139,6 +145,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
if (orientation === "horizontal") { if (orientation === "horizontal") {
// Episode: prefer series thumb image for consistent look (like hero section) // Episode: prefer series thumb image for consistent look (like hero section)
if (item.Type === "Episode") { if (item.Type === "Episode") {
// Opt-in: use the episode's own image instead of the series thumb.
if (preferEpisodeImage && item.ImageTags?.Primary) {
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
}
// First try parent/series thumb (horizontal series artwork). // First try parent/series thumb (horizontal series artwork).
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId. // Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
if (item.ParentThumbItemId && item.ParentThumbImageTag) { if (item.ParentThumbItemId && item.ParentThumbImageTag) {
@@ -174,7 +184,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
item, item,
width: width * 2, // 2x for quality on large screens width: width * 2, // 2x for quality on large screens
}); });
}, [api, item, orientation, width, imageUrlGetter]); }, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
// Progress calculation // Progress calculation
const progress = useMemo(() => { const progress = useMemo(() => {
@@ -375,7 +385,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
fontWeight: "700", fontWeight: "700",
}} }}
> >
Now Playing {t("music.now_playing")}
</Text> </Text>
</View> </View>
) : null; ) : null;

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Animated, Animated,
@@ -28,6 +29,7 @@ export const TVSubtitleResultCard = React.forwardRef<
const styles = createStyles(typography); const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
}, },
]} ]}
> >
<Text style={styles.flagText}>Hash Match</Text> <Text style={styles.flagText}>{t("player.hash_match")}</Text>
</View> </View>
)} )}
{result.hearingImpaired && ( {result.hearingImpaired && (

View File

@@ -15,6 +15,7 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay"; import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble"; import { TrickplayBubble } from "./TrickplayBubble";
@@ -33,8 +34,11 @@ interface BottomControlsProps {
showRemoteBubble: boolean; showRemoteBubble: boolean;
currentTime: number; currentTime: number;
remainingTime: number; remainingTime: number;
showSkipButton: boolean;
showSkipCreditButton: boolean; showSkipCreditButton: boolean;
hasContentAfterCredits: boolean; hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void; handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void; handleNextEpisodeManual: () => void;
@@ -82,8 +86,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble, showRemoteBubble,
currentTime, currentTime,
remainingTime, remainingTime,
showSkipButton,
showSkipCreditButton, showSkipCreditButton,
hasContentAfterCredits, hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem, nextItem,
handleNextEpisodeAutoPlay, handleNextEpisodeAutoPlay,
handleNextEpisodeManual, handleNextEpisodeManual,
@@ -173,11 +180,21 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null} ) : null}
</View> </View>
<View className='flex flex-row items-center space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
{/* Smart Skip Credits behavior (drives Next Episode timing): <SkipButton
- "Skip Credits" button itself is rendered in SkipSegmentOverlay showButton={showSkipButton}
(independent of controls visibility). onPress={skipIntro}
- "Next Episode" shows when credits extend to video end AND a next buttonText={t("player.skip_intro")}
episode exists. */} />
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
<SkipButton
showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText={t("player.skip_credits")}
/>
{settings.autoPlayNextEpisode !== false && {settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 || (settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.autoPlayEpisodeCount <

View File

@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
} }
> >
<Text className='text-2xl font-bold text-white py-4 '> <Text className='text-2xl font-bold text-white py-4 '>
Are you still watching ? {t("player.still_watching")}
</Text> </Text>
<Button <Button
onPress={() => { onPress={() => {

View File

@@ -38,7 +38,6 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider"; import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime"; import { useVideoTime } from "./hooks/useVideoTime";
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -339,16 +338,6 @@ export const Controls: FC<Props> = ({
maxMs, maxMs,
); );
// Whether the "Next Episode" countdown will actually be rendered. The Skip
// Credits button yields to it only when this is true; if autoplay is
// disabled or its episode limit is reached, Skip Credits must stay available
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
const willShowNextEpisode =
!!nextItem &&
settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
const goToItemCommon = useCallback( const goToItemCommon = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
if (!item || !settings) { if (!item || !settings) {
@@ -581,8 +570,11 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble} showRemoteBubble={showRemoteBubble}
currentTime={currentTime} currentTime={currentTime}
remainingTime={remainingTime} remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton} showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits} hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem} nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay} handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual} handleNextEpisodeManual={handleNextEpisodeManual}
@@ -602,17 +594,6 @@ export const Controls: FC<Props> = ({
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}
/> />
</Animated.View> </Animated.View>
{/* Skip Intro / Skip Credits float independently of the controls so
they're visible (and tappable) without summoning the controls. */}
<SkipSegmentOverlay
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
willShowNextEpisode={willShowNextEpisode}
skipIntro={skipIntro}
skipCredit={skipCredit}
controlsVisible={showControls}
/>
</> </>
)} )}
{settings.maxAutoPlayEpisodeCount.value !== -1 && ( {settings.maxAutoPlayEpisodeCount.value !== -1 && (

View File

@@ -4,6 +4,7 @@ import type {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
@@ -57,6 +58,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false, showTechnicalInfo = false,
onToggleTechnicalInfo, onToggleTechnicalInfo,
}) => { }) => {
const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const insets = useControlsSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -127,8 +129,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
onPress={toggleOrientation} onPress={toggleOrientation}
disabled={isTogglingOrientation} disabled={isTogglingOrientation}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
accessibilityLabel='Toggle screen orientation' accessibilityLabel={t("accessibility.toggle_orientation")}
accessibilityHint='Toggles the screen orientation between portrait and landscape' accessibilityHint={t("accessibility.toggle_orientation_hint")}
> >
<MaterialIcons <MaterialIcons
name='screen-rotation' name='screen-rotation'

View File

@@ -1,125 +0,0 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import SkipButton from "./SkipButton";
interface Props {
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
willShowNextEpisode: boolean;
skipIntro: () => void;
skipCredit: () => void;
controlsVisible: boolean;
}
// Offsets are relative to the safe-area insets so they hold in both portrait
// and landscape (the insets move with the notch / home indicator).
//
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
// spot and was working fine.
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
// of the chapters icon), while staying below the vertical volume
// slider which sits at the vertical middle of the screen.
const HIDDEN_BOTTOM = 24;
const HIDDEN_RIGHT = 12;
const VISIBLE_BOTTOM = 65;
const VISIBLE_RIGHT = 42;
const ANIM_DURATION = 250;
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
// itself instantly via a `hidden` (display:none) class, which would preempt
// the parent's opacity fade-out — lagging the flag keeps the button rendered
// (and visible) while the fade plays out.
const useDelayedHide = (value: boolean, duration: number): boolean => {
const [display, setDisplay] = useState(value);
useEffect(() => {
if (value) {
setDisplay(true);
return;
}
const t = setTimeout(() => setDisplay(false), duration);
return () => clearTimeout(t);
}, [value, duration]);
return value || display;
};
/**
* Floating Skip Intro / Skip Credits buttons shown independently of the
* player controls. They appear on their own during an intro or credits segment
* without the user having to summon the controls.
*/
export const SkipSegmentOverlay: FC<Props> = ({
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
willShowNextEpisode,
skipIntro,
skipCredit,
controlsVisible,
}) => {
const insets = useControlsSafeAreaInsets();
const showCredit =
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
const visible = showSkipButton || showCredit;
// Drive each SkipButton with a lagged flag so it stays visible while the
// opacity fade-out plays, instead of disappearing the instant its segment
// ends. `visible` above still drives opacity/pointerEvents immediately.
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, {
duration: ANIM_DURATION,
easing: Easing.out(Easing.quad),
});
}, [visible, opacity]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
// Position is recomputed on render (no slide animation) so the button never
// sweeps through an overlap zone while the controls toggle.
const bottom =
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
return (
<Animated.View
style={[styles.container, { right, bottom }, animatedStyle]}
pointerEvents={visible ? "box-none" : "none"}
>
<SkipButton
showButton={renderSkip}
onPress={skipIntro}
buttonText='Skip Intro'
/>
<SkipButton
showButton={renderCredit}
onPress={skipCredit}
buttonText='Skip Credits'
/>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
flexDirection: "row",
gap: 8,
zIndex: 15,
},
});

View File

@@ -7,6 +7,7 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, Text, View } from "react-native"; import { Platform, StyleSheet, Text, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -184,6 +185,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex, currentAudioIndex,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets(); const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.videoCodec && ( {info?.videoCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Video: {formatCodec(info.videoCodec)} {t("player.technical_info.video")} {formatCodec(info.videoCodec)}
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""} {info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
</Text> </Text>
)} )}
{info?.audioCodec && ( {info?.audioCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Audio: {formatCodec(info.audioCodec)} {t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
{streamInfo?.audioChannels {streamInfo?.audioChannels
? ` ${formatAudioChannels(streamInfo.audioChannels)}` ? ` ${formatAudioChannels(streamInfo.audioChannels)}`
: ""} : ""}
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{streamInfo?.subtitleCodec && ( {streamInfo?.subtitleCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Subtitle: {formatCodec(streamInfo.subtitleCodec)} {t("player.technical_info.subtitle")}{" "}
{formatCodec(streamInfo.subtitleCodec)}
</Text> </Text>
)} )}
{(info?.videoBitrate || info?.audioBitrate) && ( {(info?.videoBitrate || info?.audioBitrate) && (
<Text style={textStyle}> <Text style={textStyle}>
Bitrate:{" "} {t("player.technical_info.bitrate")}{" "}
{info.videoBitrate {info.videoBitrate
? formatBitrate(info.videoBitrate) ? formatBitrate(info.videoBitrate)
: info.audioBitrate : info.audioBitrate
@@ -341,7 +344,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.cacheSeconds !== undefined && ( {info?.cacheSeconds !== undefined && (
<Text style={textStyle}> <Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s {t("player.technical_info.buffer_seconds", {
seconds: info.cacheSeconds.toFixed(1),
})}
{info?.demuxerMaxBytes !== undefined {info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` + ? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` + `${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
@@ -352,7 +357,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.voDriver && ( {info?.voDriver && (
<Text style={textStyle}> <Text style={textStyle}>
VO: {info.voDriver} {t("player.technical_info.vo")} {info.voDriver}
{info.hwdec ? ` / ${info.hwdec}` : ""} {info.hwdec ? ` / ${info.hwdec}` : ""}
</Text> </Text>
)} )}
@@ -364,10 +369,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
)} )}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( {info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}> <Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames {t("player.technical_info.dropped_frames", {
count: info.droppedFrames,
})}
</Text> </Text>
)} )}
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>} {!info && !playMethod && (
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
)}
</View> </View>
</Animated.View> </Animated.View>
); );

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { import {
type OptionGroup, type OptionGroup,
@@ -54,6 +55,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
onRatioChange, onRatioChange,
disabled = false, disabled = false,
}) => { }) => {
const { t } = useTranslation();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const handleRatioSelect = (ratio: AspectRatio) => { const handleRatioSelect = (ratio: AspectRatio) => {
@@ -66,7 +68,10 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
{ {
options: ASPECT_RATIO_OPTIONS.map((option) => ({ options: ASPECT_RATIO_OPTIONS.map((option) => ({
type: "radio" as const, type: "radio" as const,
label: option.label, label:
option.id === "default"
? t("player.aspect_ratio_original")
: option.label,
value: option.id, value: option.id,
selected: option.id === currentRatio, selected: option.id === currentRatio,
onPress: () => handleRatioSelect(option.id), onPress: () => handleRatioSelect(option.id),
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Aspect Ratio' title={t("player.aspect_ratio")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
bottomSheetConfig={{ bottomSheetConfig={{

View File

@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { import {
@@ -47,6 +48,7 @@ const DropdownView = ({
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { t } = useTranslation();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{ useLocalSearchParams<{
@@ -101,7 +103,7 @@ const DropdownView = ({
// Quality Section // Quality Section
if (!isOffline) { if (!isOffline) {
groups.push({ groups.push({
title: "Quality", title: t("player.menu.quality"),
options: options:
BITRATES?.map((bitrate) => ({ BITRATES?.map((bitrate) => ({
type: "radio" as const, type: "radio" as const,
@@ -116,7 +118,7 @@ const DropdownView = ({
// Subtitle Section // Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) { if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({ groups.push({
title: "Subtitles", title: t("player.menu.subtitles"),
options: subtitleTracks.map((sub) => ({ options: subtitleTracks.map((sub) => ({
type: "radio" as const, type: "radio" as const,
label: sub.name, label: sub.name,
@@ -128,7 +130,7 @@ const DropdownView = ({
// Subtitle Scale Section // Subtitle Scale Section
groups.push({ groups.push({
title: "Subtitle Scale", title: t("player.menu.subtitle_scale"),
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({ options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const, type: "radio" as const,
label: preset.label, label: preset.label,
@@ -142,7 +144,7 @@ const DropdownView = ({
// Audio Section // Audio Section
if (audioTracks && audioTracks.length > 0) { if (audioTracks && audioTracks.length > 0) {
groups.push({ groups.push({
title: "Audio", title: t("player.menu.audio"),
options: audioTracks.map((track) => ({ options: audioTracks.map((track) => ({
type: "radio" as const, type: "radio" as const,
label: track.name, label: track.name,
@@ -156,7 +158,7 @@ const DropdownView = ({
// Speed Section // Speed Section
if (setPlaybackSpeed) { if (setPlaybackSpeed) {
groups.push({ groups.push({
title: "Speed", title: t("player.menu.speed"),
options: PLAYBACK_SPEEDS.map((speed) => ({ options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const, type: "radio" as const,
label: speed.label, label: speed.label,
@@ -174,8 +176,8 @@ const DropdownView = ({
{ {
type: "action" as const, type: "action" as const,
label: showTechnicalInfo label: showTechnicalInfo
? "Hide Technical Info" ? t("player.menu.hide_technical_info")
: "Show Technical Info", : t("player.menu.show_technical_info"),
onPress: onToggleTechnicalInfo, onPress: onToggleTechnicalInfo,
}, },
], ],
@@ -185,6 +187,7 @@ const DropdownView = ({
return groups; return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
t,
isOffline, isOffline,
bitrateValue, bitrateValue,
changeBitrate, changeBitrate,
@@ -217,7 +220,7 @@ const DropdownView = ({
return ( return (
<PlatformDropdown <PlatformDropdown
title='Playback Options' title={t("player.menu.playback_options")}
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
expoUIConfig={{}} expoUIConfig={{}}

View File

@@ -3,6 +3,7 @@ import { Alert } from "react-native";
import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { type SharedValue, useSharedValue } from "react-native-reanimated";
import { useTVBackPress } from "@/hooks/useTVBackPress"; import { useTVBackPress } from "@/hooks/useTVBackPress";
import { useTVEventHandler } from "@/hooks/useTVEventHandler"; import { useTVEventHandler } from "@/hooks/useTVEventHandler";
import i18n from "@/i18n";
interface UseRemoteControlProps { interface UseRemoteControlProps {
showControls: boolean; showControls: boolean;
@@ -124,17 +125,23 @@ export function useRemoteControl({
// Controls are hidden, so confirm before leaving playback. // Controls are hidden, so confirm before leaving playback.
Alert.alert( Alert.alert(
"Stop Playback", i18n.t("player.stopPlayback"),
videoTitleRef.current videoTitleRef.current
? `Stop playing "${videoTitleRef.current}"?` ? i18n.t("player.stopPlayingTitle", {
: "Are you sure you want to stop playback?", title: videoTitleRef.current,
})
: i18n.t("player.stopPlayingConfirm"),
[ [
{ {
text: "Cancel", text: i18n.t("common.cancel"),
style: "cancel", style: "cancel",
onPress: () => onCancelExitRef.current?.(), onPress: () => onCancelExitRef.current?.(),
}, },
{ text: "Stop", style: "destructive", onPress: onBackRef.current }, {
text: i18n.t("common.stop"),
style: "destructive",
onPress: onBackRef.current,
},
], ],
); );
return true; return true;

View File

@@ -1,13 +1,19 @@
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react"; import { NavigationContext } from "expo-router/react-navigation";
import { useCallback, useContext, useMemo } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
/** /**
* Drop-in replacement for expo-router's useRouter that automatically * Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation. * preserves offline state across navigation and guards against duplicate
* screens from rapid taps.
* *
* - For object-form navigation, automatically adds offline=true when in offline context * - For object-form navigation, automatically adds offline=true when in offline context
* - For string URLs, passes through unchanged (caller handles offline param) * - For string URLs, passes through unchanged (caller handles offline param)
* - push() is a no-op while the source screen is not focused, so taps fired
* before the pushed screen has rendered (slow devices) can't stack duplicates
* *
* @example * @example
* import useRouter from "@/hooks/useAppRouter"; * import useRouter from "@/hooks/useAppRouter";
@@ -19,8 +25,18 @@ export function useAppRouter() {
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
// Optional: undefined when used outside a navigator (root layout, providers).
// When present it reflects the focus state of the screen this hook lives in.
const navigation = useContext(NavigationContext);
const push = useCallback( const push = useCallback(
(href: Parameters<typeof router.push>[0]) => { (href: Parameters<typeof router.push>[0]) => {
// Rapid-push guard: a push blurs the source screen synchronously in the
// navigation state (only the native render is slow). Any further push from
// this screen — duplicate or not — is dropped until focus returns, so taps
// fired before the pushed screen renders can't stack screens.
// No navigation context => nothing to guard (deep-link pushes from root).
if (navigation?.isFocused?.() === false) return;
if (typeof href === "string") { if (typeof href === "string") {
router.push(href as any); router.push(href as any);
} else { } else {
@@ -36,7 +52,7 @@ export function useAppRouter() {
} as any); } as any);
} }
}, },
[router, isOffline], [router, isOffline, navigation],
); );
const replace = useCallback( const replace = useCallback(

108
hooks/useFilterReset.ts Normal file
View File

@@ -0,0 +1,108 @@
import { useAtom } from "jotai";
import { useCallback } from "react";
import {
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
genrePreferenceAtom,
SortByOption,
SortOrderOption,
sortByAtom,
sortByPreferenceAtom,
sortOrderAtom,
sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters";
/**
* Single source of truth for the library filter bar's "reset" action and its
* visibility. The mobile ResetFiltersButton and the TV filter header both use
* this so they can't drift — sort/order used to be reset on neither path, so
* the reset (X) never reflected a changed sort.
*
* A reset clears the session filters AND the per-library in-memory preferences
* (sort, order, filterBy, genres, years, tags); otherwise the saved preference
* resurfaces when the library's mount effect re-applies it on the next entry.
*/
export const useFilterReset = (libraryId: string) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [filterBy, setFilterBy] = useAtom(filterByAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [, setSortOrderPreference] = useAtom(sortOrderPreferenceAtom);
const [, setFilterByPreference] = useAtom(FilterByPreferenceAtom);
const [, setGenrePreference] = useAtom(genrePreferenceAtom);
const [, setYearPreference] = useAtom(yearPreferenceAtom);
const [, setTagPreference] = useAtom(tagPreferenceAtom);
// SortName / Ascending is the baseline a library opens with (mount-effect
// fallback), so any other value counts as an active, resettable sort.
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0 ||
sortBy[0] !== SortByOption.SortName ||
sortOrder[0] !== SortOrderOption.Ascending;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
setFilterBy([]);
setSortBy([SortByOption.SortName]);
setSortOrder([SortOrderOption.Ascending]);
setSortByPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setSortOrderPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setFilterByPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setGenrePreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setYearPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setTagPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
}, [
libraryId,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
setFilterBy,
setSortBy,
setSortOrder,
setSortByPreference,
setSortOrderPreference,
setFilterByPreference,
setGenrePreference,
setYearPreference,
setTagPreference,
]);
return { hasActiveFilters, resetAllFilters };
};

View File

@@ -143,7 +143,7 @@ export class JellyseerrApi {
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") { if (data.version < "2.0.0") {
const error = t( const error = t(
"jellyseerr.toasts.jellyseer_does_not_meet_requirements", "jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
); );
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);

View File

@@ -54,6 +54,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.18", "expo-build-properties": "~56.0.18",
"expo-camera": "~56.0.8", "expo-camera": "~56.0.8",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.18", "expo-constants": "~56.0.18",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.20", "expo-dev-client": "~56.0.20",

View File

@@ -15,6 +15,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -91,6 +92,12 @@ export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser); export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null); export const wsAtom = atom<WebSocket | null>(null);
export const cacheVersionAtom = atom<number>(0); export const cacheVersionAtom = atom<number>(0);
// Set by a login flow that wants the account saved: the protection picker
// shows AFTER the session is authorized (the login screen unmounts on
// success, so the modal lives at the root — see PendingAccountSaveModal).
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
null,
);
interface LoginOptions { interface LoginOptions {
saveAccount?: boolean; saveAccount?: boolean;
@@ -108,6 +115,11 @@ interface JellyfinContextValue {
serverName?: string, serverName?: string,
options?: LoginOptions, options?: LoginOptions,
) => Promise<void>; ) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>; initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void; stopQuickConnectPolling: () => void;
@@ -165,6 +177,46 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// --- Session-expiry handling ----------------------------------------------
// When the server revokes the token (e.g. the device/session is deleted), a
// 401 can surface from any authenticated request. Without central handling
// the dead token stays in storage, so every reload re-fires authed calls →
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
// state. A single response interceptor on the authenticated api clears the
// session on the first 401 so the app drops cleanly to the login screen.
const sessionExpiredRef = useRef(false);
const handleSessionExpired = useCallback(() => {
if (sessionExpiredRef.current) return; // run once per session
sessionExpiredRef.current = true;
storage.remove("token");
storage.remove("user");
setUser(null);
setApi(null);
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Saved credentials are kept so the user can quick-login again.
}, [setUser, setApi, queryClient]);
useEffect(() => {
// Only guard an authenticated session. A pre-auth api (login screen) keeps
// its own handling — a wrong-password 401 is not a session expiry.
if (!api?.accessToken) return;
sessionExpiredRef.current = false; // re-arm for this fresh session
const interceptorId = api.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
handleSessionExpired();
}
return Promise.reject(error);
},
);
return () => {
api.axiosInstance.interceptors.response.eject(interceptorId);
};
}, [api, handleSessionExpired]);
const headers = useMemo(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
@@ -307,6 +359,37 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, },
}); });
// Persist the CURRENT session to secure storage — used by the post-login
// save-account modal (the protection picker shows AFTER a successful
// login, for both the password and Quick Connect flows).
const saveCurrentAccount = useCallback(
async (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => {
const token = storage.getString("token");
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
const securityType = options?.securityType || "none";
let pinHash: string | undefined;
if (securityType === "pin" && options?.pinCode) {
pinHash = await hashPIN(options.pinCode);
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: options?.serverName || "",
token,
userId: user.Id,
username: user.Name,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
},
[api?.basePath, user],
);
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
username, username,
@@ -509,7 +592,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
} }
}, },
onError: (error) => { onError: (error) => {
console.error("Quick login failed:", error); // Expected, handled case (e.g. revoked token → "Session Expired", or
// server unreachable): the UI surfaces the message, so warn, don't error.
console.warn("Quick login failed:", error);
}, },
}); });
@@ -620,54 +705,62 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser); setUser(storedUser);
} }
// Dismiss splash screen with cached data immediately, // Validate the token and refresh user data in the background. Do NOT
// fetch fresh user data in the background // await this: the Jellyfin SDK axios instance has no timeout, so when
setInitialLoaded(true); // offline this call hangs for the full OS TCP timeout (75-120s) and
// blocks splash dismissal. The cached storedUser (set above) is enough
// to render; on success we just refresh it.
getUserApi(apiInstance)
.getCurrentUser()
.then(async (response) => {
setUser(response.data);
try { // Migrate current session to secure storage if not already saved
const response = await getUserApi(apiInstance).getCurrentUser(); if (storedUser?.Id && storedUser?.Name) {
setUser(response.data); const existingCredential = await getAccountCredential(
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl, serverUrl,
serverName: "", storedUser.Id,
token, );
userId: storedUser.Id, if (!existingCredential) {
username: storedUser.Name, await saveAccountCredential({
savedAt: Date.now(), serverUrl,
securityType: "none", serverName: "",
primaryImageTag: response.data.PrimaryImageTag ?? undefined, token,
}); userId: storedUser.Id,
} else if ( username: storedUser.Name,
response.data.PrimaryImageTag !== savedAt: Date.now(),
existingCredential.primaryImageTag securityType: "none",
) { primaryImageTag: response.data.PrimaryImageTag ?? undefined,
// Update image tag if it has changed });
addAccountToServer(serverUrl, existingCredential.serverName, { } else if (
userId: existingCredential.userId, response.data.PrimaryImageTag !==
username: existingCredential.username, existingCredential.primaryImageTag
securityType: existingCredential.securityType, ) {
savedAt: existingCredential.savedAt, // Update image tag if it has changed
primaryImageTag: response.data.PrimaryImageTag ?? undefined, addAccountToServer(serverUrl, existingCredential.serverName, {
}); userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
} }
} })
} catch (e) { .catch((e) => {
// Background fetch failed — app already rendered with cached data // Expected, handled case (offline, or a token the server rejects —
console.warn("Background user fetch failed, using cached data:", e); // the UI prompts re-login): warn, don't error. Log only
} // status/message — never the raw error (axios errors carry the
} else { // request config incl. the Authorization header / token).
setInitialLoaded(true); console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
);
});
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
setInitialLoaded(true); setInitialLoaded(true);
} }
}; };
@@ -681,6 +774,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(), removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) => login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }), loginMutation.mutateAsync({ username, password, serverName, options }),
saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(), logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect, initiateQuickConnect,
stopQuickConnectPolling, stopQuickConnectPolling,

View File

@@ -16,14 +16,14 @@
"got_it": "حسنًا", "got_it": "حسنًا",
"connection_failed": "فشل الاتصال", "connection_failed": "فشل الاتصال",
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.", "could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
"an_unexpected_error_occurred": "حدث خطأ غير متوقع", "an_unexpected_error_occured": "حدث خطأ غير متوقع",
"change_server": "تغيير الخادم", "change_server": "تغيير الخادم",
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة", "invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صَلاحِيَة تسجيل الدخول", "user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صَلاحِيَة تسجيل الدخول",
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا", "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": "هناك خطأ في الخادم", "there_is_a_server_error": "هناك خطأ في الخادم",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟", "an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
"too_old_server_text": "تم اكتشاف خادم Jellyfin غير مدعوم", "too_old_server_text": "تم اكتشاف خادم Jellyfin غير مدعوم",
"too_old_server_description": "يرجى تحديث Jellyfin إلى أحدث إصدار" "too_old_server_description": "يرجى تحديث Jellyfin إلى أحدث إصدار"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "تفويض الدخول السريع", "authorize_button": "تفويض الدخول السريع",
"enter_the_quick_connect_code": "أدخل رمز الدخول السريع...", "enter_the_quick_connect_code": "أدخل رمز الدخول السريع...",
"success": "نجاح", "success": "نجاح",
"quick_connect_authorized": "تم تفويض الدخول السريع", "quick_connect_autorized": "تم تفويض الدخول السريع",
"error": "خطأ", "error": "خطأ",
"invalid_code": "رمز غير صالح", "invalid_code": "رمز غير صالح",
"authorize": "تفويض" "authorize": "تفويض"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "إظهار روابط القائمة المخصصة", "show_custom_menu_links": "إظهار روابط القائمة المخصصة",
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)", "show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
"hide_libraries": "إخفاء المكتبات", "hide_libraries": "إخفاء المكتبات",
"select_libraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.", "select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية", "disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية", "default_quality": "الجودة الافتراضية",
"default_playback_speed": "سرعة التشغيل الافتراضية", "default_playback_speed": "سرعة التشغيل الافتراضية",
@@ -384,8 +384,6 @@
"device_usage": "الجهاز {{availableSpace}}%", "device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}", "size_used": "تم استخدام {{used}} من {{total}}",
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها", "delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "التخزين المؤقت للموسيقى", "music_cache_title": "التخزين المؤقت للموسيقى",
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال", "music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
"clear_music_cache": "مسح التخزين المؤقت للموسيقى", "clear_music_cache": "مسح التخزين المؤقت للموسيقى",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر", "no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو", "video": "فيديو",
"audio": "الصوت", "audio": "الصوت",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "خطأ", "error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث", "failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occurred_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.", "an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
"client_error": "خطأ في المشغّل", "client_error": "خطأ في المشغّل",
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast", "could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
"message_from_server": "رسالة من الخادم: {{message}}", "message_from_server": "رسالة من الخادم: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Entesos", "got_it": "Entesos",
"connection_failed": "Ha fallat la connexió", "connection_failed": "Ha fallat la connexió",
"could_not_connect_to_server": "No s'ha pogut connectar amb el servidor. Comproveu l'URL i la connexió de xarxa.", "could_not_connect_to_server": "No s'ha pogut connectar amb el servidor. Comproveu l'URL i la connexió de xarxa.",
"an_unexpected_error_occurred": "S'ha produït un error inesperat", "an_unexpected_error_occured": "S'ha produït un error inesperat",
"change_server": "Canvia el servidor", "change_server": "Canvia el servidor",
"invalid_username_or_password": "Nom d'usuari o contrasenya incorrectes", "invalid_username_or_password": "Nom d'usuari o contrasenya incorrectes",
"user_does_not_have_permission_to_log_in": "L'usuari no té permís per iniciar sessió", "user_does_not_have_permission_to_log_in": "L'usuari no té permís per iniciar sessió",
"server_is_taking_too_long_to_respond_try_again_later": "El servidor triga massa a respondre, torneu-ho a provar més tard", "server_is_taking_too_long_to_respond_try_again_later": "El servidor triga massa a respondre, torneu-ho a provar més tard",
"server_received_too_many_requests_try_again_later": "El servidor ha rebut massa sol·licituds, torneu-ho a provar més tard.", "server_received_too_many_requests_try_again_later": "El servidor ha rebut massa sol·licituds, torneu-ho a provar més tard.",
"there_is_a_server_error": "Error del servidor", "there_is_a_server_error": "Error del servidor",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered", "too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version" "too_old_server_description": "Please update Jellyfin to the latest version"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autoritza connexió ràpida", "authorize_button": "Autoritza connexió ràpida",
"enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...", "enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...",
"success": "Èxit", "success": "Èxit",
"quick_connect_authorized": "Connexió ràpida autoritzada", "quick_connect_autorized": "Connexió ràpida autoritzada",
"error": "Error", "error": "Error",
"invalid_code": "Codi invàlid", "invalid_code": "Codi invàlid",
"authorize": "Autoritza" "authorize": "Autoritza"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats", "show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
"show_large_home_carousel": "Show Large Home Carousel (beta)", "show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Oculta biblioteques", "hide_libraries": "Oculta biblioteques",
"select_libraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.", "select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
"disable_haptic_feedback": "Desactiva la resposta hàptica", "disable_haptic_feedback": "Desactiva la resposta hàptica",
"default_quality": "Qualitat per defecte", "default_quality": "Qualitat per defecte",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Dispositiu {{availableSpace}}%", "device_usage": "Dispositiu {{availableSpace}}%",
"size_used": "{{used}} de {{total}} utilitzat", "size_used": "{{used}} de {{total}} utilitzat",
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats", "delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Vídeo", "video": "Vídeo",
"audio": "Àudio", "audio": "Àudio",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux", "failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
"an_error_occurred_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.", "an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
"client_error": "Error del client", "client_error": "Error del client",
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast", "could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
"message_from_server": "Missatge del servidor: {{message}}", "message_from_server": "Missatge del servidor: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Rozumím", "got_it": "Rozumím",
"connection_failed": "Připojení se nezdařilo", "connection_failed": "Připojení se nezdařilo",
"could_not_connect_to_server": "Nelze se připojit k serveru. Zkontrolujte adresu URL a síťové připojení.", "could_not_connect_to_server": "Nelze se připojit k serveru. Zkontrolujte adresu URL a síťové připojení.",
"an_unexpected_error_occurred": "Došlo k neočekávané chybě", "an_unexpected_error_occured": "Došlo k neočekávané chybě",
"change_server": "Změnit server", "change_server": "Změnit server",
"invalid_username_or_password": "Neplatné uživatelské jméno nebo heslo", "invalid_username_or_password": "Neplatné uživatelské jméno nebo heslo",
"user_does_not_have_permission_to_log_in": "Uživatel nemá oprávnění k přihlášení", "user_does_not_have_permission_to_log_in": "Uživatel nemá oprávnění k přihlášení",
"server_is_taking_too_long_to_respond_try_again_later": "Reakce na server trvá příliš dlouho, zkuste to znovu později", "server_is_taking_too_long_to_respond_try_again_later": "Reakce na server trvá příliš dlouho, zkuste to znovu později",
"server_received_too_many_requests_try_again_later": "Server obdržel příliš mnoho požadavků, opakujte akci později.", "server_received_too_many_requests_try_again_later": "Server obdržel příliš mnoho požadavků, opakujte akci později.",
"there_is_a_server_error": "Došlo k chybě serveru", "there_is_a_server_error": "Došlo k chybě serveru",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Došlo k neočekávané chybě. Vložili jste adresu URL serveru?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Došlo k neočekávané chybě. Vložili jste adresu URL serveru?",
"too_old_server_text": "Objeven nepodporovaný Jellyfin server", "too_old_server_text": "Objeven nepodporovaný Jellyfin server",
"too_old_server_description": "Prosím aktualizujte Jellyfin na nejnovější verzi" "too_old_server_description": "Prosím aktualizujte Jellyfin na nejnovější verzi"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autorizovat rychlé připojení", "authorize_button": "Autorizovat rychlé připojení",
"enter_the_quick_connect_code": "Zadejte kód rychlého připojení...", "enter_the_quick_connect_code": "Zadejte kód rychlého připojení...",
"success": "Úspěšně", "success": "Úspěšně",
"quick_connect_authorized": "Oprávněné Rychlé připojení", "quick_connect_autorized": "Oprávněné Rychlé připojení",
"error": "Chyba", "error": "Chyba",
"invalid_code": "Neplatný kód", "invalid_code": "Neplatný kód",
"authorize": "Autorizovat" "authorize": "Autorizovat"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy", "show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
"show_large_home_carousel": "Zobrazit velký přehled (beta)", "show_large_home_carousel": "Zobrazit velký přehled (beta)",
"hide_libraries": "Skrýt knihovny", "hide_libraries": "Skrýt knihovny",
"select_libraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.", "select_liraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
"disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu", "disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu",
"default_quality": "Výchozí kvalita", "default_quality": "Výchozí kvalita",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Zařízení {{availableSpace}}%", "device_usage": "Zařízení {{availableSpace}}%",
"size_used": "{{used}} z {{total}} využito", "size_used": "{{used}} z {{total}} využito",
"delete_all_downloaded_files": "Odstranit všechny stažené soubory", "delete_all_downloaded_files": "Odstranit všechny stažené soubory",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici", "no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video", "video": "Video",
"audio": "Zvuk", "audio": "Zvuk",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Chyba", "error": "Chyba",
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu", "failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
"an_error_occurred_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.", "an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
"client_error": "Chyba klienta", "client_error": "Chyba klienta",
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast", "could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
"message_from_server": "Zpráva od serveru: {{message}}", "message_from_server": "Zpráva od serveru: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Forstået", "got_it": "Forstået",
"connection_failed": "Forbindelsen mislykkedes", "connection_failed": "Forbindelsen mislykkedes",
"could_not_connect_to_server": "Kunne ikke oprette forbindelse til serveren. Tjek venligst URL'en og din netværksforbindelse.", "could_not_connect_to_server": "Kunne ikke oprette forbindelse til serveren. Tjek venligst URL'en og din netværksforbindelse.",
"an_unexpected_error_occurred": "Der opstod en uventet fejl", "an_unexpected_error_occured": "Der opstod en uventet fejl",
"change_server": "Skift server", "change_server": "Skift server",
"invalid_username_or_password": "Ugyldigt brugernavn eller adgangskode", "invalid_username_or_password": "Ugyldigt brugernavn eller adgangskode",
"user_does_not_have_permission_to_log_in": "Brugeren har ikke tilladelse til at logge ind", "user_does_not_have_permission_to_log_in": "Brugeren har ikke tilladelse til at logge ind",
"server_is_taking_too_long_to_respond_try_again_later": "Serveren svarer for langsomt, prøv igen senere", "server_is_taking_too_long_to_respond_try_again_later": "Serveren svarer for langsomt, prøv igen senere",
"server_received_too_many_requests_try_again_later": "Serveren modtog for mange forespørgsler, prøv igen senere.", "server_received_too_many_requests_try_again_later": "Serveren modtog for mange forespørgsler, prøv igen senere.",
"there_is_a_server_error": "Der er en serverfejl", "there_is_a_server_error": "Der er en serverfejl",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Der opstod en uventet fejl. Har du indtastet serverens URL korrekt?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Der opstod en uventet fejl. Har du indtastet serverens URL korrekt?",
"too_old_server_text": "Ikke Understøttet Jellyfin Server Opdaget", "too_old_server_text": "Ikke Understøttet Jellyfin Server Opdaget",
"too_old_server_description": "Opdater venligst Jellyfin til den seneste version" "too_old_server_description": "Opdater venligst Jellyfin til den seneste version"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autoriser Hurtigforbindelse", "authorize_button": "Autoriser Hurtigforbindelse",
"enter_the_quick_connect_code": "Indtast koden til hurtigforbindelse...", "enter_the_quick_connect_code": "Indtast koden til hurtigforbindelse...",
"success": "Succes", "success": "Succes",
"quick_connect_authorized": "Hurtigforbindelse autoriseret", "quick_connect_autorized": "Hurtigforbindelse autoriseret",
"error": "Fejl", "error": "Fejl",
"invalid_code": "Ugyldig kode", "invalid_code": "Ugyldig kode",
"authorize": "Autoriser" "authorize": "Autoriser"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Vis tilpassede menulinks", "show_custom_menu_links": "Vis tilpassede menulinks",
"show_large_home_carousel": "Show Large Home Carousel (beta)", "show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Skjul biblioteker", "hide_libraries": "Skjul biblioteker",
"select_libraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.", "select_liraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
"disable_haptic_feedback": "Deaktiver haptisk feedback", "disable_haptic_feedback": "Deaktiver haptisk feedback",
"default_quality": "Standard kvalitet", "default_quality": "Standard kvalitet",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Enhedsforbrug: {{availableSpace}}%", "device_usage": "Enhedsforbrug: {{availableSpace}}%",
"size_used": "{{used}} af {{total}} brugt", "size_used": "{{used}} af {{total}} brugt",
"delete_all_downloaded_files": "Slet alle downloadede filer", "delete_all_downloaded_files": "Slet alle downloadede filer",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt", "no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video", "video": "Video",
"audio": "Lyd", "audio": "Lyd",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Fejl", "error": "Fejl",
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en", "failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
"an_error_occurred_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.", "an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
"client_error": "Klientfejl", "client_error": "Klientfejl",
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast", "could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
"message_from_server": "Besked fra server: {{message}}", "message_from_server": "Besked fra server: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Verstanden", "got_it": "Verstanden",
"connection_failed": "Verbindung fehlgeschlagen", "connection_failed": "Verbindung fehlgeschlagen",
"could_not_connect_to_server": "Verbindung zum Server fehlgeschlagen. Bitte überprüf die URL und deine Netzwerkverbindung.", "could_not_connect_to_server": "Verbindung zum Server fehlgeschlagen. Bitte überprüf die URL und deine Netzwerkverbindung.",
"an_unexpected_error_occurred": "Ein unerwarteter Fehler ist aufgetreten", "an_unexpected_error_occured": "Ein unerwarteter Fehler ist aufgetreten",
"change_server": "Server wechseln", "change_server": "Server wechseln",
"invalid_username_or_password": "Ungültiger Benutzername oder Passwort", "invalid_username_or_password": "Ungültiger Benutzername oder Passwort",
"user_does_not_have_permission_to_log_in": "Benutzer hat keine Berechtigung, um sich anzumelden", "user_does_not_have_permission_to_log_in": "Benutzer hat keine Berechtigung, um sich anzumelden",
"server_is_taking_too_long_to_respond_try_again_later": "Der Server benötigt zu lange, um zu antworten. Bitte versuch es später erneut", "server_is_taking_too_long_to_respond_try_again_later": "Der Server benötigt zu lange, um zu antworten. Bitte versuch es später erneut",
"server_received_too_many_requests_try_again_later": "Der Server hat zu viele Anfragen erhalten. Bitte versuch es später erneut.", "server_received_too_many_requests_try_again_later": "Der Server hat zu viele Anfragen erhalten. Bitte versuch es später erneut.",
"there_is_a_server_error": "Es gibt einen Serverfehler", "there_is_a_server_error": "Es gibt einen Serverfehler",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?",
"too_old_server_text": "Nicht unterstützter Jellyfin Server entdeckt", "too_old_server_text": "Nicht unterstützter Jellyfin Server entdeckt",
"too_old_server_description": "Bitte aktualisiere Jellyfin auf die neueste Version" "too_old_server_description": "Bitte aktualisiere Jellyfin auf die neueste Version"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Quick Connect autorisieren", "authorize_button": "Quick Connect autorisieren",
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...", "enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
"success": "Erfolgreich verbunden", "success": "Erfolgreich verbunden",
"quick_connect_authorized": "Quick Connect autorisiert", "quick_connect_autorized": "Quick Connect autorisiert",
"error": "Fehler", "error": "Fehler",
"invalid_code": "Ungültiger Code", "invalid_code": "Ungültiger Code",
"authorize": "Autorisieren" "authorize": "Autorisieren"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen", "show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)", "show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
"hide_libraries": "Bibliotheken ausblenden", "hide_libraries": "Bibliotheken ausblenden",
"select_libraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.", "select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
"disable_haptic_feedback": "Haptisches Feedback deaktivieren", "disable_haptic_feedback": "Haptisches Feedback deaktivieren",
"default_quality": "Standardqualität", "default_quality": "Standardqualität",
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit", "default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
@@ -384,8 +384,6 @@
"device_usage": "Gerät {{availableSpace}}%", "device_usage": "Gerät {{availableSpace}}%",
"size_used": "{{used}} von {{total}} genutzt", "size_used": "{{used}} von {{total}} genutzt",
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen", "delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Musik-Cache", "music_cache_title": "Musik-Cache",
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen", "music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
"clear_music_cache": "Musik-Cache leeren", "clear_music_cache": "Musik-Cache leeren",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar", "no_trailer_available": "Kein Trailer verfügbar",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Fehler", "error": "Fehler",
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL", "failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
"an_error_occurred_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.", "an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
"client_error": "Client-Fehler", "client_error": "Client-Fehler",
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen", "could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
"message_from_server": "Nachricht vom Server: {{message}}", "message_from_server": "Nachricht vom Server: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Sprache", "language": "Sprache",
"results": "Ergebnisse", "results": "Ergebnisse",
"searching": "Suche ...",
"search_failed": "Suche fehlgeschlagen", "search_failed": "Suche fehlgeschlagen",
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert", "no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
"no_subtitles_found": "Keine Untertitel gefunden", "no_subtitles_found": "Keine Untertitel gefunden",

View File

@@ -16,14 +16,14 @@
"got_it": "Το Κατάλαβα", "got_it": "Το Κατάλαβα",
"connection_failed": "Η Σύνδεση Απέτυχε", "connection_failed": "Η Σύνδεση Απέτυχε",
"could_not_connect_to_server": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Παρακαλώ ελέγξτε τη διεύθυνση URL και τη σύνδεση δικτύου σας.", "could_not_connect_to_server": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Παρακαλώ ελέγξτε τη διεύθυνση URL και τη σύνδεση δικτύου σας.",
"an_unexpected_error_occurred": "Παρουσιάστηκε Απροσδόκητο Σφάλμα", "an_unexpected_error_occured": "Παρουσιάστηκε Απροσδόκητο Σφάλμα",
"change_server": "Αλλαγή Διακομιστή", "change_server": "Αλλαγή Διακομιστή",
"invalid_username_or_password": "Μη έγκυρο όνομα χρήστη ή κωδικός πρόσβασης", "invalid_username_or_password": "Μη έγκυρο όνομα χρήστη ή κωδικός πρόσβασης",
"user_does_not_have_permission_to_log_in": "Ο χρήστης δεν έχει άδεια να συνδεθεί", "user_does_not_have_permission_to_log_in": "Ο χρήστης δεν έχει άδεια να συνδεθεί",
"server_is_taking_too_long_to_respond_try_again_later": "Ο διακομιστής καθυστερεί πάρα πολύ για να απαντήσει, δοκιμάστε ξανά αργότερα", "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": "Υπάρχει ένα σφάλμα διακομιστή", "there_is_a_server_error": "Υπάρχει ένα σφάλμα διακομιστή",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Παρουσιάστηκε μη αναμενόμενο σφάλμα. Εισαγάγετε σωστά το URL του διακομιστή?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Παρουσιάστηκε μη αναμενόμενο σφάλμα. Εισαγάγετε σωστά το URL του διακομιστή?",
"too_old_server_text": "Ανακαλύφθηκε Μη Υποστηριζόμενος Εξυπηρετητής Jellyfin", "too_old_server_text": "Ανακαλύφθηκε Μη Υποστηριζόμενος Εξυπηρετητής Jellyfin",
"too_old_server_description": "Παρακαλούμε ενημερώστε το Jellyfin στην τελευταία έκδοση" "too_old_server_description": "Παρακαλούμε ενημερώστε το Jellyfin στην τελευταία έκδοση"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Εξουσιοδότηση Γρήγορης Σύνδεσης", "authorize_button": "Εξουσιοδότηση Γρήγορης Σύνδεσης",
"enter_the_quick_connect_code": "Εισάγετε τον κωδικό γρήγορης σύνδεσης...", "enter_the_quick_connect_code": "Εισάγετε τον κωδικό γρήγορης σύνδεσης...",
"success": "Επιτυχία", "success": "Επιτυχία",
"quick_connect_authorized": "Γρήγορη Σύνδεση Εξουσιοδοτήθηκε", "quick_connect_autorized": "Γρήγορη Σύνδεση Εξουσιοδοτήθηκε",
"error": "Σφάλμα", "error": "Σφάλμα",
"invalid_code": "Μη Έγκυρος Κωδικός", "invalid_code": "Μη Έγκυρος Κωδικός",
"authorize": "Εξουσιοδότηση" "authorize": "Εξουσιοδότηση"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού", "show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
"show_large_home_carousel": "Show Large Home Carousel (beta)", "show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Απόκρυψη Βιβλιοθηκών", "hide_libraries": "Απόκρυψη Βιβλιοθηκών",
"select_libraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.", "select_liraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
"disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης", "disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης",
"default_quality": "Προεπιλεγμένη Ποιότητα", "default_quality": "Προεπιλεγμένη Ποιότητα",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Device {{availableSpace}}%", "device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} {{total}} Χρησιμοποιείται", "size_used": "{{used}} {{total}} Χρησιμοποιείται",
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων", "delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο", "no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο", "video": "Βίντεο",
"audio": "Ήχος", "audio": "Ήχος",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Σφάλμα", "error": "Σφάλμα",
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής", "failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
"an_error_occurred_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.", "an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
"client_error": "Σφάλμα Πελάτη", "client_error": "Σφάλμα Πελάτη",
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast", "could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}", "message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -12,6 +12,9 @@
"login_button": "Log in", "login_button": "Log in",
"quick_connect": "Quick Connect", "quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to log in", "enter_code_to_login": "Enter code {{code}} to log in",
"quick_connect_instructions": "Enter this code on a signed-in device — you'll be logged in automatically.",
"tap_code_to_copy": "Tap the code to copy it",
"code_copied": "Code copied",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect", "failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got it", "got_it": "Got it",
"connection_failed": "Connection failed", "connection_failed": "Connection failed",
@@ -33,6 +36,7 @@
"connect_button": "Connect", "connect_button": "Connect",
"previous_servers": "Previous servers", "previous_servers": "Previous servers",
"clear_button": "Clear all", "clear_button": "Clear all",
"server_url": "Server URL",
"swipe_to_remove": "Swipe to remove", "swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for local servers", "search_for_local_servers": "Search for local servers",
"searching": "Searching...", "searching": "Searching...",
@@ -137,7 +141,11 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue watching & Next up", "merge_next_up_continue_watching": "Merge Continue watching & Next up",
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
"hide_remote_session_button": "Hide remote session button", "hide_remote_session_button": "Hide remote session button",
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
"show_home_backdrop": "Dynamic home backdrop", "show_home_backdrop": "Dynamic home backdrop",
"show_hero_carousel": "Hero carousel", "show_hero_carousel": "Hero carousel",
"show_series_poster_on_episode": "Show series poster on episodes", "show_series_poster_on_episode": "Show series poster on episodes",
@@ -191,7 +199,8 @@
"quick_connect_authorized": "Quick Connect authorized", "quick_connect_authorized": "Quick Connect authorized",
"error": "Error", "error": "Error",
"invalid_code": "Invalid code", "invalid_code": "Invalid code",
"authorize": "Authorize" "authorize": "Authorize",
"paste_code": "Paste code"
}, },
"media_controls": { "media_controls": {
"media_controls_title": "Media controls", "media_controls_title": "Media controls",
@@ -270,6 +279,10 @@
"mpv_subtitle_margin_y": "Vertical margin", "mpv_subtitle_margin_y": "Vertical margin",
"mpv_subtitle_align_x": "Horizontal align", "mpv_subtitle_align_x": "Horizontal align",
"mpv_subtitle_align_y": "Vertical align", "mpv_subtitle_align_y": "Vertical align",
"mpv_settings_title": "MPV Subtitle Settings",
"mpv_settings_description": "Advanced subtitle customization for MPV player",
"opaque_background": "Opaque Background",
"background_opacity": "Background Opacity",
"align": { "align": {
"left": "Left", "left": "Left",
"center": "Center", "center": "Center",
@@ -296,6 +309,7 @@
}, },
"safe_area_in_controls": "Safe area in controls", "safe_area_in_controls": "Safe area in controls",
"show_custom_menu_links": "Show custom menu links", "show_custom_menu_links": "Show custom menu links",
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
"show_large_home_carousel": "Show large home carousel (beta)", "show_large_home_carousel": "Show large home carousel (beta)",
"hide_libraries": "Hide libraries", "hide_libraries": "Hide libraries",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
@@ -410,7 +424,9 @@
"click_for_more_info": "Click for more info", "click_for_more_info": "Click for more info",
"level": "Level", "level": "Level",
"no_logs_available": "No logs available", "no_logs_available": "No logs available",
"delete_all_logs": "Delete all logs" "delete_all_logs": "Delete all logs",
"copy": "Copy",
"copied": "Copied to clipboard"
}, },
"languages": { "languages": {
"title": "Languages", "title": "Languages",
@@ -437,10 +453,13 @@
}, },
"sessions": { "sessions": {
"title": "Sessions", "title": "Sessions",
"no_active_sessions": "No active sessions" "no_active_sessions": "No active sessions",
"select_session": "Select Session",
"now_playing": "Now playing:"
}, },
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads",
"transcoding": "Transcoding",
"series": "Series", "series": "Series",
"movies": "Movies", "movies": "Movies",
"other_media": "Other media", "other_media": "Other media",
@@ -496,6 +515,8 @@
"none": "None", "none": "None",
"track": "Track", "track": "Track",
"cancel": "Cancel", "cancel": "Cancel",
"stop": "Stop",
"open_menu": "Open Menu",
"delete": "Delete", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Remove", "remove": "Remove",
@@ -597,7 +618,31 @@
}, },
"player": { "player": {
"live": "LIVE", "live": "LIVE",
"menu": {
"quality": "Quality",
"subtitles": "Subtitles",
"subtitle_scale": "Subtitle Scale",
"audio": "Audio",
"speed": "Speed",
"playback_options": "Playback Options",
"show_technical_info": "Show Technical Info",
"hide_technical_info": "Hide Technical Info"
},
"technical_info": {
"video": "Video:",
"audio": "Audio:",
"subtitle": "Subtitle:",
"bitrate": "Bitrate:",
"buffer_seconds": "Buffer: {{seconds}}s",
"vo": "VO:",
"dropped_frames": "Dropped: {{count}} frames",
"loading": "Loading..."
},
"mpv_player_title": "MPV player", "mpv_player_title": "MPV player",
"aspect_ratio": "Aspect Ratio",
"aspect_ratio_original": "Original",
"hash_match": "Hash Match",
"still_watching": "Are you still watching?",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
@@ -697,6 +742,7 @@
"no_data_available": "No data available" "no_data_available": "No data available"
}, },
"live_tv": { "live_tv": {
"title": "Live TV",
"next": "Next", "next": "Next",
"previous": "Previous", "previous": "Previous",
"coming_soon": "Coming soon", "coming_soon": "Coming soon",
@@ -768,7 +814,7 @@
"request_selected": "Request selected", "request_selected": "Request selected",
"n_selected": "{{count}} selected", "n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.", "jellyseerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url", "failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
"issue_submitted": "Issue submitted!", "issue_submitted": "Issue submitted!",
@@ -781,6 +827,16 @@
"failed_to_decline_request": "Failed to decline request" "failed_to_decline_request": "Failed to decline request"
} }
}, },
"accessibility": {
"play_button": "Play button",
"play_hint": "Tap to play the media",
"toggle_orientation": "Toggle screen orientation",
"toggle_orientation_hint": "Toggles the screen orientation between portrait and landscape"
},
"not_found": {
"title": "This screen doesn't exist.",
"go_home": "Go to home screen!"
},
"tabs": { "tabs": {
"home": "Home", "home": "Home",
"search": "Search", "search": "Search",
@@ -791,6 +847,12 @@
}, },
"music": { "music": {
"title": "Music", "title": "Music",
"no_track_playing": "No track playing",
"queue_empty": "Queue is empty",
"playing_from_queue": "Playing from queue",
"up_next": "Up next",
"now_playing": "Now Playing",
"missing_library_id": "Missing music library id.",
"tabs": { "tabs": {
"suggestions": "Suggestions", "suggestions": "Suggestions",
"albums": "Albums", "albums": "Albums",

View File

@@ -16,14 +16,14 @@
"got_it": "Entendido", "got_it": "Entendido",
"connection_failed": "Conexión fallida", "connection_failed": "Conexión fallida",
"could_not_connect_to_server": "No se pudo conectar al servidor. Por favor comprueba la URL y tu conexión de red.", "could_not_connect_to_server": "No se pudo conectar al servidor. Por favor comprueba la URL y tu conexión de red.",
"an_unexpected_error_occurred": "Ha ocurrido un error inesperado", "an_unexpected_error_occured": "Ha ocurrido un error inesperado",
"change_server": "Cambiar servidor", "change_server": "Cambiar servidor",
"invalid_username_or_password": "Usuario o contraseña inválidos", "invalid_username_or_password": "Usuario o contraseña inválidos",
"user_does_not_have_permission_to_log_in": "El usuario no tiene permiso para iniciar sesión", "user_does_not_have_permission_to_log_in": "El usuario no tiene permiso para iniciar sesión",
"server_is_taking_too_long_to_respond_try_again_later": "El servidor está tardando mucho en responder, inténtalo de nuevo más tarde.", "server_is_taking_too_long_to_respond_try_again_later": "El servidor está tardando mucho en responder, inténtalo de nuevo más tarde.",
"server_received_too_many_requests_try_again_later": "El servidor está recibiendo muchas peticiones, inténtalo de nuevo más tarde.", "server_received_too_many_requests_try_again_later": "El servidor está recibiendo muchas peticiones, inténtalo de nuevo más tarde.",
"there_is_a_server_error": "Hay un error en el servidor", "there_is_a_server_error": "Hay un error en el servidor",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?",
"too_old_server_text": "Servidor Jellyfin no soportado descubierto", "too_old_server_text": "Servidor Jellyfin no soportado descubierto",
"too_old_server_description": "Por favor, actualiza Jellyfin a la última versión" "too_old_server_description": "Por favor, actualiza Jellyfin a la última versión"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autorizar conexión rápida", "authorize_button": "Autorizar conexión rápida",
"enter_the_quick_connect_code": "Introduce el código de conexión rápida...", "enter_the_quick_connect_code": "Introduce el código de conexión rápida...",
"success": "Hecho", "success": "Hecho",
"quick_connect_authorized": "Conexión rápida autorizada", "quick_connect_autorized": "Conexión rápida autorizada",
"error": "Error", "error": "Error",
"invalid_code": "Código inválido", "invalid_code": "Código inválido",
"authorize": "Autorizar" "authorize": "Autorizar"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostrar enlaces de menú personalizados", "show_custom_menu_links": "Mostrar enlaces de menú personalizados",
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)", "show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
"hide_libraries": "Ocultar bibliotecas", "hide_libraries": "Ocultar bibliotecas",
"select_libraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.", "select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
"disable_haptic_feedback": "Desactivar feedback háptico", "disable_haptic_feedback": "Desactivar feedback háptico",
"default_quality": "Calidad por defecto", "default_quality": "Calidad por defecto",
"default_playback_speed": "Velocidad de reproducción predeterminada", "default_playback_speed": "Velocidad de reproducción predeterminada",
@@ -384,8 +384,6 @@
"device_usage": "Dispositivo {{availableSpace}}%", "device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} de {{total}} usado", "size_used": "{{used}} de {{total}} usado",
"delete_all_downloaded_files": "Eliminar todos los archivos descargados", "delete_all_downloaded_files": "Eliminar todos los archivos descargados",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Caché de música", "music_cache_title": "Caché de música",
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión", "music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
"clear_music_cache": "Borrar Caché de Música", "clear_music_cache": "Borrar Caché de Música",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible", "no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo", "video": "Vídeo",
"audio": "Audio", "audio": "Audio",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Error al obtener la URL del Steam", "failed_to_get_stream_url": "Error al obtener la URL del Steam",
"an_error_occurred_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.", "an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
"client_error": "Error del cliente", "client_error": "Error del cliente",
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast", "could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
"message_from_server": "Mensaje del servidor: {{message}}", "message_from_server": "Mensaje del servidor: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Okei", "got_it": "Okei",
"connection_failed": "Yhteys epäonnistui", "connection_failed": "Yhteys epäonnistui",
"could_not_connect_to_server": "Yhteyttä palvelimeen ei voitu muodostaa. Tarkista URL-osoite ja verkkoyhteytesi.", "could_not_connect_to_server": "Yhteyttä palvelimeen ei voitu muodostaa. Tarkista URL-osoite ja verkkoyhteytesi.",
"an_unexpected_error_occurred": "Odottamaton virhe tapahtui", "an_unexpected_error_occured": "Odottamaton virhe tapahtui",
"change_server": "Vaihda palvelinta", "change_server": "Vaihda palvelinta",
"invalid_username_or_password": "Virheellinen käyttäjätunnus tai salasana", "invalid_username_or_password": "Virheellinen käyttäjätunnus tai salasana",
"user_does_not_have_permission_to_log_in": "Käyttäjällä ei ole oikeuksia kirjautua sisään", "user_does_not_have_permission_to_log_in": "Käyttäjällä ei ole oikeuksia kirjautua sisään",
"server_is_taking_too_long_to_respond_try_again_later": "Palvelin reagoi liian hitaasti, yritä myöhemmin uudelleen", "server_is_taking_too_long_to_respond_try_again_later": "Palvelin reagoi liian hitaasti, yritä myöhemmin uudelleen",
"server_received_too_many_requests_try_again_later": "Palvelin sai liian monta pyyntöä, yritä myöhemmin uudelleen.", "server_received_too_many_requests_try_again_later": "Palvelin sai liian monta pyyntöä, yritä myöhemmin uudelleen.",
"there_is_a_server_error": "Palvelimessa on virhe", "there_is_a_server_error": "Palvelimessa on virhe",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Odottamaton virhe tapahtui. Syötitkö palvelimen URL-osoitteen oikein?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Odottamaton virhe tapahtui. Syötitkö palvelimen URL-osoitteen oikein?",
"too_old_server_text": "Ei-tuettu Jellyfin-palvelin löydetty", "too_old_server_text": "Ei-tuettu Jellyfin-palvelin löydetty",
"too_old_server_description": "Ole hyvä ja päivitä Jellyfin uusimpaan versioon" "too_old_server_description": "Ole hyvä ja päivitä Jellyfin uusimpaan versioon"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Valtuuta pikayhdistys", "authorize_button": "Valtuuta pikayhdistys",
"enter_the_quick_connect_code": "Syötä nopean yhteyden koodi...", "enter_the_quick_connect_code": "Syötä nopean yhteyden koodi...",
"success": "Onnistui", "success": "Onnistui",
"quick_connect_authorized": "Pikayhdistys valtuutettu", "quick_connect_autorized": "Pikayhdistys valtuutettu",
"error": "Virhe", "error": "Virhe",
"invalid_code": "Virheellinen koodi", "invalid_code": "Virheellinen koodi",
"authorize": "Valtuuta" "authorize": "Valtuuta"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Näytä mukautetut valikkolinkit", "show_custom_menu_links": "Näytä mukautetut valikkolinkit",
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)", "show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
"hide_libraries": "Piilota kirjastot", "hide_libraries": "Piilota kirjastot",
"select_libraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.", "select_liraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
"disable_haptic_feedback": "Poista haptinen palautteet käytöstä", "disable_haptic_feedback": "Poista haptinen palautteet käytöstä",
"default_quality": "Oletuslaatu", "default_quality": "Oletuslaatu",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Laitteen käyttö {{availableSpace}}%", "device_usage": "Laitteen käyttö {{availableSpace}}%",
"size_used": "{{used}} / {{total}} käytössä", "size_used": "{{used}} / {{total}} käytössä",
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot", "delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla", "no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video", "video": "Video",
"audio": "Ääni", "audio": "Ääni",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Virhe", "error": "Virhe",
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui", "failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
"an_error_occurred_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.", "an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
"client_error": "Asiakkaan Virhe", "client_error": "Asiakkaan Virhe",
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten", "could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
"message_from_server": "Viesti palvelimelta: {{message}}", "message_from_server": "Viesti palvelimelta: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -4,9 +4,9 @@
"error_title": "Erreur", "error_title": "Erreur",
"login_title": "Se connecter", "login_title": "Se connecter",
"login_to_title": "Se connecter à", "login_to_title": "Se connecter à",
"select_user": "Sélectionnez un utilisateur pour vous connecter", "select_user": "Select a user to log in",
"add_user_to_login": "Ajouter un utilisateur pour se connecter", "add_user_to_login": "Add a user to log in",
"add_user": "Ajouter un utilisateur", "add_user": "Add User",
"username_placeholder": "Nom d'utilisateur", "username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe", "password_placeholder": "Mot de passe",
"login_button": "Se connecter", "login_button": "Se connecter",
@@ -16,14 +16,14 @@
"got_it": "D'accord", "got_it": "D'accord",
"connection_failed": "La connexion a échoué", "connection_failed": "La connexion a échoué",
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier lURL et votre connexion réseau.", "could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier lURL et votre connexion réseau.",
"an_unexpected_error_occurred": "Une erreur inattendue s'est produite", "an_unexpected_error_occured": "Une erreur inattendue s'est produite",
"change_server": "Changer de serveur", "change_server": "Changer de serveur",
"invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide", "invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
"user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter", "user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter",
"server_is_taking_too_long_to_respond_try_again_later": "Le serveur prend trop de temps à répondre, réessayez plus tard", "server_is_taking_too_long_to_respond_try_again_later": "Le serveur prend trop de temps à répondre, réessayez plus tard",
"server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard.", "server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard.",
"there_is_a_server_error": "Il y a une erreur de serveur", "there_is_a_server_error": "Il y a une erreur de serveur",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Une erreur inattendue sest produite. Avez-vous correctement saisi lURL du serveur ?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue sest produite. Avez-vous correctement saisi lURL du serveur ?",
"too_old_server_text": "Serveur Jellyfin non pris en charge découvert", "too_old_server_text": "Serveur Jellyfin non pris en charge découvert",
"too_old_server_description": "Veuillez mettre à jour Jellyfin vers la dernière version" "too_old_server_description": "Veuillez mettre à jour Jellyfin vers la dernière version"
}, },
@@ -42,19 +42,19 @@
"please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.", "please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.",
"remove_saved_login": "Supprimer l'identifiant enregistré", "remove_saved_login": "Supprimer l'identifiant enregistré",
"remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom dutilisateur et votre mot de passe la prochaine fois.", "remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom dutilisateur et votre mot de passe la prochaine fois.",
"accounts_count": "{{count}} comptes", "accounts_count": "Comptes {{count}}",
"select_account": "Sélectionnez un compte", "select_account": "Sélectionnez un compte",
"add_account": "Ajouter un compte", "add_account": "Ajouter un compte",
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.", "remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
"remove_server": "Supprimer le serveur", "remove_server": "Remove Server",
"remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.", "remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Sélectionnez votre serveur", "select_your_server": "Select Your Server",
"add_server_to_get_started": "Ajouter un serveur pour commencer", "add_server_to_get_started": "Add a server to get started",
"add_server": "Ajouter un serveur", "add_server": "Add Server",
"change_server": "Changer de serveur" "change_server": "Change Server"
}, },
"save_account": { "save_account": {
"title": "Enregistrer le compte", "title": "Sauvegarder le compte",
"save_for_later": "Enregistrer ce compte", "save_for_later": "Enregistrer ce compte",
"security_option": "Options de sécurité", "security_option": "Options de sécurité",
"no_protection": "Aucune protection", "no_protection": "Aucune protection",
@@ -95,9 +95,9 @@
"oops": "Oups!", "oops": "Oups!",
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.", "error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
"continue_watching": "Continuer à regarder", "continue_watching": "Continuer à regarder",
"continue": "Continuer", "continue": "Continue",
"next_up": "À suivre", "next_up": "À suivre",
"continue_and_next_up": "Continuer à regarder et à suivre", "continue_and_next_up": "Continuer de regarder et à suivre",
"recently_added_in": "Ajoutés récemment dans {{libraryName}}", "recently_added_in": "Ajoutés récemment dans {{libraryName}}",
"suggested_movies": "Films suggérés", "suggested_movies": "Films suggérés",
"suggested_episodes": "Épisodes suggérés", "suggested_episodes": "Épisodes suggérés",
@@ -120,10 +120,10 @@
"settings_title": "Paramètres", "settings_title": "Paramètres",
"log_out_button": "Déconnexion", "log_out_button": "Déconnexion",
"switch_user": { "switch_user": {
"title": "Changer d'utilisateur", "title": "Switch User",
"account": "Compte", "account": "Account",
"switch_user": "Changer d'utilisateur sur ce serveur", "switch_user": "Switch User on This Server",
"current": "actuel" "current": "current"
}, },
"categories": { "categories": {
"title": "Catégories" "title": "Catégories"
@@ -136,32 +136,32 @@
}, },
"appearance": { "appearance": {
"title": "Apparence", "title": "Apparence",
"merge_next_up_continue_watching": "Fusionner « Continuer à regarder » et « À suivre »", "merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
"hide_remote_session_button": "Masquer le bouton de session distante", "hide_remote_session_button": "Masquer le bouton de session distante",
"show_home_backdrop": "Arrière-plan d'accueil dynamique", "show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Carrousel principal", "show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes", "show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Musique de thème", "theme_music": "Theme Music",
"display_size": "Taille d'affichage", "display_size": "Display Size",
"display_size_small": "Petite", "display_size_small": "Small",
"display_size_default": "Par défaut", "display_size_default": "Default",
"display_size_large": "Grande", "display_size_large": "Large",
"display_size_extra_large": "Très grande" "display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Réseau", "title": "Réseau",
"local_network": "Réseau local", "local_network": "Réseau local",
"auto_switch_enabled": "Basculement automatique à la maison", "auto_switch_enabled": "Basculement automatique quand à la maison",
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison", "auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
"local_url": "URL locale", "local_url": "URL locale",
"local_url_hint": "Entrez l'adresse de votre serveur local (par exemple http://192.168.1.100:8096)", "local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096", "local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Réseaux Wi-Fi domestiques", "home_wifi_networks": "Réseaux Wi-Fi domestiques",
"add_current_network": "Ajouter \"{{ssid}}\"", "add_current_network": "Ajouter \"{{ssid}}\"",
"not_connected_to_wifi": "Non connecté au Wi-Fi", "not_connected_to_wifi": "Non connecté au WiFi",
"no_networks_configured": "Pas de réseau configuré", "no_networks_configured": "Pas de réseau configuré",
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer le basculement automatique", "add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
"current_wifi": "Wi-Fi actuel", "current_wifi": "WiFi actuel",
"using_url": "Utilisant", "using_url": "Utilisant",
"local": "URL locale", "local": "URL locale",
"remote": "URL à distance", "remote": "URL à distance",
@@ -172,9 +172,9 @@
"not_configured": "Non configuré", "not_configured": "Non configuré",
"network_added": "Réseau ajouté", "network_added": "Réseau ajouté",
"network_already_added": "Réseau déjà ajouté", "network_already_added": "Réseau déjà ajouté",
"no_wifi_connected": "Non connecté au Wi-Fi", "no_wifi_connected": "Non connecté au WiFi",
"permission_denied": "Autorisation de localisation refusée", "permission_denied": "Autorisation de localisation refusée",
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres." "permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez lactiver dans les paramètres."
}, },
"user_info": { "user_info": {
"user_info_title": "Informations utilisateur", "user_info_title": "Informations utilisateur",
@@ -184,35 +184,35 @@
"app_version": "Version de l'application" "app_version": "Version de l'application"
}, },
"quick_connect": { "quick_connect": {
"quick_connect_title": "Connexion rapide", "quick_connect_title": "Connexion Rapide",
"authorize_button": "Autoriser une connexion rapide", "authorize_button": "Autoriser une Connexion Rapide",
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...", "enter_the_quick_connect_code": "Entrez le code de Connexion Rapide...",
"success": "Succès", "success": "Succès",
"quick_connect_authorized": "Connexion rapide autorisée", "quick_connect_autorized": "Connexion Rapide autorisé",
"error": "Erreur", "error": "Erreur",
"invalid_code": "Code invalide", "invalid_code": "Code invalide",
"authorize": "Autoriser" "authorize": "Autoriser"
}, },
"media_controls": { "media_controls": {
"media_controls_title": "Contrôles média", "media_controls_title": "Contrôles Média",
"forward_skip_length": "Durée de saut en avant", "forward_skip_length": "Durée de saut en avant",
"rewind_length": "Durée de retour en arrière", "rewind_length": "Durée de retour en arrière",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": { "buffer": {
"title": "Paramètres du tampon", "title": "Buffer Settings",
"cache_mode": "Mode de cache", "cache_mode": "Cache Mode",
"cache_auto": "Auto", "cache_auto": "Auto",
"cache_yes": "Activé", "cache_yes": "Enabled",
"cache_no": "Désactivé", "cache_no": "Disabled",
"buffer_duration": "Durée du tampon", "buffer_duration": "Buffer Duration",
"max_cache_size": "Taille maximale du cache", "max_cache_size": "Max Cache Size",
"max_backward_cache": "Cache arrière maximal" "max_backward_cache": "Max Backward Cache"
}, },
"vo_driver": { "vo_driver": {
"title": "Sortie vidéo", "title": "Video Output",
"vo_mode": "Pilote VO", "vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommandé)", "gpu_next": "gpu-next (Recommended)",
"gpu": "gpu" "gpu": "gpu"
}, },
"gesture_controls": { "gesture_controls": {
@@ -225,8 +225,8 @@
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume", "right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
"hide_volume_slider": "Masquer le curseur de volume", "hide_volume_slider": "Masquer le curseur de volume",
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo", "hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
"hide_brightness_slider": "Masquer le curseur de luminosité", "hide_brightness_slider": "Cacher le curseur de luminosité",
"hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo" "hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo"
}, },
"audio": { "audio": {
"audio_title": "Audio", "audio_title": "Audio",
@@ -234,14 +234,14 @@
"audio_language": "Langue audio", "audio_language": "Langue audio",
"audio_hint": "Choisissez une langue audio par défaut.", "audio_hint": "Choisissez une langue audio par défaut.",
"none": "Aucune", "none": "Aucune",
"language": "Langue", "language": "Langage",
"transcode_mode": { "transcode_mode": {
"title": "Transcodage audio", "title": "Transcodage audio",
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)", "description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
"auto": "Auto", "auto": "Auto",
"stereo": "Forcer la stéréo", "stereo": "Forcer la stéréo",
"5_1": "Autoriser 5.1", "5_1": "Autoriser 5.1",
"passthrough": "Passthrough" "passthrough": "Intercommunication"
} }
}, },
"subtitles": { "subtitles": {
@@ -252,7 +252,7 @@
"set_subtitle_track": "Piste de sous-titres de l'élément précédent", "set_subtitle_track": "Piste de sous-titres de l'élément précédent",
"subtitle_size": "Taille des sous-titres", "subtitle_size": "Taille des sous-titres",
"none": "Aucune", "none": "Aucune",
"language": "Langue", "language": "Langage",
"loading": "Chargement", "loading": "Chargement",
"modes": { "modes": {
"Default": "Par défaut", "Default": "Par défaut",
@@ -262,20 +262,20 @@
"OnlyForced": "Forcés seulement" "OnlyForced": "Forcés seulement"
}, },
"opensubtitles_title": "OpenSubtitles", "opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Entrez votre clé API OpenSubtitles pour activer la recherche de sous-titres côté client comme solution de secours lorsque votre serveur Jellyfin n'a pas de fournisseur de sous-titres configuré.", "opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "Clé API", "opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Entrez la clé API...", "opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers", "opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Échelle des sous-titres", "mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Marge verticale", "mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Alignement horizontal", "mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Alignement vertical", "mpv_subtitle_align_y": "Vertical Align",
"align": { "align": {
"left": "Gauche", "left": "Left",
"center": "Centre", "center": "Center",
"right": "Droite", "right": "Right",
"top": "Haut", "top": "Top",
"bottom": "Bas" "bottom": "Bottom"
} }
}, },
"other": { "other": {
@@ -283,38 +283,38 @@
"video_orientation": "Orientation vidéo", "video_orientation": "Orientation vidéo",
"orientation": "Orientation", "orientation": "Orientation",
"orientations": { "orientations": {
"DEFAULT": "Suivre l'orientation de l'appareil", "DEFAULT": "Par défaut",
"ALL": "Toutes", "ALL": "Toutes",
"PORTRAIT": "Portrait auto", "PORTRAIT": "Portrait",
"PORTRAIT_UP": "Portrait haut", "PORTRAIT_UP": "Portrait Haut",
"PORTRAIT_DOWN": "Portrait bas", "PORTRAIT_DOWN": "Portrait Bas",
"LANDSCAPE": "Paysage auto", "LANDSCAPE": "Paysage",
"LANDSCAPE_LEFT": "Paysage gauche", "LANDSCAPE_LEFT": "Paysage Gauche",
"LANDSCAPE_RIGHT": "Paysage droite", "LANDSCAPE_RIGHT": "Paysage Droite",
"OTHER": "Autre", "OTHER": "Autre",
"UNKNOWN": "Inconnu" "UNKNOWN": "Inconnu"
}, },
"safe_area_in_controls": "Zone de sécurité dans les contrôles", "safe_area_in_controls": "Zone de sécurité dans les contrôles",
"show_custom_menu_links": "Afficher les liens personnalisés", "show_custom_menu_links": "Afficher les liens personnalisés",
"show_large_home_carousel": "Afficher le grand carrousel daccueil (bêta)", "show_large_home_carousel": "Afficher le grand carrousel daccueil (bêta)",
"hide_libraries": "Masquer les bibliothèques", "hide_libraries": "Cacher des bibliothèques",
"select_libraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.", "select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
"disable_haptic_feedback": "Désactiver le retour haptique", "disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut", "default_quality": "Qualité par défaut",
"default_playback_speed": "Vitesse de lecture par défaut", "default_playback_speed": "Vitesse de lecture par défaut",
"auto_play_next_episode": "Lecture automatique de l'épisode suivant", "auto_play_next_episode": "Lecture automatique de l'épisode suivant",
"max_auto_play_episode_count": "Nombre max d'épisodes en lecture automatique", "max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
"disabled": "Désactivé" "disabled": "Désactivé"
}, },
"music": { "music": {
"title": "Musique", "title": "Musique",
"playback_title": "Lecture", "playback_title": "Lecture",
"playback_description": "Configurer le mode de lecture de la musique.", "playback_description": "Configurer le mode de lecture de la musique.",
"prefer_downloaded": "Préférer les musiques téléchargées", "prefer_downloaded": "Supprimer toutes les musiques téléchargées",
"caching_title": "Mise en cache", "caching_title": "Mise en cache",
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.", "caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
"lookahead_enabled": "Activer la mise en cache anticipée", "lookahead_enabled": "Activer la mise en cache guidée",
"lookahead_count": "Pistes à mettre en cache à l'avance", "lookahead_count": "Pistes à pré-mettre en cache",
"max_cache_size": "Taille max de cache" "max_cache_size": "Taille max de cache"
}, },
"plugins": { "plugins": {
@@ -333,7 +333,7 @@
"tv_quota_days": "Jours de quota de séries", "tv_quota_days": "Jours de quota de séries",
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr", "reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
"unlimited": "Illimité", "unlimited": "Illimité",
"plus_n_more": "+{{n}} de plus", "plus_n_more": "+{{n}} Plus",
"order_by": { "order_by": {
"DEFAULT": "Par défaut", "DEFAULT": "Par défaut",
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne", "VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
@@ -341,7 +341,7 @@
} }
}, },
"marlin_search": { "marlin_search": {
"enable_marlin_search": "Activer la recherche Marlin", "enable_marlin_search": "Activer Marlin Search",
"url": "URL", "url": "URL",
"server_url_placeholder": "http(s)://domaine.org:port", "server_url_placeholder": "http(s)://domaine.org:port",
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.", "marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
@@ -362,10 +362,10 @@
"features_title": "Fonctionnalités", "features_title": "Fonctionnalités",
"enable_movie_recommendations": "Recommandations de films", "enable_movie_recommendations": "Recommandations de films",
"enable_series_recommendations": "Recommandations de séries", "enable_series_recommendations": "Recommandations de séries",
"enable_promoted_watchlists": "Listes de suivi promues", "enable_promoted_watchlists": "Listes de lecture promues",
"hide_watchlists_tab": "Masquer l'onglet des listes de suivi", "hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page daccueil.", "home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page daccueil.",
"recommended_movies": "Films recommandés", "recommended_movies": "Films Recommandés",
"recommended_series": "Séries recommandées", "recommended_series": "Séries recommandées",
"toasts": { "toasts": {
"saved": "Enregistré", "saved": "Enregistré",
@@ -375,7 +375,7 @@
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur" "refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
}, },
"kefinTweaks": { "kefinTweaks": {
"watchlist_enabler": "Activer l'intégration de la liste de suivi" "watchlist_enabler": "Activer l'intégration de notre liste de lecture"
} }
}, },
"storage": { "storage": {
@@ -384,8 +384,6 @@
"device_usage": "Appareil {{availableSpace}}%", "device_usage": "Appareil {{availableSpace}}%",
"size_used": "{{used}} de {{total}} utilisés", "size_used": "{{used}} de {{total}} utilisés",
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés", "delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés",
"delete_all_downloaded_files_confirm": "Supprimer tous les fichiers téléchargés ?",
"delete_all_downloaded_files_confirm_desc": "Êtes-vous sûr de vouloir supprimer tous les fichiers téléchargés ? Cette action est irréversible.",
"music_cache_title": "Mise en cache de la musique", "music_cache_title": "Mise en cache de la musique",
"music_cache_description": "Mettez automatiquement en cache les chansons au fur et à mesure que vous écoutez pour une lecture plus fluide et une prise en charge hors ligne", "music_cache_description": "Mettez automatiquement en cache les chansons au fur et à mesure que vous écoutez pour une lecture plus fluide et une prise en charge hors ligne",
"clear_music_cache": "Vider le cache de la musique", "clear_music_cache": "Vider le cache de la musique",
@@ -394,10 +392,10 @@
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées", "delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
"downloaded_songs_size": "{{size}} téléchargé", "downloaded_songs_size": "{{size}} téléchargé",
"downloaded_songs_deleted": "Chansons téléchargées supprimées", "downloaded_songs_deleted": "Chansons téléchargées supprimées",
"clear_all_cache": "Effacer tout le cache", "clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Effacer tout le cache ?", "clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Êtes-vous sûr de vouloir effacer toutes les données en cache ? Cela supprimera toutes les images, fichiers musicaux, sous-titres et caches de requêtes mis en cache. Vos paramètres et votre session de connexion seront conservés.", "clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache." "clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Introduction", "title": "Introduction",
@@ -421,17 +419,17 @@
"error_deleting_files": "Erreur lors de la suppression des fichiers" "error_deleting_files": "Erreur lors de la suppression des fichiers"
}, },
"security": { "security": {
"title": "Sécurité", "title": "Security",
"inactivity_timeout": { "inactivity_timeout": {
"title": "Délai d'inactivité", "title": "Inactivity Timeout",
"disabled": "Désactivé", "disabled": "Disabled",
"1_minute": "1 minute", "1_minute": "1 minute",
"5_minutes": "5 minutes", "5_minutes": "5 minutes",
"15_minutes": "15 minutes", "15_minutes": "15 minutes",
"30_minutes": "30 minutes", "30_minutes": "30 minutes",
"1_hour": "1 heure", "1_hour": "1 hour",
"4_hours": "4 heures", "4_hours": "4 hours",
"24_hours": "24 heures" "24_hours": "24 hours"
} }
} }
}, },
@@ -448,7 +446,7 @@
"delete_all_movies_button": "Supprimer tous les films", "delete_all_movies_button": "Supprimer tous les films",
"delete_all_series_button": "Supprimer toutes les séries", "delete_all_series_button": "Supprimer toutes les séries",
"delete_all_button": "Supprimer tous les médias", "delete_all_button": "Supprimer tous les médias",
"delete_all_other_media_button": "Supprimer les autres médias", "delete_all_other_media_button": "Supprimer un autre média",
"active_download": "Téléchargement actif", "active_download": "Téléchargement actif",
"no_active_downloads": "Pas de téléchargements actifs", "no_active_downloads": "Pas de téléchargements actifs",
"active_downloads": "Téléchargements actifs", "active_downloads": "Téléchargements actifs",
@@ -456,7 +454,7 @@
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.", "new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
"back": "Retour", "back": "Retour",
"delete": "Supprimer", "delete": "Supprimer",
"delete_download": "Supprimer le téléchargement", "delete_download": "Delete Download",
"something_went_wrong": "Quelque chose s'est mal passé", "something_went_wrong": "Quelque chose s'est mal passé",
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin", "could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -467,7 +465,7 @@
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès!", "deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès!",
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries", "failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !", "deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
"failed_to_delete_media": "Échec de la suppression des autres médias", "failed_to_delete_media": "Échec de la suppression d'un autre média",
"download_cancelled": "Téléchargement annulé", "download_cancelled": "Téléchargement annulé",
"could_not_delete_download": "Impossible de supprimer le téléchargement", "could_not_delete_download": "Impossible de supprimer le téléchargement",
"download_completed": "Téléchargement terminé", "download_completed": "Téléchargement terminé",
@@ -485,16 +483,17 @@
} }
}, },
"common": { "common": {
"no_results": "Aucun résultat", "no_results": "No Results",
"select": "Sélectionner",
"no_trailer_available": "Aucune bande-annonce disponible", "no_trailer_available": "Aucune bande-annonce disponible",
"video": "Vidéo", "video": "Vidéo",
"audio": "Audio", "audio": "Audio",
"subtitle": "Sous-titres", "subtitle": "Sous-titres",
"play": "Lecture", "play": "Lecture",
"mark_as_played": "Marquer comme vu", "mark_as_played": "Mark as Played",
"mark_as_not_played": "Marquer comme non vu", "mark_as_not_played": "Mark as not Played",
"none": "Aucun", "none": "Aucun",
"track": "Piste", "track": "Suivre",
"cancel": "Annuler", "cancel": "Annuler",
"delete": "Supprimer", "delete": "Supprimer",
"ok": "Ok", "ok": "Ok",
@@ -502,15 +501,15 @@
"back": "Précédent", "back": "Précédent",
"continue": "Continuer", "continue": "Continuer",
"verifying": "Vérification...", "verifying": "Vérification...",
"login": "Connexion", "login": "Login",
"episodes": "Épisodes", "episodes": "Episodes",
"movies": "Films", "movies": "Movies",
"loading": "Chargement…", "loading": "Loading…",
"seeAll": "Tout afficher" "seeAll": "See all"
}, },
"search": { "search": {
"search": "Rechercher...", "search": "Rechercher...",
"x_items": "{{count}} médias", "x_items": "{{count}} Médias",
"library": "Bibliothèque", "library": "Bibliothèque",
"discover": "Découvrir", "discover": "Découvrir",
"no_results": "Aucun résultat", "no_results": "Aucun résultat",
@@ -528,7 +527,7 @@
"request_series": "Demander une série", "request_series": "Demander une série",
"recently_added": "Ajoutés récemment", "recently_added": "Ajoutés récemment",
"recent_requests": "Demandes récentes", "recent_requests": "Demandes récentes",
"plex_watchlist": "Liste de suivi Plex", "plex_watchlist": "Liste de lecture Plex",
"trending": "Tendance", "trending": "Tendance",
"popular_movies": "Films populaires", "popular_movies": "Films populaires",
"movie_genres": "Genres de films", "movie_genres": "Genres de films",
@@ -537,7 +536,7 @@
"popular_tv": "Séries populaires", "popular_tv": "Séries populaires",
"tv_genres": "Genres des séries", "tv_genres": "Genres des séries",
"upcoming_tv": "Séries à venir", "upcoming_tv": "Séries à venir",
"networks": "Chaînes", "networks": "Studios",
"tmdb_movie_keyword": "Mots-clés de film TMDB", "tmdb_movie_keyword": "Mots-clés de film TMDB",
"tmdb_movie_genre": "Genre de film TMDB", "tmdb_movie_genre": "Genre de film TMDB",
"tmdb_tv_keyword": "Mots-clés de séries TMDB", "tmdb_tv_keyword": "Mots-clés de séries TMDB",
@@ -554,7 +553,7 @@
"item_types": { "item_types": {
"movies": "Films", "movies": "Films",
"series": "Séries", "series": "Séries",
"boxsets": "Coffrets", "boxsets": "Coffrets ",
"playlists": "Playlists", "playlists": "Playlists",
"items": "Médias" "items": "Médias"
}, },
@@ -576,10 +575,10 @@
"filter_by": "Filtrer par", "filter_by": "Filtrer par",
"sort_order": "Ordre de tri", "sort_order": "Ordre de tri",
"tags": "Tags", "tags": "Tags",
"all": "Tout", "all": "All",
"reset": "Réinitialiser", "reset": "Reset",
"asc": "Croissant", "asc": "Ascending",
"desc": "Décroissant" "desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -593,14 +592,14 @@
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide." "noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
}, },
"custom_links": { "custom_links": {
"no_links": "Aucun lien" "no_links": "Aucuns liens"
}, },
"player": { "player": {
"live": "EN DIRECT", "live": "LIVE",
"mpv_player_title": "Lecteur MPV", "mpv_player_title": "MPV Player",
"error": "Erreur", "error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux", "failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occurred_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.", "an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
"client_error": "Erreur du client", "client_error": "Erreur du client",
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast", "could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
"message_from_server": "Message du serveur : {{message}}", "message_from_server": "Message du serveur : {{message}}",
@@ -612,71 +611,72 @@
"downloaded_file_yes": "Oui", "downloaded_file_yes": "Oui",
"downloaded_file_no": "Non", "downloaded_file_no": "Non",
"downloaded_file_cancel": "Annuler", "downloaded_file_cancel": "Annuler",
"swipe_down_settings": "Balayez vers le bas pour les paramètres", "swipe_down_settings": "Swipe down for settings",
"ends_at": "Se termine à {{time}}", "ends_at": "Ends at {{time}}",
"search_subtitles": "Rechercher des sous-titres", "search_subtitles": "Search Subtitles",
"subtitle_tracks": "Pistes", "subtitle_tracks": "Tracks",
"subtitle_search": "Rechercher et télécharger", "subtitle_search": "Search & Download",
"download": "Télécharger", "download": "Download",
"subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque", "subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Utilisation du serveur Jellyfin", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Langue", "language": "Language",
"results": "Résultats", "results": "Results",
"search_failed": "Recherche échouée", "searching": "Searching...",
"no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur", "search_failed": "Search failed",
"no_subtitles_found": "Aucun sous-titre trouvé", "no_subtitle_provider": "No subtitle provider configured on server",
"add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client", "no_subtitles_found": "No subtitles found",
"settings": "Paramètres", "add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"skip_intro": "Passer l'intro", "settings": "Settings",
"skip_credits": "Passer le générique", "skip_intro": "Skip Intro",
"stopPlayback": "Arrêter la lecture", "skip_credits": "Skip Credits",
"stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?", "stopPlayback": "Stop Playback",
"stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?", "stopPlayingTitle": "Stop playing \"{{title}}\"?",
"downloaded": "Téléchargé", "stopPlayingConfirm": "Are you sure you want to stop playback?",
"missing_parameters": "Paramètres de lecture manquants" "downloaded": "Downloaded",
"missing_parameters": "Missing playback parameters"
}, },
"chapters": { "chapters": {
"title": "Chapitres", "title": "Chapters",
"chapter_number": "Chapitre {{number}}", "chapter_number": "Chapter {{number}}",
"open": "Ouvrir les chapitres", "open": "Open chapters",
"close": "Fermer les chapitres" "close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "À suivre", "next_up": "À suivre",
"no_items_to_display": "Aucun média à afficher", "no_items_to_display": "Aucuns médias à afficher",
"cast_and_crew": "Distribution et équipe", "cast_and_crew": "Distribution et équipe",
"series": "Séries", "series": "Séries",
"seasons": "Saisons", "seasons": "Saisons",
"season": "Saison", "season": "Saison",
"from_this_series": "De cette série", "from_this_series": "From This Series",
"more_from_this_season": "Plus de cette saison", "more_from_this_season": "More from this Season",
"view_series": "Voir la série", "view_series": "View Series",
"view_season": "Voir la saison", "view_season": "View Season",
"select_season": "Sélectionner une saison", "select_season": "Select Season",
"no_episodes_for_this_season": "Aucun épisode pour cette saison", "no_episodes_for_this_season": "Aucun épisode pour cette saison",
"overview": "Aperçu", "overview": "Aperçu",
"more_with": "Plus avec {{name}}", "more_with": "Plus avec {{name}}",
"similar_items": "Médias similaires", "similar_items": "Médias similaires",
"no_similar_items_found": "Aucun média similaire trouvé", "no_similar_items_found": "Aucuns médias similaires trouvés",
"video": "Vidéo", "video": "Vidéo",
"more_details": "Plus de détails", "more_details": "Plus de détails",
"media_options": "Options média", "media_options": "Options média",
"quality": "Qualité", "quality": "Qualité",
"audio": "Audio", "audio": "Audio",
"subtitles": { "subtitles": {
"label": "Sous-titres", "label": "Subtitle",
"none": "Aucun", "none": "None",
"tracks": "Pistes" "tracks": "Tracks"
}, },
"show_more": "Afficher plus", "show_more": "Afficher plus",
"show_less": "Afficher moins", "show_less": "Afficher moins",
"left": "restant", "left": "left",
"director": "Réalisateur", "director": "Director",
"cast": "Distribution", "cast": "Cast",
"technical_details": "Détails techniques", "technical_details": "Technical Details",
"appeared_in": "Apparu dans", "appeared_in": "Apparu dans",
"movies": "Films", "movies": "Movies",
"shows": "Séries", "shows": "Shows",
"could_not_load_item": "Impossible de charger le média", "could_not_load_item": "Impossible de charger le média",
"none": "Aucun", "none": "Aucun",
"download": { "download": {
@@ -688,13 +688,13 @@
"download_unwatched_only": "Non visionné uniquement", "download_unwatched_only": "Non visionné uniquement",
"download_button": "Télécharger" "download_button": "Télécharger"
}, },
"mark_played": "Marquer comme vu", "mark_played": "Mark as Watched",
"mark_unplayed": "Marquer comme non vu", "mark_unplayed": "Mark as Unwatched",
"resume_playback": "Reprendre la lecture", "resume_playback": "Resume Playback",
"resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?", "resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Lire depuis le début", "play_from_start": "Play from Start",
"continue_from": "Continuer depuis {{time}}", "continue_from": "Continue from {{time}}",
"no_data_available": "Aucune donnée disponible" "no_data_available": "No data available"
}, },
"live_tv": { "live_tv": {
"next": "Suivant", "next": "Suivant",
@@ -706,16 +706,16 @@
"sports": "Sports", "sports": "Sports",
"for_kids": "Pour enfants", "for_kids": "Pour enfants",
"news": "Actualités", "news": "Actualités",
"page_of": "Page {{current}} sur {{total}}", "page_of": "Page {{current}} of {{total}}",
"no_programs": "Aucun programme disponible", "no_programs": "No programs available",
"no_channels": "Aucune chaîne disponible", "no_channels": "No channels available",
"tabs": { "tabs": {
"programs": "Programmes", "programs": "Programs",
"guide": "Guide", "guide": "Guide",
"channels": "Chaînes", "channels": "Channels",
"recordings": "Enregistrements", "recordings": "Recordings",
"schedule": "Programmation", "schedule": "Schedule",
"series": "Séries" "series": "Series"
} }
}, },
"jellyseerr": { "jellyseerr": {
@@ -725,7 +725,7 @@
"whats_wrong": "Quel est le problème?", "whats_wrong": "Quel est le problème?",
"issue_type": "Type de problème", "issue_type": "Type de problème",
"select_an_issue": "Sélectionnez un problème", "select_an_issue": "Sélectionnez un problème",
"types": "Types", "types": "Types de fichiers",
"describe_the_issue": "(optionnel) Décrivez le problème...", "describe_the_issue": "(optionnel) Décrivez le problème...",
"submit_button": "Soumettre", "submit_button": "Soumettre",
"report_issue_button": "Signaler un problème", "report_issue_button": "Signaler un problème",
@@ -734,7 +734,7 @@
"failed_to_login": "Échec de la connexion", "failed_to_login": "Échec de la connexion",
"cast": "Distribution", "cast": "Distribution",
"details": "Détails", "details": "Détails",
"status": "Statut", "status": "Statuts",
"original_title": "Titre original", "original_title": "Titre original",
"series_type": "Type de série", "series_type": "Type de série",
"release_dates": "Dates de sortie", "release_dates": "Dates de sortie",
@@ -752,32 +752,32 @@
"tags": "Tags", "tags": "Tags",
"quality_profile": "Profil de qualité", "quality_profile": "Profil de qualité",
"root_folder": "Dossier racine", "root_folder": "Dossier racine",
"season_all": "Saison (Toutes)", "season_all": "Saison (Tous)",
"season_number": "Saison {{season_number}}", "season_number": "Saison {{season_number}}",
"number_episodes": "{{episode_number}} épisodes", "number_episodes": "{{episode_number}} Épisodes",
"born": "Né(e) le", "born": "Né(e) le",
"appearances": "Apparitions", "appearances": "Apparences",
"approve": "Valider", "approve": "Valider",
"decline": "Refuser", "decline": "Refuser",
"requested_by": "Demandé par {{user}}", "requested_by": "Demandé par {{user}}",
"unknown_user": "Utilisateur inconnu", "unknown_user": "Utilisateur inconnu",
"select": "Sélectionner", "select": "Select",
"request_all": "Tout demander", "request_all": "Request All",
"request_seasons": "Demander des saisons", "request_seasons": "Request Seasons",
"select_seasons": "Sélectionner les saisons", "select_seasons": "Select Seasons",
"request_selected": "Demander la sélection", "request_selected": "Request Selected",
"n_selected": "{{count}} sélectionnés", "n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.", "jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.", "jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr", "failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
"issue_submitted": "Problème soumis!", "issue_submitted": "Problème soumis!",
"requested_item": "{{item}} demandé !", "requested_item": "{{item}}} demandé!",
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !", "you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
"something_went_wrong_requesting_media": "Une erreur s'est produite lors de la demande du média !", "something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
"request_approved": "Demande approuvée !", "request_approved": "Demande approuvée !",
"request_declined": "Demande refusée !", "request_declined": "Demande déclinée !",
"failed_to_approve_request": "Échec de l'approbation de la demande", "failed_to_approve_request": "Échec d'approbation de la demande",
"failed_to_decline_request": "Échec du refus de la demande" "failed_to_decline_request": "Échec du refus de la demande"
} }
}, },
@@ -787,7 +787,7 @@
"library": "Bibliothèque", "library": "Bibliothèque",
"custom_links": "Liens personnalisés", "custom_links": "Liens personnalisés",
"favorites": "Favoris", "favorites": "Favoris",
"settings": "Réglages" "settings": "Settings"
}, },
"music": { "music": {
"title": "Musique", "title": "Musique",
@@ -807,13 +807,13 @@
"play_top_tracks": "Jouer les pistes les plus populaires", "play_top_tracks": "Jouer les pistes les plus populaires",
"no_suggestions": "Pas de suggestion disponible", "no_suggestions": "Pas de suggestion disponible",
"no_albums": "Pas d'albums trouvés", "no_albums": "Pas d'albums trouvés",
"no_artists": "Pas d'artistes trouvés", "no_artists": "Pas d'artistes trouvé",
"no_playlists": "Pas de playlists trouvées", "no_playlists": "Pas de playlists trouvées",
"album_not_found": "Album introuvable", "album_not_found": "Album introuvable",
"artist_not_found": "Artiste introuvable", "artist_not_found": "Artiste introuvable",
"playlist_not_found": "Playlist introuvable", "playlist_not_found": "Playlist introuvable",
"track_options": { "track_options": {
"play_next": "Lire ensuite", "play_next": "Lecture suivante",
"add_to_queue": "Ajouter à la file d'attente", "add_to_queue": "Ajouter à la file d'attente",
"add_to_playlist": "Ajouter à la playlist", "add_to_playlist": "Ajouter à la playlist",
"download": "Télécharger", "download": "Télécharger",
@@ -822,15 +822,15 @@
"cached": "En cache", "cached": "En cache",
"delete_download": "Supprimer un téléchargement", "delete_download": "Supprimer un téléchargement",
"delete_cache": "Supprimer du cache", "delete_cache": "Supprimer du cache",
"go_to_artist": "Aller à l'artiste", "go_to_artist": "Voir l'artiste",
"go_to_album": "Aller à l'album", "go_to_album": "Aller à lalbum",
"add_to_favorites": "Ajouter aux favoris", "add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris", "remove_from_favorites": "Retirer des favoris",
"remove_from_playlist": "Retirer de la playlist" "remove_from_playlist": "Retirer de la playlist"
}, },
"playlists": { "playlists": {
"create_playlist": "Créer une playlist", "create_playlist": "Créer une Playlist",
"playlist_name": "Nom de la playlist", "playlist_name": "Nom de la Playlist",
"enter_name": "Entrer le nom de la playlist", "enter_name": "Entrer le nom de la playlist",
"create": "Créer", "create": "Créer",
"search_playlists": "Rechercher des playlists...", "search_playlists": "Rechercher des playlists...",
@@ -841,10 +841,10 @@
"created": "Playlist créée", "created": "Playlist créée",
"create_new": "Créer une nouvelle playlist", "create_new": "Créer une nouvelle playlist",
"failed_to_add": "Échec de l'ajout à la playlist", "failed_to_add": "Échec de l'ajout à la playlist",
"failed_to_remove": "Échec du retrait de la playlist", "failed_to_remove": "Échec de la suppression de la playlist",
"failed_to_create": "Échec de la création de la playlist", "failed_to_create": "Échec de la suppression de la playlist",
"delete_playlist": "Supprimer la playlist", "delete_playlist": "Supprimer la playlist",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.", "delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
"deleted": "Playlist supprimée", "deleted": "Playlist supprimée",
"failed_to_delete": "Échec de la suppression de la playlist" "failed_to_delete": "Échec de la suppression de la playlist"
}, },
@@ -855,46 +855,46 @@
} }
}, },
"watchlists": { "watchlists": {
"title": "Listes de suivi", "title": "Listes de lecture",
"my_watchlists": "Mes listes de suivi", "my_watchlists": "Mes listes de lecture",
"public_watchlists": "Listes de suivi publiques", "public_watchlists": "Watchlist publique",
"create_title": "Créer une liste de suivi", "create_title": "Créer une Watchlist",
"edit_title": "Modifier la liste de suivi", "edit_title": "Modifier la Watchlist",
"create_button": "Créer une liste de suivi", "create_button": "Créer une Watchlist",
"save_button": "Enregistrer les modifications", "save_button": "Enregistrer les modifications",
"delete_button": "Supprimer", "delete_button": "Supprimer",
"remove_button": "Retirer", "remove_button": "Retirer",
"cancel_button": "Annuler", "cancel_button": "Annuler",
"name_label": "Nom", "name_label": "Nom",
"name_placeholder": "Entrez le nom de la liste de suivi", "name_placeholder": "Entrer le nom de la playlist",
"description_label": "Description", "description_label": "Description",
"description_placeholder": "Entrez la description (facultatif)", "description_placeholder": "Entrez la description (facultatif)",
"is_public_label": "Liste de suivi publique", "is_public_label": "Liste de lecture Publique",
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi", "is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
"allowed_type_label": "Type de contenu", "allowed_type_label": "Type de contenu",
"sort_order_label": "Ordre de tri par défaut", "sort_order_label": "Ordre de tri par défaut",
"empty_title": "Aucune liste de suivi", "empty_title": "Pas de Watchlists",
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias", "empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
"empty_watchlist": "Cette liste de suivi est vide", "empty_watchlist": "Cette liste de suivi est vide",
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi", "empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
"not_configured_title": "Streamystats non configuré", "not_configured_title": "Streamystats non configuré",
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi", "not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
"go_to_settings": "Accédez aux paramètres", "go_to_settings": "Accédez aux Paramètres",
"add_to_watchlist": "Ajouter à la liste de suivi", "add_to_watchlist": "Ajouter à la Watchlist",
"remove_from_watchlist": "Retirer de la liste de suivi", "remove_from_watchlist": "Retirer de la Watchlist",
"select_watchlist": "Sélectionner la liste de suivi", "select_watchlist": "Sélectionner la liste de suivi",
"create_new": "Créer une nouvelle liste de suivi", "create_new": "Créer une Watchlist",
"item": "élément", "item": "médias",
"items": "éléments", "items": "élément",
"public": "Publique", "public": "Publique",
"private": "Privée", "private": "Privée",
"you": "Vous", "you": "Vous-même",
"by_owner": "Par un autre utilisateur", "by_owner": "Par un autre utilisateur",
"not_found": "Liste de suivi introuvable", "not_found": "Playlist introuvable",
"delete_confirm_title": "Supprimer la liste de suivi", "delete_confirm_title": "Supprimer la Watchlist",
"delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.", "delete_confirm_message": "Tous les médias (par défaut)",
"remove_item_title": "Retirer de la liste de suivi", "remove_item_title": "Retirer de la Watchlist",
"remove_item_message": "Retirer « {{name}} » de cette liste de suivi ?", "remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
"loading": "Chargement des listes de suivi...", "loading": "Chargement des listes de suivi...",
"no_compatible_watchlists": "Aucune liste de suivi compatible", "no_compatible_watchlists": "Aucune liste de suivi compatible",
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu" "create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
@@ -910,33 +910,33 @@
} }
}, },
"companion_login": { "companion_login": {
"title": "Associer à la TV", "title": "Pair with TV",
"align_qr": "Alignez le code QR dans le cadre", "align_qr": "Align the QR code within the frame",
"enter_code_manually": "Saisir le code manuellement", "enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Entrez les identifiants pour le téléviseur", "pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Code d'appairage", "pairing_code_label": "Pairing code",
"server": "Serveur", "server": "Server",
"authorize_button": "Autoriser", "authorize_button": "Authorize",
"authorizing": "Autorisation en cours...", "authorizing": "Authorizing...",
"scan_again": "Scanner à nouveau", "scan_again": "Scan Again",
"done": "Terminé", "done": "Done",
"success_title": "Autorisation envoyée", "success_title": "Authorization Sent",
"pairing_tv_connecting": "La TV se connecte à votre compte", "pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Échec de l'autorisation", "error_title": "Authorization Failed",
"error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.", "error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Une erreur s'est produite. Veuillez réessayer.", "error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.", "error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Se connecter en tant que {{username}} ?", "login_as": "Log in as {{username}}?",
"on_server": "sur {{server}}", "on_server": "on {{server}}",
"use_different_user": "Utiliser un autre utilisateur", "use_different_user": "Use a different user",
"open_settings": "Ouvrir les paramètres" "open_settings": "Open Settings"
}, },
"pairing": { "pairing": {
"pair_with_phone": "Associer au téléphone", "pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Se connecter sur la TV", "pair_with_phone_title": "Login TV",
"waiting_for_phone": "En attente du téléphone...", "waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone", "scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Connexion en cours...", "logging_in": "Logging in...",
"logging_in_description": "Connexion à votre serveur" "logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -16,14 +16,14 @@
"got_it": "קיבלתי", "got_it": "קיבלתי",
"connection_failed": "ההתחברות נכשלה", "connection_failed": "ההתחברות נכשלה",
"could_not_connect_to_server": "לא היה ניתן להתחבר לשרת. אנא בדוק את הקישור ואת חיבור הרשת שלך.", "could_not_connect_to_server": "לא היה ניתן להתחבר לשרת. אנא בדוק את הקישור ואת חיבור הרשת שלך.",
"an_unexpected_error_occurred": "קרתה שגיאה לא צפויה", "an_unexpected_error_occured": "קרתה שגיאה לא צפויה",
"change_server": "החלף שרת", "change_server": "החלף שרת",
"invalid_username_or_password": "שם משתמש או סיסמה שגויים", "invalid_username_or_password": "שם משתמש או סיסמה שגויים",
"user_does_not_have_permission_to_log_in": "לחשבון זה אין גישה להתחבר", "user_does_not_have_permission_to_log_in": "לחשבון זה אין גישה להתחבר",
"server_is_taking_too_long_to_respond_try_again_later": "השרת לוקח יותר מדי זמן כדי להגיב, נסה שוב מאוחר יותר", "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": "קרתה שגיאה בצד השרת", "there_is_a_server_error": "קרתה שגיאה בצד השרת",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "קרתה שגיאה לא צפויה. האם כתובת השרת שהוקלדה נכונה?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "קרתה שגיאה לא צפויה. האם כתובת השרת שהוקלדה נכונה?",
"too_old_server_text": "נמצא שרת Jellyfin שלא נתמך", "too_old_server_text": "נמצא שרת Jellyfin שלא נתמך",
"too_old_server_description": "אנא עדכן את גרסת ה-Jellyfin למעודכנת ביותר" "too_old_server_description": "אנא עדכן את גרסת ה-Jellyfin למעודכנת ביותר"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "אשר התחברות מהירה", "authorize_button": "אשר התחברות מהירה",
"enter_the_quick_connect_code": "הקלד את קוד ההתחברות המהירה...", "enter_the_quick_connect_code": "הקלד את קוד ההתחברות המהירה...",
"success": "הצלחה", "success": "הצלחה",
"quick_connect_authorized": "חיבור מהיר אושר", "quick_connect_autorized": "חיבור מהיר אושר",
"error": "שגיאה", "error": "שגיאה",
"invalid_code": "קוד שגוי", "invalid_code": "קוד שגוי",
"authorize": "אשר" "authorize": "אשר"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "הצג קישורים לתפריטים מותאמים אישית", "show_custom_menu_links": "הצג קישורים לתפריטים מותאמים אישית",
"show_large_home_carousel": "הצג קרוסלה גדולה במסך הבית (בטא)", "show_large_home_carousel": "הצג קרוסלה גדולה במסך הבית (בטא)",
"hide_libraries": "הסתר ספריות", "hide_libraries": "הסתר ספריות",
"select_libraries_you_want_to_hide": "בחר את הספריות שתרצה להסתיר ממסך הספריות וגם ממסך הבית.", "select_liraries_you_want_to_hide": "בחר את הספריות שתרצה להסתיר ממסך הספריות וגם ממסך הבית.",
"disable_haptic_feedback": "בטל משוב רטט", "disable_haptic_feedback": "בטל משוב רטט",
"default_quality": "איכות ברירת מחדל", "default_quality": "איכות ברירת מחדל",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "מכשיר {{availableSpace}}%", "device_usage": "מכשיר {{availableSpace}}%",
"size_used": "השתמשת ב-{{used}} מתוך {{total}}", "size_used": "השתמשת ב-{{used}} מתוך {{total}}",
"delete_all_downloaded_files": "מחק את כל הקבצים שהורדו", "delete_all_downloaded_files": "מחק את כל הקבצים שהורדו",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "בחר",
"no_trailer_available": "אין טריילר זמין", "no_trailer_available": "אין טריילר זמין",
"video": "וידאו", "video": "וידאו",
"audio": "שמע", "audio": "שמע",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "שגיאה", "error": "שגיאה",
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם", "failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
"an_error_occurred_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.", "an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
"client_error": "שגיאת לקוח", "client_error": "שגיאת לקוח",
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast", "could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
"message_from_server": "הודעה מהשרת: {{message}}", "message_from_server": "הודעה מהשרת: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Értettem", "got_it": "Értettem",
"connection_failed": "Kapcsolódás Sikertelen", "connection_failed": "Kapcsolódás Sikertelen",
"could_not_connect_to_server": "Nem sikerült csatlakozni a szerverhez. Kérjük, ellenőrizd az URL-t és a hálózati kapcsolatot.", "could_not_connect_to_server": "Nem sikerült csatlakozni a szerverhez. Kérjük, ellenőrizd az URL-t és a hálózati kapcsolatot.",
"an_unexpected_error_occurred": "Váratlan Hiba Történt", "an_unexpected_error_occured": "Váratlan Hiba Történt",
"change_server": "Szerverváltás", "change_server": "Szerverváltás",
"invalid_username_or_password": "Érvénytelen Felhasználónév vagy Jelszó", "invalid_username_or_password": "Érvénytelen Felhasználónév vagy Jelszó",
"user_does_not_have_permission_to_log_in": "A felhasználónak nincs jogosultsága a bejelentkezéshez", "user_does_not_have_permission_to_log_in": "A felhasználónak nincs jogosultsága a bejelentkezéshez",
"server_is_taking_too_long_to_respond_try_again_later": "A szerver túl sokáig válaszol, próbáld újra később", "server_is_taking_too_long_to_respond_try_again_later": "A szerver túl sokáig válaszol, próbáld újra később",
"server_received_too_many_requests_try_again_later": "A szerver túl sok kérést kapott, próbáld újra később.", "server_received_too_many_requests_try_again_later": "A szerver túl sok kérést kapott, próbáld újra később.",
"there_is_a_server_error": "Szerverhiba történt", "there_is_a_server_error": "Szerverhiba történt",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Váratlan hiba történt. Helyesen adtad meg a szerver URL-jét?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Váratlan hiba történt. Helyesen adtad meg a szerver URL-jét?",
"too_old_server_text": "Nem Támogatott Jellyfin-szerver", "too_old_server_text": "Nem Támogatott Jellyfin-szerver",
"too_old_server_description": "Frissítsd a Jellyfint a legújabb verzióra" "too_old_server_description": "Frissítsd a Jellyfint a legújabb verzióra"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Gyorscsatlakozás Engedélyezése", "authorize_button": "Gyorscsatlakozás Engedélyezése",
"enter_the_quick_connect_code": "Add meg a gyors csatlakozási kódot...", "enter_the_quick_connect_code": "Add meg a gyors csatlakozási kódot...",
"success": "Siker", "success": "Siker",
"quick_connect_authorized": "Gyorscsatlakozás Engedélyezve", "quick_connect_autorized": "Gyorscsatlakozás Engedélyezve",
"error": "Hiba", "error": "Hiba",
"invalid_code": "Érvénytelen Kód", "invalid_code": "Érvénytelen Kód",
"authorize": "Engedélyezés" "authorize": "Engedélyezés"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése", "show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
"show_large_home_carousel": "Show Large Home Carousel (beta)", "show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Könyvtárak Elrejtése", "hide_libraries": "Könyvtárak Elrejtése",
"select_libraries_you_want_to_hide": "Válaszd ki azokat a könyvtárakat, amelyeket el szeretnél rejteni a Könyvtár fülön és a kezdőlapon.", "select_liraries_you_want_to_hide": "Válaszd ki azokat a könyvtárakat, amelyeket el szeretnél rejteni a Könyvtár fülön és a kezdőlapon.",
"disable_haptic_feedback": "Haptikus Visszajelzés Letiltása", "disable_haptic_feedback": "Haptikus Visszajelzés Letiltása",
"default_quality": "Alapértelmezett Minőség", "default_quality": "Alapértelmezett Minőség",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Eszköz {{availableSpace}}%", "device_usage": "Eszköz {{availableSpace}}%",
"size_used": "{{used}} / {{total}} Használatban", "size_used": "{{used}} / {{total}} Használatban",
"delete_all_downloaded_files": "Minden Letöltött Fájl Törlése", "delete_all_downloaded_files": "Minden Letöltött Fájl Törlése",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Videó", "video": "Videó",
"audio": "Hang", "audio": "Hang",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Hiba", "error": "Hiba",
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t", "failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
"an_error_occurred_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.", "an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
"client_error": "Kliens Hiba", "client_error": "Kliens Hiba",
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt", "could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
"message_from_server": "Üzenet a szervertől: {{message}}", "message_from_server": "Üzenet a szervertől: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Capito", "got_it": "Capito",
"connection_failed": "Connessione fallita", "connection_failed": "Connessione fallita",
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.", "could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
"an_unexpected_error_occurred": "Si è verificato un errore inaspettato", "an_unexpected_error_occured": "Si è verificato un errore inaspettato",
"change_server": "Cambiare il server", "change_server": "Cambiare il server",
"invalid_username_or_password": "Nome utente o password non validi", "invalid_username_or_password": "Nome utente o password non validi",
"user_does_not_have_permission_to_log_in": "L'utente non ha il permesso di accedere", "user_does_not_have_permission_to_log_in": "L'utente non ha il permesso di accedere",
"server_is_taking_too_long_to_respond_try_again_later": "Il server sta impiegando troppo tempo per rispondere, riprovare più tardi", "server_is_taking_too_long_to_respond_try_again_later": "Il server sta impiegando troppo tempo per rispondere, riprovare più tardi",
"server_received_too_many_requests_try_again_later": "Il server ha ricevuto troppe richieste, riprovare più tardi.", "server_received_too_many_requests_try_again_later": "Il server ha ricevuto troppe richieste, riprovare più tardi.",
"there_is_a_server_error": "Si è verificato un errore del server", "there_is_a_server_error": "Si è verificato un errore del server",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?",
"too_old_server_text": "Scoperto Server Jellyfin non supportato", "too_old_server_text": "Scoperto Server Jellyfin non supportato",
"too_old_server_description": "Aggiorna Jellyfin all'ultima versione" "too_old_server_description": "Aggiorna Jellyfin all'ultima versione"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autorizza Connessione Rapida", "authorize_button": "Autorizza Connessione Rapida",
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...", "enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
"success": "Successo", "success": "Successo",
"quick_connect_authorized": "Connessione Rapida autorizzata", "quick_connect_autorized": "Connessione Rapida autorizzata",
"error": "Errore", "error": "Errore",
"invalid_code": "Codice invalido", "invalid_code": "Codice invalido",
"authorize": "Autorizza" "authorize": "Autorizza"
@@ -226,7 +226,7 @@
"hide_volume_slider": "Hide Volume Slider", "hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video", "hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
"hide_brightness_slider": "Hide Brightness Slider", "hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video" "hide_brightness_slider_description": "Hide the brightness slider in the video player"
}, },
"audio": { "audio": {
"audio_title": "Audio", "audio_title": "Audio",
@@ -237,10 +237,10 @@
"language": "Lingua", "language": "Lingua",
"transcode_mode": { "transcode_mode": {
"title": "Audio Transcoding", "title": "Audio Transcoding",
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)", "description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Automatico", "auto": "Auto",
"stereo": "Force Stereo", "stereo": "Force Stereo",
"5_1": "Consenti 5.1", "5_1": "Allow 5.1",
"passthrough": "Passthrough" "passthrough": "Passthrough"
} }
}, },
@@ -262,20 +262,20 @@
"OnlyForced": "Solo forzati" "OnlyForced": "Solo forzati"
}, },
"opensubtitles_title": "OpenSubtitles", "opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.", "opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key", "opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...", "opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers", "opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale", "mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin", "mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align", "mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align", "mpv_subtitle_align_y": "Vertical Align",
"align": { "align": {
"left": "Sinistra", "left": "Left",
"center": "Centro", "center": "Center",
"right": "Destra", "right": "Right",
"top": "Alto", "top": "Top",
"bottom": "Basso" "bottom": "Bottom"
} }
}, },
"other": { "other": {
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostra i link del menu personalizzato", "show_custom_menu_links": "Mostra i link del menu personalizzato",
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)", "show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
"hide_libraries": "Nascondi Librerie", "hide_libraries": "Nascondi Librerie",
"select_libraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.", "select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"disable_haptic_feedback": "Disabilita il feedback aptico", "disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita", "default_quality": "Qualità predefinita",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -307,9 +307,9 @@
"disabled": "Disabilitato" "disabled": "Disabilitato"
}, },
"music": { "music": {
"title": "Musica", "title": "Music",
"playback_title": "Riproduzione", "playback_title": "Playback",
"playback_description": "Configura come viene riprodotta la musica.", "playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs", "prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching", "caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.", "caching_description": "Automatically cache upcoming tracks for smoother playback.",
@@ -333,7 +333,7 @@
"tv_quota_days": "Giorni di quota per le serie TV", "tv_quota_days": "Giorni di quota per le serie TV",
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr", "reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"unlimited": "Illimitato", "unlimited": "Illimitato",
"plus_n_more": "+{{n}} altro", "plus_n_more": "+{{n}} more",
"order_by": { "order_by": {
"DEFAULT": "Predefinito", "DEFAULT": "Predefinito",
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media", "VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
@@ -352,25 +352,25 @@
} }
}, },
"streamystats": { "streamystats": {
"disable_streamystats": "Disabilita Streamystats", "disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search", "enable_search": "Use for Search",
"url": "URL", "url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com", "server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.", "streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read More About Streamystats.", "read_more_about_streamystats": "Read More About Streamystats.",
"save": "Salva", "save": "Save",
"features_title": "Funzionalità", "features_title": "Features",
"enable_movie_recommendations": "Movie Recommendations", "enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations", "enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists", "enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab", "hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.", "home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies", "recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series", "recommended_series": "Recommended Series",
"toasts": { "toasts": {
"saved": "Salvato", "saved": "Saved",
"refreshed": "Impostazioni aggiornate dal server", "refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabilitato" "disabled": "Streamystats disabled"
}, },
"refresh_from_server": "Refresh Settings from Server" "refresh_from_server": "Refresh Settings from Server"
}, },
@@ -384,20 +384,18 @@
"device_usage": "Dispositivo {{availableSpace}}%", "device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} di {{total}} usato", "size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati", "delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} nella cache", "music_cache_size": "{{size}} cached",
"music_cache_cleared": "Cache musicale cancellata", "music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs", "delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} scaricato", "downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Brani scaricati eliminati", "downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache", "clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?", "clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.", "clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache." "clear_all_cache_error_desc": "An error occurred while clearing the cache."
}, },
"intro": { "intro": {
"title": "Intro", "title": "Intro",
@@ -406,8 +404,8 @@
}, },
"logs": { "logs": {
"logs_title": "Log", "logs_title": "Log",
"export_logs": "Esporta i logs", "export_logs": "Export logs",
"click_for_more_info": "Clicca per maggiori informazioni", "click_for_more_info": "Click for more info",
"level": "Livello", "level": "Livello",
"no_logs_available": "Nessun log disponibile", "no_logs_available": "Nessun log disponibile",
"delete_all_logs": "Cancella tutti i log" "delete_all_logs": "Cancella tutti i log"
@@ -421,17 +419,17 @@
"error_deleting_files": "Errore nella cancellazione dei file" "error_deleting_files": "Errore nella cancellazione dei file"
}, },
"security": { "security": {
"title": "Sicurezza", "title": "Security",
"inactivity_timeout": { "inactivity_timeout": {
"title": "Inactivity Timeout", "title": "Inactivity Timeout",
"disabled": "Disabilitato", "disabled": "Disabled",
"1_minute": "1 minuto", "1_minute": "1 minute",
"5_minutes": "5 minuti", "5_minutes": "5 minutes",
"15_minutes": "15 minuti", "15_minutes": "15 minutes",
"30_minutes": "30 minuti", "30_minutes": "30 minutes",
"1_hour": "1 ora", "1_hour": "1 hour",
"4_hours": "4 ore", "4_hours": "4 hours",
"24_hours": "24 ore" "24_hours": "24 hours"
} }
} }
}, },
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Seleziona",
"no_trailer_available": "Nessun trailer disponibile", "no_trailer_available": "Nessun trailer disponibile",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -495,18 +494,18 @@
"mark_as_not_played": "Mark as not Played", "mark_as_not_played": "Mark as not Played",
"none": "Nulla", "none": "Nulla",
"track": "Traccia", "track": "Traccia",
"cancel": "Annulla", "cancel": "Cancel",
"delete": "Cancella", "delete": "Delete",
"ok": "OK", "ok": "OK",
"remove": "Rimuovi", "remove": "Remove",
"back": "Indietro", "back": "Back",
"continue": "Continua", "continue": "Continue",
"verifying": "Verifica in corso...", "verifying": "Verifying...",
"login": "Accedi", "login": "Login",
"episodes": "Episodi", "episodes": "Episodes",
"movies": "Film", "movies": "Movies",
"loading": "Caricamento…", "loading": "Loading…",
"seeAll": "Visualizza tutti" "seeAll": "See all"
}, },
"search": { "search": {
"search": "Cerca...", "search": "Cerca...",
@@ -520,10 +519,10 @@
"episodes": "Episodi", "episodes": "Episodi",
"collections": "Collezioni", "collections": "Collezioni",
"actors": "Attori", "actors": "Attori",
"artists": "Artisti", "artists": "Artists",
"albums": "Album", "albums": "Albums",
"songs": "Tracce", "songs": "Songs",
"playlists": "Playlist", "playlists": "Playlists",
"request_movies": "Film Richiesti", "request_movies": "Film Richiesti",
"request_series": "Serie Richieste", "request_series": "Serie Richieste",
"recently_added": "Aggiunti di Recente", "recently_added": "Aggiunti di Recente",
@@ -555,7 +554,7 @@
"movies": "film", "movies": "film",
"series": "serie TV", "series": "serie TV",
"boxsets": "cofanetti", "boxsets": "cofanetti",
"playlists": "Playlist", "playlists": "Playlists",
"items": "elementi" "items": "elementi"
}, },
"options": { "options": {
@@ -567,7 +566,7 @@
"cover": "Copertina", "cover": "Copertina",
"show_titles": "Mostra titoli", "show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche", "show_stats": "Mostra statistiche",
"options_title": "Impostazioni" "options_title": "Options"
}, },
"filters": { "filters": {
"genres": "Generi", "genres": "Generi",
@@ -576,10 +575,10 @@
"filter_by": "Filter By", "filter_by": "Filter By",
"sort_order": "Criterio di ordinamento", "sort_order": "Criterio di ordinamento",
"tags": "Tag", "tags": "Tag",
"all": "Tutto", "all": "All",
"reset": "Ripristina", "reset": "Reset",
"asc": "Crescente", "asc": "Ascending",
"desc": "Decrescente" "desc": "Descending"
} }
}, },
"favorites": { "favorites": {
@@ -596,50 +595,51 @@
"no_links": "Nessun link" "no_links": "Nessun link"
}, },
"player": { "player": {
"live": "IN DIRETTA", "live": "LIVE",
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Errore", "error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream", "failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
"an_error_occurred_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.", "an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
"client_error": "Errore del client", "client_error": "Errore del client",
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast", "could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
"message_from_server": "Messaggio dal server", "message_from_server": "Messaggio dal server",
"next_episode": "Prossimo Episodio", "next_episode": "Prossimo Episodio",
"continue_watching": "Continua a guardare", "continue_watching": "Continua a guardare",
"go_back": "Indietro", "go_back": "Indietro",
"downloaded_file_title": "Questo file è stato scaricato", "downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Vuoi riprodurre il file scaricato?", "downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Si", "downloaded_file_yes": "Yes",
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Annulla", "downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Scorri in basso per le impostazioni", "swipe_down_settings": "Swipe down for settings",
"ends_at": "Termina alle {{time}}", "ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles", "search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracce", "subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download", "subtitle_search": "Search & Download",
"download": "Scarica", "download": "Download",
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria", "subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Lingua", "language": "Language",
"results": "Risultati", "results": "Results",
"search_failed": "Ricerca fallita", "searching": "Searching...",
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server", "search_failed": "Search failed",
"no_subtitles_found": "Nessun sottotitolo trovato", "no_subtitle_provider": "No subtitle provider configured on server",
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni", "no_subtitles_found": "No subtitles found",
"settings": "Impostazioni", "add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro", "skip_intro": "Skip Intro",
"skip_credits": "Skip Credits", "skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback", "stopPlayback": "Stop Playback",
"stopPlayingTitle": "Interrompere la riproduzione \"{{title}}\"?", "stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?", "stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Scaricato", "downloaded": "Downloaded",
"missing_parameters": "Parametri di riproduzione mancanti" "missing_parameters": "Missing playback parameters"
}, },
"chapters": { "chapters": {
"title": "Capitoli", "title": "Chapters",
"chapter_number": "Capitolo {{number}}", "chapter_number": "Chapter {{number}}",
"open": "Apri capitoli", "open": "Open chapters",
"close": "Chiudi i capitoli" "close": "Close chapters"
}, },
"item_card": { "item_card": {
"next_up": "Il prossimo", "next_up": "Il prossimo",
@@ -664,19 +664,19 @@
"quality": "Qualità", "quality": "Qualità",
"audio": "Audio", "audio": "Audio",
"subtitles": { "subtitles": {
"label": "Sottotitoli", "label": "Subtitle",
"none": "Vuoto", "none": "None",
"tracks": "Tracce" "tracks": "Tracks"
}, },
"show_more": "Mostra di più", "show_more": "Mostra di più",
"show_less": "Mostra di meno", "show_less": "Mostra di meno",
"left": "sinistra", "left": "left",
"director": "Regista", "director": "Director",
"cast": "Cast", "cast": "Cast",
"technical_details": "Technical Details", "technical_details": "Technical Details",
"appeared_in": "Apparso in", "appeared_in": "Apparso in",
"movies": "Film", "movies": "Movies",
"shows": "Serie", "shows": "Shows",
"could_not_load_item": "Impossibile caricare l'elemento", "could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno", "none": "Nessuno",
"download": { "download": {
@@ -691,10 +691,10 @@
"mark_played": "Mark as Watched", "mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched", "mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback", "resume_playback": "Resume Playback",
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?", "resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start", "play_from_start": "Play from Start",
"continue_from": "Continua da {{time}}", "continue_from": "Continue from {{time}}",
"no_data_available": "Nessun dato disponibile" "no_data_available": "No data available"
}, },
"live_tv": { "live_tv": {
"next": "Prossimo", "next": "Prossimo",
@@ -706,16 +706,16 @@
"sports": "Sport", "sports": "Sport",
"for_kids": "Per Bambini", "for_kids": "Per Bambini",
"news": "Notiziari", "news": "Notiziari",
"page_of": "Pagina {{current}} di {{total}}", "page_of": "Page {{current}} of {{total}}",
"no_programs": "Nessun programma disponibile", "no_programs": "No programs available",
"no_channels": "Nessun canale disponibile", "no_channels": "No channels available",
"tabs": { "tabs": {
"programs": "Programmi", "programs": "Programs",
"guide": "Guida", "guide": "Guide",
"channels": "Canali", "channels": "Channels",
"recordings": "Registrazioni", "recordings": "Recordings",
"schedule": "Pianifica", "schedule": "Schedule",
"series": "Serie Tv" "series": "Series"
} }
}, },
"jellyseerr": { "jellyseerr": {
@@ -761,12 +761,12 @@
"decline": "Rifiuta", "decline": "Rifiuta",
"requested_by": "Richiesto da {{user}}", "requested_by": "Richiesto da {{user}}",
"unknown_user": "Utente Sconosciuto", "unknown_user": "Utente Sconosciuto",
"select": "Seleziona", "select": "Select",
"request_all": "Request All", "request_all": "Request All",
"request_seasons": "Request Seasons", "request_seasons": "Request Seasons",
"select_seasons": "Select Seasons", "select_seasons": "Select Seasons",
"request_selected": "Request Selected", "request_selected": "Request Selected",
"n_selected": "{{count}} selezionati", "n_selected": "{{count}} selected",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.", "jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.", "jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
@@ -787,39 +787,39 @@
"library": "Libreria", "library": "Libreria",
"custom_links": "Collegamenti personalizzati", "custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti", "favorites": "Preferiti",
"settings": "Impostazioni" "settings": "Settings"
}, },
"music": { "music": {
"title": "Musica", "title": "Music",
"tabs": { "tabs": {
"suggestions": "Suggerimenti", "suggestions": "Suggestions",
"albums": "Album", "albums": "Albums",
"artists": "Artisti", "artists": "Artists",
"playlists": "Playlist", "playlists": "Playlists",
"tracks": "tracks" "tracks": "tracks"
}, },
"recently_added": "Recently Added", "recently_added": "Recently Added",
"recently_played": "Recently Played", "recently_played": "Recently Played",
"frequently_played": "Frequently Played", "frequently_played": "Frequently Played",
"top_tracks": "Top Tracks", "top_tracks": "Top Tracks",
"play": "Riproduci", "play": "Play",
"shuffle": "Riproduzione casuale", "shuffle": "Shuffle",
"play_top_tracks": "Play Top Tracks", "play_top_tracks": "Play Top Tracks",
"no_suggestions": "Nessun suggerimento disponibile", "no_suggestions": "No suggestions available",
"no_albums": "Nessun album trovato", "no_albums": "No albums found",
"no_artists": "Artista non trovato", "no_artists": "No artists found",
"no_playlists": "Nessuna playlist trovata", "no_playlists": "No playlists found",
"album_not_found": "Album non trovato", "album_not_found": "Album not found",
"artist_not_found": "Artista non trovato", "artist_not_found": "Artist not found",
"playlist_not_found": "Playlist non trovata", "playlist_not_found": "Playlist not found",
"track_options": { "track_options": {
"play_next": "Play Next", "play_next": "Play Next",
"add_to_queue": "Add to Queue", "add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist", "add_to_playlist": "Add to Playlist",
"download": "Scarica", "download": "Download",
"downloaded": "Scaricato", "downloaded": "Downloaded",
"downloading": "Scaricamento...", "downloading": "Downloading...",
"cached": "Memorizzato nella cache", "cached": "Cached",
"delete_download": "Delete Download", "delete_download": "Delete Download",
"delete_cache": "Remove from Cache", "delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist", "go_to_artist": "Go to Artist",
@@ -831,112 +831,112 @@
"playlists": { "playlists": {
"create_playlist": "Create Playlist", "create_playlist": "Create Playlist",
"playlist_name": "Playlist Name", "playlist_name": "Playlist Name",
"enter_name": "Inserisci il nome della playlist", "enter_name": "Enter playlist name",
"create": "Crea", "create": "Create",
"search_playlists": "Cerca playlist...", "search_playlists": "Search playlists...",
"added_to": "Aggiunto a {{name}}", "added_to": "Added to {{name}}",
"added": "Aggiunto alla playlist", "added": "Added to playlist",
"removed_from": "Rimosso da {{name}}", "removed_from": "Removed from {{name}}",
"removed": "Rimosso dalla playlist", "removed": "Removed from playlist",
"created": "Playlist creata", "created": "Playlist created",
"create_new": "Create New Playlist", "create_new": "Create New Playlist",
"failed_to_add": "Impossibile aggiungere alla playlist", "failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Impossibile rimuovere dalla playlist", "failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Impossibile creare la playlist", "failed_to_create": "Failed to create playlist",
"delete_playlist": "Delete Playlist", "delete_playlist": "Delete Playlist",
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.", "delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist eliminata", "deleted": "Playlist deleted",
"failed_to_delete": "Impossibile eliminare la playlist" "failed_to_delete": "Failed to delete playlist"
}, },
"sort": { "sort": {
"title": "Sort By", "title": "Sort By",
"alphabetical": "Alfabetico", "alphabetical": "Alphabetical",
"date_created": "Date Created" "date_created": "Date Created"
} }
}, },
"watchlists": { "watchlists": {
"title": "Da vedere", "title": "Watchlists",
"my_watchlists": "My Watchlists", "my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists", "public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist", "create_title": "Create Watchlist",
"edit_title": "Edit Watchlist", "edit_title": "Edit Watchlist",
"create_button": "Create Watchlist", "create_button": "Create Watchlist",
"save_button": "Save Changes", "save_button": "Save Changes",
"delete_button": "Cancella", "delete_button": "Delete",
"remove_button": "Rimuovi", "remove_button": "Remove",
"cancel_button": "Annulla", "cancel_button": "Cancel",
"name_label": "Nome", "name_label": "Name",
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"", "name_placeholder": "Enter watchlist name",
"description_label": "Descrizione", "description_label": "Description",
"description_placeholder": "Inserisci descrizione (opzionale)", "description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist", "is_public_label": "Public Watchlist",
"is_public_description": "Permetti ad altri di vedere questa lista", "is_public_description": "Allow others to view this watchlist",
"allowed_type_label": "Content Type", "allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order", "sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists", "empty_title": "No Watchlists",
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media", "empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "Questa lista è vuota", "empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista", "empty_watchlist_hint": "Add items from your library to this watchlist",
"not_configured_title": "Streamystats Not Configured", "not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist", "not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Vai alle impostazioni", "go_to_settings": "Go to Settings",
"add_to_watchlist": "Add to Watchlist", "add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist", "remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist", "select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist", "create_new": "Create New Watchlist",
"item": "elemento", "item": "item",
"items": "elementi", "items": "items",
"public": "Pubblico", "public": "Public",
"private": "Privato", "private": "Private",
"you": "Tu", "you": "You",
"by_owner": "Da un altro utente", "by_owner": "By another user",
"not_found": "\"Da vedere\" non trovata", "not_found": "Watchlist not found",
"delete_confirm_title": "Delete Watchlist", "delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.", "delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"remove_item_title": "Remove from Watchlist", "remove_item_title": "Remove from Watchlist",
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?", "remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Caricamento liste...", "loading": "Loading watchlists...",
"no_compatible_watchlists": "Nessuna lista compatibile", "no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Crea una lista che accetti questo tipo di contenuto" "create_one_first": "Create a watchlist that accepts this content type"
}, },
"playback_speed": { "playback_speed": {
"title": "Playback Speed", "title": "Playback Speed",
"apply_to": "Apply To", "apply_to": "Apply To",
"speed": "Velocità", "speed": "Speed",
"scope": { "scope": {
"media": "Solo questo media", "media": "This media only",
"show": "Questo show", "show": "This show",
"all": "Tutti i media (predefinito)" "all": "All media (default)"
} }
}, },
"companion_login": { "companion_login": {
"title": "Associa con la TV", "title": "Pair with TV",
"align_qr": "Allinea il QR code all'interno del riquadro", "align_qr": "Align the QR code within the frame",
"enter_code_manually": "Inserisci il codice manualmente", "enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Inserire le credenziali per la TV", "pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Codice di associazione", "pairing_code_label": "Pairing code",
"server": "Server", "server": "Server",
"authorize_button": "Autorizza", "authorize_button": "Authorize",
"authorizing": "Autorizzando...", "authorizing": "Authorizing...",
"scan_again": "Scan Again", "scan_again": "Scan Again",
"done": "Fatto", "done": "Done",
"success_title": "Authorization Sent", "success_title": "Authorization Sent",
"pairing_tv_connecting": "La TV si sta collegando al tuo account", "pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed", "error_title": "Authorization Failed",
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.", "error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Si è verificato un errore. Riprova.", "error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.", "error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Accedi come {{username}}?", "login_as": "Log in as {{username}}?",
"on_server": "su {{server}}", "on_server": "on {{server}}",
"use_different_user": "Usa un altro utente", "use_different_user": "Use a different user",
"open_settings": "Apri le impostazioni" "open_settings": "Open Settings"
}, },
"pairing": { "pairing": {
"pair_with_phone": "Pair with Phone", "pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV", "pair_with_phone_title": "Login TV",
"waiting_for_phone": "In attesa del telefono...", "waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono", "scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Accesso in corso...", "logging_in": "Logging in...",
"logging_in_description": "Sto connettendo al server" "logging_in_description": "Connecting to your server"
} }
} }

View File

@@ -16,14 +16,14 @@
"got_it": "了解", "got_it": "了解",
"connection_failed": "接続に失敗しました", "connection_failed": "接続に失敗しました",
"could_not_connect_to_server": "サーバーに接続できませんでした。URLとネットワーク接続を確認してください。", "could_not_connect_to_server": "サーバーに接続できませんでした。URLとネットワーク接続を確認してください。",
"an_unexpected_error_occurred": "予期しないエラーが発生しました", "an_unexpected_error_occured": "予期しないエラーが発生しました",
"change_server": "サーバーの変更", "change_server": "サーバーの変更",
"invalid_username_or_password": "ユーザー名またはパスワードが無効です", "invalid_username_or_password": "ユーザー名またはパスワードが無効です",
"user_does_not_have_permission_to_log_in": "ユーザーにログイン権限がありません", "user_does_not_have_permission_to_log_in": "ユーザーにログイン権限がありません",
"server_is_taking_too_long_to_respond_try_again_later": "サーバーの応答に時間がかかりすぎています。しばらくしてからもう一度お試しください。", "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": "サーバーエラーが発生しました", "there_is_a_server_error": "サーバーエラーが発生しました",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか", "an_unexpected_error_occured_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか",
"too_old_server_text": "サポートされていないJellyfinサーバー発見", "too_old_server_text": "サポートされていないJellyfinサーバー発見",
"too_old_server_description": "Jellyfinを最新バージョンにアップデートしてください" "too_old_server_description": "Jellyfinを最新バージョンにアップデートしてください"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "クイックコネクトを承認する", "authorize_button": "クイックコネクトを承認する",
"enter_the_quick_connect_code": "クイックコネクトコードを入力...", "enter_the_quick_connect_code": "クイックコネクトコードを入力...",
"success": "成功しました", "success": "成功しました",
"quick_connect_authorized": "クイックコネクトが承認されました", "quick_connect_autorized": "クイックコネクトが承認されました",
"error": "エラー", "error": "エラー",
"invalid_code": "無効なコードです", "invalid_code": "無効なコードです",
"authorize": "承認" "authorize": "承認"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "カスタムメニューのリンクを表示", "show_custom_menu_links": "カスタムメニューのリンクを表示",
"show_large_home_carousel": "大きなヒーローBeta", "show_large_home_carousel": "大きなヒーローBeta",
"hide_libraries": "ライブラリを非表示", "hide_libraries": "ライブラリを非表示",
"select_libraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。", "select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
"disable_haptic_feedback": "触覚フィードバックを無効にする", "disable_haptic_feedback": "触覚フィードバックを無効にする",
"default_quality": "デフォルトの品質", "default_quality": "デフォルトの品質",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "デバイス {{availableSpace}}%", "device_usage": "デバイス {{availableSpace}}%",
"size_used": "{{used}} / {{total}} 使用済み", "size_used": "{{used}} / {{total}} 使用済み",
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除", "delete_all_downloaded_files": "すべてのダウンロードファイルを削除",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "選択",
"no_trailer_available": "トレーラーがありません", "no_trailer_available": "トレーラーがありません",
"video": "映像", "video": "映像",
"audio": "音声", "audio": "音声",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "エラー", "error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした", "failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occurred_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。", "an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
"client_error": "クライアントエラー", "client_error": "クライアントエラー",
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした", "could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
"message_from_server": "サーバーからのメッセージ", "message_from_server": "サーバーからのメッセージ",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "성공", "got_it": "성공",
"connection_failed": "연결 실패", "connection_failed": "연결 실패",
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.", "could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
"an_unexpected_error_occurred": "예기치 않은 오류가 발생했습니다", "an_unexpected_error_occured": "예기치 않은 오류가 발생했습니다",
"change_server": "서버 변경", "change_server": "서버 변경",
"invalid_username_or_password": "잘못된 아이디 혹은 비밀번호입니다", "invalid_username_or_password": "잘못된 아이디 혹은 비밀번호입니다",
"user_does_not_have_permission_to_log_in": "로그인 하기 위한 권한이 없습니다", "user_does_not_have_permission_to_log_in": "로그인 하기 위한 권한이 없습니다",
"server_is_taking_too_long_to_respond_try_again_later": "서버 응답이 너무 느립니다. 나중에 다시 시도하세요", "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": "서버 에러", "there_is_a_server_error": "서버 에러",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered", "too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version" "too_old_server_description": "Please update Jellyfin to the latest version"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "퀵 커넥트 승인", "authorize_button": "퀵 커넥트 승인",
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...", "enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
"success": "성공", "success": "성공",
"quick_connect_authorized": "퀵 커넥트 승인됨", "quick_connect_autorized": "퀵 커넥트 승인됨",
"error": "오류", "error": "오류",
"invalid_code": "유효하지 않은 코드", "invalid_code": "유효하지 않은 코드",
"authorize": "승인" "authorize": "승인"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시", "show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)", "show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
"hide_libraries": "라이브러리 숨기기", "hide_libraries": "라이브러리 숨기기",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback", "disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality", "default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "디바이스 {{availableSpace}}%", "device_usage": "디바이스 {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used", "size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files", "delete_all_downloaded_files": "Delete All Downloaded Files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error", "client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}", "message_from_server": "Message from Server: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Begrepen", "got_it": "Begrepen",
"connection_failed": "Verbinding mislukt", "connection_failed": "Verbinding mislukt",
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.", "could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
"an_unexpected_error_occurred": "Er is een onverwachte fout opgetreden", "an_unexpected_error_occured": "Er is een onverwachte fout opgetreden",
"change_server": "Server wijzigen", "change_server": "Server wijzigen",
"invalid_username_or_password": "Onjuiste gebruikersnaam of wachtwoord", "invalid_username_or_password": "Onjuiste gebruikersnaam of wachtwoord",
"user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden", "user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden",
"server_is_taking_too_long_to_respond_try_again_later": "De server doet er te lang over om te antwoorden, probeer later opnieuw", "server_is_taking_too_long_to_respond_try_again_later": "De server doet er te lang over om te antwoorden, probeer later opnieuw",
"server_received_too_many_requests_try_again_later": "De server heeft te veel aanvragen ontvangen, probeer later opnieuw", "server_received_too_many_requests_try_again_later": "De server heeft te veel aanvragen ontvangen, probeer later opnieuw",
"there_is_a_server_error": "Er is een serverfout", "there_is_a_server_error": "Er is een serverfout",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?",
"too_old_server_text": "Niet-ondersteunde Jellyfin Server Ontdekt", "too_old_server_text": "Niet-ondersteunde Jellyfin Server Ontdekt",
"too_old_server_description": "Werk Jellyfin bij naar de laatste versie" "too_old_server_description": "Werk Jellyfin bij naar de laatste versie"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Snel Verbinden toestaan", "authorize_button": "Snel Verbinden toestaan",
"enter_the_quick_connect_code": "Vul de Snel Verbinden code in...", "enter_the_quick_connect_code": "Vul de Snel Verbinden code in...",
"success": "Succes", "success": "Succes",
"quick_connect_authorized": "Snel Verbinden toegestaan", "quick_connect_autorized": "Snel Verbinden toegestaan",
"error": "Fout", "error": "Fout",
"invalid_code": "Ongeldige code", "invalid_code": "Ongeldige code",
"authorize": "Toestaan" "authorize": "Toestaan"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Aangepaste menulinks tonen", "show_custom_menu_links": "Aangepaste menulinks tonen",
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)", "show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
"hide_libraries": "Verberg Bibliotheken", "hide_libraries": "Verberg Bibliotheken",
"select_libraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.", "select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
"disable_haptic_feedback": "Haptische feedback uitschakelen", "disable_haptic_feedback": "Haptische feedback uitschakelen",
"default_quality": "Standaard kwaliteit", "default_quality": "Standaard kwaliteit",
"default_playback_speed": "Standaard Afspeelsnelheid", "default_playback_speed": "Standaard Afspeelsnelheid",
@@ -384,8 +384,6 @@
"device_usage": "Toestel {{availableSpace}}%", "device_usage": "Toestel {{availableSpace}}%",
"size_used": "{{used}} van {{total}} gebruikt", "size_used": "{{used}} van {{total}} gebruikt",
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden", "delete_all_downloaded_files": "Verwijder alle gedownloade bestanden",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Selecteren",
"no_trailer_available": "Geen trailer beschikbaar", "no_trailer_available": "Geen trailer beschikbaar",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Fout", "error": "Fout",
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen", "failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
"an_error_occurred_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.", "an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
"client_error": "Fout van de client", "client_error": "Fout van de client",
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast", "could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
"message_from_server": "Bericht van de server", "message_from_server": "Bericht van de server",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Har det", "got_it": "Har det",
"connection_failed": "Tilkobling mislyktes", "connection_failed": "Tilkobling mislyktes",
"could_not_connect_to_server": "Kunne ikke koble til serveren. Kontroller nettadressen og nettverkstilkoblingen.", "could_not_connect_to_server": "Kunne ikke koble til serveren. Kontroller nettadressen og nettverkstilkoblingen.",
"an_unexpected_error_occurred": "En uventet feil oppstod", "an_unexpected_error_occured": "En uventet feil oppstod",
"change_server": "Endre server", "change_server": "Endre server",
"invalid_username_or_password": "Ugyldig brukernavn eller passord", "invalid_username_or_password": "Ugyldig brukernavn eller passord",
"user_does_not_have_permission_to_log_in": "Brukeren har ikke tillatelse til å logge inn", "user_does_not_have_permission_to_log_in": "Brukeren har ikke tillatelse til å logge inn",
"server_is_taking_too_long_to_respond_try_again_later": "Serveren tar for lang tid å svare, prøv igjen senere", "server_is_taking_too_long_to_respond_try_again_later": "Serveren tar for lang tid å svare, prøv igjen senere",
"server_received_too_many_requests_try_again_later": "Serveren mottok for mange forespørsler, prøv igjen senere.", "server_received_too_many_requests_try_again_later": "Serveren mottok for mange forespørsler, prøv igjen senere.",
"there_is_a_server_error": "Det er en serverfeil", "there_is_a_server_error": "Det er en serverfeil",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Det oppstod en uventet feil. Sendte du inn URLen til serveren riktig?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Det oppstod en uventet feil. Sendte du inn URLen til serveren riktig?",
"too_old_server_text": "Ustøttet Jellyfin Server oppdaget", "too_old_server_text": "Ustøttet Jellyfin Server oppdaget",
"too_old_server_description": "Vennligst oppdater Jellyfin til nyeste versjon" "too_old_server_description": "Vennligst oppdater Jellyfin til nyeste versjon"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autoriser rask tilkobling", "authorize_button": "Autoriser rask tilkobling",
"enter_the_quick_connect_code": "Skriv inn hurtig-tilkobling kode...", "enter_the_quick_connect_code": "Skriv inn hurtig-tilkobling kode...",
"success": "Vellykket", "success": "Vellykket",
"quick_connect_authorized": "Hurtig tilkobling autorisert", "quick_connect_autorized": "Hurtig tilkobling autorisert",
"error": "Feil", "error": "Feil",
"invalid_code": "Ugyldig kode", "invalid_code": "Ugyldig kode",
"authorize": "Autoriser" "authorize": "Autoriser"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Vis tilpassede menylenker", "show_custom_menu_links": "Vis tilpassede menylenker",
"show_large_home_carousel": "Show Large Home Carousel (beta)", "show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Skjul biblioteker", "hide_libraries": "Skjul biblioteker",
"select_libraries_you_want_to_hide": "Velg bibliotekene du vil skjule deg for Biblioteket og avsnittene for hjemmesider.", "select_liraries_you_want_to_hide": "Velg bibliotekene du vil skjule deg for Biblioteket og avsnittene for hjemmesider.",
"disable_haptic_feedback": "Deaktiver Haptisk tilbakemelding", "disable_haptic_feedback": "Deaktiver Haptisk tilbakemelding",
"default_quality": "Standard kvalitet", "default_quality": "Standard kvalitet",
"default_playback_speed": "Default Playback Speed", "default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Enhet {{availableSpace}}%", "device_usage": "Enhet {{availableSpace}}%",
"size_used": "{{used}} av {{total}} er i bruk", "size_used": "{{used}} av {{total}} er i bruk",
"delete_all_downloaded_files": "Slett alle nedlastede filer", "delete_all_downloaded_files": "Slett alle nedlastede filer",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache", "music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache", "clear_music_cache": "Clear Music Cache",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Velg",
"no_trailer_available": "Ingen trailer tilgjengelig", "no_trailer_available": "Ingen trailer tilgjengelig",
"video": "Video", "video": "Video",
"audio": "Lyd", "audio": "Lyd",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Feil", "error": "Feil",
"failed_to_get_stream_url": "Kan ikke hente nettadressen for stream", "failed_to_get_stream_url": "Kan ikke hente nettadressen for stream",
"an_error_occurred_while_playing_the_video": "En feil oppstod under video. Sjekk loggene i innstillingene.", "an_error_occured_while_playing_the_video": "En feil oppstod under video. Sjekk loggene i innstillingene.",
"client_error": "Feil med annonsør", "client_error": "Feil med annonsør",
"could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast", "could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast",
"message_from_server": "Melding fra tjener: {{message}}", "message_from_server": "Melding fra tjener: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Rozumiem", "got_it": "Rozumiem",
"connection_failed": "Połączenie nieudane", "connection_failed": "Połączenie nieudane",
"could_not_connect_to_server": "Nie można połączyć się z serwerem. Sprawdź adres URL oraz połączenie sieciowe.", "could_not_connect_to_server": "Nie można połączyć się z serwerem. Sprawdź adres URL oraz połączenie sieciowe.",
"an_unexpected_error_occurred": "Wystąpił nieoczekiwany błąd", "an_unexpected_error_occured": "Wystąpił nieoczekiwany błąd",
"change_server": "Zmień serwer", "change_server": "Zmień serwer",
"invalid_username_or_password": "Nieprawidłowa nazwa użytkownika lub hasło", "invalid_username_or_password": "Nieprawidłowa nazwa użytkownika lub hasło",
"user_does_not_have_permission_to_log_in": "Użytkownik nie ma uprawnień do logowania", "user_does_not_have_permission_to_log_in": "Użytkownik nie ma uprawnień do logowania",
"server_is_taking_too_long_to_respond_try_again_later": "Serwer zbyt długo nie odpowiada spróbuj ponownie później", "server_is_taking_too_long_to_respond_try_again_later": "Serwer zbyt długo nie odpowiada spróbuj ponownie później",
"server_received_too_many_requests_try_again_later": "Serwer otrzymał zbyt wiele żądań spróbuj ponownie później.", "server_received_too_many_requests_try_again_later": "Serwer otrzymał zbyt wiele żądań spróbuj ponownie później.",
"there_is_a_server_error": "Wystąpił błąd serwera", "there_is_a_server_error": "Wystąpił błąd serwera",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Wystąpił nieoczekiwany błąd. Czy wpisałeś poprawny adres URL?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Wystąpił nieoczekiwany błąd. Czy wpisałeś poprawny adres URL?",
"too_old_server_text": "Wykryto nieobsługiwany serwer Jellyfin", "too_old_server_text": "Wykryto nieobsługiwany serwer Jellyfin",
"too_old_server_description": "Proszę zaktualizować Jellyfin do najnowszej wersji" "too_old_server_description": "Proszę zaktualizować Jellyfin do najnowszej wersji"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autoryzuj szybkie połączenie", "authorize_button": "Autoryzuj szybkie połączenie",
"enter_the_quick_connect_code": "Wpisz kod szybkiego połączenia...", "enter_the_quick_connect_code": "Wpisz kod szybkiego połączenia...",
"success": "Sukces", "success": "Sukces",
"quick_connect_authorized": "Szybkie połączenie autoryzowane", "quick_connect_autorized": "Szybkie połączenie autoryzowane",
"error": "Błąd", "error": "Błąd",
"invalid_code": "Nieprawidłowy kod", "invalid_code": "Nieprawidłowy kod",
"authorize": "Autoryzuj" "authorize": "Autoryzuj"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu", "show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu",
"show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)", "show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)",
"hide_libraries": "Ukryj biblioteki", "hide_libraries": "Ukryj biblioteki",
"select_libraries_you_want_to_hide": "Wybierz biblioteki, które chcesz ukryć na karcie Biblioteka i w sekcjach strony głównej.", "select_liraries_you_want_to_hide": "Wybierz biblioteki, które chcesz ukryć na karcie Biblioteka i w sekcjach strony głównej.",
"disable_haptic_feedback": "Wyłącz wibracje", "disable_haptic_feedback": "Wyłącz wibracje",
"default_quality": "Domyślna jakość", "default_quality": "Domyślna jakość",
"default_playback_speed": "Domyślna prędkość odtwarzania", "default_playback_speed": "Domyślna prędkość odtwarzania",
@@ -384,8 +384,6 @@
"device_usage": "Urządzenie {{availableSpace}}%", "device_usage": "Urządzenie {{availableSpace}}%",
"size_used": "{{used}} z {{total}} wykorzystane", "size_used": "{{used}} z {{total}} wykorzystane",
"delete_all_downloaded_files": "Usuń wszystkie pobrane pliki", "delete_all_downloaded_files": "Usuń wszystkie pobrane pliki",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Bufor muzyki", "music_cache_title": "Bufor muzyki",
"music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline", "music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline",
"clear_music_cache": "Wyczyść bufor muzyki", "clear_music_cache": "Wyczyść bufor muzyki",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Wybierz",
"no_trailer_available": "Brak dostępnego zwiastunu", "no_trailer_available": "Brak dostępnego zwiastunu",
"video": "Wideo", "video": "Wideo",
"audio": "Dźwięk", "audio": "Dźwięk",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "Błąd", "error": "Błąd",
"failed_to_get_stream_url": "Nie udało się pobrać adresu strumienia", "failed_to_get_stream_url": "Nie udało się pobrać adresu strumienia",
"an_error_occurred_while_playing_the_video": "Wystąpił błąd podczas odtwarzania wideo. Sprawdź logi w ustawieniach.", "an_error_occured_while_playing_the_video": "Wystąpił błąd podczas odtwarzania wideo. Sprawdź logi w ustawieniach.",
"client_error": "Błąd klienta", "client_error": "Błąd klienta",
"could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta", "could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta",
"message_from_server": "Wiadomość z serwera: {{message}}", "message_from_server": "Wiadomość z serwera: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -16,14 +16,14 @@
"got_it": "Tenho isso", "got_it": "Tenho isso",
"connection_failed": "Falha na conexão", "connection_failed": "Falha na conexão",
"could_not_connect_to_server": "Não foi possível conectar ao servidor. Verifique a URL e sua conexão de rede.", "could_not_connect_to_server": "Não foi possível conectar ao servidor. Verifique a URL e sua conexão de rede.",
"an_unexpected_error_occurred": "An unexpected error occurred", "an_unexpected_error_occured": "Ocorreu um erro inesperado",
"change_server": "Alterar Servidor", "change_server": "Alterar Servidor",
"invalid_username_or_password": "Usuário ou senha inválidos", "invalid_username_or_password": "Usuário ou senha inválidos",
"user_does_not_have_permission_to_log_in": "Usuário não tem permissão para fazer login", "user_does_not_have_permission_to_log_in": "Usuário não tem permissão para fazer login",
"server_is_taking_too_long_to_respond_try_again_later": "O servidor está demorando muito para responder, tente novamente mais tarde", "server_is_taking_too_long_to_respond_try_again_later": "O servidor está demorando muito para responder, tente novamente mais tarde",
"server_received_too_many_requests_try_again_later": "O servidor recebeu muitos pedidos, tente novamente mais tarde.", "server_received_too_many_requests_try_again_later": "O servidor recebeu muitos pedidos, tente novamente mais tarde.",
"there_is_a_server_error": "Existe um erro no servidor", "there_is_a_server_error": "Existe um erro no servidor",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?", "an_unexpected_error_occured_did_you_enter_the_correct_url": "Ocorreu um erro inesperado. Você inseriu a URL do servidor corretamente?",
"too_old_server_text": "Servidor Jellyfin Descoberto Não Suportado", "too_old_server_text": "Servidor Jellyfin Descoberto Não Suportado",
"too_old_server_description": "Por favor, atualize o Jellyfin para a versão mais recente" "too_old_server_description": "Por favor, atualize o Jellyfin para a versão mais recente"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Autorizar conexão rápida", "authorize_button": "Autorizar conexão rápida",
"enter_the_quick_connect_code": "Insira o código de conexão rápida...", "enter_the_quick_connect_code": "Insira o código de conexão rápida...",
"success": "Sucesso", "success": "Sucesso",
"quick_connect_authorized": "Quick Connect authorized", "quick_connect_autorized": "Acesso Rápido Autorizado",
"error": "Erro", "error": "Erro",
"invalid_code": "Código inválido", "invalid_code": "Código inválido",
"authorize": "Autorizar" "authorize": "Autorizar"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostrar Links de Menu Personalizado", "show_custom_menu_links": "Mostrar Links de Menu Personalizado",
"show_large_home_carousel": "Mostrar Carrossel Grande (beta)", "show_large_home_carousel": "Mostrar Carrossel Grande (beta)",
"hide_libraries": "Ocultar bibliotecas", "hide_libraries": "Ocultar bibliotecas",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "select_liraries_you_want_to_hide": "Selecione as bibliotecas que você deseja ocultar da aba Biblioteca e seções da página inicial.",
"disable_haptic_feedback": "Desativar o retorno tátil", "disable_haptic_feedback": "Desativar o retorno tátil",
"default_quality": "Qualidade Padrão", "default_quality": "Qualidade Padrão",
"default_playback_speed": "Velocidade padrão de reprodução", "default_playback_speed": "Velocidade padrão de reprodução",
@@ -384,8 +384,6 @@
"device_usage": "Dispositivo {{availableSpace}}%", "device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} de {{total}} Utilizados", "size_used": "{{used}} de {{total}} Utilizados",
"delete_all_downloaded_files": "Excluir todos os arquivos baixados", "delete_all_downloaded_files": "Excluir todos os arquivos baixados",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Cache de Música", "music_cache_title": "Cache de Música",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Limpar Cache de Música", "clear_music_cache": "Limpar Cache de Música",
@@ -486,6 +484,7 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Selecionar",
"no_trailer_available": "Nenhum trailer disponível", "no_trailer_available": "Nenhum trailer disponível",
"video": "Vídeo", "video": "Vídeo",
"audio": "Áudio", "audio": "Áudio",
@@ -600,7 +599,7 @@
"mpv_player_title": "MPV Player", "mpv_player_title": "MPV Player",
"error": "ERRO", "error": "ERRO",
"failed_to_get_stream_url": "Falha ao obter a URL de transmissão", "failed_to_get_stream_url": "Falha ao obter a URL de transmissão",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occured_while_playing_the_video": "Ocorreu um erro ao reproduzir o vídeo. Verifique os logs nas configurações.",
"client_error": "Erro de Cliente", "client_error": "Erro de Cliente",
"could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast", "could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast",
"message_from_server": "Mensagem do Servidor: {{message}}", "message_from_server": "Mensagem do Servidor: {{message}}",
@@ -622,6 +621,7 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

Some files were not shown because too many files have changed in this diff Show More