Compare commits

..

4 Commits

Author SHA1 Message Date
lance chant
b39eb35795 Merge branch 'develop' into jellyfin-12-fixes 2026-07-15 08:36:40 +02:00
Gauvain
bd0eef0407 fix(i18n): correct misspelled translation keys (#1804)
Some checks are pending
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🌐 Translation Sync / sync-translations (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
2026-07-15 08:36:11 +02:00
github-actions[bot]
95b5dab293 feat: New Crowdin Translations (#1758)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-15 00:23:30 +02:00
Simon Eklundh
fbeb025d73 fix: jellyfin 12 fixes 2026-07-14 16:13:49 +02:00
118 changed files with 1332 additions and 2698 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { File, Paths } from "expo-file-system";
import { requireOptionalNativeModule } from "expo-modules-core";
import { useNavigation } from "expo-router";
import type * as SharingType from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
@@ -8,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
@@ -75,25 +72,6 @@ export default function Page() {
}
}, [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(() => {
if (Platform.isTV) return;
@@ -110,15 +88,8 @@ export default function Page() {
}, [share, loading]);
return (
<ScrollView
// 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'>
<View className='flex-1'>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton
id={orderFilterId}
queryKey='log'
@@ -141,77 +112,67 @@ export default function Page() {
multiple={true}
/>
</View>
<View className='flex flex-col space-y-2 px-4'>
{filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
<TouchableOpacity
disabled={!log.data}
onPress={() =>
setState((v) => ({
...v,
[log.timestamp]: !v[log.timestamp],
}))
}
>
<View className='flex flex-row justify-between'>
<Text
className={`mb-1
<ScrollView
className='pb-4 px-4'
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View className='flex flex-col space-y-2'>
{filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
<TouchableOpacity
disabled={!log.data}
onPress={() =>
setState((v) => ({
...v,
[log.timestamp]: !v[log.timestamp],
}))
}
>
<View className='flex flex-row justify-between'>
<Text
className={`mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
${log.level === "DEBUG" && "text-purple-500"}
`}
>
{log.level}
</Text>
<Text className='text-xs'>
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text className='text-xs'>{log.message}</Text>
{/* Keep the whole collapsed row tappable: the hint lives inside
the toggle so tapping it expands too. */}
{log.data && !state[log.timestamp] && (
<Text className='text-xs mt-0.5'>
{t("home.settings.logs.click_for_more_info")}
</Text>
)}
</TouchableOpacity>
{log.data && (
<Collapsible collapsed={!state[log.timestamp]}>
<View className='mt-2 flex flex-col space-y-2'>
<ScrollView
className='rounded-xl'
style={codeBlockStyle}
nestedScrollEnabled
>
{/* 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>
)}
{log.level}
</Text>
<Text className='text-xs'>
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
</Collapsible>
)}
</View>
))}
{filteredLogs?.length === 0 && (
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
</Text>
)}
</View>
</ScrollView>
<Text selectable className='text-xs'>
{log.message}
</Text>
</TouchableOpacity>
{log.data && (
<>
{!state[log.timestamp] && (
<Text className='text-xs mt-0.5'>
{t("home.settings.logs.click_for_more_info")}
</Text>
)}
<Collapsible collapsed={!state[log.timestamp]}>
<View className='mt-2 flex flex-col space-y-2'>
<ScrollView className='rounded-xl' style={codeBlockStyle}>
<Text>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>
</View>
</Collapsible>
</>
)}
</View>
))}
{filteredLogs?.length === 0 && (
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
</Text>
)}
</View>
</ScrollView>
</View>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,9 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { Platform, ScrollView, View } from "react-native";
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 { useSettings } from "@/utils/atoms/settings";
export default function PluginsPage() {
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 (
<ScrollView
@@ -30,17 +18,6 @@ export default function PluginsPage() {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<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>
</ScrollView>
);

View File

@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
@@ -22,7 +22,12 @@ export default function StreamystatsPage() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const {
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields
@@ -44,21 +49,7 @@ export default function StreamystatsPage() {
);
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
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 isStreamystatsEnabled = !!url;
const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
@@ -122,6 +113,17 @@ export default function StreamystatsPage() {
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;
return (
@@ -132,7 +134,7 @@ export default function StreamystatsPage() {
paddingRight: insets.right,
}}
>
<View className='px-4 pt-4'>
<View className='px-4'>
<ListGroup className='flex-1'>
<ListItem
title={t("home.settings.plugins.streamystats.url")}
@@ -144,7 +146,7 @@ export default function StreamystatsPage() {
placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder",
)}
value={effectiveUrl}
value={url}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
@@ -169,18 +171,11 @@ export default function StreamystatsPage() {
>
<ListItem
title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={searchLocked}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
>
{/* Locked controls show the live admin value and can't be toggled —
local form state would let the switch flip while the write guard
drops the change. */}
<SettingSwitch
value={
searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
<Switch
value={useForSearch}
disabled={!isStreamystatsEnabled}
onValueChange={setUseForSearch}
/>
</ListItem>
@@ -188,62 +183,52 @@ export default function StreamystatsPage() {
title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations",
)}
disabledByAdmin={movieRecsLocked}
disabledByAdmin={
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
>
<SettingSwitch
value={
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
<Switch
value={movieRecs}
onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled || movieRecsLocked}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_series_recommendations",
)}
disabledByAdmin={seriesRecsLocked}
disabledByAdmin={
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
>
<SettingSwitch
value={
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
<Switch
value={seriesRecs}
onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled || seriesRecsLocked}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists",
)}
disabledByAdmin={promotedWatchlistsLocked}
disabledByAdmin={
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
>
<SettingSwitch
value={
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
<Switch
value={promotedWatchlists}
onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={hideWatchlistsTabLocked}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
>
<SettingSwitch
value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
<Switch
value={hideWatchlistsTab}
onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
</ListGroup>
@@ -251,6 +236,15 @@ export default function StreamystatsPage() {
{t("home.settings.plugins.streamystats.home_sections_hint")}
</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 */}
{!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity

View File

@@ -23,7 +23,6 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -135,12 +134,6 @@ const page: React.FC = () => {
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
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;
@@ -211,39 +204,40 @@ const page: React.FC = () => {
],
);
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
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;
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;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
@@ -332,7 +326,7 @@ const page: React.FC = () => {
data={[
{
key: "reset",
component: <ResetFiltersButton libraryId={collectionId} />,
component: <ResetFiltersButton />,
},
{
key: "genre",
@@ -472,6 +466,7 @@ const page: React.FC = () => {
setSortBy,
sortOrder,
setSortOrder,
isFetching,
],
);
@@ -638,45 +633,43 @@ const page: React.FC = () => {
// Mobile return
if (!Platform.isTV) {
return (
<FilterSheetProvider>
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
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>
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}

View File

@@ -9,7 +9,7 @@ import {
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import {
@@ -35,7 +35,6 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -45,7 +44,6 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useFilterReset } from "@/hooks/useFilterReset";
import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -57,9 +55,7 @@ import {
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
genrePreferenceAtom,
getFilterByPreference,
getMultiFilterPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -70,12 +66,11 @@ import {
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom,
useFilterOptions,
yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -113,9 +108,6 @@ const Page = () => {
const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
const { orientation } = useOrientation();
@@ -213,13 +205,6 @@ const Page = () => {
const fp = getFilterByPreference(libraryId, filterByPreference);
_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,
sortOrderPreference,
@@ -228,12 +213,6 @@ const Page = () => {
_setSortBy,
filterByPreference,
_setFilterBy,
genrePreference,
yearPreference,
tagPreference,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
@@ -278,32 +257,6 @@ const Page = () => {
[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(() => {
if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile
@@ -462,29 +415,6 @@ const Page = () => {
);
}, [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(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
@@ -600,6 +530,7 @@ const Page = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
<FlatList
@@ -614,7 +545,7 @@ const Page = () => {
data={[
{
key: "reset",
component: <ResetFiltersButton libraryId={libraryId} />,
component: <ResetFiltersButton />,
},
{
key: "genre",
@@ -633,7 +564,7 @@ const Page = () => {
});
return response.data.Genres || [];
}}
set={setGenres}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
@@ -660,7 +591,7 @@ const Page = () => {
});
return response.data.Years || [];
}}
set={setYears}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
@@ -685,7 +616,7 @@ const Page = () => {
});
return response.data.Tags || [];
}}
set={setTags}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
@@ -765,23 +696,35 @@ const Page = () => {
api,
user?.Id,
selectedGenres,
setGenres,
setSelectedGenres,
selectedYears,
setYears,
setSelectedYears,
selectedTags,
setTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
filterBy,
setFilter,
settings,
],
);
// Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
// sort/order can't be forgotten on one path (it used to be reset on neither).
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
// TV Filter bar header
const hasActiveFilters =
selectedGenres.length > 0 ||
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
const tvGenreFilterOptions = useMemo(
@@ -875,15 +818,15 @@ const Page = () => {
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setGenres([]);
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setGenres(selectedGenres.filter((g) => g !== value));
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setGenres([...selectedGenres, value]);
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
@@ -891,15 +834,15 @@ const Page = () => {
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setYears([]);
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setYears(selectedYears.filter((y) => y !== value));
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setYears([...selectedYears, value]);
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
@@ -907,15 +850,15 @@ const Page = () => {
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setTags([]);
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setTags(selectedTags.filter((tag) => tag !== value));
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setTags([...selectedTags, value]);
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
@@ -963,45 +906,42 @@ const Page = () => {
// Mobile return
if (!Platform.isTV) {
return (
<FilterSheetProvider>
<FlashList
ref={flashListRef}
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -86,8 +85,7 @@ configureReanimatedLogger({
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
@@ -352,12 +350,9 @@ function Layout() {
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log(
"Notification received while app running:",
notification.request.content.title,
"Notification received while app running",
notification,
);
},
);
@@ -552,7 +547,6 @@ function Layout() {
closeButton
/>
{!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>

View File

@@ -31,7 +31,6 @@
"expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.18",
"expo-camera": "~56.0.8",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.18",
"expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.20",
@@ -1002,8 +1001,6 @@
"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-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],

View File

@@ -1,5 +1,4 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -62,7 +61,6 @@ export const BitrateSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => {
if (inverted)
@@ -94,10 +92,7 @@ export const BitrateSheet: React.FC<Props> = ({
</Text>
<TouchableOpacity
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);
sheetModalRef.current?.present();
}}
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -108,7 +103,6 @@ export const BitrateSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")}
data={sorted}
values={selected ? [selected] : []}

View File

@@ -35,10 +35,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Episode") {
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
@@ -63,9 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
// useEpisodePoster in deps so flipping the setting re-computes the URL live
// (no app restart needed).
}, [item, useEpisodePoster]);
}, [item]);
if (!url)
return <View className='aspect-video border border-neutral-800 w-44' />;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { Keyboard, Platform } from "react-native";
import { Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
@@ -16,37 +16,30 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
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") {
return (
<Pressable
onPress={handleBack}
onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9'
{...pressableProps}
>
<Feather name='chevron-left' size={28} color='white' />
<Ionicons name='arrow-back' size={24} color='white' />
</Pressable>
);
}
if (background === "transparent" && Platform.OS !== "android")
return (
<Pressable onPress={handleBack} {...pressableProps}>
<Pressable onPress={() => router.back()} {...pressableProps}>
<BlurView
{...props}
intensity={100}
className='overflow-hidden rounded-full p-2'
>
<Feather
<Ionicons
className='drop-shadow-2xl'
name='chevron-left'
size={28}
name='arrow-back'
size={24}
color='white'
/>
</BlurView>
@@ -55,17 +48,14 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<Pressable
onPress={handleBack}
// 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 }}
onPress={() => router.back()}
className=' rounded-full p-2'
{...pressableProps}
>
<Feather
<Ionicons
className='drop-shadow-2xl'
name='chevron-left'
size={28}
name='arrow-back'
size={24}
color='white'
/>
</Pressable>

View File

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

View File

@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter();
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
// instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => {
const seriesId = items[0]?.SeriesId;
return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () =>

View File

@@ -1,11 +1,9 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useRef, useState } from "react";
import { useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet";
import { useFilterSheet } from "./FilterSheetProvider";
interface FilterButtonProps<T> extends ViewProps {
id: string;
@@ -35,63 +33,22 @@ export const FilterButton = <T,>({
icon = "filter",
...props
}: FilterButtonProps<T>) => {
// 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 [open, setOpen] = useState(false);
const { data: filters, isLoading } = useQuery<T[]>({
const { data: filters } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
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 (
<>
<TouchableOpacity onPress={onButtonPress}>
<TouchableOpacity
onPress={() => {
filters?.length && setOpen(true);
}}
>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
@@ -128,22 +85,18 @@ export const FilterButton = <T,>({
)}
</View>
</TouchableOpacity>
{!shared && (
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
loading={isLoading}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
)}
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
</>
);
};

View File

@@ -7,14 +7,7 @@ import {
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import type React from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
@@ -26,21 +19,11 @@ 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";
interface Props<T> extends ViewProps {
open: boolean;
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;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[];
set: (value: T[]) => void;
title: string;
@@ -83,18 +66,16 @@ const LIMIT = 100;
export const FilterSheet = <T,>({
values,
data: _data,
loading = false,
open,
set,
setOpen,
modalRef,
title,
searchFilter,
renderItemLabel,
disableSearch = false,
multiple = false,
}: Props<T>) => {
const bottomSheetModalRef = modalRef;
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -103,24 +84,19 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0);
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 filteredData = useMemo(() => {
if (!deferredSearch) return _data;
if (!search) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
if (_data && searchFilter?.(_data[i], search)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [deferredSearch, _data, searchFilter]);
}, [search, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
@@ -151,28 +127,21 @@ export const FilterSheet = <T,>({
setData(newData);
}, [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(() => {
if (!open && wasPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
}
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
if (index === -1) {
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
if (deferredSearch.length > 0 && showSearch) return filteredData;
if (search.length > 0 && showSearch) return filteredData;
return data;
}, [deferredSearch, showSearch, filteredData, data]);
}, [search, filteredData, data]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -185,54 +154,6 @@ 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 (
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -261,15 +182,9 @@ export const FilterSheet = <T,>({
}}
>
<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>
)}
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
{showSearch && (
<Input
placeholder={t("search.search")}
@@ -288,7 +203,43 @@ export const FilterSheet = <T,>({
}}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderedRows}
{renderData?.map((item, index) => (
<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>
{data.length < (_data?.length || 0) && (
<Button

View File

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

View File

@@ -1,286 +0,0 @@
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,11 +39,7 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
import {
apiAtom,
pendingAccountSaveAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -93,9 +89,6 @@ const HomeMobile = () => {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
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
// (primary path is the LibraryChanged WebSocket event).
@@ -104,9 +97,7 @@ const HomeMobile = () => {
// Show intro modal on first launch
useEffect(() => {
const hasShownIntro = storage.getBoolean("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) {
if (!hasShownIntro) {
const timer = setTimeout(() => {
showIntro();
}, 1000);
@@ -115,7 +106,7 @@ const HomeMobile = () => {
clearTimeout(timer);
};
}
}, [showIntro, pendingAccountSave]);
}, [showIntro]);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
@@ -230,22 +221,33 @@ const HomeMobile = () => {
queryKey,
queryFn: async ({ pageParam = 0 }) => {
if (!api) return [];
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 10,
fields: ["PrimaryImageAspectRatio"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
// Use getItems (not getLatestMedia) so we get item-level results
// filtered by type from a specific library. getLatestMedia is
// episode-oriented and groups results, which drops Series when
// combined with a parentId + includeItemTypes filter.
//
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
//
// For Series we sort by DateLastContentAdded so shows bubble up when
// a new episode is added (series cards for new episodes, matching how
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId,
includeItemTypes,
recursive: true,
sortBy: includeItemTypes.includes("Series")
? ["DateLastContentAdded"]
: ["DateCreated"],
sortOrder: ["Descending"],
startIndex: pageParam,
limit: pageSize,
fields: ["PrimaryImageAspectRatio"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response.data.Items || [];
},
type: "InfiniteScrollingCollectionList",
pageSize,
@@ -259,9 +261,7 @@ const HomeMobile = () => {
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
"home",

View File

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

View File

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

View File

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

View File

@@ -65,11 +65,10 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
const posterUrl = useMemo(() => {
if (!api) return null;
// For episodes, always use series thumb.
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
// For episodes, always use series thumb
if (item.Type === "Episode") {
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
if (item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
}
if (item.SeriesId) {
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;

View File

@@ -23,11 +23,9 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
return (
<View {...props}>
{title ? (
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
{title}
</Text>
) : null}
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
{title}
</Text>
<View
style={[]}
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 type { PropsWithChildren, ReactNode } from "react";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text";
interface Props extends ViewProps {
@@ -34,17 +34,12 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
}) => {
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
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)
return (
<TouchableOpacity
disabled={isDisabled}
onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
<ListItemContent
@@ -63,7 +58,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
);
return (
<View
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...viewProps}
>
<ListItemContent

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -20,16 +20,14 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import {
apiAtom,
pendingAccountSaveAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
import type { SavedServer } from "@/utils/secureCredentials";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
@@ -39,17 +37,14 @@ export const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const user = useAtomValue(userAtom);
const {
setServer,
login,
removeServer,
initiateQuickConnect,
stopQuickConnectPolling,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const {
apiUrl: _apiUrl,
@@ -69,43 +64,13 @@ export const Login: React.FC = () => {
password: _password || "",
});
// 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.
// Save account state
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
useEffect(() => {
@@ -152,22 +117,29 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
const ok = await performLogin(credentials.username, credentials.password);
// The protection picker shows AFTER a successful login (global modal) —
// never for a failed one.
if (ok && saveAccount) {
setPendingAccountSave({ serverName });
if (saveAccount) {
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
): Promise<boolean> => {
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName);
return true;
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
@@ -177,9 +149,23 @@ export const Login: React.FC = () => {
t("login.an_unexpected_error_occurred"),
);
}
return false;
} finally {
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,
});
}
};
@@ -273,7 +259,15 @@ export const Login: React.FC = () => {
try {
const code = await initiateQuickConnect();
if (code) {
setQuickConnectCode(code);
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
}
} catch (_error) {
Alert.alert(
@@ -408,7 +402,7 @@ export const Login: React.FC = () => {
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label={t("server.server_url")}
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
@@ -450,11 +444,14 @@ export const Login: React.FC = () => {
)}
</KeyboardAvoidingView>
{/* Dismissing only hides the code — polling continues so the login still
completes if the code is authorized from another device afterwards. */}
<QuickConnectCodeModal
code={quickConnectCode}
onClose={() => setQuickConnectCode(null)}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,10 +20,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation();
const [user] = useAtom(userAtom);
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 { settings, updateSettings } = useSettings();
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
@@ -118,41 +115,30 @@ export const JellyseerrSettings = () => {
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</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>
)}
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
/>
<View>
<Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { Feather } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
@@ -6,13 +5,11 @@ import {
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { requireOptionalNativeModule } from "expo-modules-core";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button";
@@ -82,15 +79,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
}
}, [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;
return (
@@ -142,15 +130,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
style={{ paddingHorizontal: 16 }}
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>
<Button

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
const calculatePercentage = (value: number, total: number) => {
@@ -125,7 +102,7 @@ export const StorageSettings = () => {
</View>
</View>
{!Platform.isTV && (
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}

View File

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

View File

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

View File

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

View File

@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText={t("player.skip_intro")}
buttonText='Skip Intro'
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText={t("player.skip_credits")}
buttonText='Skip Credits'
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,13 @@
// 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 { NavigationContext } from "expo-router/react-navigation";
import { useCallback, useContext, useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
/**
* Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation and guards against duplicate
* screens from rapid taps.
* preserves offline state across navigation.
*
* - For object-form navigation, automatically adds offline=true when in offline context
* - 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
* import useRouter from "@/hooks/useAppRouter";
@@ -25,18 +19,8 @@ export function useAppRouter() {
const router = useRouter();
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(
(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") {
router.push(href as any);
} else {
@@ -52,7 +36,7 @@ export function useAppRouter() {
} as any);
}
},
[router, isOffline, navigation],
[router, isOffline],
);
const replace = useCallback(

View File

@@ -1,108 +0,0 @@
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 (data.version < "2.0.0") {
const error = t(
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
);
toast.error(error);
throw Error(error);

View File

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

View File

@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
return items.reduce((sum, item) => {
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
}

View File

@@ -289,24 +289,7 @@ export function useDownloadOperations({
);
const appSizeUsage = useCallback(async () => {
let totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
const totalSize = calculateTotalDownloadedSize();
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -327,7 +310,7 @@ export function useDownloadOperations({
appSize: totalSize,
};
}
}, [processes]);
}, []);
return {
startBackgroundDownload,

View File

@@ -15,7 +15,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
@@ -92,12 +91,6 @@ export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null);
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 {
saveAccount?: boolean;
@@ -115,11 +108,6 @@ interface JellyfinContextValue {
serverName?: string,
options?: LoginOptions,
) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void;
@@ -177,46 +165,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
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(() => {
if (!deviceId) return {};
return {
@@ -359,37 +307,6 @@ 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({
mutationFn: async ({
username,
@@ -592,9 +509,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
},
onError: (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);
console.error("Quick login failed:", error);
},
});
@@ -705,62 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser);
}
// Validate the token and refresh user data in the background. Do NOT
// await this: the Jellyfin SDK axios instance has no timeout, so when
// 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);
// Dismiss splash screen with cached data immediately,
// fetch fresh user data in the background
setInitialLoaded(true);
// 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,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
})
.catch((e) => {
// Expected, handled case (offline, or a token the server rejects —
// the UI prompts re-login): warn, don't error. Log only
// status/message — never the raw error (axios errors carry the
// request config incl. the Authorization header / token).
console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
try {
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// 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,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
}
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
}
};
@@ -774,7 +681,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }),
saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
stopQuickConnectPolling,

View File

@@ -173,7 +173,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const protocol = api.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
.replace("http://", "")}/socket?ApiKey=${
api.accessToken
}&deviceId=${deviceId}`;

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو",
"audio": "الصوت",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Vídeo",
"audio": "Àudio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video",
"audio": "Zvuk",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video",
"audio": "Lyd",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Sprache",
"results": "Ergebnisse",
"searching": "Suche ...",
"search_failed": "Suche fehlgeschlagen",
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
"no_subtitles_found": "Keine Untertitel gefunden",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο",
"audio": "Ήχος",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -12,9 +12,6 @@
"login_button": "Log in",
"quick_connect": "Quick Connect",
"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",
"got_it": "Got it",
"connection_failed": "Connection failed",
@@ -36,7 +33,6 @@
"connect_button": "Connect",
"previous_servers": "Previous servers",
"clear_button": "Clear all",
"server_url": "Server URL",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for local servers",
"searching": "Searching...",
@@ -141,11 +137,7 @@
"appearance": {
"title": "Appearance",
"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_hint": "Hide the remote-sessions button from the home header.",
"show_home_backdrop": "Dynamic home backdrop",
"show_hero_carousel": "Hero carousel",
"show_series_poster_on_episode": "Show series poster on episodes",
@@ -199,8 +191,7 @@
"quick_connect_authorized": "Quick Connect authorized",
"error": "Error",
"invalid_code": "Invalid code",
"authorize": "Authorize",
"paste_code": "Paste code"
"authorize": "Authorize"
},
"media_controls": {
"media_controls_title": "Media controls",
@@ -279,10 +270,6 @@
"mpv_subtitle_margin_y": "Vertical margin",
"mpv_subtitle_align_x": "Horizontal 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": {
"left": "Left",
"center": "Center",
@@ -309,7 +296,6 @@
},
"safe_area_in_controls": "Safe area in controls",
"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)",
"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.",
@@ -398,8 +384,6 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used",
"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_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear music cache",
@@ -424,9 +408,7 @@
"click_for_more_info": "Click for more info",
"level": "Level",
"no_logs_available": "No logs available",
"delete_all_logs": "Delete all logs",
"copy": "Copy",
"copied": "Copied to clipboard"
"delete_all_logs": "Delete all logs"
},
"languages": {
"title": "Languages",
@@ -453,13 +435,10 @@
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No active sessions",
"select_session": "Select Session",
"now_playing": "Now playing:"
"no_active_sessions": "No active sessions"
},
"downloads": {
"downloads_title": "Downloads",
"transcoding": "Transcoding",
"series": "Series",
"movies": "Movies",
"other_media": "Other media",
@@ -515,8 +494,6 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"open_menu": "Open Menu",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
@@ -618,31 +595,7 @@
},
"player": {
"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",
"aspect_ratio": "Aspect Ratio",
"aspect_ratio_original": "Original",
"hash_match": "Hash Match",
"still_watching": "Are you still watching?",
"error": "Error",
"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.",
@@ -742,7 +695,6 @@
"no_data_available": "No data available"
},
"live_tv": {
"title": "Live TV",
"next": "Next",
"previous": "Previous",
"coming_soon": "Coming soon",
@@ -814,7 +766,7 @@
"request_selected": "Request selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseer_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.",
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
"issue_submitted": "Issue submitted!",
@@ -827,16 +779,6 @@
"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": {
"home": "Home",
"search": "Search",
@@ -847,12 +789,6 @@
},
"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": {
"suggestions": "Suggestions",
"albums": "Albums",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo",
"audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video",
"audio": "Ääni",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

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

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "בחר",
"no_trailer_available": "אין טריילר זמין",
"video": "וידאו",
"audio": "שמע",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Videó",
"audio": "Hang",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

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

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "選択",
"no_trailer_available": "トレーラーがありません",
"video": "映像",
"audio": "音声",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Selecteren",
"no_trailer_available": "Geen trailer beschikbaar",
"video": "Video",
"audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
},
"common": {
"no_results": "No Results",
"select": "Velg",
"no_trailer_available": "Ingen trailer tilgjengelig",
"video": "Video",
"audio": "Lyd",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",

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