Compare commits

..

13 Commits

Author SHA1 Message Date
lance chant
4813fdab81 Merge branch 'develop' into fix/network-location 2026-07-17 14:47:54 +02:00
Gauvain
6dd98ccae8 fix(home): black episode thumbnails in Next Up & Continue Watching (#1815) 2026-07-17 12:00:23 +02:00
kevbot
b4eb853048 fix(livetv): release live streams so the tuner doesn't wedge (#1799)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Gauvino <contact@uruk.dev>
2026-07-17 11:27:32 +02:00
lance chant
fd504cef14 Merge branch 'develop' into fix/network-location 2026-07-17 09:28:52 +02:00
github-actions[bot]
76d9b3177b feat: New Crowdin Translations (#1810)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ 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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (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
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-15 20:47:34 +02:00
Gauvain
e0017b8092 Merge branch 'develop' into fix/network-location 2026-07-15 11:24:17 +02:00
Gauvain
2e79815d78 fix(downloads): delete-all confirmation, live storage usage, series poster cache (#1808)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ 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
🔒 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
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 11:19:51 +02:00
Gauvain
43ce50fe70 fix(notifications): drop deprecated handler flags and payload logging (#1805)
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
2026-07-15 10:50:47 +02:00
lance chant
4c5a3dc658 Merge branch 'develop' into fix/network-location 2026-07-15 08:54:07 +02:00
Gauvain
bd0eef0407 fix(i18n): correct misspelled translation keys (#1804)
Some checks failed
🏗️ 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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (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
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 08:36:11 +02:00
Lance Chant
c4b6390a5e Addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-15 08:29:18 +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
Lance Chant
64b9224b7a fix: network location settings
Fixed android location settings for wifi network connections as didn't
exist
Added some better wording to assist

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-14 14:43:12 +02:00
118 changed files with 2186 additions and 3036 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,14 +17,13 @@ export default function PlaybackControlsPage() {
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: insets.bottom,
}} }}
> >
<View <View
className='p-4 flex flex-col' className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<View> <View className='mb-4'>
<MediaProvider> <MediaProvider>
<MediaToggles className='mb-4' /> <MediaToggles className='mb-4' />
<GestureControls 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function JellyseerrPluginPage() { export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -13,9 +16,12 @@ export default function JellyseerrPluginPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='p-4'> <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings /> <JellyseerrSettings />
</View> </DisabledSetting>
</ScrollView> </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 { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() { export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -13,9 +16,12 @@ export default function KefinTweaksPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='px-4 pt-4'> <DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<KefinTweaksSettings /> <KefinTweaksSettings />
</View> </DisabledSetting>
</ScrollView> </ScrollView>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -28,6 +27,7 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider"; import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
@@ -426,144 +426,146 @@ function Layout() {
> >
<JellyfinProvider> <JellyfinProvider>
<InactivityProvider> <InactivityProvider>
<ServerUrlProvider> <WifiSsidProvider>
<NetworkStatusProvider> <ServerUrlProvider>
<PlaySettingsProvider> <NetworkStatusProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<MusicPlayerProvider> <DownloadProvider>
<GlobalModalProvider> <MusicPlayerProvider>
<BottomSheetModalProvider> <GlobalModalProvider>
<IntroSheetProvider> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <IntroSheetProvider>
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen {!Platform.isTV && <GlobalModal />}
name='(auth)/player' </ThemeProvider>
options={{ </IntroSheetProvider>
headerShown: false, </BottomSheetModalProvider>
title: "", </GlobalModalProvider>
header: () => null, </MusicPlayerProvider>
}} </DownloadProvider>
/> </WebSocketProvider>
<Stack.Screen </LogProvider>
name='(auth)/now-playing' </PlaySettingsProvider>
options={{ </NetworkStatusProvider>
headerShown: false, </ServerUrlProvider>
presentation: "modal", </WifiSsidProvider>
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</InactivityProvider> </InactivityProvider>
</JellyfinProvider> </JellyfinProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import {
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
FlatList, FlatList,
Modal, Modal,
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { sessions, isLoading } = useAllSessions({} as useSessionsProps); const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
const { t } = useTranslation();
const handlePlayInSession = async (sessionId: string) => { const handlePlayInSession = async (sessionId: string) => {
if (!api || !item.Id) return; if (!api || !item.Id) return;
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
<View style={styles.centeredView}> <View style={styles.centeredView}>
<View style={styles.modalView}> <View style={styles.modalView}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={styles.modalTitle}> <Text style={styles.modalTitle}>Select Session</Text>
{t("home.sessions.select_session")}
</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}> <TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
</TouchableOpacity> </TouchableOpacity>
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</View> </View>
) : !sessions || sessions.length === 0 ? ( ) : !sessions || sessions.length === 0 ? (
<Text style={styles.noSessionsText}> <Text style={styles.noSessionsText}>
{t("home.sessions.no_active_sessions")} No active sessions found
</Text> </Text>
) : ( ) : (
<FlatList <FlatList
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
</Text> </Text>
{session.NowPlayingItem && ( {session.NowPlayingItem && (
<Text style={styles.nowPlaying} numberOfLines={1}> <Text style={styles.nowPlaying} numberOfLines={1}>
{t("home.sessions.now_playing")}{" "} Now playing:{" "}
{session.NowPlayingItem.SeriesName {session.NowPlayingItem.SeriesName
? `${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 { 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 { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
@@ -16,37 +16,30 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
// Dismiss the keyboard before navigating — otherwise it lingers over the
// previous screen (e.g. leaving the Jellyseerr login while typing).
const handleBack = () => {
Keyboard.dismiss();
router.back();
};
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<Pressable <Pressable
onPress={handleBack} onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9' className='flex items-center justify-center w-9 h-9'
{...pressableProps} {...pressableProps}
> >
<Feather name='chevron-left' size={28} color='white' /> <Ionicons name='arrow-back' size={24} color='white' />
</Pressable> </Pressable>
); );
} }
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<Pressable onPress={handleBack} {...pressableProps}> <Pressable onPress={() => router.back()} {...pressableProps}>
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
className='overflow-hidden rounded-full p-2' className='overflow-hidden rounded-full p-2'
> >
<Feather <Ionicons
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='chevron-left' name='arrow-back'
size={28} size={24}
color='white' color='white'
/> />
</BlurView> </BlurView>
@@ -55,17 +48,14 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<Pressable <Pressable
onPress={handleBack} onPress={() => router.back()}
// Match the Settings page back button: chevron flush to the edge with a className=' rounded-full p-2'
// 16px gap before the title (the old `p-2` pushed both arrow and title
// too far right). drop-shadow keeps it readable over images.
style={{ marginRight: 16 }}
{...pressableProps} {...pressableProps}
> >
<Feather <Ionicons
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='chevron-left' name='arrow-back'
size={28} size={24}
color='white' color='white'
/> />
</Pressable> </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 { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
TouchableOpacity, TouchableOpacity,
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
} }
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { t } = useTranslation();
const { cancelDownload } = useDownload(); const { cancelDownload } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{isTranscoding && ( {isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'> <View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'> <Text className='text-xs text-purple-400'>Transcoding</Text>
{t("home.downloads.transcoding")}
</Text>
</View> </View>
)} )}

View File

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

View File

@@ -7,14 +7,7 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import type React from "react"; import type React from "react";
import { import { useCallback, useEffect, useMemo, useRef, useState } from "react";
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
StyleSheet, StyleSheet,
@@ -26,21 +19,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { Loader } from "../Loader";
interface Props<T> extends ViewProps { interface Props<T> extends ViewProps {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
/**
* Modal ref the opener must use to present() the sheet from inside its
* press handler. On the new architecture with Reanimated 4, present()
* called from an effect after a state update silently no-ops — the sheet
* mounts nothing. Presenting straight from the gesture handler works.
*/
modalRef: React.RefObject<BottomSheetModal | null>;
data?: T[] | null; data?: T[] | null;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[]; values: T[];
set: (value: T[]) => void; set: (value: T[]) => void;
title: string; title: string;
@@ -83,18 +66,16 @@ const LIMIT = 100;
export const FilterSheet = <T,>({ export const FilterSheet = <T,>({
values, values,
data: _data, data: _data,
loading = false,
open, open,
set, set,
setOpen, setOpen,
modalRef,
title, title,
searchFilter, searchFilter,
renderItemLabel, renderItemLabel,
disableSearch = false, disableSearch = false,
multiple = false, multiple = false,
}: Props<T>) => { }: Props<T>) => {
const bottomSheetModalRef = modalRef; const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["85%"], []); const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -103,24 +84,19 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0); const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
// Filtering and re-rendering the option list on every keystroke blocks the
// JS thread on large lists (2000+ tags); the controlled input then snaps the
// native text back to a stale value (lost/reappearing letters). Deferring the
// value keeps the keystroke render cheap and runs the list update after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState<boolean>(false); const [showSearch, setShowSearch] = useState<boolean>(false);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!deferredSearch) return _data; if (!search) return _data;
const results = []; const results = [];
for (let i = 0; i < (_data?.length || 0); i++) { for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], deferredSearch)) { if (_data && searchFilter?.(_data[i], search)) {
results.push(_data[i]); results.push(_data[i]);
} }
} }
return results.slice(0, 100); return results.slice(0, 100);
}, [deferredSearch, _data, searchFilter]); }, [search, _data, searchFilter]);
useEffect(() => { useEffect(() => {
if (!data || data.length === 0 || disableSearch) return; if (!data || data.length === 0 || disableSearch) return;
@@ -151,28 +127,21 @@ export const FilterSheet = <T,>({
setData(newData); setData(newData);
}, [offset, _data]); }, [offset, _data]);
// Opening is imperative (see the modalRef prop); this effect only closes.
// It also never calls dismiss() on a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!open && wasPresentedRef.current) { if (open) bottomSheetModalRef.current?.present();
bottomSheetModalRef.current?.dismiss(); else bottomSheetModalRef.current?.dismiss();
}
}, [open]); }, [open]);
const handleSheetChanges = useCallback((index: number) => { const handleSheetChanges = useCallback((index: number) => {
if (index >= 0) { if (index === -1) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false); setOpen(false);
} }
}, []); }, []);
const renderData = useMemo(() => { const renderData = useMemo(() => {
if (deferredSearch.length > 0 && showSearch) return filteredData; if (search.length > 0 && showSearch) return filteredData;
return data; return data;
}, [deferredSearch, showSearch, filteredData, data]); }, [search, filteredData, data]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (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 ( return (
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
@@ -261,15 +182,9 @@ export const FilterSheet = <T,>({
}} }}
> >
<Text className='font-bold text-2xl'>{title}</Text> <Text className='font-bold text-2xl'>{title}</Text>
{loading ? ( <Text className='mb-2 text-neutral-500'>
<View className='my-8 flex items-center justify-center'> {t("search.x_items", { count: _data?.length })}
<Loader /> </Text>
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && ( {showSearch && (
<Input <Input
placeholder={t("search.search")} placeholder={t("search.search")}
@@ -288,7 +203,43 @@ export const FilterSheet = <T,>({
}} }}
className='mb-4 flex flex-col rounded-xl overflow-hidden' 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> </View>
{data.length < (_data?.length || 0) && ( {data.length < (_data?.length || 0) && (
<Button <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 { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; 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 { interface Props extends TouchableOpacityProps {}
libraryId: string;
}
export const ResetFiltersButton: React.FC<Props> = ({ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
libraryId, const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
...props const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
}) => { const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId); const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
if (!hasActiveFilters) { if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
selectedYears.length === 0 &&
selectedFilters.length === 0
) {
return null; return null;
} }
return ( return (
<TouchableOpacity <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' className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props} {...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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
apiAtom,
pendingAccountSaveAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -93,9 +89,6 @@ const HomeMobile = () => {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set()); const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet(); const { showIntro } = useIntroSheet();
// Gate the intro so it can't steal presentation from the post-login
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
// Fallback refresh for newly added content when returning to the home screen // Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event). // (primary path is the LibraryChanged WebSocket event).
@@ -104,9 +97,7 @@ const HomeMobile = () => {
// Show intro modal on first launch // Show intro modal on first launch
useEffect(() => { useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro"); const hasShownIntro = storage.getBoolean("hasShownIntro");
// Defer while the save-account sheet is up; this effect re-runs and schedules if (!hasShownIntro) {
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
if (!hasShownIntro && !pendingAccountSave) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
showIntro(); showIntro();
}, 1000); }, 1000);
@@ -115,7 +106,7 @@ const HomeMobile = () => {
clearTimeout(timer); clearTimeout(timer);
}; };
} }
}, [showIntro, pendingAccountSave]); }, [showIntro]);
useEffect(() => { useEffect(() => {
if (isConnected && !prevIsConnected.current) { if (isConnected && !prevIsConnected.current) {

View File

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

View File

@@ -24,7 +24,6 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize"; import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow // Extra padding to accommodate scale animation (1.05x) and glow shadow
@@ -166,7 +165,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
const allItems = useMemo(() => { const allItems = useMemo(() => {
const items = data?.pages.flat() ?? []; const items = data?.pages.flat() ?? [];
@@ -233,7 +231,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hasTVPreferredFocus={isFirstItem} hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)} onFocus={() => handleItemFocus(item)}
width={itemWidth} width={itemWidth}
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
/> />
</View> </View>
); );
@@ -246,7 +243,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showItemActions, showItemActions,
handleItemFocus, handleItemFocus,
ITEM_GAP, 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 { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { useInView } from "@/hooks/useInView"; import { useInView } from "@/hooks/useInView";
import { useSettings } from "@/utils/atoms/settings";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
@@ -51,7 +50,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
// Show skeleton if loading OR if lazy loading is enabled and not in view yet // Show skeleton if loading OR if lazy loading is enabled and not in view yet
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView); const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
@@ -110,10 +108,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
`} `}
> >
{item.Type === "Episode" && orientation === "horizontal" && ( {item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster <ContinueWatchingPoster item={item} />
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)} )}
{item.Type === "Episode" && orientation === "vertical" && ( {item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,10 +20,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings } = useSettings();
// Only the server URL is admin-lockable — the password stays editable so
// the user can still sign in to the admin-pinned Jellyseerr server.
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
const [jellyseerrPassword, setJellyseerrPassword] = useState< const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined string | undefined
@@ -118,41 +115,30 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<View style={{ opacity: urlLocked ? 0.5 : 1 }}> <Text className='font-bold mb-1'>
<Text className='font-bold mb-1'> {t("home.settings.plugins.jellyseerr.server_url")}
{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> </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> </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> <View>
<Text className='font-bold mb-2'> <Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")} {t("home.settings.plugins.jellyseerr.password")}

View File

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

View File

@@ -2,10 +2,10 @@ import { Ionicons } from "@expo/vector-icons";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { Switch, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useWifiSSID } from "@/hooks/useWifiSSID"; import { useWifiSSID } from "@/hooks/useWifiSSID";
import { openLocationSettings } from "@/modules/wifi-ssid";
import { useServerUrl } from "@/providers/ServerUrlProvider"; import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { import {
@@ -27,16 +27,26 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
interface StatusDisplayProps { interface StatusDisplayProps {
currentSSID: string | null; currentSSID: string | null;
connectedToWifi: boolean;
isUsingLocalUrl: boolean; isUsingLocalUrl: boolean;
locationBlocked: boolean;
onOpenLocationSettings: () => void;
t: (key: string) => string; t: (key: string) => string;
} }
function StatusDisplay({ function StatusDisplay({
currentSSID, currentSSID,
connectedToWifi,
isUsingLocalUrl, isUsingLocalUrl,
locationBlocked,
onOpenLocationSettings,
t, t,
}: StatusDisplayProps): React.ReactElement { }: StatusDisplayProps): React.ReactElement {
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected"); const wifiStatus = currentSSID
? currentSSID
: connectedToWifi
? t("home.settings.network.ssid_hidden")
: t("home.settings.network.not_connected");
const urlType = isUsingLocalUrl const urlType = isUsingLocalUrl
? t("home.settings.network.local") ? t("home.settings.network.local")
: t("home.settings.network.remote"); : t("home.settings.network.remote");
@@ -56,6 +66,23 @@ function StatusDisplay({
</Text> </Text>
<Text className={urlTypeColor}>{urlType}</Text> <Text className={urlTypeColor}>{urlType}</Text>
</View> </View>
{locationBlocked && (
<View className='mt-2 pt-2 border-t border-neutral-800'>
<Text className='text-xs text-amber-400'>
{t("home.settings.network.location_off_description")}
</Text>
<TouchableOpacity
onPress={onOpenLocationSettings}
className='mt-2 self-start'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text className='text-xs text-blue-400 font-semibold'>
{t("home.settings.network.open_location_settings")}
</Text>
</TouchableOpacity>
</View>
)}
</View> </View>
); );
} }
@@ -63,7 +90,17 @@ function StatusDisplay({
export function LocalNetworkSettings(): React.ReactElement | null { export function LocalNetworkSettings(): React.ReactElement | null {
const { t } = useTranslation(); const { t } = useTranslation();
const { permissionStatus, requestPermission } = useWifiSSID(); const { permissionStatus, requestPermission } = useWifiSSID();
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl(); const { isUsingLocalUrl, currentSSID, connectedToWifi, refreshUrlState } =
useServerUrl();
// Connected to Wi-Fi and have permission, but the OS won't reveal the SSID —
// on Android this means device Location services are off.
const locationBlocked =
permissionStatus === "granted" && connectedToWifi && !currentSSID;
const handleOpenLocationSettings = useCallback(() => {
openLocationSettings();
}, []);
const remoteUrl = storage.getString("serverUrl"); const remoteUrl = storage.getString("serverUrl");
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG); const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
@@ -148,10 +185,7 @@ export function LocalNetworkSettings(): React.ReactElement | null {
title={t("home.settings.network.auto_switch_enabled")} title={t("home.settings.network.auto_switch_enabled")}
subtitle={t("home.settings.network.auto_switch_description")} subtitle={t("home.settings.network.auto_switch_description")}
> >
<SettingSwitch <Switch value={config.enabled} onValueChange={handleToggleEnabled} />
value={config.enabled}
onValueChange={handleToggleEnabled}
/>
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -199,18 +233,23 @@ export function LocalNetworkSettings(): React.ReactElement | null {
)} )}
</ListGroup> </ListGroup>
<View className='py-2'> {!locationBlocked && (
<Button <View className='py-2'>
onPress={handleAddCurrentNetwork} <Button
disabled={!currentSSID || permissionStatus !== "granted"} onPress={handleAddCurrentNetwork}
> disabled={!currentSSID || permissionStatus !== "granted"}
{addNetworkButtonText} >
</Button> {addNetworkButtonText}
</View> </Button>
</View>
)}
<StatusDisplay <StatusDisplay
currentSSID={currentSSID} currentSSID={currentSSID}
connectedToWifi={connectedToWifi}
isUsingLocalUrl={isUsingLocalUrl} isUsingLocalUrl={isUsingLocalUrl}
locationBlocked={locationBlocked}
onOpenLocationSettings={handleOpenLocationSettings}
t={t} t={t}
/> />
</View> </View>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Animated, Animated,
@@ -29,7 +28,6 @@ export const TVSubtitleResultCard = React.forwardRef<
const styles = createStyles(typography); const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 }); useTVFocusAnimation({ scaleAmount: 1.03 });
const { t } = useTranslation();
return ( return (
<Pressable <Pressable
@@ -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> </View>
)} )}
{result.hearingImpaired && ( {result.hearingImpaired && (

View File

@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
buttonText={t("player.skip_intro")} buttonText='Skip Intro'
/> />
{/* Smart Skip Credits behavior: {/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode - 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) showSkipCreditButton && (hasContentAfterCredits || !nextItem)
} }
onPress={skipCredit} onPress={skipCredit}
buttonText={t("player.skip_credits")} buttonText='Skip Credits'
/> />
{settings.autoPlayNextEpisode !== false && {settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 || (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 '> <Text className='text-2xl font-bold text-white py-4 '>
{t("player.still_watching")} Are you still watching ?
</Text> </Text>
<Button <Button
onPress={() => { onPress={() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,125 +1,5 @@
import { useCallback, useEffect, useState } from "react"; export {
import { Platform } from "react-native"; type PermissionStatus,
import { getSSID } from "@/modules/wifi-ssid"; type UseWifiSSIDReturn,
useWifiSsid as useWifiSSID,
export type PermissionStatus = } from "@/providers/WifiSsidProvider";
| "granted"
| "denied"
| "undetermined"
| "unavailable";
export interface UseWifiSSIDReturn {
ssid: string | null;
permissionStatus: PermissionStatus;
requestPermission: () => Promise<boolean>;
isLoading: boolean;
}
// WiFi SSID is not available on tvOS
if (Platform.isTV) {
// Export a stub hook for tvOS
module.exports = {
useWifiSSID: (): UseWifiSSIDReturn => ({
ssid: null,
permissionStatus: "unavailable" as PermissionStatus,
requestPermission: async () => false,
isLoading: false,
}),
};
}
// Only import Location on non-TV platforms
const Location = Platform.isTV ? null : require("expo-location");
function mapLocationStatus(status: number | undefined): PermissionStatus {
if (!Location) return "unavailable";
switch (status) {
case Location.PermissionStatus?.GRANTED:
return "granted";
case Location.PermissionStatus?.DENIED:
return "denied";
default:
return "undetermined";
}
}
export function useWifiSSID(): UseWifiSSIDReturn {
const [ssid, setSSID] = useState<string | null>(null);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
Platform.isTV ? "unavailable" : "undetermined",
);
const [isLoading, setIsLoading] = useState(!Platform.isTV);
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
setSSID(result);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (Platform.isTV || !Location) {
setPermissionStatus("unavailable");
return false;
}
try {
const { status } = await Location.requestForegroundPermissionsAsync();
const newStatus = mapLocationStatus(status);
setPermissionStatus(newStatus);
if (newStatus === "granted") {
await fetchSSID();
}
return newStatus === "granted";
} catch {
setPermissionStatus("unavailable");
return false;
}
}, [fetchSSID]);
useEffect(() => {
if (Platform.isTV || !Location) {
setIsLoading(false);
return;
}
async function initialize() {
setIsLoading(true);
try {
const { status } = await Location.getForegroundPermissionsAsync();
const mappedStatus = mapLocationStatus(status);
setPermissionStatus(mappedStatus);
if (mappedStatus === "granted") {
await fetchSSID();
}
} catch {
setPermissionStatus("unavailable");
}
setIsLoading(false);
}
initialize();
}, [fetchSSID]);
// Refresh SSID when permission status changes to granted
useEffect(() => {
if (Platform.isTV) return;
if (permissionStatus === "granted") {
fetchSSID();
// Also set up an interval to periodically check SSID
const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds
return () => clearInterval(interval);
}
}, [permissionStatus, fetchSSID]);
return {
ssid,
permissionStatus,
requestPermission,
isLoading,
};
}

View File

@@ -0,0 +1,19 @@
apply plugin: 'expo-module-gradle-plugin'
group = 'expo.modules.wifissid'
version = '1.0.0'
expoModule {
canBePublished false
}
android {
namespace "expo.modules.wifissid"
defaultConfig {
versionCode 1
versionName "1.0.0"
}
}
dependencies {
}

View File

@@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Reading the connected Wi-Fi SSID requires ACCESS_WIFI_STATE, and (since
Android 8) ACCESS_FINE_LOCATION granted at runtime. Location is requested
at runtime by the app via expo-location; the manifest declares the
Wi-Fi/network permissions this module needs directly. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>

View File

@@ -0,0 +1,121 @@
package expo.modules.wifissid
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.provider.Settings
import android.util.Log
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class WifiSsidModule : Module() {
companion object {
private const val TAG = "WifiSsid"
private const val UNKNOWN_SSID = "<unknown ssid>"
}
private val context
get() = requireNotNull(appContext.reactContext)
override fun definition() = ModuleDefinition {
Name("WifiSsid")
AsyncFunction("getSSID") {
val (ssid, connectedToWifi) = readWifi()
mapOf(
"ssid" to ssid,
"connectedToWifi" to connectedToWifi,
)
}
Function("getSSIDSync") {
val (ssid, connectedToWifi) = readWifi()
mapOf(
"ssid" to ssid,
"connectedToWifi" to connectedToWifi,
)
}
Function("openLocationSettings") {
openLocationSettings()
}
}
/**
* Returns the current Wi-Fi SSID (or null when it can't be read) and whether
* the device is connected to a Wi-Fi network. When the device is connected but
* the SSID is null, the OS is withholding the network name — on Android this
* typically means device Location services are off (the SSID is treated as
* location-sensitive).
*/
private fun readWifi(): Pair<String?, Boolean> {
val androidContext = context.applicationContext
var connectedToWifi = false
var ssid: String? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val caps = cm?.let { manager ->
manager.activeNetwork?.let { net -> manager.getNetworkCapabilities(net) }
}
connectedToWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
if (hasFineLocationPermission()) {
ssid = (caps?.transportInfo as? WifiInfo)?.ssid?.sanitize()
}
} catch (e: Exception) {
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager", e)
}
} else {
// Pre-Q (API 2628): NetworkCapabilities/transportInfo aren't available, so
// detect a Wi-Fi connection via the legacy active-network API. Without this,
// connectedToWifi stays false on Android 8/9 even when connected, so the
// "connected but SSID hidden" state never surfaces there.
try {
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
@Suppress("DEPRECATION")
connectedToWifi = cm?.activeNetworkInfo?.type == ConnectivityManager.TYPE_WIFI
} catch (e: Exception) {
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager (legacy)", e)
}
}
if (ssid == null && hasFineLocationPermission()) {
try {
val wm = androidContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
@Suppress("DEPRECATION")
ssid = wm?.connectionInfo?.ssid?.sanitize()
} catch (e: Exception) {
Log.e(TAG, "Error reading SSID via WifiManager", e)
}
}
return Pair(ssid, connectedToWifi)
}
private fun openLocationSettings() {
try {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.applicationContext.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Unable to open Location settings", e)
}
}
private fun hasFineLocationPermission(): Boolean =
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun String.sanitize(): String? {
val cleaned = trim('"')
return if (cleaned.isBlank() || cleaned == UNKNOWN_SSID) null else cleaned
}
}

View File

@@ -1,8 +1,11 @@
{ {
"name": "wifi-ssid", "name": "wifi-ssid",
"version": "1.0.0", "version": "1.0.0",
"platforms": ["ios"], "platforms": ["ios", "android"],
"ios": { "ios": {
"modules": ["WifiSsidModule"] "modules": ["WifiSsidModule"]
},
"android": {
"modules": ["expo.modules.wifissid.WifiSsidModule"]
} }
} }

View File

@@ -1,44 +1,92 @@
import { Platform, requireNativeModule } from "expo-modules-core"; import { Platform, requireNativeModule } from "expo-modules-core";
import { Linking } from "react-native";
// Only load the native module on iOS export interface WifiSsidResult {
const WifiSsidModule = ssid: string | null;
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : null; /**
* Whether the device is connected to a Wi-Fi network. When true but `ssid` is
* null, the OS is withholding the network name — on Android this happens when
* device Location services are off (the SSID is location-sensitive).
*/
connectedToWifi: boolean;
}
// Load the native module on iOS and Android (not TV/web). Wrapped so a
// missing/unlinked module degrades to null instead of crashing the bundle.
const WifiSsidModule = (() => {
if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
try {
return requireNativeModule("WifiSsid");
} catch {
return null;
}
})();
/** /**
* Get the current WiFi SSID on iOS. * Get the current WiFi SSID and whether the device is on a Wi-Fi network.
* Returns null on Android or if not connected to WiFi.
* *
* Requires: * iOS requirements:
* - Location permission granted * - Location permission granted
* - com.apple.developer.networking.wifi-info entitlement * - com.apple.developer.networking.wifi-info entitlement
* - Access WiFi Information capability enabled in Apple Developer Portal *
* Android requirements:
* - ACCESS_FINE_LOCATION granted at runtime, and device Location services on
*/ */
export async function getSSID(): Promise<string | null> { export async function getSSID(): Promise<WifiSsidResult> {
if (!WifiSsidModule) { if (!WifiSsidModule) {
return null; return { ssid: null, connectedToWifi: false };
} }
try { try {
const ssid = await WifiSsidModule.getSSID(); return normalize(await WifiSsidModule.getSSID());
return ssid ?? null;
} catch (error) { } catch (error) {
console.error("[WifiSsid] Error getting SSID:", error); console.error("[WifiSsid] Error getting SSID:", error);
return null; return { ssid: null, connectedToWifi: false };
}
}
/** Synchronous version (uses the older CNCopyCurrentNetworkInfo API on iOS). */
export function getSSIDSync(): WifiSsidResult {
if (!WifiSsidModule) {
return { ssid: null, connectedToWifi: false };
}
try {
return normalize(WifiSsidModule.getSSIDSync());
} catch (error) {
console.error("[WifiSsid] Error getting SSID (sync):", error);
return { ssid: null, connectedToWifi: false };
} }
} }
/** /**
* Synchronous version - uses older CNCopyCurrentNetworkInfo API * Open the system Location settings so the user can enable Location services
* (required on Android for the OS to reveal the Wi-Fi SSID). Falls back to the
* app's settings page where there is no dedicated native action (e.g. iOS).
*/ */
export function getSSIDSync(): string | null { export function openLocationSettings(): void {
if (!WifiSsidModule) {
return null;
}
try { try {
return WifiSsidModule.getSSIDSync() ?? null; if (WifiSsidModule?.openLocationSettings) {
WifiSsidModule.openLocationSettings();
return;
}
} catch (error) { } catch (error) {
console.error("[WifiSsid] Error getting SSID (sync):", error); console.error("[WifiSsid] Error opening location settings:", error);
return null;
} }
Linking.openSettings();
}
// Both native modules return { ssid, connectedToWifi }. The string branch is a
// defensive fallback in case a build returns the older plain-string shape.
function normalize(result: unknown): WifiSsidResult {
if (typeof result === "string") {
const ssid = result || null;
return { ssid, connectedToWifi: ssid !== null };
}
if (result && typeof result === "object") {
const obj = result as { ssid?: string | null; connectedToWifi?: boolean };
return {
ssid: obj.ssid ?? null,
connectedToWifi: Boolean(obj.connectedToWifi),
};
}
return { ssid: null, connectedToWifi: false };
} }

View File

@@ -1,50 +1,113 @@
import ExpoModulesCore import ExpoModulesCore
#if !os(tvOS) #if !os(tvOS)
import Network
import NetworkExtension import NetworkExtension
import SystemConfiguration.CaptiveNetwork import SystemConfiguration.CaptiveNetwork
#endif #endif
import UIKit
public class WifiSsidModule: Module { public class WifiSsidModule: Module {
#if !os(tvOS)
// NWPathMonitor reports whether the active path uses Wi-Fi WITHOUT requiring
// the wifi-info entitlement, so we can tell "on Wi-Fi but can't read the SSID"
// apart from "not connected to Wi-Fi" even when fetchCurrent returns nil.
private let pathMonitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "expo.modules.wifissid.pathmonitor")
private let lock = NSLock()
private var cachedOnWifi = false
private var pathMonitorStarted = false
private var isOnWifi: Bool {
lock.lock()
defer { lock.unlock() }
return cachedOnWifi
}
private func startPathMonitorIfNeeded() {
lock.lock()
if pathMonitorStarted {
lock.unlock()
return
}
pathMonitorStarted = true
lock.unlock()
pathMonitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let onWifi = path.status == .satisfied && path.usesInterfaceType(.wifi)
self.lock.lock()
self.cachedOnWifi = onWifi
self.lock.unlock()
}
pathMonitor.start(queue: monitorQueue)
}
deinit {
pathMonitor.cancel()
}
#endif
public func definition() -> ModuleDefinition { public func definition() -> ModuleDefinition {
Name("WifiSsid") Name("WifiSsid")
// Get current WiFi SSID using NEHotspotNetwork (iOS 14+) AsyncFunction("getSSID") { () -> [String: Any] in
// Not available on tvOS
AsyncFunction("getSSID") { () -> String? in
#if os(tvOS) #if os(tvOS)
return nil return ["ssid": NSNull(), "connectedToWifi": false]
#else #else
return await withCheckedContinuation { continuation in self.startPathMonitorIfNeeded()
NEHotspotNetwork.fetchCurrent { network in let ssid = await self.resolveSSID()
if let ssid = network?.ssid { var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)") if let ssid = ssid {
continuation.resume(returning: ssid) result["ssid"] = ssid
} else { } else {
// Fallback to CNCopyCurrentNetworkInfo for older iOS result["ssid"] = NSNull()
print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
let ssid = self.getSSIDViaCNCopy()
continuation.resume(returning: ssid)
}
}
} }
return result
#endif #endif
} }
// Synchronous version using only CNCopyCurrentNetworkInfo Function("getSSIDSync") { () -> [String: Any] in
// Not available on tvOS
Function("getSSIDSync") { () -> String? in
#if os(tvOS) #if os(tvOS)
return nil return ["ssid": NSNull(), "connectedToWifi": false]
#else #else
return self.getSSIDViaCNCopy() self.startPathMonitorIfNeeded()
let ssid = self.getSSIDViaCNCopy()
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
if let ssid = ssid {
result["ssid"] = ssid
} else {
result["ssid"] = NSNull()
}
return result
#endif
}
Function("openLocationSettings") {
#if !os(tvOS)
DispatchQueue.main.async {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
#endif #endif
} }
} }
#if !os(tvOS) #if !os(tvOS)
private func resolveSSID() async -> String? {
await withCheckedContinuation { continuation in
NEHotspotNetwork.fetchCurrent { network in
if let ssid = network?.ssid {
continuation.resume(returning: ssid)
} else {
continuation.resume(returning: self.getSSIDViaCNCopy())
}
}
}
}
private func getSSIDViaCNCopy() -> String? { private func getSSIDViaCNCopy() -> String? {
guard let interfaces = CNCopySupportedInterfaces() as? [String] else { guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
return nil return nil
} }
@@ -54,12 +117,10 @@ public class WifiSsidModule: Module {
} }
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String { if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
return ssid return ssid
} }
} }
print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
return nil return nil
} }
#endif #endif

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@@ -18,6 +19,7 @@ interface ServerUrlContextValue {
effectiveServerUrl: string | null; effectiveServerUrl: string | null;
isUsingLocalUrl: boolean; isUsingLocalUrl: boolean;
currentSSID: string | null; currentSSID: string | null;
connectedToWifi: boolean;
refreshUrlState: () => void; refreshUrlState: () => void;
} }
@@ -32,7 +34,7 @@ interface Props {
export function ServerUrlProvider({ children }: Props): React.ReactElement { export function ServerUrlProvider({ children }: Props): React.ReactElement {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { switchServerUrl } = useJellyfin(); const { switchServerUrl } = useJellyfin();
const { ssid, permissionStatus } = useWifiSSID(); const { ssid, connectedToWifi, permissionStatus } = useWifiSSID();
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false); const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>( const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
@@ -101,15 +103,25 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
}; };
}, [ssid, permissionStatus, evaluateAndSwitchUrl]); }, [ssid, permissionStatus, evaluateAndSwitchUrl]);
const value = useMemo(
() => ({
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
connectedToWifi,
refreshUrlState,
}),
[
effectiveServerUrl,
isUsingLocalUrl,
ssid,
connectedToWifi,
refreshUrlState,
],
);
return ( return (
<ServerUrlContext.Provider <ServerUrlContext.Provider value={value}>
value={{
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
refreshUrlState,
}}
>
{children} {children}
</ServerUrlContext.Provider> </ServerUrlContext.Provider>
); );

View File

@@ -0,0 +1,157 @@
import type React from "react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Platform } from "react-native";
import { getSSID } from "@/modules/wifi-ssid";
export type PermissionStatus =
| "granted"
| "denied"
| "undetermined"
| "unavailable";
export interface UseWifiSSIDReturn {
ssid: string | null;
connectedToWifi: boolean;
permissionStatus: PermissionStatus;
requestPermission: () => Promise<boolean>;
isLoading: boolean;
}
const WifiSsidContext = createContext<UseWifiSSIDReturn | null>(null);
// WiFi SSID is not available on tvOS, and expo-location isn't needed there.
const Location = Platform.isTV ? null : require("expo-location");
const POLL_INTERVAL_MS = 10_000;
function mapLocationStatus(status: number | undefined): PermissionStatus {
if (!Location) return "unavailable";
switch (status) {
case Location.PermissionStatus?.GRANTED:
return "granted";
case Location.PermissionStatus?.DENIED:
return "denied";
default:
return "undetermined";
}
}
interface Props {
children: ReactNode;
}
/**
* Single source of truth for the current WiFi SSID and the location permission
* that gates it. State is shared via context so that granting permission in one
* place (e.g. the network settings screen) is reflected everywhere else — most
* importantly ServerUrlProvider, which switches between the local and remote
* server URLs based on the connected SSID.
*
* Previously each consumer created its own useWifiSSID instance with independent
* state, so the provider never learned that permission had been granted later
* in the session and reported "not connected to WiFi" forever.
*/
export function WifiSsidProvider({ children }: Props): React.ReactElement {
const [ssid, setSSID] = useState<string | null>(null);
const [connectedToWifi, setConnectedToWifi] = useState(false);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
Platform.isTV ? "unavailable" : "undetermined",
);
const [isLoading, setIsLoading] = useState(!Platform.isTV);
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
setSSID(result.ssid);
setConnectedToWifi(result.connectedToWifi);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (Platform.isTV || !Location) {
setPermissionStatus("unavailable");
return false;
}
try {
const { status } = await Location.requestForegroundPermissionsAsync();
const newStatus = mapLocationStatus(status);
setPermissionStatus(newStatus);
return newStatus === "granted";
} catch {
setPermissionStatus("unavailable");
return false;
}
}, []);
// Check the location permission once on mount. The poll effect below takes
// over fetching the SSID whenever permission becomes "granted".
useEffect(() => {
if (Platform.isTV || !Location) {
setIsLoading(false);
return;
}
let cancelled = false;
async function initialize() {
setIsLoading(true);
try {
const { status } = await Location.getForegroundPermissionsAsync();
if (cancelled) return;
setPermissionStatus(mapLocationStatus(status));
} catch {
if (!cancelled) setPermissionStatus("unavailable");
}
if (!cancelled) setIsLoading(false);
}
initialize();
return () => {
cancelled = true;
};
}, []);
// Fetch the SSID immediately, then poll, whenever permission is granted.
// Because permissionStatus is shared, this also covers the case where
// permission was granted later (from another consumer) than this provider's
// mount time — the original root cause of the "not connected to WiFi" bug.
useEffect(() => {
if (Platform.isTV || permissionStatus !== "granted") return;
fetchSSID();
const interval = setInterval(fetchSSID, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [permissionStatus, fetchSSID]);
const value = useMemo(
() => ({
ssid,
connectedToWifi,
permissionStatus,
requestPermission,
isLoading,
}),
[ssid, connectedToWifi, permissionStatus, requestPermission, isLoading],
);
return (
<WifiSsidContext.Provider value={value}>
{children}
</WifiSsidContext.Provider>
);
}
export function useWifiSsid(): UseWifiSSIDReturn {
const context = useContext(WifiSsidContext);
if (!context) {
throw new Error("useWifiSsid must be used within WifiSsidProvider");
}
return context;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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