mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 17:02:58 +01:00
Compare commits
44 Commits
fix/auth-s
...
fix/ui-and
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933c6a6588 | ||
|
|
743a8a0ce5 | ||
|
|
1e7f5a4ef8 | ||
|
|
8a781f2462 | ||
|
|
4a0dc44251 | ||
|
|
a3dc08c323 | ||
|
|
bd5b95c835 | ||
|
|
8a37ec46bf | ||
|
|
b70f644fbc | ||
|
|
d5ef69b612 | ||
|
|
2ac664de5a | ||
|
|
91ea791663 | ||
|
|
e1f7968352 | ||
|
|
f8e0baa0c0 | ||
|
|
9ed49e040e | ||
|
|
e2acc40c49 | ||
|
|
3d84b558fb | ||
|
|
8270c4c30c | ||
|
|
4d9fc1d615 | ||
|
|
3478f7a040 | ||
|
|
32adb5c43a | ||
|
|
b6da8c0784 | ||
|
|
fc53a7d760 | ||
|
|
3035a9019c | ||
|
|
182a2cf1e6 | ||
|
|
6195db2a83 | ||
|
|
599096f883 | ||
|
|
3247bf709c | ||
|
|
2af252d639 | ||
|
|
1636523d48 | ||
|
|
855957707a | ||
|
|
4bad8ae054 | ||
|
|
16188ac2a3 | ||
|
|
d12d62863e | ||
|
|
7eb65ba430 | ||
|
|
43d469f398 | ||
|
|
d397233991 | ||
|
|
7f020120b3 | ||
|
|
aec3444829 | ||
|
|
24f9c38098 | ||
|
|
1dd1940334 | ||
|
|
1e537bc11e | ||
|
|
b163c2abb4 | ||
|
|
0d47c8d43a |
@@ -73,7 +73,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
@@ -158,7 +157,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label='Vertical Margin'
|
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
@@ -663,11 +663,11 @@ export default function SettingsTV() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Horizontal Alignment'
|
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
value={alignXLabel}
|
value={alignXLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: "Horizontal Alignment",
|
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||||
options: alignXOptions,
|
options: alignXOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -677,11 +677,11 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Vertical Alignment'
|
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: "Vertical Alignment",
|
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
||||||
options: alignYOptions,
|
options: alignYOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -854,6 +854,13 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TVSettingsToggle
|
<TVSettingsToggle
|
||||||
label={t("home.settings.appearance.show_home_backdrop")}
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, Switch, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className='px-4'
|
className='px-4 pt-4'
|
||||||
>
|
>
|
||||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -71,7 +72,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
|
|||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -60,7 +61,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import type * as SharingType from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
@@ -6,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -72,6 +75,25 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, Sharing]);
|
}, [filteredLogs, Sharing]);
|
||||||
|
|
||||||
|
const copyLog = useCallback(
|
||||||
|
async (log: NonNullable<typeof logs>[number]) => {
|
||||||
|
// Skip on builds that don't ship the expo-clipboard native module
|
||||||
|
// (probe returns null instead of throwing); same guard as Quick Connect.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
const text = [
|
||||||
|
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
|
||||||
|
log.message,
|
||||||
|
log.data ? JSON.stringify(log.data, null, 2) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
await Clipboard.setStringAsync(text);
|
||||||
|
toast.success(t("home.settings.logs.copied"));
|
||||||
|
},
|
||||||
|
[logs, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -88,8 +110,15 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1'>
|
<ScrollView
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
// Like the sibling settings pages, let iOS auto-inset the content below the
|
||||||
|
// transparent header (no manual header-height math). The filter bar is a
|
||||||
|
// sticky header so it stays pinned just under the header while logs scroll.
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
@@ -112,11 +141,7 @@ export default function Page() {
|
|||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
<View className='flex flex-col space-y-2 px-4'>
|
||||||
className='pb-4 px-4'
|
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col space-y-2'>
|
|
||||||
{filteredLogs?.map((log, index) => (
|
{filteredLogs?.map((log, index) => (
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -143,26 +168,41 @@ export default function Page() {
|
|||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text selectable className='text-xs'>
|
<Text className='text-xs'>{log.message}</Text>
|
||||||
{log.message}
|
{/* Keep the whole collapsed row tappable: the hint lives inside
|
||||||
</Text>
|
the toggle so tapping it expands too. */}
|
||||||
</TouchableOpacity>
|
{log.data && !state[log.timestamp] && (
|
||||||
|
|
||||||
{log.data && (
|
|
||||||
<>
|
|
||||||
{!state[log.timestamp] && (
|
|
||||||
<Text className='text-xs mt-0.5'>
|
<Text className='text-xs mt-0.5'>
|
||||||
{t("home.settings.logs.click_for_more_info")}
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{log.data && (
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
<View className='mt-2 flex flex-col space-y-2'>
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
<ScrollView
|
||||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
className='rounded-xl'
|
||||||
|
style={codeBlockStyle}
|
||||||
|
nestedScrollEnabled
|
||||||
|
>
|
||||||
|
{/* Only the raw payload is selectable (per request); the
|
||||||
|
header/message stay tap-to-toggle. */}
|
||||||
|
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => copyLog(log)}
|
||||||
|
className='flex flex-row items-center self-end px-2 py-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='copy-outline' size={16} color='white' />
|
||||||
|
<Text className='text-xs ml-1'>
|
||||||
|
{t("home.settings.logs.copy")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -173,6 +213,5 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.prefer_downloaded")}
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.preferLocalAudio}
|
value={settings.preferLocalAudio}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.lookahead_enabled")}
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.audioLookaheadEnabled}
|
value={settings.audioLookaheadEnabled}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<ListGroup>
|
<ListGroup className='mt-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteDownloadedSongsClicked}
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4 flex flex-col'
|
className='p-4 flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<View className='mb-4'>
|
<View>
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function JellyseerrPluginPage() {
|
export default function JellyseerrPluginPage() {
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<View className='p-4'>
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function KefinTweaksPage() {
|
export default function KefinTweaksPage() {
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<View className='px-4 pt-4'>
|
||||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<KefinTweaksSettings />
|
<KefinTweaksSettings />
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
|
||||||
|
const hasStreamystats = !!pluginSettings?.streamyStatsServerUrl?.value;
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
const onSave = (val: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
@@ -41,15 +41,8 @@ export default function MarlinSearchPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
return (
|
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
if (!marlinUrlLocked) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
@@ -60,7 +53,7 @@ export default function MarlinSearchPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
}, [navigation, value, marlinUrlLocked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -72,52 +65,39 @@ export default function MarlinSearchPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
<View className='px-4 pt-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
||||||
disabled={
|
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
||||||
pluginSettings?.searchEngine?.locked === true ||
|
|
||||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
|
||||||
}
|
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
|
||||||
>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
)}
|
)}
|
||||||
|
disabledByAdmin={searchEngineLocked}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.searchEngine === "Marlin"}
|
value={settings.searchEngine === "Marlin"}
|
||||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
disabled={searchEngineLocked || hasStreamystats}
|
||||||
onValueChange={(value) => {
|
onValueChange={(val) => {
|
||||||
updateSettings({
|
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</DisabledSetting>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<DisabledSetting
|
<ListGroup className='mt-2'>
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
<ListItem
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
title={t("home.settings.plugins.marlin_search.url")}
|
||||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
disabledByAdmin={marlinUrlLocked}
|
||||||
>
|
>
|
||||||
<View
|
|
||||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
|
||||||
>
|
|
||||||
<Text className='mr-4'>
|
|
||||||
{t("home.settings.plugins.marlin_search.url")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={settings.searchEngine === "Marlin"}
|
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||||
className='text-white'
|
className='text-white text-right flex-1'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -128,15 +108,16 @@ export default function MarlinSearchPage() {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</ListItem>
|
||||||
</DisabledSetting>
|
</ListGroup>
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -18,6 +30,17 @@ export default function PluginsPage() {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
|
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
|
||||||
|
so it lives on the plugins index rather than inside Streamystats. */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const {
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
} = useSettings();
|
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const isStreamystatsEnabled = !!url;
|
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const movieRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
|
||||||
|
const seriesRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
|
||||||
|
const promotedWatchlistsLocked =
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
|
||||||
|
const hideWatchlistsTabLocked =
|
||||||
|
pluginSettings?.hideWatchlistsTab?.locked === true;
|
||||||
|
// The input renders the locked admin URL; enablement must follow the same
|
||||||
|
// effective value or every toggle stays disabled until local state syncs.
|
||||||
|
const effectiveUrl = isUrlLocked
|
||||||
|
? (settings?.streamyStatsServerUrl ?? "")
|
||||||
|
: url;
|
||||||
|
const isStreamystatsEnabled = !!effectiveUrl;
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
|
||||||
// Update local state with new values
|
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
|
||||||
setUrl(newUrl);
|
|
||||||
if (newUrl) {
|
|
||||||
setUseForSearch(true);
|
|
||||||
}
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
|
||||||
}, [refreshStreamyfinPluginSettings, t]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +132,7 @@ export default function StreamystatsPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4'>
|
<View className='px-4 pt-4'>
|
||||||
<ListGroup className='flex-1'>
|
<ListGroup className='flex-1'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.url")}
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={url}
|
value={effectiveUrl}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
disabledByAdmin={searchLocked}
|
||||||
>
|
>
|
||||||
<Switch
|
{/* Locked controls show the live admin value and can't be toggled —
|
||||||
value={useForSearch}
|
local form state would let the switch flip while the write guard
|
||||||
disabled={!isStreamystatsEnabled}
|
drops the change. */}
|
||||||
|
<SettingSwitch
|
||||||
|
value={
|
||||||
|
searchLocked
|
||||||
|
? settings?.searchEngine === "Streamystats"
|
||||||
|
: useForSearch
|
||||||
|
}
|
||||||
|
disabled={!isStreamystatsEnabled || searchLocked}
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={movieRecsLocked}
|
||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={movieRecs}
|
value={
|
||||||
|
movieRecsLocked
|
||||||
|
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||||
|
: movieRecs
|
||||||
|
}
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={seriesRecsLocked}
|
||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={seriesRecs}
|
value={
|
||||||
|
seriesRecsLocked
|
||||||
|
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||||
|
: seriesRecs
|
||||||
|
}
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={promotedWatchlistsLocked}
|
||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={promotedWatchlists}
|
value={
|
||||||
|
promotedWatchlistsLocked
|
||||||
|
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||||
|
: promotedWatchlists
|
||||||
|
}
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
disabledByAdmin={hideWatchlistsTabLocked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={hideWatchlistsTab}
|
value={
|
||||||
|
hideWatchlistsTabLocked
|
||||||
|
? (settings?.hideWatchlistsTab ?? false)
|
||||||
|
: hideWatchlistsTab
|
||||||
|
}
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
|
|||||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefreshFromServer}
|
|
||||||
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-blue-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
{!isUrlLocked && isStreamystatsEnabled && (
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
TouchableItemRouter,
|
TouchableItemRouter,
|
||||||
} from "@/components/common/TouchableItemRouter";
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -134,6 +135,12 @@ const page: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
|
// Collections open with a clean filter slate: the genre/year/tag atoms are
|
||||||
|
// global, so without this the previously viewed library's selection bleeds
|
||||||
|
// in (libraries now keep their own per-library memory).
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
|
|
||||||
@@ -204,8 +211,7 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||||
useInfiniteQuery({
|
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"collection-items",
|
"collection-items",
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -326,7 +332,7 @@ const page: React.FC = () => {
|
|||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
key: "reset",
|
key: "reset",
|
||||||
component: <ResetFiltersButton />,
|
component: <ResetFiltersButton libraryId={collectionId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "genre",
|
key: "genre",
|
||||||
@@ -466,7 +472,6 @@ const page: React.FC = () => {
|
|||||||
setSortBy,
|
setSortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -633,6 +638,7 @@ 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'>
|
||||||
@@ -670,6 +676,7 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</FilterSheetProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
getItemsApi,
|
getItemsApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList, type FlashListRef } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
TouchableItemRouter,
|
TouchableItemRouter,
|
||||||
} from "@/components/common/TouchableItemRouter";
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -44,6 +45,7 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
|||||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useFilterReset } from "@/hooks/useFilterReset";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
@@ -55,7 +57,9 @@ import {
|
|||||||
FilterByPreferenceAtom,
|
FilterByPreferenceAtom,
|
||||||
filterByAtom,
|
filterByAtom,
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
|
genrePreferenceAtom,
|
||||||
getFilterByPreference,
|
getFilterByPreference,
|
||||||
|
getMultiFilterPreference,
|
||||||
getSortByPreference,
|
getSortByPreference,
|
||||||
getSortOrderPreference,
|
getSortOrderPreference,
|
||||||
SortByOption,
|
SortByOption,
|
||||||
@@ -66,11 +70,12 @@ import {
|
|||||||
sortOrderAtom,
|
sortOrderAtom,
|
||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
|
tagPreferenceAtom,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
useFilterOptions,
|
useFilterOptions,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
|
yearPreferenceAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
@@ -108,6 +113,9 @@ const Page = () => {
|
|||||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
|
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
|
||||||
|
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
|
||||||
|
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
@@ -205,6 +213,13 @@ const Page = () => {
|
|||||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
_setFilterBy(fp ? [fp] : []);
|
_setFilterBy(fp ? [fp] : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Genres / years / tags: per-library saved preference (no URL params), so
|
||||||
|
// switching libraries restores each library's own selection instead of
|
||||||
|
// bleeding the previous one.
|
||||||
|
setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
|
||||||
|
setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
|
||||||
|
setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
|
||||||
}, [
|
}, [
|
||||||
libraryId,
|
libraryId,
|
||||||
sortOrderPreference,
|
sortOrderPreference,
|
||||||
@@ -213,6 +228,12 @@ const Page = () => {
|
|||||||
_setSortBy,
|
_setSortBy,
|
||||||
filterByPreference,
|
filterByPreference,
|
||||||
_setFilterBy,
|
_setFilterBy,
|
||||||
|
genrePreference,
|
||||||
|
yearPreference,
|
||||||
|
tagPreference,
|
||||||
|
setSelectedGenres,
|
||||||
|
setSelectedYears,
|
||||||
|
setSelectedTags,
|
||||||
searchParams.sortBy,
|
searchParams.sortBy,
|
||||||
searchParams.sortOrder,
|
searchParams.sortOrder,
|
||||||
searchParams.filterBy,
|
searchParams.filterBy,
|
||||||
@@ -257,6 +278,32 @@ const Page = () => {
|
|||||||
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Genres / years / tags: save the per-library memory then update the active
|
||||||
|
// atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
|
||||||
|
const setGenres = useCallback(
|
||||||
|
(genres: string[]) => {
|
||||||
|
setGenrePreference({ ...genrePreference, [libraryId]: genres });
|
||||||
|
setSelectedGenres(genres);
|
||||||
|
},
|
||||||
|
[libraryId, genrePreference, setGenrePreference, setSelectedGenres],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setYears = useCallback(
|
||||||
|
(years: string[]) => {
|
||||||
|
setYearPreference({ ...yearPreference, [libraryId]: years });
|
||||||
|
setSelectedYears(years);
|
||||||
|
},
|
||||||
|
[libraryId, yearPreference, setYearPreference, setSelectedYears],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTags = useCallback(
|
||||||
|
(tags: string[]) => {
|
||||||
|
setTagPreference({ ...tagPreference, [libraryId]: tags });
|
||||||
|
setSelectedTags(tags);
|
||||||
|
},
|
||||||
|
[libraryId, tagPreference, setTagPreference, setSelectedTags],
|
||||||
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
// TV uses flexWrap, so nrOfCols is just for mobile
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
@@ -415,6 +462,29 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const flashListRef = useRef<FlashListRef<BaseItemDto>>(null);
|
||||||
|
|
||||||
|
// Jump the grid to the top when the filters/sort change (incl. reset).
|
||||||
|
const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
|
||||||
|
const pendingScrollTopRef = useRef(false);
|
||||||
|
|
||||||
|
// Instant feedback: pin to the top the moment the filters change, without
|
||||||
|
// waiting for the new fetch — and flag a re-pin for once it settles.
|
||||||
|
useEffect(() => {
|
||||||
|
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||||
|
pendingScrollTopRef.current = true;
|
||||||
|
}, [filterSignature]);
|
||||||
|
|
||||||
|
// Safety net: FlashList can restore the previous offset as the filtered list
|
||||||
|
// grows, so re-pin once the fetch settles. Pagination keeps the same
|
||||||
|
// signature, so it never re-pins.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingScrollTopRef.current && !isFetching) {
|
||||||
|
pendingScrollTopRef.current = false;
|
||||||
|
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||||
|
}
|
||||||
|
}, [isFetching, flatData]);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -530,7 +600,6 @@ const Page = () => {
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
const generalFilters = useFilterOptions();
|
const generalFilters = useFilterOptions();
|
||||||
const settings = useSettings();
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -545,7 +614,7 @@ const Page = () => {
|
|||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
key: "reset",
|
key: "reset",
|
||||||
component: <ResetFiltersButton />,
|
component: <ResetFiltersButton libraryId={libraryId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "genre",
|
key: "genre",
|
||||||
@@ -564,7 +633,7 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
return response.data.Genres || [];
|
return response.data.Genres || [];
|
||||||
}}
|
}}
|
||||||
set={setSelectedGenres}
|
set={setGenres}
|
||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
@@ -591,7 +660,7 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
return response.data.Years || [];
|
return response.data.Years || [];
|
||||||
}}
|
}}
|
||||||
set={setSelectedYears}
|
set={setYears}
|
||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
@@ -616,7 +685,7 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
return response.data.Tags || [];
|
return response.data.Tags || [];
|
||||||
}}
|
}}
|
||||||
set={setSelectedTags}
|
set={setTags}
|
||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
@@ -696,35 +765,23 @@ const Page = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
setSelectedGenres,
|
setGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
setSelectedYears,
|
setYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
setSelectedTags,
|
setTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
|
||||||
filterBy,
|
filterBy,
|
||||||
setFilter,
|
setFilter,
|
||||||
settings,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TV Filter bar header
|
// Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
|
||||||
const hasActiveFilters =
|
// sort/order can't be forgotten on one path (it used to be reset on neither).
|
||||||
selectedGenres.length > 0 ||
|
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
|
||||||
selectedYears.length > 0 ||
|
|
||||||
selectedTags.length > 0 ||
|
|
||||||
filterBy.length > 0;
|
|
||||||
|
|
||||||
const resetAllFilters = useCallback(() => {
|
|
||||||
setSelectedGenres([]);
|
|
||||||
setSelectedYears([]);
|
|
||||||
setSelectedTags([]);
|
|
||||||
_setFilterBy([]);
|
|
||||||
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
|
||||||
|
|
||||||
// TV Filter options - with "All" option for clearable filters
|
// TV Filter options - with "All" option for clearable filters
|
||||||
const tvGenreFilterOptions = useMemo(
|
const tvGenreFilterOptions = useMemo(
|
||||||
@@ -818,15 +875,15 @@ const Page = () => {
|
|||||||
options: tvGenreFilterOptions,
|
options: tvGenreFilterOptions,
|
||||||
onSelect: (value: string) => {
|
onSelect: (value: string) => {
|
||||||
if (value === "__all__") {
|
if (value === "__all__") {
|
||||||
setSelectedGenres([]);
|
setGenres([]);
|
||||||
} else if (selectedGenres.includes(value)) {
|
} else if (selectedGenres.includes(value)) {
|
||||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
setGenres(selectedGenres.filter((g) => g !== value));
|
||||||
} else {
|
} else {
|
||||||
setSelectedGenres([...selectedGenres, value]);
|
setGenres([...selectedGenres, value]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
|
||||||
|
|
||||||
const handleShowYearFilter = useCallback(() => {
|
const handleShowYearFilter = useCallback(() => {
|
||||||
showOptions({
|
showOptions({
|
||||||
@@ -834,15 +891,15 @@ const Page = () => {
|
|||||||
options: tvYearFilterOptions,
|
options: tvYearFilterOptions,
|
||||||
onSelect: (value: string) => {
|
onSelect: (value: string) => {
|
||||||
if (value === "__all__") {
|
if (value === "__all__") {
|
||||||
setSelectedYears([]);
|
setYears([]);
|
||||||
} else if (selectedYears.includes(value)) {
|
} else if (selectedYears.includes(value)) {
|
||||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
setYears(selectedYears.filter((y) => y !== value));
|
||||||
} else {
|
} else {
|
||||||
setSelectedYears([...selectedYears, value]);
|
setYears([...selectedYears, value]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
|
||||||
|
|
||||||
const handleShowTagFilter = useCallback(() => {
|
const handleShowTagFilter = useCallback(() => {
|
||||||
showOptions({
|
showOptions({
|
||||||
@@ -850,15 +907,15 @@ const Page = () => {
|
|||||||
options: tvTagFilterOptions,
|
options: tvTagFilterOptions,
|
||||||
onSelect: (value: string) => {
|
onSelect: (value: string) => {
|
||||||
if (value === "__all__") {
|
if (value === "__all__") {
|
||||||
setSelectedTags([]);
|
setTags([]);
|
||||||
} else if (selectedTags.includes(value)) {
|
} else if (selectedTags.includes(value)) {
|
||||||
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
setTags(selectedTags.filter((tag) => tag !== value));
|
||||||
} else {
|
} else {
|
||||||
setSelectedTags([...selectedTags, value]);
|
setTags([...selectedTags, value]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
|
||||||
|
|
||||||
const handleShowSortByFilter = useCallback(() => {
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
showOptions({
|
showOptions({
|
||||||
@@ -906,7 +963,9 @@ const Page = () => {
|
|||||||
// Mobile return
|
// Mobile return
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
|
<FilterSheetProvider>
|
||||||
<FlashList
|
<FlashList
|
||||||
|
ref={flashListRef}
|
||||||
key={orientation}
|
key={orientation}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
@@ -942,6 +1001,7 @@ const Page = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</FilterSheetProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ function TVTabLayout() {
|
|||||||
|
|
||||||
const currentTab = segments.find(isTabRoute);
|
const currentTab = segments.find(isTabRoute);
|
||||||
const lastSegment = segments[segments.length - 1] ?? "";
|
const lastSegment = segments[segments.length - 1] ?? "";
|
||||||
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
const atTabRoot =
|
||||||
|
isTabRoute(lastSegment) || (lastSegment as string) === "index";
|
||||||
|
|
||||||
const tabs: TVNavBarTab[] = useMemo(
|
const tabs: TVNavBarTab[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|||||||
type ViewMode = "player" | "queue";
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
|
|||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>No track playing</Text>
|
<Text className='text-neutral-500'>
|
||||||
|
{t("music.no_track_playing")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
|
|||||||
: "text-neutral-500"
|
: "text-neutral-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Now Playing
|
{t("music.now_playing")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onReorderQueue,
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
{history.length > 0
|
||||||
|
? t("music.playing_from_queue")
|
||||||
|
: t("music.up_next")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
t("player.an_error_occurred_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -328,7 +329,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
Hash Match
|
{t("player.hash_match")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: t("home.oops") }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -351,9 +352,12 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
|
// Log only the title — serializing the whole notification touches
|
||||||
|
// the deprecated dataString getter (deprecation warning) and dumps
|
||||||
|
// noisy payloads into the console.
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running",
|
"Notification received while app running:",
|
||||||
notification,
|
notification.request.content.title,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -31,6 +31,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -1001,6 +1002,8 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
||||||
|
|
||||||
|
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => {
|
||||||
|
setOpen(true);
|
||||||
|
sheetModalRef.current?.present();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
|
modalRef={sheetModalRef}
|
||||||
title={t("item_card.quality")}
|
title={t("item_card.quality")}
|
||||||
data={sorted}
|
data={sorted}
|
||||||
values={selected ? [selected] : []}
|
values={selected ? [selected] : []}
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ 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`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
||||||
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
@@ -61,7 +63,9 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}, [item]);
|
// useEpisodePoster in deps so flipping the setting re-computes the URL live
|
||||||
|
// (no app restart needed).
|
||||||
|
}, [item, useEpisodePoster]);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => {
|
||||||
|
setOpen(true);
|
||||||
|
sheetModalRef.current?.present();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
|
modalRef={sheetModalRef}
|
||||||
title={t("item_card.video")}
|
title={t("item_card.video")}
|
||||||
data={item.MediaSources || []}
|
data={item.MediaSources || []}
|
||||||
values={selected ? [selected] : []}
|
values={selected ? [selected] : []}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel='Play button'
|
accessibilityLabel={t("accessibility.play_button")}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint={t("accessibility.play_hint")}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative flex-1"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
colors,
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityLabel='Play button'
|
accessibilityLabel={t("accessibility.play_button")}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint={t("accessibility.play_hint")}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
if (!api || !item.Id) return;
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
<View style={styles.centeredView}>
|
<View style={styles.centeredView}>
|
||||||
<View style={styles.modalView}>
|
<View style={styles.modalView}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>Select Session</Text>
|
<Text style={styles.modalTitle}>
|
||||||
|
{t("home.sessions.select_session")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : !sessions || sessions.length === 0 ? (
|
) : !sessions || sessions.length === 0 ? (
|
||||||
<Text style={styles.noSessionsText}>
|
<Text style={styles.noSessionsText}>
|
||||||
No active sessions found
|
{t("home.sessions.no_active_sessions")}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{session.NowPlayingItem && (
|
{session.NowPlayingItem && (
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
Now playing:{" "}
|
{t("home.sessions.now_playing")}{" "}
|
||||||
{session.NowPlayingItem.SeriesName
|
{session.NowPlayingItem.SeriesName
|
||||||
? `${session.NowPlayingItem.SeriesName} :`
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { Platform } from "react-native";
|
import { Keyboard, Platform } from "react-native";
|
||||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
@@ -16,30 +16,37 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Dismiss the keyboard before navigating — otherwise it lingers over the
|
||||||
|
// previous screen (e.g. leaving the Jellyseerr login while typing).
|
||||||
|
const handleBack = () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={handleBack}
|
||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Ionicons name='arrow-back' size={24} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent" && Platform.OS !== "android")
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={() => router.back()} {...pressableProps}>
|
<Pressable onPress={handleBack} {...pressableProps}>
|
||||||
<BlurView
|
<BlurView
|
||||||
{...props}
|
{...props}
|
||||||
intensity={100}
|
intensity={100}
|
||||||
className='overflow-hidden rounded-full p-2'
|
className='overflow-hidden rounded-full p-2'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Feather
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='arrow-back'
|
name='chevron-left'
|
||||||
size={24}
|
size={28}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -48,14 +55,17 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={handleBack}
|
||||||
className=' rounded-full p-2'
|
// Match the Settings page back button: chevron flush to the edge with a
|
||||||
|
// 16px gap before the title (the old `p-2` pushed both arrow and title
|
||||||
|
// too far right). drop-shadow keeps it readable over images.
|
||||||
|
style={{ marginRight: 16 }}
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Feather
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='arrow-back'
|
name='chevron-left'
|
||||||
size={24}
|
size={28}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
40
components/common/SettingSwitch.tsx
Normal file
40
components/common/SettingSwitch.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Platform, Switch, type SwitchProps, View } from "react-native";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
|
||||||
|
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
|
||||||
|
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
|
||||||
|
* FIXED-SIZE box (overflow hidden) and center it:
|
||||||
|
* - the fixed height caps the row height (compact, uniform rows),
|
||||||
|
* - the fixed width + centering keep the switch in the exact same spot in the
|
||||||
|
* on/off states (a non-fixed wrapper let its width fluctuate between states,
|
||||||
|
* which shifted the switch sideways on toggle).
|
||||||
|
* iOS renders the switch untouched.
|
||||||
|
*
|
||||||
|
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
|
||||||
|
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
|
||||||
|
*/
|
||||||
|
const BOX_W = 40;
|
||||||
|
const BOX_H = 30;
|
||||||
|
const SCALE = 0.9;
|
||||||
|
|
||||||
|
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
|
||||||
|
if (Platform.OS !== "android") return <Switch {...props} />;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: BOX_W,
|
||||||
|
height: BOX_H,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
{...props}
|
||||||
|
style={[props.style, { transform: [{ scale: SCALE }] }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { t } from "i18next";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
{isTranscoding && (
|
{isTranscoding && (
|
||||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
<Text className='text-xs text-purple-400'>
|
||||||
|
{t("home.downloads.transcoding")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||||
|
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(items[0].SeriesId!);
|
const seriesId = items[0]?.SeriesId;
|
||||||
}, []);
|
return seriesId ? storage.getString(seriesId) : undefined;
|
||||||
|
}, [items[0]?.SeriesId]);
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
|
import { useFilterSheet } from "./FilterSheetProvider";
|
||||||
|
|
||||||
interface FilterButtonProps<T> extends ViewProps {
|
interface FilterButtonProps<T> extends ViewProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,22 +35,63 @@ export const FilterButton = <T,>({
|
|||||||
icon = "filter",
|
icon = "filter",
|
||||||
...props
|
...props
|
||||||
}: FilterButtonProps<T>) => {
|
}: FilterButtonProps<T>) => {
|
||||||
const [open, setOpen] = useState(false);
|
// When a FilterSheetProvider is present (library / collections), all buttons
|
||||||
|
// share one sheet so two can never stack. Outside a provider (e.g. logs,
|
||||||
|
// discover), fall back to this button's own standalone sheet.
|
||||||
|
const shared = useFilterSheet();
|
||||||
|
|
||||||
const { data: filters } = useQuery<T[]>({
|
const { data: filters, isLoading } = useQuery<T[]>({
|
||||||
queryKey: ["filters", title, queryKey, id],
|
queryKey: ["filters", title, queryKey, id],
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
enabled: !!id && !!queryFn && !!queryKey,
|
enabled: !!id && !!queryFn && !!queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Standalone-mode state (unused in shared mode).
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
|
||||||
|
const onButtonPress = useCallback(() => {
|
||||||
|
if (shared) {
|
||||||
|
shared.openFilter({
|
||||||
|
key: `${id}:${queryKey}`,
|
||||||
|
id,
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
title,
|
||||||
|
values: values as unknown[],
|
||||||
|
set: set as (value: unknown[]) => void,
|
||||||
|
renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
|
||||||
|
searchFilter: searchFilter as
|
||||||
|
| ((item: unknown, query: string) => boolean)
|
||||||
|
| undefined,
|
||||||
|
disableSearch,
|
||||||
|
multiple,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// present() must run from the press handler: from an effect after a state
|
||||||
|
// update it silently no-ops on the new architecture and the sheet never
|
||||||
|
// appears.
|
||||||
|
setOpen(true);
|
||||||
|
sheetModalRef.current?.present();
|
||||||
|
}, [
|
||||||
|
shared,
|
||||||
|
id,
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
title,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
renderItemLabel,
|
||||||
|
searchFilter,
|
||||||
|
disableSearch,
|
||||||
|
multiple,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={onButtonPress}>
|
||||||
onPress={() => {
|
|
||||||
filters?.length && setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
@@ -85,10 +128,13 @@ 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}
|
||||||
|
modalRef={sheetModalRef}
|
||||||
|
loading={isLoading}
|
||||||
data={filters}
|
data={filters}
|
||||||
values={values}
|
values={values}
|
||||||
set={set}
|
set={set}
|
||||||
@@ -97,6 +143,7 @@ export const FilterButton = <T,>({
|
|||||||
disableSearch={disableSearch}
|
disableSearch={disableSearch}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { isEqual } from "lodash";
|
import { isEqual } from "lodash";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
interface Props<T> extends ViewProps {
|
interface Props<T> extends ViewProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Modal ref the opener must use to present() the sheet from inside its
|
||||||
|
* press handler. On the new architecture with Reanimated 4, present()
|
||||||
|
* called from an effect after a state update silently no-ops — the sheet
|
||||||
|
* mounts nothing. Presenting straight from the gesture handler works.
|
||||||
|
*/
|
||||||
|
modalRef: React.RefObject<BottomSheetModal | null>;
|
||||||
data?: T[] | null;
|
data?: T[] | null;
|
||||||
|
/** True while the options are loading — shows a loader inside the sheet. */
|
||||||
|
loading?: boolean;
|
||||||
values: T[];
|
values: T[];
|
||||||
set: (value: T[]) => void;
|
set: (value: T[]) => void;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -66,16 +83,18 @@ const LIMIT = 100;
|
|||||||
export const FilterSheet = <T,>({
|
export const FilterSheet = <T,>({
|
||||||
values,
|
values,
|
||||||
data: _data,
|
data: _data,
|
||||||
|
loading = false,
|
||||||
open,
|
open,
|
||||||
set,
|
set,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
modalRef,
|
||||||
title,
|
title,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
disableSearch = false,
|
disableSearch = false,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = modalRef;
|
||||||
const snapPoints = useMemo(() => ["85%"], []);
|
const snapPoints = useMemo(() => ["85%"], []);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
|
|||||||
const [offset, setOffset] = useState<number>(0);
|
const [offset, setOffset] = useState<number>(0);
|
||||||
|
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
// Filtering and re-rendering the option list on every keystroke blocks the
|
||||||
|
// JS thread on large lists (2000+ tags); the controlled input then snaps the
|
||||||
|
// native text back to a stale value (lost/reappearing letters). Deferring the
|
||||||
|
// value keeps the keystroke render cheap and runs the list update after.
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
|
||||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!search) return _data;
|
if (!deferredSearch) return _data;
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||||
if (_data && searchFilter?.(_data[i], search)) {
|
if (_data && searchFilter?.(_data[i], deferredSearch)) {
|
||||||
results.push(_data[i]);
|
results.push(_data[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results.slice(0, 100);
|
return results.slice(0, 100);
|
||||||
}, [search, _data, searchFilter]);
|
}, [deferredSearch, _data, searchFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || data.length === 0 || disableSearch) return;
|
if (!data || data.length === 0 || disableSearch) return;
|
||||||
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
|
|||||||
setData(newData);
|
setData(newData);
|
||||||
}, [offset, _data]);
|
}, [offset, _data]);
|
||||||
|
|
||||||
|
// Opening is imperative (see the modalRef prop); this effect only closes.
|
||||||
|
// It also never calls dismiss() on a modal that was never presented.
|
||||||
|
const wasPresentedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) bottomSheetModalRef.current?.present();
|
if (!open && wasPresentedRef.current) {
|
||||||
else bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
if (index === -1) {
|
if (index >= 0) {
|
||||||
|
wasPresentedRef.current = true;
|
||||||
|
} else if (index === -1) {
|
||||||
|
wasPresentedRef.current = false;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderData = useMemo(() => {
|
const renderData = useMemo(() => {
|
||||||
if (search.length > 0 && showSearch) return filteredData;
|
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
||||||
return data;
|
return data;
|
||||||
}, [search, filteredData, data]);
|
}, [deferredSearch, showSearch, filteredData, data]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -154,6 +185,54 @@ export const FilterSheet = <T,>({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Memoized so typing in the search input (urgent render with an unchanged
|
||||||
|
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
|
||||||
|
const renderedRows = useMemo(
|
||||||
|
() =>
|
||||||
|
renderData?.map((item, index) => (
|
||||||
|
<View key={index}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// Match the deep-equality rule used to render the selected
|
||||||
|
// state below — option objects are recreated across renders,
|
||||||
|
// so reference checks would re-add an already selected item.
|
||||||
|
const isSelected = values.some((value) => isEqual(value, item));
|
||||||
|
if (multiple) {
|
||||||
|
if (!isSelected) set(values.concat(item));
|
||||||
|
else set(values.filter((value) => !isEqual(value, item)));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
} else {
|
||||||
|
if (!isSelected) {
|
||||||
|
set([item]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||||
|
>
|
||||||
|
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||||
|
{values.some((i) => isEqual(i, item)) ? (
|
||||||
|
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className='h-1 divide-neutral-700 '
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)),
|
||||||
|
[renderData, values, multiple, set, setOpen, renderItemLabel],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='font-bold text-2xl'>{title}</Text>
|
<Text className='font-bold text-2xl'>{title}</Text>
|
||||||
|
{loading ? (
|
||||||
|
<View className='my-8 flex items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<Text className='mb-2 text-neutral-500'>
|
<Text className='mb-2 text-neutral-500'>
|
||||||
{t("search.x_items", { count: _data?.length })}
|
{t("search.x_items", { count: _data?.length })}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||||
>
|
>
|
||||||
{renderData?.map((item, index) => (
|
{renderedRows}
|
||||||
<View key={index}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (multiple) {
|
|
||||||
if (!values.includes(item)) set(values.concat(item));
|
|
||||||
else set(values.filter((v) => v !== item));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
} else {
|
|
||||||
if (!values.includes(item)) {
|
|
||||||
set([item]);
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
|
||||||
{values.some((i) => isEqual(i, item)) ? (
|
|
||||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='h-1 divide-neutral-700 '
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
{data.length < (_data?.length || 0) && (
|
{data.length < (_data?.length || 0) && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
74
components/filters/FilterSheetProvider.tsx
Normal file
74
components/filters/FilterSheetProvider.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet";
|
||||||
|
|
||||||
|
interface FilterSheetContextType {
|
||||||
|
openFilter: (config: FilterConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterSheetContext = createContext<FilterSheetContextType | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the shared-sheet controller, or null when rendered outside a
|
||||||
|
* FilterSheetProvider — FilterButton then falls back to its own standalone
|
||||||
|
* sheet (used by screens that don't host a provider, e.g. logs / discover).
|
||||||
|
*/
|
||||||
|
export const useFilterSheet = (): FilterSheetContextType | null =>
|
||||||
|
useContext(FilterSheetContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the single shared filter sheet for a screen. Every FilterButton under
|
||||||
|
* it calls openFilter() to show its options in that one sheet — so two sheets
|
||||||
|
* can never stack regardless of how fast the buttons are tapped. present() runs
|
||||||
|
* synchronously from the button's press handler (the modal is always mounted).
|
||||||
|
*/
|
||||||
|
export const FilterSheetProvider: React.FC<PropsWithChildren> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const modalRef = useRef<BottomSheetModal | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [config, setConfig] = useState<FilterConfig | null>(null);
|
||||||
|
|
||||||
|
// First-wins guard. With a single shared sheet there is exactly one source of
|
||||||
|
// truth (this ref) reset on the one close path — so unlike a per-button guard
|
||||||
|
// it can't get stuck on remounts or multiple instances. A second tap during
|
||||||
|
// the first sheet's open animation is ignored; the first tapped filter wins.
|
||||||
|
const openRef = useRef(false);
|
||||||
|
|
||||||
|
const openFilter = useCallback((next: FilterConfig) => {
|
||||||
|
if (openRef.current) return;
|
||||||
|
openRef.current = true;
|
||||||
|
setConfig(next);
|
||||||
|
setOpen(true);
|
||||||
|
modalRef.current?.present();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Single close path for every dismissal (select / swipe / backdrop) — frees
|
||||||
|
// the guard reliably.
|
||||||
|
const closeSheet = useCallback(() => {
|
||||||
|
openRef.current = false;
|
||||||
|
setOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ openFilter }), [openFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterSheetContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<SharedFilterSheet
|
||||||
|
modalRef={modalRef}
|
||||||
|
open={open}
|
||||||
|
setOpen={closeSheet}
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
|
</FilterSheetContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,38 +1,24 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
import {
|
import { useFilterReset } from "@/hooks/useFilterReset";
|
||||||
filterByAtom,
|
|
||||||
genreFilterAtom,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {}
|
interface Props extends TouchableOpacityProps {
|
||||||
|
libraryId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
export const ResetFiltersButton: React.FC<Props> = ({
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
libraryId,
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
...props
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
}) => {
|
||||||
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
|
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
|
||||||
|
|
||||||
if (
|
if (!hasActiveFilters) {
|
||||||
selectedGenres.length === 0 &&
|
|
||||||
selectedTags.length === 0 &&
|
|
||||||
selectedYears.length === 0 &&
|
|
||||||
selectedFilters.length === 0
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={resetAllFilters}
|
||||||
setSelectedGenres([]);
|
|
||||||
setSelectedTags([]);
|
|
||||||
setSelectedYears([]);
|
|
||||||
setSelectedFilters([]);
|
|
||||||
}}
|
|
||||||
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
286
components/filters/SharedFilterSheet.tsx
Normal file
286
components/filters/SharedFilterSheet.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { isEqual } from "lodash";
|
||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config for the filter currently shown by the shared sheet. Generics are erased
|
||||||
|
* at the FilterButton → provider boundary, so item-typed callbacks use `any`.
|
||||||
|
*/
|
||||||
|
export interface FilterConfig {
|
||||||
|
/** Stable identity — changing it remounts the content with fresh state. */
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
queryKey: string;
|
||||||
|
queryFn: (params: any) => Promise<any>;
|
||||||
|
title: string;
|
||||||
|
values: any[];
|
||||||
|
set: (value: any[]) => void;
|
||||||
|
renderItemLabel: (item: any) => React.ReactNode;
|
||||||
|
searchFilter?: (item: any, query: string) => boolean;
|
||||||
|
disableSearch?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 100;
|
||||||
|
|
||||||
|
interface SharedFilterSheetProps {
|
||||||
|
modalRef: React.RefObject<BottomSheetModal | null>;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
config: FilterConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single shared filter sheet — one BottomSheetModal hosted by
|
||||||
|
* FilterSheetProvider for a whole screen; FilterButtons only swap its `config`.
|
||||||
|
* Because only one modal ever exists, rapid taps across buttons can never stack
|
||||||
|
* two sheets, so no guard/timer is needed. The modal shell stays mounted with a
|
||||||
|
* stable ref (present() can run synchronously from the tapping button); the
|
||||||
|
* inner content is keyed by the active filter so its pagination/search reset
|
||||||
|
* cleanly between filters.
|
||||||
|
*/
|
||||||
|
export const SharedFilterSheet: React.FC<SharedFilterSheetProps> = ({
|
||||||
|
modalRef,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
const snapPoints = useMemo(() => ["85%"], []);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Opening is imperative (the provider calls present()); this effect only
|
||||||
|
// closes, and never dismisses a modal that was never presented.
|
||||||
|
const wasPresentedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && wasPresentedRef.current) {
|
||||||
|
modalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [open, modalRef]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= 0) {
|
||||||
|
wasPresentedRef.current = true;
|
||||||
|
} else if (index === -1) {
|
||||||
|
wasPresentedRef.current = false;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestClose = useCallback(() => setOpen(false), [setOpen]);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={modalRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView style={{ flex: 1 }}>
|
||||||
|
<View
|
||||||
|
className='mt-2 mb-8'
|
||||||
|
style={{
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config && (
|
||||||
|
<SharedFilterSheetContent
|
||||||
|
key={config.key}
|
||||||
|
config={config}
|
||||||
|
onRequestClose={requestClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SharedFilterSheetContentProps {
|
||||||
|
config: FilterConfig;
|
||||||
|
onRequestClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SharedFilterSheetContent: React.FC<SharedFilterSheetContentProps> = ({
|
||||||
|
config,
|
||||||
|
onRequestClose,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
title,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
renderItemLabel,
|
||||||
|
searchFilter,
|
||||||
|
disableSearch = false,
|
||||||
|
multiple = false,
|
||||||
|
} = config;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// The options query lives here (deduped with the FilterButton's own query via
|
||||||
|
// the shared React Query key), so the list stays live after the sheet opens.
|
||||||
|
const { data: _data, isLoading: loading } = useQuery<any[]>({
|
||||||
|
queryKey: ["filters", title, queryKey, id],
|
||||||
|
queryFn,
|
||||||
|
staleTime: 0,
|
||||||
|
enabled: !!id && !!queryFn && !!queryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
// Filtering on every keystroke blocks the JS thread on large lists; defer the
|
||||||
|
// value so the keystroke render stays cheap and the list update runs after.
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!deferredSearch) return _data;
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||||
|
if (_data && searchFilter?.(_data[i], deferredSearch)) {
|
||||||
|
results.push(_data[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results.slice(0, 100);
|
||||||
|
}, [deferredSearch, _data, searchFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0 || disableSearch) return;
|
||||||
|
if (data.length > 15) {
|
||||||
|
setShowSearch(true);
|
||||||
|
}
|
||||||
|
}, [data, disableSearch]);
|
||||||
|
|
||||||
|
// Loads data in batches of LIMIT from offset (efficient "load more").
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_data || _data.length === 0) return;
|
||||||
|
|
||||||
|
const newData = [...data];
|
||||||
|
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
||||||
|
const item = _data[i];
|
||||||
|
// Option objects are recreated across renders → dedupe by value.
|
||||||
|
const exists = newData.some((existingItem) =>
|
||||||
|
isEqual(existingItem, item),
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
newData.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setData(newData);
|
||||||
|
}, [offset, _data]);
|
||||||
|
|
||||||
|
const renderData = useMemo(() => {
|
||||||
|
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
||||||
|
return data;
|
||||||
|
}, [deferredSearch, showSearch, filteredData, data]);
|
||||||
|
|
||||||
|
const renderedRows = useMemo(
|
||||||
|
() =>
|
||||||
|
renderData?.map((item, index) => (
|
||||||
|
<View key={index}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
const isSelected = values.some((value) => isEqual(value, item));
|
||||||
|
if (multiple) {
|
||||||
|
if (!isSelected) set(values.concat(item));
|
||||||
|
else set(values.filter((value) => !isEqual(value, item)));
|
||||||
|
setTimeout(() => onRequestClose(), 250);
|
||||||
|
} else if (!isSelected) {
|
||||||
|
set([item]);
|
||||||
|
setTimeout(() => onRequestClose(), 250);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||||
|
>
|
||||||
|
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||||
|
{values.some((i) => isEqual(i, item)) ? (
|
||||||
|
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View
|
||||||
|
style={{ height: StyleSheet.hairlineWidth }}
|
||||||
|
className='h-1 divide-neutral-700'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)),
|
||||||
|
[renderData, values, multiple, set, renderItemLabel, onRequestClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text className='font-bold text-2xl'>{title}</Text>
|
||||||
|
{loading ? (
|
||||||
|
<View className='my-8 flex items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className='mb-2 text-neutral-500'>
|
||||||
|
{t("search.x_items", { count: _data?.length })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{showSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
className='my-2 border-neutral-800 border'
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{ borderRadius: 20, overflow: "hidden" }}
|
||||||
|
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||||
|
>
|
||||||
|
{renderedRows}
|
||||||
|
</View>
|
||||||
|
{data.length < (_data?.length || 0) && (
|
||||||
|
<Button onPress={() => setOffset(offset + LIMIT)}>Load more</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Colors } from "../../constants/Colors";
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
}, [isSuccess, onLoaded]);
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
@@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
const items = data?.pages.flat() ?? [];
|
const items = data?.pages.flat() ?? [];
|
||||||
@@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
width={itemWidth}
|
width={itemWidth}
|
||||||
|
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -243,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showItemActions,
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
ITEM_GAP,
|
ITEM_GAP,
|
||||||
|
settings?.useEpisodeImagesForNextUp,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||||
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|||||||
const posterUrl = useMemo(() => {
|
const posterUrl = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
|
|
||||||
// For episodes, always use series thumb
|
// For episodes, always use series thumb.
|
||||||
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentThumbImageTag) {
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
if (item.SeriesId) {
|
if (item.SeriesId) {
|
||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ 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'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -34,12 +34,17 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
|
// Keep the row floor uniform; Android trims padding slightly (its native
|
||||||
|
// controls sit taller). Switch height is capped via SettingSwitch so toggle
|
||||||
|
// rows match non-toggle rows.
|
||||||
|
const rowSizing =
|
||||||
|
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -58,7 +63,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
scaleAmount: 1,
|
scaleAmount: 1,
|
||||||
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
LIVE
|
{t("player.live")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Live TV
|
{t("live_tv.title")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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 { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import {
|
import {
|
||||||
@@ -36,14 +37,15 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
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();
|
||||||
@@ -67,25 +69,44 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state — only the intent lives here; the protection picker is
|
// Quick Connect code shown in the in-app sheet while polling for authorization
|
||||||
// the global PendingAccountSaveModal, shown after the login succeeds.
|
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
|
||||||
|
|
||||||
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
|
||||||
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
|
||||||
|
|
||||||
|
// 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:
|
// A Quick Connect login with "save account" on flags the post-login save:
|
||||||
// the protection picker shows globally once the session exists (this screen
|
// the protection picker shows globally once the session exists (this screen
|
||||||
// unmounts on login, so it can't host the modal).
|
// unmounts on login, so it can't host the modal).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
if (quickConnectActive && saveAccount) {
|
if (quickConnectCode && saveAccount) {
|
||||||
setPendingAccountSave({ serverName });
|
setPendingAccountSave({ serverName });
|
||||||
}
|
}
|
||||||
setQuickConnectActive(false);
|
setQuickConnectCode(null);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [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);
|
||||||
|
|
||||||
// Handle URL params for server connection
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -153,7 +174,7 @@ export const Login: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -252,16 +273,7 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
setQuickConnectActive(true);
|
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(
|
||||||
@@ -396,7 +408,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label='Server URL'
|
aria-label={t("server.server_url")}
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -437,6 +449,13 @@ export const Login: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* Dismissing only hides the code — polling continues so the login still
|
||||||
|
completes if the code is authorized from another device afterwards. */}
|
||||||
|
<QuickConnectCodeModal
|
||||||
|
code={quickConnectCode}
|
||||||
|
onClose={() => setQuickConnectCode(null)}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
137
components/login/QuickConnectCodeModal.tsx
Normal file
137
components/login/QuickConnectCodeModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The Quick Connect code to display, or null when hidden. */
|
||||||
|
code: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the Quick Connect code while the app polls for authorization.
|
||||||
|
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
||||||
|
* session is authorized — a native alert has no programmatic dismiss and
|
||||||
|
* lingers over the app after login completes.
|
||||||
|
*/
|
||||||
|
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["50%"], []);
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep the last code around so the dismiss animation doesn't flash empty
|
||||||
|
// when the parent clears the code to close the sheet.
|
||||||
|
const lastCodeRef = useRef<string | null>(null);
|
||||||
|
if (code) lastCodeRef.current = code;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else if (isPresentedRef.current) {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyCode = useCallback(async () => {
|
||||||
|
const value = code ?? lastCodeRef.current;
|
||||||
|
if (!value) return;
|
||||||
|
// Builds that don't ship the expo-clipboard native module yet: probe with
|
||||||
|
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
||||||
|
// and skip — importing the JS wrapper there would error out.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
toast.success(t("login.code_copied"));
|
||||||
|
}, [code, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
||||||
|
onPress={copyCode}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className='text-center font-bold text-5xl text-neutral-100'
|
||||||
|
style={{ letterSpacing: 10 }}
|
||||||
|
>
|
||||||
|
{code ?? lastCodeRef.current}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='copy-outline'
|
||||||
|
size={22}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.4, marginLeft: 16 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
||||||
|
{t("login.tap_code_to_copy")}
|
||||||
|
</Text>
|
||||||
|
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
||||||
|
{t("login.quick_connect_instructions")}
|
||||||
|
</Text>
|
||||||
|
<Button className='mt-auto' color='purple' onPress={onClose}>
|
||||||
|
{t("login.got_it")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
showDiscover,
|
showDiscover,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!showDiscover) {
|
if (!showDiscover) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label='Library'
|
label={t("search.library")}
|
||||||
isSelected={searchType === "Library"}
|
isSelected={searchType === "Library"}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label='Discover'
|
label={t("search.discover")}
|
||||||
isSelected={searchType === "Discover"}
|
isSelected={searchType === "Discover"}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch } from "react-native";
|
import { Linking } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -27,6 +28,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.show_custom_menu_links")}
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
|
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
@@ -34,7 +36,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -44,31 +46,51 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.mergeNextUpAndContinueWatching}
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.use_episode_images_next_up_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingSwitch
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.hide_remote_session_button_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingSwitch
|
||||||
|
value={settings.hideRemoteSessionButton}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ hideRemoteSessionButton: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/settings/appearance/hide-libraries/page")
|
router.push("/settings/appearance/hide-libraries/page")
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
|
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.hideRemoteSessionButton}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ hideRemoteSessionButton: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.audio.set_audio_track")}
|
title={t("home.settings.audio.set_audio_track")}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberAudioSelections}
|
value={settings.rememberAudioSelections}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -9,7 +10,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
|
|||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
// Only the server URL is admin-lockable — the password stays editable so
|
||||||
|
// the user can still sign in to the admin-pinned Jellyseerr server.
|
||||||
|
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
@@ -115,6 +118,7 @@ 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>
|
</Text>
|
||||||
@@ -128,7 +132,11 @@ export const JellyseerrSettings = () => {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
value={
|
||||||
|
urlLocked
|
||||||
|
? settings?.jellyseerrServerUrl
|
||||||
|
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
||||||
|
}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
}
|
}
|
||||||
@@ -137,8 +145,14 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={setjellyseerrServerUrl}
|
onChangeText={setjellyseerrServerUrl}
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
{urlLocked && (
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
Disabled by admin
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<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")}
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, Text, View } from "react-native";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const KefinTweaksSettings = () => {
|
export const KefinTweaksSettings = () => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||||
|
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className=''>
|
<ListGroup>
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<ListItem
|
||||||
<Text className='text-xs text-red-600 mb-2'>
|
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||||
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
disabledByAdmin={locked}
|
||||||
</Text>
|
>
|
||||||
|
<SettingSwitch
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
|
||||||
<Text className='text-white'>
|
|
||||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
|
disabled={locked}
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
trackColor={{ false: "#555", true: "purple" }}
|
|
||||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</ListItem>
|
||||||
</View>
|
</ListGroup>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -147,7 +148,10 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
title={t("home.settings.network.auto_switch_enabled")}
|
title={t("home.settings.network.auto_switch_enabled")}
|
||||||
subtitle={t("home.settings.network.auto_switch_description")}
|
subtitle={t("home.settings.network.auto_switch_description")}
|
||||||
>
|
>
|
||||||
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
<SettingSwitch
|
||||||
|
value={config.enabled}
|
||||||
|
onValueChange={handleToggleEnabled}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -17,20 +19,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||||
|
|
||||||
const alignXLabels: Record<AlignX, string> = {
|
const alignXLabels: Record<AlignX, string> = {
|
||||||
left: "Left",
|
left: t("home.settings.subtitles.align.left"),
|
||||||
center: "Center",
|
center: t("home.settings.subtitles.align.center"),
|
||||||
right: "Right",
|
right: t("home.settings.subtitles.align.right"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignYLabels: Record<AlignY, string> = {
|
const alignYLabels: Record<AlignY, string> = {
|
||||||
top: "Top",
|
top: t("home.settings.subtitles.align.top"),
|
||||||
center: "Center",
|
center: t("home.settings.subtitles.align.center"),
|
||||||
bottom: "Bottom",
|
bottom: t("home.settings.subtitles.align.bottom"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignXOptionGroups = useMemo(() => {
|
const alignXOptionGroups = useMemo(() => {
|
||||||
@@ -60,16 +63,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title='MPV Subtitle Settings'
|
title={t("home.settings.subtitles.mpv_settings_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
Advanced subtitle customization for MPV player
|
{t("home.settings.subtitles.mpv_settings_description")}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isTv && (
|
{!isTv && (
|
||||||
<>
|
<>
|
||||||
<ListItem title='Vertical Margin'>
|
<ListItem
|
||||||
|
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||||
|
>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -81,7 +86,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Horizontal Alignment'>
|
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignXOptionGroups}
|
groups={alignXOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -96,11 +101,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Horizontal Alignment'
|
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Vertical Alignment'>
|
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignYOptionGroups}
|
groups={alignYOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -115,14 +120,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Vertical Alignment'
|
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ListItem title='Opaque Background'>
|
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
||||||
@@ -131,7 +136,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{settings.mpvSubtitleBackgroundEnabled && (
|
{settings.mpvSubtitleBackgroundEnabled && (
|
||||||
<ListItem title='Background Opacity'>
|
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||||
step={5}
|
step={5}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch, View } from "react-native";
|
import { Linking, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -132,7 +133,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -150,7 +151,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -188,7 +189,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
@@ -115,7 +116,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
@@ -146,7 +147,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -205,7 +206,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
@@ -218,7 +219,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.autoPlayNextEpisode}
|
value={settings.autoPlayNextEpisode}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||||
title={"Jellyseerr"}
|
title='Jellyseerr'
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
|
||||||
title='Marlin Search'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
|||||||
title='Streamystats'
|
title='Streamystats'
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||||
|
title='Marlin Search'
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title='KefinTweaks'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
@@ -5,11 +6,13 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
@@ -58,7 +61,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
@@ -79,6 +82,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
|
const pasteCode = useCallback(async () => {
|
||||||
|
// Builds without the expo-clipboard native module: probe first (no-op).
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
const text = await Clipboard.getStringAsync();
|
||||||
|
const digits = (text || "").replace(/\D/g, "").slice(0, 6);
|
||||||
|
if (digits) setQuickConnectCode(digits);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,6 +142,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
style={{ paddingHorizontal: 16 }}
|
style={{ paddingHorizontal: 16 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={pasteCode}
|
||||||
|
className='flex-row items-center justify-center self-center'
|
||||||
|
>
|
||||||
|
<Feather name='clipboard' size={15} color='#a3a3a3' />
|
||||||
|
<Text className='text-neutral-400 ml-2'>
|
||||||
|
{t("home.settings.quick_connect.paste_code")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Keep the bar moving while a download is writing to disk.
|
||||||
|
refetchInterval: 10 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||||
|
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.ok"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
} finally {
|
||||||
|
// Reflect the freed space immediately instead of waiting for
|
||||||
|
// the next poll.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
@@ -102,7 +125,7 @@ export const StorageSettings = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<ListGroup>
|
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteClicked}
|
onPress={onDeleteClicked}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -98,6 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
|
className='mb-4'
|
||||||
title={t("home.settings.subtitles.subtitle_title")}
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
@@ -152,7 +153,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.subtitles.set_subtitle_track")}
|
title={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberSubtitleSelections}
|
value={settings.rememberSubtitleSelections}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -72,6 +73,9 @@ export interface TVPosterCardProps {
|
|||||||
|
|
||||||
/** Custom image URL getter - if not provided, uses smart URL logic */
|
/** Custom image URL getter - if not provided, uses smart URL logic */
|
||||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||||
|
|
||||||
|
/** For horizontal episodes, prefer the episode's own image over the series thumb */
|
||||||
|
preferEpisodeImage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +112,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
glowColor = "white",
|
glowColor = "white",
|
||||||
scaleAmount = 1.05,
|
scaleAmount = 1.05,
|
||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
|
preferEpisodeImage = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
@@ -139,9 +145,14 @@ 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") {
|
||||||
// First try parent/series thumb (horizontal series artwork)
|
// Opt-in: use the episode's own image instead of the series thumb.
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
if (preferEpisodeImage && item.ImageTags?.Primary) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
// First try parent/series thumb (horizontal series artwork).
|
||||||
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
// Fall back to episode's own primary image
|
// Fall back to episode's own primary image
|
||||||
if (item.ImageTags?.Primary) {
|
if (item.ImageTags?.Primary) {
|
||||||
@@ -173,7 +184,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
item,
|
item,
|
||||||
width: width * 2, // 2x for quality on large screens
|
width: width * 2, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item, orientation, width, imageUrlGetter]);
|
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
|
||||||
|
|
||||||
// Progress calculation
|
// Progress calculation
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
@@ -374,7 +385,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Now Playing
|
{t("music.now_playing")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -28,6 +29,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
const styles = createStyles(typography);
|
const styles = createStyles(typography);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.flagText}>Hash Match</Text>
|
<Text style={styles.flagText}>{t("player.hash_match")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{result.hearingImpaired && (
|
{result.hearingImpaired && (
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText='Skip Intro'
|
buttonText={t("player.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='Skip Credits'
|
buttonText={t("player.skip_credits")}
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
Are you still watching ?
|
{t("player.still_watching")}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
@@ -57,6 +58,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -127,8 +129,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
onPress={toggleOrientation}
|
onPress={toggleOrientation}
|
||||||
disabled={isTogglingOrientation}
|
disabled={isTogglingOrientation}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
accessibilityLabel='Toggle screen orientation'
|
accessibilityLabel={t("accessibility.toggle_orientation")}
|
||||||
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name='screen-rotation'
|
name='screen-rotation'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -184,6 +185,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Video: {formatCodec(info.videoCodec)}
|
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
||||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Audio: {formatCodec(info.audioCodec)}
|
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
||||||
{streamInfo?.audioChannels
|
{streamInfo?.audioChannels
|
||||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{streamInfo?.subtitleCodec && (
|
{streamInfo?.subtitleCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
{t("player.technical_info.subtitle")}{" "}
|
||||||
|
{formatCodec(streamInfo.subtitleCodec)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Bitrate:{" "}
|
{t("player.technical_info.bitrate")}{" "}
|
||||||
{info.videoBitrate
|
{info.videoBitrate
|
||||||
? formatBitrate(info.videoBitrate)
|
? formatBitrate(info.videoBitrate)
|
||||||
: info.audioBitrate
|
: info.audioBitrate
|
||||||
@@ -341,7 +344,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
{t("player.technical_info.buffer_seconds", {
|
||||||
|
seconds: info.cacheSeconds.toFixed(1),
|
||||||
|
})}
|
||||||
{info?.demuxerMaxBytes !== undefined
|
{info?.demuxerMaxBytes !== undefined
|
||||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||||
@@ -352,7 +357,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
VO: {info.voDriver}
|
{t("player.technical_info.vo")} {info.voDriver}
|
||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -364,10 +369,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
{t("player.technical_info.dropped_frames", {
|
||||||
|
count: info.droppedFrames,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
{!info && !playMethod && (
|
||||||
|
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
@@ -54,6 +55,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
onRatioChange,
|
onRatioChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||||
@@ -66,7 +68,10 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
{
|
{
|
||||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: option.label,
|
label:
|
||||||
|
option.id === "default"
|
||||||
|
? t("player.aspect_ratio_original")
|
||||||
|
: option.label,
|
||||||
value: option.id,
|
value: option.id,
|
||||||
selected: option.id === currentRatio,
|
selected: option.id === currentRatio,
|
||||||
onPress: () => handleRatioSelect(option.id),
|
onPress: () => handleRatioSelect(option.id),
|
||||||
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title='Aspect Ratio'
|
title={t("player.aspect_ratio")}
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
bottomSheetConfig={{
|
bottomSheetConfig={{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ const DropdownView = ({
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -101,7 +103,7 @@ const DropdownView = ({
|
|||||||
// Quality Section
|
// Quality Section
|
||||||
if (!isOffline) {
|
if (!isOffline) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Quality",
|
title: t("player.menu.quality"),
|
||||||
options:
|
options:
|
||||||
BITRATES?.map((bitrate) => ({
|
BITRATES?.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -116,7 +118,7 @@ const DropdownView = ({
|
|||||||
// Subtitle Section
|
// Subtitle Section
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Subtitles",
|
title: t("player.menu.subtitles"),
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: subtitleTracks.map((sub) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: sub.name,
|
label: sub.name,
|
||||||
@@ -128,7 +130,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
// Subtitle Scale Section
|
// Subtitle Scale Section
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Subtitle Scale",
|
title: t("player.menu.subtitle_scale"),
|
||||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: preset.label,
|
label: preset.label,
|
||||||
@@ -142,7 +144,7 @@ const DropdownView = ({
|
|||||||
// Audio Section
|
// Audio Section
|
||||||
if (audioTracks && audioTracks.length > 0) {
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Audio",
|
title: t("player.menu.audio"),
|
||||||
options: audioTracks.map((track) => ({
|
options: audioTracks.map((track) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: track.name,
|
label: track.name,
|
||||||
@@ -156,7 +158,7 @@ const DropdownView = ({
|
|||||||
// Speed Section
|
// Speed Section
|
||||||
if (setPlaybackSpeed) {
|
if (setPlaybackSpeed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Speed",
|
title: t("player.menu.speed"),
|
||||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: speed.label,
|
label: speed.label,
|
||||||
@@ -174,8 +176,8 @@ const DropdownView = ({
|
|||||||
{
|
{
|
||||||
type: "action" as const,
|
type: "action" as const,
|
||||||
label: showTechnicalInfo
|
label: showTechnicalInfo
|
||||||
? "Hide Technical Info"
|
? t("player.menu.hide_technical_info")
|
||||||
: "Show Technical Info",
|
: t("player.menu.show_technical_info"),
|
||||||
onPress: onToggleTechnicalInfo,
|
onPress: onToggleTechnicalInfo,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -185,6 +187,7 @@ const DropdownView = ({
|
|||||||
return groups;
|
return groups;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
|
t,
|
||||||
isOffline,
|
isOffline,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
changeBitrate,
|
changeBitrate,
|
||||||
@@ -217,7 +220,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title='Playback Options'
|
title={t("player.menu.playback_options")}
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
expoUIConfig={{}}
|
expoUIConfig={{}}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Alert } from "react-native";
|
|||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
|
||||||
interface UseRemoteControlProps {
|
interface UseRemoteControlProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -124,17 +125,23 @@ export function useRemoteControl({
|
|||||||
|
|
||||||
// Controls are hidden, so confirm before leaving playback.
|
// Controls are hidden, so confirm before leaving playback.
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Stop Playback",
|
i18n.t("player.stopPlayback"),
|
||||||
videoTitleRef.current
|
videoTitleRef.current
|
||||||
? `Stop playing "${videoTitleRef.current}"?`
|
? i18n.t("player.stopPlayingTitle", {
|
||||||
: "Are you sure you want to stop playback?",
|
title: videoTitleRef.current,
|
||||||
|
})
|
||||||
|
: i18n.t("player.stopPlayingConfirm"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Cancel",
|
text: i18n.t("common.cancel"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
onPress: () => onCancelExitRef.current?.(),
|
onPress: () => onCancelExitRef.current?.(),
|
||||||
},
|
},
|
||||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
{
|
||||||
|
text: i18n.t("common.stop"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: onBackRef.current,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
|
||||||
|
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { NavigationContext } from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useContext, useMemo } from "react";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement for expo-router's useRouter that automatically
|
* Drop-in replacement for expo-router's useRouter that automatically
|
||||||
* preserves offline state across navigation.
|
* preserves offline state across navigation and guards against duplicate
|
||||||
|
* screens from rapid taps.
|
||||||
*
|
*
|
||||||
* - For object-form navigation, automatically adds offline=true when in offline context
|
* - For object-form navigation, automatically adds offline=true when in offline context
|
||||||
* - For string URLs, passes through unchanged (caller handles offline param)
|
* - For string URLs, passes through unchanged (caller handles offline param)
|
||||||
|
* - push() is a no-op while the source screen is not focused, so taps fired
|
||||||
|
* before the pushed screen has rendered (slow devices) can't stack duplicates
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* import useRouter from "@/hooks/useAppRouter";
|
* import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -19,8 +25,18 @@ export function useAppRouter() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
|
||||||
|
// Optional: undefined when used outside a navigator (root layout, providers).
|
||||||
|
// When present it reflects the focus state of the screen this hook lives in.
|
||||||
|
const navigation = useContext(NavigationContext);
|
||||||
|
|
||||||
const push = useCallback(
|
const push = useCallback(
|
||||||
(href: Parameters<typeof router.push>[0]) => {
|
(href: Parameters<typeof router.push>[0]) => {
|
||||||
|
// Rapid-push guard: a push blurs the source screen synchronously in the
|
||||||
|
// navigation state (only the native render is slow). Any further push from
|
||||||
|
// this screen — duplicate or not — is dropped until focus returns, so taps
|
||||||
|
// fired before the pushed screen renders can't stack screens.
|
||||||
|
// No navigation context => nothing to guard (deep-link pushes from root).
|
||||||
|
if (navigation?.isFocused?.() === false) return;
|
||||||
if (typeof href === "string") {
|
if (typeof href === "string") {
|
||||||
router.push(href as any);
|
router.push(href as any);
|
||||||
} else {
|
} else {
|
||||||
@@ -36,7 +52,7 @@ export function useAppRouter() {
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, isOffline],
|
[router, isOffline, navigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const replace = useCallback(
|
const replace = useCallback(
|
||||||
|
|||||||
108
hooks/useFilterReset.ts
Normal file
108
hooks/useFilterReset.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
FilterByPreferenceAtom,
|
||||||
|
filterByAtom,
|
||||||
|
genreFilterAtom,
|
||||||
|
genrePreferenceAtom,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortByPreferenceAtom,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderPreferenceAtom,
|
||||||
|
tagPreferenceAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
yearPreferenceAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the library filter bar's "reset" action and its
|
||||||
|
* visibility. The mobile ResetFiltersButton and the TV filter header both use
|
||||||
|
* this so they can't drift — sort/order used to be reset on neither path, so
|
||||||
|
* the reset (X) never reflected a changed sort.
|
||||||
|
*
|
||||||
|
* A reset clears the session filters AND the per-library in-memory preferences
|
||||||
|
* (sort, order, filterBy, genres, years, tags); otherwise the saved preference
|
||||||
|
* resurfaces when the library's mount effect re-applies it on the next entry.
|
||||||
|
*/
|
||||||
|
export const useFilterReset = (libraryId: string) => {
|
||||||
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
|
const [filterBy, setFilterBy] = useAtom(filterByAtom);
|
||||||
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
const [, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
|
const [, setSortOrderPreference] = useAtom(sortOrderPreferenceAtom);
|
||||||
|
const [, setFilterByPreference] = useAtom(FilterByPreferenceAtom);
|
||||||
|
const [, setGenrePreference] = useAtom(genrePreferenceAtom);
|
||||||
|
const [, setYearPreference] = useAtom(yearPreferenceAtom);
|
||||||
|
const [, setTagPreference] = useAtom(tagPreferenceAtom);
|
||||||
|
|
||||||
|
// SortName / Ascending is the baseline a library opens with (mount-effect
|
||||||
|
// fallback), so any other value counts as an active, resettable sort.
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0 ||
|
||||||
|
filterBy.length > 0 ||
|
||||||
|
sortBy[0] !== SortByOption.SortName ||
|
||||||
|
sortOrder[0] !== SortOrderOption.Ascending;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
setFilterBy([]);
|
||||||
|
setSortBy([SortByOption.SortName]);
|
||||||
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
|
setSortByPreference((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[libraryId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSortOrderPreference((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[libraryId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setFilterByPreference((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[libraryId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setGenrePreference((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[libraryId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setYearPreference((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[libraryId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setTagPreference((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[libraryId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
libraryId,
|
||||||
|
setSelectedGenres,
|
||||||
|
setSelectedYears,
|
||||||
|
setSelectedTags,
|
||||||
|
setFilterBy,
|
||||||
|
setSortBy,
|
||||||
|
setSortOrder,
|
||||||
|
setSortByPreference,
|
||||||
|
setSortOrderPreference,
|
||||||
|
setFilterByPreference,
|
||||||
|
setGenrePreference,
|
||||||
|
setYearPreference,
|
||||||
|
setTagPreference,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { hasActiveFilters, resetAllFilters };
|
||||||
|
};
|
||||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
|||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version < "2.0.0") {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t(
|
||||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
||||||
);
|
);
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
|||||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
return items.reduce((sum, item) => {
|
||||||
|
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||||
|
// as video + trickplay, the total must match.
|
||||||
|
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||||
|
// Read the live file size on disk so the total reflects actual usage and
|
||||||
|
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||||
|
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||||
|
// value if the file can't be stat'd.
|
||||||
|
if (item.videoFilePath) {
|
||||||
|
try {
|
||||||
|
const file = new File(filePathToUri(item.videoFilePath));
|
||||||
|
if (file.exists) {
|
||||||
|
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to stat downloaded file for size:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,24 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
const totalSize = calculateTotalDownloadedSize();
|
let totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
|
// Also count in-progress downloads (they write straight to their final
|
||||||
|
// path) so the growing file shows up as app usage instead of drifting
|
||||||
|
// into the generic device share until completion.
|
||||||
|
for (const process of processes) {
|
||||||
|
try {
|
||||||
|
const file = new File(
|
||||||
|
Paths.document,
|
||||||
|
`${generateFilename(process.item)}.mp4`,
|
||||||
|
);
|
||||||
|
if (file.exists) {
|
||||||
|
totalSize += file.size ?? 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File not created yet — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, []);
|
}, [processes]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "اختر",
|
||||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||||
"video": "فيديو",
|
"video": "فيديو",
|
||||||
"audio": "الصوت",
|
"audio": "الصوت",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Select",
|
||||||
"no_trailer_available": "No trailer available",
|
"no_trailer_available": "No trailer available",
|
||||||
"video": "Vídeo",
|
"video": "Vídeo",
|
||||||
"audio": "Àudio",
|
"audio": "Àudio",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Vybrat",
|
||||||
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Zvuk",
|
"audio": "Zvuk",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Vælg",
|
||||||
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Lyd",
|
"audio": "Lyd",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Auswählen",
|
||||||
"no_trailer_available": "Kein Trailer verfügbar",
|
"no_trailer_available": "Kein Trailer verfügbar",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"results": "Ergebnisse",
|
"results": "Ergebnisse",
|
||||||
|
"searching": "Suche ...",
|
||||||
"search_failed": "Suche fehlgeschlagen",
|
"search_failed": "Suche fehlgeschlagen",
|
||||||
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||||
"no_subtitles_found": "Keine Untertitel gefunden",
|
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Επιλογή",
|
||||||
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
||||||
"video": "Βίντεο",
|
"video": "Βίντεο",
|
||||||
"audio": "Ήχος",
|
"audio": "Ήχος",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -12,18 +12,21 @@
|
|||||||
"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",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occured": "An unexpected error occurred",
|
"an_unexpected_error_occurred": "An unexpected error occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid username or password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"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_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.",
|
"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",
|
"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 server URL correctly?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
"connect_button": "Connect",
|
"connect_button": "Connect",
|
||||||
"previous_servers": "Previous servers",
|
"previous_servers": "Previous servers",
|
||||||
"clear_button": "Clear all",
|
"clear_button": "Clear all",
|
||||||
|
"server_url": "Server URL",
|
||||||
"swipe_to_remove": "Swipe to remove",
|
"swipe_to_remove": "Swipe to remove",
|
||||||
"search_for_local_servers": "Search for local servers",
|
"search_for_local_servers": "Search for local servers",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
@@ -137,7 +141,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
|
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Hide remote session button",
|
"hide_remote_session_button": "Hide remote session button",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic home backdrop",
|
"show_home_backdrop": "Dynamic home backdrop",
|
||||||
"show_hero_carousel": "Hero carousel",
|
"show_hero_carousel": "Hero carousel",
|
||||||
"show_series_poster_on_episode": "Show series poster on episodes",
|
"show_series_poster_on_episode": "Show series poster on episodes",
|
||||||
@@ -188,10 +196,11 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_autorized": "Quick Connect authorized",
|
"quick_connect_authorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize",
|
||||||
|
"paste_code": "Paste code"
|
||||||
},
|
},
|
||||||
"media_controls": {
|
"media_controls": {
|
||||||
"media_controls_title": "Media controls",
|
"media_controls_title": "Media controls",
|
||||||
@@ -270,6 +279,10 @@
|
|||||||
"mpv_subtitle_margin_y": "Vertical margin",
|
"mpv_subtitle_margin_y": "Vertical margin",
|
||||||
"mpv_subtitle_align_x": "Horizontal align",
|
"mpv_subtitle_align_x": "Horizontal align",
|
||||||
"mpv_subtitle_align_y": "Vertical align",
|
"mpv_subtitle_align_y": "Vertical align",
|
||||||
|
"mpv_settings_title": "MPV Subtitle Settings",
|
||||||
|
"mpv_settings_description": "Advanced subtitle customization for MPV player",
|
||||||
|
"opaque_background": "Opaque Background",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Left",
|
"left": "Left",
|
||||||
"center": "Center",
|
"center": "Center",
|
||||||
@@ -296,9 +309,10 @@
|
|||||||
},
|
},
|
||||||
"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_liraries_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.",
|
||||||
"disable_haptic_feedback": "Disable haptic feedback",
|
"disable_haptic_feedback": "Disable haptic feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default quality",
|
||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
@@ -384,6 +398,8 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
|
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||||
|
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -408,7 +424,9 @@
|
|||||||
"click_for_more_info": "Click for more info",
|
"click_for_more_info": "Click for more info",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"no_logs_available": "No logs available",
|
"no_logs_available": "No logs available",
|
||||||
"delete_all_logs": "Delete all logs"
|
"delete_all_logs": "Delete all logs",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied to clipboard"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Languages",
|
"title": "Languages",
|
||||||
@@ -435,10 +453,13 @@
|
|||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"title": "Sessions",
|
"title": "Sessions",
|
||||||
"no_active_sessions": "No active sessions"
|
"no_active_sessions": "No active sessions",
|
||||||
|
"select_session": "Select Session",
|
||||||
|
"now_playing": "Now playing:"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
|
"transcoding": "Transcoding",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -494,6 +515,8 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"stop": "Stop",
|
||||||
|
"open_menu": "Open Menu",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
@@ -595,10 +618,34 @@
|
|||||||
},
|
},
|
||||||
"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_occured_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.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
@@ -695,6 +742,7 @@
|
|||||||
"no_data_available": "No data available"
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
|
"title": "Live TV",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"coming_soon": "Coming soon",
|
"coming_soon": "Coming soon",
|
||||||
@@ -766,7 +814,7 @@
|
|||||||
"request_selected": "Request selected",
|
"request_selected": "Request selected",
|
||||||
"n_selected": "{{count}} selected",
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
"jellyseerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||||
"issue_submitted": "Issue submitted!",
|
"issue_submitted": "Issue submitted!",
|
||||||
@@ -779,6 +827,16 @@
|
|||||||
"failed_to_decline_request": "Failed to decline request"
|
"failed_to_decline_request": "Failed to decline request"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"play_button": "Play button",
|
||||||
|
"play_hint": "Tap to play the media",
|
||||||
|
"toggle_orientation": "Toggle screen orientation",
|
||||||
|
"toggle_orientation_hint": "Toggles the screen orientation between portrait and landscape"
|
||||||
|
},
|
||||||
|
"not_found": {
|
||||||
|
"title": "This screen doesn't exist.",
|
||||||
|
"go_home": "Go to home screen!"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -789,6 +847,12 @@
|
|||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Music",
|
"title": "Music",
|
||||||
|
"no_track_playing": "No track playing",
|
||||||
|
"queue_empty": "Queue is empty",
|
||||||
|
"playing_from_queue": "Playing from queue",
|
||||||
|
"up_next": "Up next",
|
||||||
|
"now_playing": "Now Playing",
|
||||||
|
"missing_library_id": "Missing music library id.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "Suggestions",
|
"suggestions": "Suggestions",
|
||||||
"albums": "Albums",
|
"albums": "Albums",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Seleccionar",
|
||||||
"no_trailer_available": "No hay tráiler disponible",
|
"no_trailer_available": "No hay tráiler disponible",
|
||||||
"video": "Vídeo",
|
"video": "Vídeo",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Valitse",
|
||||||
"no_trailer_available": "Perävaunua ei saatavilla",
|
"no_trailer_available": "Perävaunua ei saatavilla",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Ääni",
|
"audio": "Ääni",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
"error_title": "Erreur",
|
"error_title": "Erreur",
|
||||||
"login_title": "Se connecter",
|
"login_title": "Se connecter",
|
||||||
"login_to_title": "Se connecter à",
|
"login_to_title": "Se connecter à",
|
||||||
"select_user": "Sélectionnez un utilisateur pour vous connecter",
|
"select_user": "Select a user to log in",
|
||||||
"add_user_to_login": "Ajouter un utilisateur pour se connecter",
|
"add_user_to_login": "Add a user to log in",
|
||||||
"add_user": "Ajouter un utilisateur",
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Nom d'utilisateur",
|
"username_placeholder": "Nom d'utilisateur",
|
||||||
"password_placeholder": "Mot de passe",
|
"password_placeholder": "Mot de passe",
|
||||||
"login_button": "Se connecter",
|
"login_button": "Se connecter",
|
||||||
@@ -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 d’utilisateur 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 d’utilisateur et votre mot de passe la prochaine fois.",
|
||||||
"accounts_count": "{{count}} comptes",
|
"accounts_count": "Comptes {{count}}",
|
||||||
"select_account": "Sélectionnez un compte",
|
"select_account": "Sélectionnez un compte",
|
||||||
"add_account": "Ajouter un compte",
|
"add_account": "Ajouter un compte",
|
||||||
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
|
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
|
||||||
"remove_server": "Supprimer le serveur",
|
"remove_server": "Remove Server",
|
||||||
"remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.",
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
"select_your_server": "Sélectionnez votre serveur",
|
"select_your_server": "Select Your Server",
|
||||||
"add_server_to_get_started": "Ajouter un serveur pour commencer",
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
"add_server": "Ajouter un serveur",
|
"add_server": "Add Server",
|
||||||
"change_server": "Changer de serveur"
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Enregistrer le compte",
|
"title": "Sauvegarder le compte",
|
||||||
"save_for_later": "Enregistrer ce compte",
|
"save_for_later": "Enregistrer ce compte",
|
||||||
"security_option": "Options de sécurité",
|
"security_option": "Options de sécurité",
|
||||||
"no_protection": "Aucune protection",
|
"no_protection": "Aucune protection",
|
||||||
@@ -95,9 +95,9 @@
|
|||||||
"oops": "Oups !",
|
"oops": "Oups !",
|
||||||
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
||||||
"continue_watching": "Continuer à regarder",
|
"continue_watching": "Continuer à regarder",
|
||||||
"continue": "Continuer",
|
"continue": "Continue",
|
||||||
"next_up": "À suivre",
|
"next_up": "À suivre",
|
||||||
"continue_and_next_up": "Continuer à regarder et à suivre",
|
"continue_and_next_up": "Continuer de regarder et à suivre",
|
||||||
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
||||||
"suggested_movies": "Films suggérés",
|
"suggested_movies": "Films suggérés",
|
||||||
"suggested_episodes": "Épisodes suggérés",
|
"suggested_episodes": "Épisodes suggérés",
|
||||||
@@ -120,10 +120,10 @@
|
|||||||
"settings_title": "Paramètres",
|
"settings_title": "Paramètres",
|
||||||
"log_out_button": "Déconnexion",
|
"log_out_button": "Déconnexion",
|
||||||
"switch_user": {
|
"switch_user": {
|
||||||
"title": "Changer d'utilisateur",
|
"title": "Switch User",
|
||||||
"account": "Compte",
|
"account": "Account",
|
||||||
"switch_user": "Changer d'utilisateur sur ce serveur",
|
"switch_user": "Switch User on This Server",
|
||||||
"current": "actuel"
|
"current": "current"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Catégories"
|
"title": "Catégories"
|
||||||
@@ -136,32 +136,32 @@
|
|||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Apparence",
|
"title": "Apparence",
|
||||||
"merge_next_up_continue_watching": "Fusionner « Continuer à regarder » et « À suivre »",
|
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
|
||||||
"hide_remote_session_button": "Masquer le bouton de session distante",
|
"hide_remote_session_button": "Masquer le bouton de session distante",
|
||||||
"show_home_backdrop": "Arrière-plan d'accueil dynamique",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Carrousel principal",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
"theme_music": "Musique de thème",
|
"theme_music": "Theme Music",
|
||||||
"display_size": "Taille d'affichage",
|
"display_size": "Display Size",
|
||||||
"display_size_small": "Petite",
|
"display_size_small": "Small",
|
||||||
"display_size_default": "Par défaut",
|
"display_size_default": "Default",
|
||||||
"display_size_large": "Grande",
|
"display_size_large": "Large",
|
||||||
"display_size_extra_large": "Très grande"
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Réseau",
|
"title": "Réseau",
|
||||||
"local_network": "Réseau local",
|
"local_network": "Réseau local",
|
||||||
"auto_switch_enabled": "Basculement automatique à la maison",
|
"auto_switch_enabled": "Basculement automatique quand à la maison",
|
||||||
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
|
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
|
||||||
"local_url": "URL locale",
|
"local_url": "URL locale",
|
||||||
"local_url_hint": "Entrez l'adresse de votre serveur local (par exemple http://192.168.1.100:8096)",
|
"local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)",
|
||||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||||
"home_wifi_networks": "Réseaux Wi-Fi domestiques",
|
"home_wifi_networks": "Réseaux Wi-Fi domestiques",
|
||||||
"add_current_network": "Ajouter \"{{ssid}}\"",
|
"add_current_network": "Ajouter \"{{ssid}}\"",
|
||||||
"not_connected_to_wifi": "Non connecté au Wi-Fi",
|
"not_connected_to_wifi": "Non connecté au WiFi",
|
||||||
"no_networks_configured": "Pas de réseau configuré",
|
"no_networks_configured": "Pas de réseau configuré",
|
||||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer le basculement automatique",
|
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
|
||||||
"current_wifi": "Wi-Fi actuel",
|
"current_wifi": "WiFi actuel",
|
||||||
"using_url": "Utilisant",
|
"using_url": "Utilisant",
|
||||||
"local": "URL locale",
|
"local": "URL locale",
|
||||||
"remote": "URL à distance",
|
"remote": "URL à distance",
|
||||||
@@ -172,9 +172,9 @@
|
|||||||
"not_configured": "Non configuré",
|
"not_configured": "Non configuré",
|
||||||
"network_added": "Réseau ajouté",
|
"network_added": "Réseau ajouté",
|
||||||
"network_already_added": "Réseau déjà ajouté",
|
"network_already_added": "Réseau déjà ajouté",
|
||||||
"no_wifi_connected": "Non connecté au Wi-Fi",
|
"no_wifi_connected": "Non connecté au WiFi",
|
||||||
"permission_denied": "Autorisation de localisation refusée",
|
"permission_denied": "Autorisation de localisation refusée",
|
||||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres."
|
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez 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ée",
|
"quick_connect_autorized": "Connexion Rapide autorisé",
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"invalid_code": "Code invalide",
|
"invalid_code": "Code invalide",
|
||||||
"authorize": "Autoriser"
|
"authorize": "Autoriser"
|
||||||
},
|
},
|
||||||
"media_controls": {
|
"media_controls": {
|
||||||
"media_controls_title": "Contrôles média",
|
"media_controls_title": "Contrôles Média",
|
||||||
"forward_skip_length": "Durée de saut en avant",
|
"forward_skip_length": "Durée de saut en avant",
|
||||||
"rewind_length": "Durée de retour en arrière",
|
"rewind_length": "Durée de retour en arrière",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
"buffer": {
|
"buffer": {
|
||||||
"title": "Paramètres du tampon",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Mode de cache",
|
"cache_mode": "Cache Mode",
|
||||||
"cache_auto": "Auto",
|
"cache_auto": "Auto",
|
||||||
"cache_yes": "Activé",
|
"cache_yes": "Enabled",
|
||||||
"cache_no": "Désactivé",
|
"cache_no": "Disabled",
|
||||||
"buffer_duration": "Durée du tampon",
|
"buffer_duration": "Buffer Duration",
|
||||||
"max_cache_size": "Taille maximale du cache",
|
"max_cache_size": "Max Cache Size",
|
||||||
"max_backward_cache": "Cache arrière maximal"
|
"max_backward_cache": "Max Backward Cache"
|
||||||
},
|
},
|
||||||
"vo_driver": {
|
"vo_driver": {
|
||||||
"title": "Sortie vidéo",
|
"title": "Video Output",
|
||||||
"vo_mode": "Pilote VO",
|
"vo_mode": "VO Driver",
|
||||||
"gpu_next": "gpu-next (Recommandé)",
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
"gpu": "gpu"
|
"gpu": "gpu"
|
||||||
},
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
@@ -225,8 +225,8 @@
|
|||||||
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
|
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
|
||||||
"hide_volume_slider": "Masquer le curseur de volume",
|
"hide_volume_slider": "Masquer le curseur de volume",
|
||||||
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
|
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
|
||||||
"hide_brightness_slider": "Masquer le curseur de luminosité",
|
"hide_brightness_slider": "Cacher le curseur de luminosité",
|
||||||
"hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo"
|
"hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "Audio",
|
"audio_title": "Audio",
|
||||||
@@ -234,14 +234,14 @@
|
|||||||
"audio_language": "Langue audio",
|
"audio_language": "Langue audio",
|
||||||
"audio_hint": "Choisissez une langue audio par défaut.",
|
"audio_hint": "Choisissez une langue audio par défaut.",
|
||||||
"none": "Aucune",
|
"none": "Aucune",
|
||||||
"language": "Langue",
|
"language": "Langage",
|
||||||
"transcode_mode": {
|
"transcode_mode": {
|
||||||
"title": "Transcodage audio",
|
"title": "Transcodage audio",
|
||||||
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
|
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"stereo": "Forcer la stéréo",
|
"stereo": "Forcer la stéréo",
|
||||||
"5_1": "Autoriser 5.1",
|
"5_1": "Autoriser 5.1",
|
||||||
"passthrough": "Passthrough"
|
"passthrough": "Intercommunication"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
"set_subtitle_track": "Piste de sous-titres de l'élément précédent",
|
"set_subtitle_track": "Piste de sous-titres de l'élément précédent",
|
||||||
"subtitle_size": "Taille des sous-titres",
|
"subtitle_size": "Taille des sous-titres",
|
||||||
"none": "Aucune",
|
"none": "Aucune",
|
||||||
"language": "Langue",
|
"language": "Langage",
|
||||||
"loading": "Chargement",
|
"loading": "Chargement",
|
||||||
"modes": {
|
"modes": {
|
||||||
"Default": "Par défaut",
|
"Default": "Par défaut",
|
||||||
@@ -262,20 +262,20 @@
|
|||||||
"OnlyForced": "Forcés seulement"
|
"OnlyForced": "Forcés seulement"
|
||||||
},
|
},
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Entrez votre clé API OpenSubtitles pour activer la recherche de sous-titres côté client comme solution de secours lorsque votre serveur Jellyfin n'a pas de fournisseur de sous-titres configuré.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "Clé API",
|
"opensubtitles_api_key": "API Key",
|
||||||
"opensubtitles_api_key_placeholder": "Entrez la clé API...",
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
"opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers",
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
"mpv_subtitle_scale": "Échelle des sous-titres",
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
"mpv_subtitle_margin_y": "Marge verticale",
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
"mpv_subtitle_align_x": "Alignement horizontal",
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
"mpv_subtitle_align_y": "Alignement vertical",
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Gauche",
|
"left": "Left",
|
||||||
"center": "Centre",
|
"center": "Center",
|
||||||
"right": "Droite",
|
"right": "Right",
|
||||||
"top": "Haut",
|
"top": "Top",
|
||||||
"bottom": "Bas"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
@@ -283,38 +283,38 @@
|
|||||||
"video_orientation": "Orientation vidéo",
|
"video_orientation": "Orientation vidéo",
|
||||||
"orientation": "Orientation",
|
"orientation": "Orientation",
|
||||||
"orientations": {
|
"orientations": {
|
||||||
"DEFAULT": "Suivre l'orientation de l'appareil",
|
"DEFAULT": "Par défaut",
|
||||||
"ALL": "Toutes",
|
"ALL": "Toutes",
|
||||||
"PORTRAIT": "Portrait auto",
|
"PORTRAIT": "Portrait",
|
||||||
"PORTRAIT_UP": "Portrait haut",
|
"PORTRAIT_UP": "Portrait Haut",
|
||||||
"PORTRAIT_DOWN": "Portrait bas",
|
"PORTRAIT_DOWN": "Portrait Bas",
|
||||||
"LANDSCAPE": "Paysage auto",
|
"LANDSCAPE": "Paysage",
|
||||||
"LANDSCAPE_LEFT": "Paysage gauche",
|
"LANDSCAPE_LEFT": "Paysage Gauche",
|
||||||
"LANDSCAPE_RIGHT": "Paysage droite",
|
"LANDSCAPE_RIGHT": "Paysage Droite",
|
||||||
"OTHER": "Autre",
|
"OTHER": "Autre",
|
||||||
"UNKNOWN": "Inconnu"
|
"UNKNOWN": "Inconnu"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
||||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||||
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||||
"hide_libraries": "Masquer les bibliothèques",
|
"hide_libraries": "Cacher des 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_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||||
"default_quality": "Qualité par défaut",
|
"default_quality": "Qualité par défaut",
|
||||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||||
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
||||||
"max_auto_play_episode_count": "Nombre max d'épisodes en lecture automatique",
|
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||||
"disabled": "Désactivé"
|
"disabled": "Désactivé"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musique",
|
"title": "Musique",
|
||||||
"playback_title": "Lecture",
|
"playback_title": "Lecture",
|
||||||
"playback_description": "Configurer le mode de lecture de la musique.",
|
"playback_description": "Configurer le mode de lecture de la musique.",
|
||||||
"prefer_downloaded": "Préférer les musiques téléchargées",
|
"prefer_downloaded": "Supprimer toutes les musiques téléchargées",
|
||||||
"caching_title": "Mise en cache",
|
"caching_title": "Mise en cache",
|
||||||
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
|
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
|
||||||
"lookahead_enabled": "Activer la mise en cache anticipée",
|
"lookahead_enabled": "Activer la mise en cache guidée",
|
||||||
"lookahead_count": "Pistes à mettre en cache à l'avance",
|
"lookahead_count": "Pistes à pré-mettre en cache",
|
||||||
"max_cache_size": "Taille max de cache"
|
"max_cache_size": "Taille max de cache"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
"tv_quota_days": "Jours de quota de séries",
|
"tv_quota_days": "Jours de quota de séries",
|
||||||
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
|
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
|
||||||
"unlimited": "Illimité",
|
"unlimited": "Illimité",
|
||||||
"plus_n_more": "+{{n}} de plus",
|
"plus_n_more": "+{{n}} Plus",
|
||||||
"order_by": {
|
"order_by": {
|
||||||
"DEFAULT": "Par défaut",
|
"DEFAULT": "Par défaut",
|
||||||
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
|
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marlin_search": {
|
"marlin_search": {
|
||||||
"enable_marlin_search": "Activer la recherche Marlin",
|
"enable_marlin_search": "Activer Marlin Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://domaine.org:port",
|
"server_url_placeholder": "http(s)://domaine.org:port",
|
||||||
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
|
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
|
||||||
@@ -362,10 +362,10 @@
|
|||||||
"features_title": "Fonctionnalités",
|
"features_title": "Fonctionnalités",
|
||||||
"enable_movie_recommendations": "Recommandations de films",
|
"enable_movie_recommendations": "Recommandations de films",
|
||||||
"enable_series_recommendations": "Recommandations de séries",
|
"enable_series_recommendations": "Recommandations de séries",
|
||||||
"enable_promoted_watchlists": "Listes de suivi promues",
|
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||||
"hide_watchlists_tab": "Masquer l'onglet des listes de suivi",
|
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
|
||||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page d’accueil.",
|
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page d’accueil.",
|
||||||
"recommended_movies": "Films recommandés",
|
"recommended_movies": "Films Recommandés",
|
||||||
"recommended_series": "Séries recommandées",
|
"recommended_series": "Séries recommandées",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Enregistré",
|
"saved": "Enregistré",
|
||||||
@@ -375,7 +375,7 @@
|
|||||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "Activer l'intégration de la liste de suivi"
|
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -392,10 +392,10 @@
|
|||||||
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
||||||
"downloaded_songs_size": "{{size}} téléchargé",
|
"downloaded_songs_size": "{{size}} téléchargé",
|
||||||
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
|
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
|
||||||
"clear_all_cache": "Effacer tout le cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Effacer tout le cache ?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Êtes-vous sûr de vouloir effacer toutes les données en cache ? Cela supprimera toutes les images, fichiers musicaux, sous-titres et caches de requêtes mis en cache. Vos paramètres et votre session de connexion seront conservés.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
"clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Introduction",
|
"title": "Introduction",
|
||||||
@@ -419,17 +419,17 @@
|
|||||||
"error_deleting_files": "Erreur lors de la suppression des fichiers"
|
"error_deleting_files": "Erreur lors de la suppression des fichiers"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Sécurité",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Délai d'inactivité",
|
"title": "Inactivity Timeout",
|
||||||
"disabled": "Désactivé",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 minutes",
|
||||||
"15_minutes": "15 minutes",
|
"15_minutes": "15 minutes",
|
||||||
"30_minutes": "30 minutes",
|
"30_minutes": "30 minutes",
|
||||||
"1_hour": "1 heure",
|
"1_hour": "1 hour",
|
||||||
"4_hours": "4 heures",
|
"4_hours": "4 hours",
|
||||||
"24_hours": "24 heures"
|
"24_hours": "24 hours"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
"delete_all_movies_button": "Supprimer tous les films",
|
"delete_all_movies_button": "Supprimer tous les films",
|
||||||
"delete_all_series_button": "Supprimer toutes les séries",
|
"delete_all_series_button": "Supprimer toutes les séries",
|
||||||
"delete_all_button": "Supprimer tous les médias",
|
"delete_all_button": "Supprimer tous les médias",
|
||||||
"delete_all_other_media_button": "Supprimer les autres médias",
|
"delete_all_other_media_button": "Supprimer un autre média",
|
||||||
"active_download": "Téléchargement actif",
|
"active_download": "Téléchargement actif",
|
||||||
"no_active_downloads": "Pas de téléchargements actifs",
|
"no_active_downloads": "Pas de téléchargements actifs",
|
||||||
"active_downloads": "Téléchargements actifs",
|
"active_downloads": "Téléchargements actifs",
|
||||||
@@ -454,7 +454,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
||||||
"back": "Retour",
|
"back": "Retour",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"delete_download": "Supprimer le téléchargement",
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Quelque chose s'est mal passé",
|
"something_went_wrong": "Quelque chose s'est mal passé",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -465,7 +465,7 @@
|
|||||||
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||||
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
||||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||||
"failed_to_delete_media": "Échec de la suppression des autres médias",
|
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||||
"download_cancelled": "Téléchargement annulé",
|
"download_cancelled": "Téléchargement annulé",
|
||||||
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
||||||
"download_completed": "Téléchargement terminé",
|
"download_completed": "Téléchargement terminé",
|
||||||
@@ -483,16 +483,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "Aucun résultat",
|
"no_results": "No Results",
|
||||||
|
"select": "Sélectionner",
|
||||||
"no_trailer_available": "Aucune bande-annonce disponible",
|
"no_trailer_available": "Aucune bande-annonce disponible",
|
||||||
"video": "Vidéo",
|
"video": "Vidéo",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Sous-titres",
|
"subtitle": "Sous-titres",
|
||||||
"play": "Lecture",
|
"play": "Lecture",
|
||||||
"mark_as_played": "Marquer comme vu",
|
"mark_as_played": "Mark as Played",
|
||||||
"mark_as_not_played": "Marquer comme non vu",
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"track": "Piste",
|
"track": "Suivre",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"ok": "Ok",
|
"ok": "Ok",
|
||||||
@@ -500,15 +501,15 @@
|
|||||||
"back": "Précédent",
|
"back": "Précédent",
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"verifying": "Vérification...",
|
"verifying": "Vérification...",
|
||||||
"login": "Connexion",
|
"login": "Login",
|
||||||
"episodes": "Épisodes",
|
"episodes": "Episodes",
|
||||||
"movies": "Films",
|
"movies": "Movies",
|
||||||
"loading": "Chargement…",
|
"loading": "Loading…",
|
||||||
"seeAll": "Tout afficher"
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
"x_items": "{{count}} médias",
|
"x_items": "{{count}} Médias",
|
||||||
"library": "Bibliothèque",
|
"library": "Bibliothèque",
|
||||||
"discover": "Découvrir",
|
"discover": "Découvrir",
|
||||||
"no_results": "Aucun résultat",
|
"no_results": "Aucun résultat",
|
||||||
@@ -526,7 +527,7 @@
|
|||||||
"request_series": "Demander une série",
|
"request_series": "Demander une série",
|
||||||
"recently_added": "Ajoutés récemment",
|
"recently_added": "Ajoutés récemment",
|
||||||
"recent_requests": "Demandes récentes",
|
"recent_requests": "Demandes récentes",
|
||||||
"plex_watchlist": "Liste de suivi Plex",
|
"plex_watchlist": "Liste de lecture Plex",
|
||||||
"trending": "Tendance",
|
"trending": "Tendance",
|
||||||
"popular_movies": "Films populaires",
|
"popular_movies": "Films populaires",
|
||||||
"movie_genres": "Genres de films",
|
"movie_genres": "Genres de films",
|
||||||
@@ -535,7 +536,7 @@
|
|||||||
"popular_tv": "Séries populaires",
|
"popular_tv": "Séries populaires",
|
||||||
"tv_genres": "Genres des séries",
|
"tv_genres": "Genres des séries",
|
||||||
"upcoming_tv": "Séries à venir",
|
"upcoming_tv": "Séries à venir",
|
||||||
"networks": "Chaînes",
|
"networks": "Studios",
|
||||||
"tmdb_movie_keyword": "Mots-clés de film TMDB",
|
"tmdb_movie_keyword": "Mots-clés de film TMDB",
|
||||||
"tmdb_movie_genre": "Genre de film TMDB",
|
"tmdb_movie_genre": "Genre de film TMDB",
|
||||||
"tmdb_tv_keyword": "Mots-clés de séries TMDB",
|
"tmdb_tv_keyword": "Mots-clés de séries TMDB",
|
||||||
@@ -574,10 +575,10 @@
|
|||||||
"filter_by": "Filtrer par",
|
"filter_by": "Filtrer par",
|
||||||
"sort_order": "Ordre de tri",
|
"sort_order": "Ordre de tri",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"all": "Tout",
|
"all": "All",
|
||||||
"reset": "Réinitialiser",
|
"reset": "Reset",
|
||||||
"asc": "Croissant",
|
"asc": "Ascending",
|
||||||
"desc": "Décroissant"
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,11 +592,11 @@
|
|||||||
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
|
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "Aucun lien"
|
"no_links": "Aucuns liens"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "EN DIRECT",
|
"live": "LIVE",
|
||||||
"mpv_player_title": "Lecteur MPV",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||||
@@ -610,71 +611,72 @@
|
|||||||
"downloaded_file_yes": "Oui",
|
"downloaded_file_yes": "Oui",
|
||||||
"downloaded_file_no": "Non",
|
"downloaded_file_no": "Non",
|
||||||
"downloaded_file_cancel": "Annuler",
|
"downloaded_file_cancel": "Annuler",
|
||||||
"swipe_down_settings": "Balayez vers le bas pour les paramètres",
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
"ends_at": "Se termine à {{time}}",
|
"ends_at": "Ends at {{time}}",
|
||||||
"search_subtitles": "Rechercher des sous-titres",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Pistes",
|
"subtitle_tracks": "Tracks",
|
||||||
"subtitle_search": "Rechercher et télécharger",
|
"subtitle_search": "Search & Download",
|
||||||
"download": "Télécharger",
|
"download": "Download",
|
||||||
"subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque",
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
"using_jellyfin_server": "Utilisation du serveur Jellyfin",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Langue",
|
"language": "Language",
|
||||||
"results": "Résultats",
|
"results": "Results",
|
||||||
"search_failed": "Recherche échouée",
|
"searching": "Searching...",
|
||||||
"no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur",
|
"search_failed": "Search failed",
|
||||||
"no_subtitles_found": "Aucun sous-titre trouvé",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client",
|
"no_subtitles_found": "No subtitles found",
|
||||||
"settings": "Paramètres",
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
"skip_intro": "Passer l'intro",
|
"settings": "Settings",
|
||||||
"skip_credits": "Passer le générique",
|
"skip_intro": "Skip Intro",
|
||||||
"stopPlayback": "Arrêter la lecture",
|
"skip_credits": "Skip Credits",
|
||||||
"stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"downloaded": "Téléchargé",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"missing_parameters": "Paramètres de lecture manquants"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapitres",
|
"title": "Chapters",
|
||||||
"chapter_number": "Chapitre {{number}}",
|
"chapter_number": "Chapter {{number}}",
|
||||||
"open": "Ouvrir les chapitres",
|
"open": "Open chapters",
|
||||||
"close": "Fermer les chapitres"
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "À suivre",
|
"next_up": "À suivre",
|
||||||
"no_items_to_display": "Aucun média à afficher",
|
"no_items_to_display": "Aucuns médias à afficher",
|
||||||
"cast_and_crew": "Distribution et équipe",
|
"cast_and_crew": "Distribution et équipe",
|
||||||
"series": "Séries",
|
"series": "Séries",
|
||||||
"seasons": "Saisons",
|
"seasons": "Saisons",
|
||||||
"season": "Saison",
|
"season": "Saison",
|
||||||
"from_this_series": "De cette série",
|
"from_this_series": "From This Series",
|
||||||
"more_from_this_season": "Plus de cette saison",
|
"more_from_this_season": "More from this Season",
|
||||||
"view_series": "Voir la série",
|
"view_series": "View Series",
|
||||||
"view_season": "Voir la saison",
|
"view_season": "View Season",
|
||||||
"select_season": "Sélectionner une saison",
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
||||||
"overview": "Aperçu",
|
"overview": "Aperçu",
|
||||||
"more_with": "Plus avec {{name}}",
|
"more_with": "Plus avec {{name}}",
|
||||||
"similar_items": "Médias similaires",
|
"similar_items": "Médias similaires",
|
||||||
"no_similar_items_found": "Aucun média similaire trouvé",
|
"no_similar_items_found": "Aucuns médias similaires trouvés",
|
||||||
"video": "Vidéo",
|
"video": "Vidéo",
|
||||||
"more_details": "Plus de détails",
|
"more_details": "Plus de détails",
|
||||||
"media_options": "Options média",
|
"media_options": "Options média",
|
||||||
"quality": "Qualité",
|
"quality": "Qualité",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"label": "Sous-titres",
|
"label": "Subtitle",
|
||||||
"none": "Aucun",
|
"none": "None",
|
||||||
"tracks": "Pistes"
|
"tracks": "Tracks"
|
||||||
},
|
},
|
||||||
"show_more": "Afficher plus",
|
"show_more": "Afficher plus",
|
||||||
"show_less": "Afficher moins",
|
"show_less": "Afficher moins",
|
||||||
"left": "restant",
|
"left": "left",
|
||||||
"director": "Réalisateur",
|
"director": "Director",
|
||||||
"cast": "Distribution",
|
"cast": "Cast",
|
||||||
"technical_details": "Détails techniques",
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Apparu dans",
|
"appeared_in": "Apparu dans",
|
||||||
"movies": "Films",
|
"movies": "Movies",
|
||||||
"shows": "Séries",
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Impossible de charger le média",
|
"could_not_load_item": "Impossible de charger le média",
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -686,13 +688,13 @@
|
|||||||
"download_unwatched_only": "Non visionné uniquement",
|
"download_unwatched_only": "Non visionné uniquement",
|
||||||
"download_button": "Télécharger"
|
"download_button": "Télécharger"
|
||||||
},
|
},
|
||||||
"mark_played": "Marquer comme vu",
|
"mark_played": "Mark as Watched",
|
||||||
"mark_unplayed": "Marquer comme non vu",
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
"resume_playback": "Reprendre la lecture",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Lire depuis le début",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continuer depuis {{time}}",
|
"continue_from": "Continue from {{time}}",
|
||||||
"no_data_available": "Aucune donnée disponible"
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
@@ -704,16 +706,16 @@
|
|||||||
"sports": "Sports",
|
"sports": "Sports",
|
||||||
"for_kids": "Pour enfants",
|
"for_kids": "Pour enfants",
|
||||||
"news": "Actualités",
|
"news": "Actualités",
|
||||||
"page_of": "Page {{current}} sur {{total}}",
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
"no_programs": "Aucun programme disponible",
|
"no_programs": "No programs available",
|
||||||
"no_channels": "Aucune chaîne disponible",
|
"no_channels": "No channels available",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"programs": "Programmes",
|
"programs": "Programs",
|
||||||
"guide": "Guide",
|
"guide": "Guide",
|
||||||
"channels": "Chaînes",
|
"channels": "Channels",
|
||||||
"recordings": "Enregistrements",
|
"recordings": "Recordings",
|
||||||
"schedule": "Programmation",
|
"schedule": "Schedule",
|
||||||
"series": "Séries"
|
"series": "Series"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
@@ -723,7 +725,7 @@
|
|||||||
"whats_wrong": "Quel est le problème ?",
|
"whats_wrong": "Quel est le problème ?",
|
||||||
"issue_type": "Type de problème",
|
"issue_type": "Type de problème",
|
||||||
"select_an_issue": "Sélectionnez un problème",
|
"select_an_issue": "Sélectionnez un problème",
|
||||||
"types": "Types",
|
"types": "Types de fichiers",
|
||||||
"describe_the_issue": "(optionnel) Décrivez le problème...",
|
"describe_the_issue": "(optionnel) Décrivez le problème...",
|
||||||
"submit_button": "Soumettre",
|
"submit_button": "Soumettre",
|
||||||
"report_issue_button": "Signaler un problème",
|
"report_issue_button": "Signaler un problème",
|
||||||
@@ -732,7 +734,7 @@
|
|||||||
"failed_to_login": "Échec de la connexion",
|
"failed_to_login": "Échec de la connexion",
|
||||||
"cast": "Distribution",
|
"cast": "Distribution",
|
||||||
"details": "Détails",
|
"details": "Détails",
|
||||||
"status": "Statut",
|
"status": "Statuts",
|
||||||
"original_title": "Titre original",
|
"original_title": "Titre original",
|
||||||
"series_type": "Type de série",
|
"series_type": "Type de série",
|
||||||
"release_dates": "Dates de sortie",
|
"release_dates": "Dates de sortie",
|
||||||
@@ -750,32 +752,32 @@
|
|||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"quality_profile": "Profil de qualité",
|
"quality_profile": "Profil de qualité",
|
||||||
"root_folder": "Dossier racine",
|
"root_folder": "Dossier racine",
|
||||||
"season_all": "Saison (Toutes)",
|
"season_all": "Saison (Tous)",
|
||||||
"season_number": "Saison {{season_number}}",
|
"season_number": "Saison {{season_number}}",
|
||||||
"number_episodes": "{{episode_number}} épisodes",
|
"number_episodes": "{{episode_number}} Épisodes",
|
||||||
"born": "Né(e) le",
|
"born": "Né(e) le",
|
||||||
"appearances": "Apparitions",
|
"appearances": "Apparences",
|
||||||
"approve": "Valider",
|
"approve": "Valider",
|
||||||
"decline": "Refuser",
|
"decline": "Refuser",
|
||||||
"requested_by": "Demandé par {{user}}",
|
"requested_by": "Demandé par {{user}}",
|
||||||
"unknown_user": "Utilisateur inconnu",
|
"unknown_user": "Utilisateur inconnu",
|
||||||
"select": "Sélectionner",
|
"select": "Select",
|
||||||
"request_all": "Tout demander",
|
"request_all": "Request All",
|
||||||
"request_seasons": "Demander des saisons",
|
"request_seasons": "Request Seasons",
|
||||||
"select_seasons": "Sélectionner les saisons",
|
"select_seasons": "Select Seasons",
|
||||||
"request_selected": "Demander la sélection",
|
"request_selected": "Request Selected",
|
||||||
"n_selected": "{{count}} sélectionnés",
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||||
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
||||||
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
|
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
|
||||||
"issue_submitted": "Problème soumis !",
|
"issue_submitted": "Problème soumis !",
|
||||||
"requested_item": "{{item}} demandé !",
|
"requested_item": "{{item}}} demandé !",
|
||||||
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
|
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
|
||||||
"something_went_wrong_requesting_media": "Une erreur s'est produite lors de la demande du média !",
|
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
|
||||||
"request_approved": "Demande approuvée !",
|
"request_approved": "Demande approuvée !",
|
||||||
"request_declined": "Demande refusée !",
|
"request_declined": "Demande déclinée !",
|
||||||
"failed_to_approve_request": "Échec de l'approbation de la demande",
|
"failed_to_approve_request": "Échec d'approbation de la demande",
|
||||||
"failed_to_decline_request": "Échec du refus de la demande"
|
"failed_to_decline_request": "Échec du refus de la demande"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -785,7 +787,7 @@
|
|||||||
"library": "Bibliothèque",
|
"library": "Bibliothèque",
|
||||||
"custom_links": "Liens personnalisés",
|
"custom_links": "Liens personnalisés",
|
||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
"settings": "Réglages"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musique",
|
"title": "Musique",
|
||||||
@@ -805,13 +807,13 @@
|
|||||||
"play_top_tracks": "Jouer les pistes les plus populaires",
|
"play_top_tracks": "Jouer les pistes les plus populaires",
|
||||||
"no_suggestions": "Pas de suggestion disponible",
|
"no_suggestions": "Pas de suggestion disponible",
|
||||||
"no_albums": "Pas d'albums trouvés",
|
"no_albums": "Pas d'albums trouvés",
|
||||||
"no_artists": "Pas d'artistes trouvés",
|
"no_artists": "Pas d'artistes trouvé",
|
||||||
"no_playlists": "Pas de playlists trouvées",
|
"no_playlists": "Pas de playlists trouvées",
|
||||||
"album_not_found": "Album introuvable",
|
"album_not_found": "Album introuvable",
|
||||||
"artist_not_found": "Artiste introuvable",
|
"artist_not_found": "Artiste introuvable",
|
||||||
"playlist_not_found": "Playlist introuvable",
|
"playlist_not_found": "Playlist introuvable",
|
||||||
"track_options": {
|
"track_options": {
|
||||||
"play_next": "Lire ensuite",
|
"play_next": "Lecture suivante",
|
||||||
"add_to_queue": "Ajouter à la file d'attente",
|
"add_to_queue": "Ajouter à la file d'attente",
|
||||||
"add_to_playlist": "Ajouter à la playlist",
|
"add_to_playlist": "Ajouter à la playlist",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
@@ -820,15 +822,15 @@
|
|||||||
"cached": "En cache",
|
"cached": "En cache",
|
||||||
"delete_download": "Supprimer un téléchargement",
|
"delete_download": "Supprimer un téléchargement",
|
||||||
"delete_cache": "Supprimer du cache",
|
"delete_cache": "Supprimer du cache",
|
||||||
"go_to_artist": "Aller à l'artiste",
|
"go_to_artist": "Voir l'artiste",
|
||||||
"go_to_album": "Aller à l'album",
|
"go_to_album": "Aller à 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...",
|
||||||
@@ -839,8 +841,8 @@
|
|||||||
"created": "Playlist créée",
|
"created": "Playlist créée",
|
||||||
"create_new": "Créer une nouvelle playlist",
|
"create_new": "Créer une nouvelle playlist",
|
||||||
"failed_to_add": "Échec de l'ajout à la playlist",
|
"failed_to_add": "Échec de l'ajout à la playlist",
|
||||||
"failed_to_remove": "Échec du retrait de la playlist",
|
"failed_to_remove": "Échec de la suppression de la playlist",
|
||||||
"failed_to_create": "Échec de la création de la playlist",
|
"failed_to_create": "Échec de la suppression de la playlist",
|
||||||
"delete_playlist": "Supprimer la playlist",
|
"delete_playlist": "Supprimer la playlist",
|
||||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
|
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
|
||||||
"deleted": "Playlist supprimée",
|
"deleted": "Playlist supprimée",
|
||||||
@@ -853,45 +855,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "Listes de suivi",
|
"title": "Listes de lecture",
|
||||||
"my_watchlists": "Mes listes de suivi",
|
"my_watchlists": "Mes listes de lecture",
|
||||||
"public_watchlists": "Listes de suivi publiques",
|
"public_watchlists": "Watchlist publique",
|
||||||
"create_title": "Créer une liste de suivi",
|
"create_title": "Créer une Watchlist",
|
||||||
"edit_title": "Modifier la liste de suivi",
|
"edit_title": "Modifier la Watchlist",
|
||||||
"create_button": "Créer une liste de suivi",
|
"create_button": "Créer une Watchlist",
|
||||||
"save_button": "Enregistrer les modifications",
|
"save_button": "Enregistrer les modifications",
|
||||||
"delete_button": "Supprimer",
|
"delete_button": "Supprimer",
|
||||||
"remove_button": "Retirer",
|
"remove_button": "Retirer",
|
||||||
"cancel_button": "Annuler",
|
"cancel_button": "Annuler",
|
||||||
"name_label": "Nom",
|
"name_label": "Nom",
|
||||||
"name_placeholder": "Entrez le nom de la liste de suivi",
|
"name_placeholder": "Entrer le nom de la playlist",
|
||||||
"description_label": "Description",
|
"description_label": "Description",
|
||||||
"description_placeholder": "Entrez la description (facultatif)",
|
"description_placeholder": "Entrez la description (facultatif)",
|
||||||
"is_public_label": "Liste de suivi publique",
|
"is_public_label": "Liste de lecture Publique",
|
||||||
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
||||||
"allowed_type_label": "Type de contenu",
|
"allowed_type_label": "Type de contenu",
|
||||||
"sort_order_label": "Ordre de tri par défaut",
|
"sort_order_label": "Ordre de tri par défaut",
|
||||||
"empty_title": "Aucune liste de suivi",
|
"empty_title": "Pas de Watchlists",
|
||||||
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
|
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
|
||||||
"empty_watchlist": "Cette liste de suivi est vide",
|
"empty_watchlist": "Cette liste de suivi est vide",
|
||||||
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
|
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
|
||||||
"not_configured_title": "Streamystats non configuré",
|
"not_configured_title": "Streamystats non configuré",
|
||||||
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
|
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
|
||||||
"go_to_settings": "Accédez aux paramètres",
|
"go_to_settings": "Accédez aux Paramètres",
|
||||||
"add_to_watchlist": "Ajouter à la liste de suivi",
|
"add_to_watchlist": "Ajouter à la Watchlist",
|
||||||
"remove_from_watchlist": "Retirer de la liste de suivi",
|
"remove_from_watchlist": "Retirer de la Watchlist",
|
||||||
"select_watchlist": "Sélectionner la liste de suivi",
|
"select_watchlist": "Sélectionner la liste de suivi",
|
||||||
"create_new": "Créer une nouvelle liste de suivi",
|
"create_new": "Créer une Watchlist",
|
||||||
"item": "élément",
|
"item": "médias",
|
||||||
"items": "éléments",
|
"items": "élément",
|
||||||
"public": "Publique",
|
"public": "Publique",
|
||||||
"private": "Privée",
|
"private": "Privée",
|
||||||
"you": "Vous",
|
"you": "Vous-même",
|
||||||
"by_owner": "Par un autre utilisateur",
|
"by_owner": "Par un autre utilisateur",
|
||||||
"not_found": "Liste de suivi introuvable",
|
"not_found": "Playlist introuvable",
|
||||||
"delete_confirm_title": "Supprimer la liste de suivi",
|
"delete_confirm_title": "Supprimer la Watchlist",
|
||||||
"delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.",
|
"delete_confirm_message": "Tous les médias (par défaut)",
|
||||||
"remove_item_title": "Retirer de la liste de suivi",
|
"remove_item_title": "Retirer de la Watchlist",
|
||||||
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
|
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
|
||||||
"loading": "Chargement des listes de suivi...",
|
"loading": "Chargement des listes de suivi...",
|
||||||
"no_compatible_watchlists": "Aucune liste de suivi compatible",
|
"no_compatible_watchlists": "Aucune liste de suivi compatible",
|
||||||
@@ -908,33 +910,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Associer à la TV",
|
"title": "Pair with TV",
|
||||||
"align_qr": "Alignez le code QR dans le cadre",
|
"align_qr": "Align the QR code within the frame",
|
||||||
"enter_code_manually": "Saisir le code manuellement",
|
"enter_code_manually": "Enter code manually",
|
||||||
"pairing_enter_credentials": "Entrez les identifiants pour le téléviseur",
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
"pairing_code_label": "Code d'appairage",
|
"pairing_code_label": "Pairing code",
|
||||||
"server": "Serveur",
|
"server": "Server",
|
||||||
"authorize_button": "Autoriser",
|
"authorize_button": "Authorize",
|
||||||
"authorizing": "Autorisation en cours...",
|
"authorizing": "Authorizing...",
|
||||||
"scan_again": "Scanner à nouveau",
|
"scan_again": "Scan Again",
|
||||||
"done": "Terminé",
|
"done": "Done",
|
||||||
"success_title": "Autorisation envoyée",
|
"success_title": "Authorization Sent",
|
||||||
"pairing_tv_connecting": "La TV se connecte à votre compte",
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
"error_title": "Échec de l'autorisation",
|
"error_title": "Authorization Failed",
|
||||||
"error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.",
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
"error_generic": "Une erreur s'est produite. Veuillez réessayer.",
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
"error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.",
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
"login_as": "Se connecter en tant que {{username}} ?",
|
"login_as": "Log in as {{username}}?",
|
||||||
"on_server": "sur {{server}}",
|
"on_server": "on {{server}}",
|
||||||
"use_different_user": "Utiliser un autre utilisateur",
|
"use_different_user": "Use a different user",
|
||||||
"open_settings": "Ouvrir les paramètres"
|
"open_settings": "Open Settings"
|
||||||
},
|
},
|
||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Associer au téléphone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Se connecter sur la TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
"waiting_for_phone": "En attente du téléphone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Connexion en cours...",
|
"logging_in": "Logging in...",
|
||||||
"logging_in_description": "Connexion à votre serveur"
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "בחר",
|
||||||
"no_trailer_available": "אין טריילר זמין",
|
"no_trailer_available": "אין טריילר זמין",
|
||||||
"video": "וידאו",
|
"video": "וידאו",
|
||||||
"audio": "שמע",
|
"audio": "שמע",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Select",
|
||||||
"no_trailer_available": "No trailer available",
|
"no_trailer_available": "No trailer available",
|
||||||
"video": "Videó",
|
"video": "Videó",
|
||||||
"audio": "Hang",
|
"audio": "Hang",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -226,7 +226,7 @@
|
|||||||
"hide_volume_slider": "Hide Volume Slider",
|
"hide_volume_slider": "Hide Volume Slider",
|
||||||
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
|
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
|
||||||
"hide_brightness_slider": "Hide Brightness Slider",
|
"hide_brightness_slider": "Hide Brightness Slider",
|
||||||
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video"
|
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "Audio",
|
"audio_title": "Audio",
|
||||||
@@ -237,10 +237,10 @@
|
|||||||
"language": "Lingua",
|
"language": "Lingua",
|
||||||
"transcode_mode": {
|
"transcode_mode": {
|
||||||
"title": "Audio Transcoding",
|
"title": "Audio Transcoding",
|
||||||
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
|
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||||
"auto": "Automatico",
|
"auto": "Auto",
|
||||||
"stereo": "Force Stereo",
|
"stereo": "Force Stereo",
|
||||||
"5_1": "Consenti 5.1",
|
"5_1": "Allow 5.1",
|
||||||
"passthrough": "Passthrough"
|
"passthrough": "Passthrough"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -262,20 +262,20 @@
|
|||||||
"OnlyForced": "Solo forzati"
|
"OnlyForced": "Solo forzati"
|
||||||
},
|
},
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...",
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers",
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
"mpv_subtitle_scale": "Subtitle Scale",
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
"mpv_subtitle_align_x": "Horizontal Align",
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
"mpv_subtitle_align_y": "Vertical Align",
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Sinistra",
|
"left": "Left",
|
||||||
"center": "Centro",
|
"center": "Center",
|
||||||
"right": "Destra",
|
"right": "Right",
|
||||||
"top": "Alto",
|
"top": "Top",
|
||||||
"bottom": "Basso"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
@@ -307,9 +307,9 @@
|
|||||||
"disabled": "Disabilitato"
|
"disabled": "Disabilitato"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musica",
|
"title": "Music",
|
||||||
"playback_title": "Riproduzione",
|
"playback_title": "Playback",
|
||||||
"playback_description": "Configura come viene riprodotta la musica.",
|
"playback_description": "Configure how music is played.",
|
||||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||||
"caching_title": "Caching",
|
"caching_title": "Caching",
|
||||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
"tv_quota_days": "Giorni di quota per le serie TV",
|
"tv_quota_days": "Giorni di quota per le serie TV",
|
||||||
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
|
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
|
||||||
"unlimited": "Illimitato",
|
"unlimited": "Illimitato",
|
||||||
"plus_n_more": "+{{n}} altro",
|
"plus_n_more": "+{{n}} more",
|
||||||
"order_by": {
|
"order_by": {
|
||||||
"DEFAULT": "Predefinito",
|
"DEFAULT": "Predefinito",
|
||||||
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
|
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
|
||||||
@@ -352,25 +352,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"disable_streamystats": "Disabilita Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "Use for Search",
|
"enable_search": "Use for Search",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save": "Salva",
|
"save": "Save",
|
||||||
"features_title": "Funzionalità",
|
"features_title": "Features",
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||||
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
|
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||||
"recommended_movies": "Recommended Movies",
|
"recommended_movies": "Recommended Movies",
|
||||||
"recommended_series": "Recommended Series",
|
"recommended_series": "Recommended Series",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Salvato",
|
"saved": "Saved",
|
||||||
"refreshed": "Impostazioni aggiornate dal server",
|
"refreshed": "Settings refreshed from server",
|
||||||
"disabled": "Streamystats disabilitato"
|
"disabled": "Streamystats disabled"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
@@ -385,17 +385,17 @@
|
|||||||
"size_used": "{{used}} di {{total}} usato",
|
"size_used": "{{used}} di {{total}} usato",
|
||||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||||
"music_cache_title": "Music Cache",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} nella cache",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Cache musicale cancellata",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} scaricato",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Brani scaricati eliminati",
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.",
|
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -404,8 +404,8 @@
|
|||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"logs_title": "Log",
|
"logs_title": "Log",
|
||||||
"export_logs": "Esporta i logs",
|
"export_logs": "Export logs",
|
||||||
"click_for_more_info": "Clicca per maggiori informazioni",
|
"click_for_more_info": "Click for more info",
|
||||||
"level": "Livello",
|
"level": "Livello",
|
||||||
"no_logs_available": "Nessun log disponibile",
|
"no_logs_available": "Nessun log disponibile",
|
||||||
"delete_all_logs": "Cancella tutti i log"
|
"delete_all_logs": "Cancella tutti i log"
|
||||||
@@ -419,17 +419,17 @@
|
|||||||
"error_deleting_files": "Errore nella cancellazione dei file"
|
"error_deleting_files": "Errore nella cancellazione dei file"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Sicurezza",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"disabled": "Disabilitato",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minuto",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minuti",
|
"5_minutes": "5 minutes",
|
||||||
"15_minutes": "15 minuti",
|
"15_minutes": "15 minutes",
|
||||||
"30_minutes": "30 minuti",
|
"30_minutes": "30 minutes",
|
||||||
"1_hour": "1 ora",
|
"1_hour": "1 hour",
|
||||||
"4_hours": "4 ore",
|
"4_hours": "4 hours",
|
||||||
"24_hours": "24 ore"
|
"24_hours": "24 hours"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Seleziona",
|
||||||
"no_trailer_available": "Nessun trailer disponibile",
|
"no_trailer_available": "Nessun trailer disponibile",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
@@ -493,18 +494,18 @@
|
|||||||
"mark_as_not_played": "Mark as not Played",
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "Nulla",
|
"none": "Nulla",
|
||||||
"track": "Traccia",
|
"track": "Traccia",
|
||||||
"cancel": "Annulla",
|
"cancel": "Cancel",
|
||||||
"delete": "Cancella",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Rimuovi",
|
"remove": "Remove",
|
||||||
"back": "Indietro",
|
"back": "Back",
|
||||||
"continue": "Continua",
|
"continue": "Continue",
|
||||||
"verifying": "Verifica in corso...",
|
"verifying": "Verifying...",
|
||||||
"login": "Accedi",
|
"login": "Login",
|
||||||
"episodes": "Episodi",
|
"episodes": "Episodes",
|
||||||
"movies": "Film",
|
"movies": "Movies",
|
||||||
"loading": "Caricamento…",
|
"loading": "Loading…",
|
||||||
"seeAll": "Visualizza tutti"
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Cerca...",
|
"search": "Cerca...",
|
||||||
@@ -518,10 +519,10 @@
|
|||||||
"episodes": "Episodi",
|
"episodes": "Episodi",
|
||||||
"collections": "Collezioni",
|
"collections": "Collezioni",
|
||||||
"actors": "Attori",
|
"actors": "Attori",
|
||||||
"artists": "Artisti",
|
"artists": "Artists",
|
||||||
"albums": "Album",
|
"albums": "Albums",
|
||||||
"songs": "Tracce",
|
"songs": "Songs",
|
||||||
"playlists": "Playlist",
|
"playlists": "Playlists",
|
||||||
"request_movies": "Film Richiesti",
|
"request_movies": "Film Richiesti",
|
||||||
"request_series": "Serie Richieste",
|
"request_series": "Serie Richieste",
|
||||||
"recently_added": "Aggiunti di Recente",
|
"recently_added": "Aggiunti di Recente",
|
||||||
@@ -553,7 +554,7 @@
|
|||||||
"movies": "film",
|
"movies": "film",
|
||||||
"series": "serie TV",
|
"series": "serie TV",
|
||||||
"boxsets": "cofanetti",
|
"boxsets": "cofanetti",
|
||||||
"playlists": "Playlist",
|
"playlists": "Playlists",
|
||||||
"items": "elementi"
|
"items": "elementi"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -565,7 +566,7 @@
|
|||||||
"cover": "Copertina",
|
"cover": "Copertina",
|
||||||
"show_titles": "Mostra titoli",
|
"show_titles": "Mostra titoli",
|
||||||
"show_stats": "Mostra statistiche",
|
"show_stats": "Mostra statistiche",
|
||||||
"options_title": "Impostazioni"
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Generi",
|
"genres": "Generi",
|
||||||
@@ -574,10 +575,10 @@
|
|||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Criterio di ordinamento",
|
"sort_order": "Criterio di ordinamento",
|
||||||
"tags": "Tag",
|
"tags": "Tag",
|
||||||
"all": "Tutto",
|
"all": "All",
|
||||||
"reset": "Ripristina",
|
"reset": "Reset",
|
||||||
"asc": "Crescente",
|
"asc": "Ascending",
|
||||||
"desc": "Decrescente"
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -594,7 +595,7 @@
|
|||||||
"no_links": "Nessun link"
|
"no_links": "Nessun link"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "IN DIRETTA",
|
"live": "LIVE",
|
||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Errore",
|
"error": "Errore",
|
||||||
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
||||||
@@ -605,39 +606,40 @@
|
|||||||
"next_episode": "Prossimo Episodio",
|
"next_episode": "Prossimo Episodio",
|
||||||
"continue_watching": "Continua a guardare",
|
"continue_watching": "Continua a guardare",
|
||||||
"go_back": "Indietro",
|
"go_back": "Indietro",
|
||||||
"downloaded_file_title": "Questo file è stato scaricato",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Si",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Annulla",
|
"downloaded_file_cancel": "Cancel",
|
||||||
"swipe_down_settings": "Scorri in basso per le impostazioni",
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
"ends_at": "Termina alle {{time}}",
|
"ends_at": "Ends at {{time}}",
|
||||||
"search_subtitles": "Search Subtitles",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Tracce",
|
"subtitle_tracks": "Tracks",
|
||||||
"subtitle_search": "Search & Download",
|
"subtitle_search": "Search & Download",
|
||||||
"download": "Scarica",
|
"download": "Download",
|
||||||
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Lingua",
|
"language": "Language",
|
||||||
"results": "Risultati",
|
"results": "Results",
|
||||||
"search_failed": "Ricerca fallita",
|
"searching": "Searching...",
|
||||||
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
|
"search_failed": "Search failed",
|
||||||
"no_subtitles_found": "Nessun sottotitolo trovato",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
|
"no_subtitles_found": "No subtitles found",
|
||||||
"settings": "Impostazioni",
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"settings": "Settings",
|
||||||
"skip_intro": "Skip Intro",
|
"skip_intro": "Skip Intro",
|
||||||
"skip_credits": "Skip Credits",
|
"skip_credits": "Skip Credits",
|
||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Interrompere la riproduzione \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Scaricato",
|
"downloaded": "Downloaded",
|
||||||
"missing_parameters": "Parametri di riproduzione mancanti"
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Capitoli",
|
"title": "Chapters",
|
||||||
"chapter_number": "Capitolo {{number}}",
|
"chapter_number": "Chapter {{number}}",
|
||||||
"open": "Apri capitoli",
|
"open": "Open chapters",
|
||||||
"close": "Chiudi i capitoli"
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Il prossimo",
|
"next_up": "Il prossimo",
|
||||||
@@ -662,19 +664,19 @@
|
|||||||
"quality": "Qualità",
|
"quality": "Qualità",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"label": "Sottotitoli",
|
"label": "Subtitle",
|
||||||
"none": "Vuoto",
|
"none": "None",
|
||||||
"tracks": "Tracce"
|
"tracks": "Tracks"
|
||||||
},
|
},
|
||||||
"show_more": "Mostra di più",
|
"show_more": "Mostra di più",
|
||||||
"show_less": "Mostra di meno",
|
"show_less": "Mostra di meno",
|
||||||
"left": "sinistra",
|
"left": "left",
|
||||||
"director": "Regista",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Apparso in",
|
"appeared_in": "Apparso in",
|
||||||
"movies": "Film",
|
"movies": "Movies",
|
||||||
"shows": "Serie",
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Impossibile caricare l'elemento",
|
"could_not_load_item": "Impossibile caricare l'elemento",
|
||||||
"none": "Nessuno",
|
"none": "Nessuno",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -689,10 +691,10 @@
|
|||||||
"mark_played": "Mark as Watched",
|
"mark_played": "Mark as Watched",
|
||||||
"mark_unplayed": "Mark as Unwatched",
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continua da {{time}}",
|
"continue_from": "Continue from {{time}}",
|
||||||
"no_data_available": "Nessun dato disponibile"
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Prossimo",
|
"next": "Prossimo",
|
||||||
@@ -704,16 +706,16 @@
|
|||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "Per Bambini",
|
"for_kids": "Per Bambini",
|
||||||
"news": "Notiziari",
|
"news": "Notiziari",
|
||||||
"page_of": "Pagina {{current}} di {{total}}",
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
"no_programs": "Nessun programma disponibile",
|
"no_programs": "No programs available",
|
||||||
"no_channels": "Nessun canale disponibile",
|
"no_channels": "No channels available",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"programs": "Programmi",
|
"programs": "Programs",
|
||||||
"guide": "Guida",
|
"guide": "Guide",
|
||||||
"channels": "Canali",
|
"channels": "Channels",
|
||||||
"recordings": "Registrazioni",
|
"recordings": "Recordings",
|
||||||
"schedule": "Pianifica",
|
"schedule": "Schedule",
|
||||||
"series": "Serie Tv"
|
"series": "Series"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
@@ -759,12 +761,12 @@
|
|||||||
"decline": "Rifiuta",
|
"decline": "Rifiuta",
|
||||||
"requested_by": "Richiesto da {{user}}",
|
"requested_by": "Richiesto da {{user}}",
|
||||||
"unknown_user": "Utente Sconosciuto",
|
"unknown_user": "Utente Sconosciuto",
|
||||||
"select": "Seleziona",
|
"select": "Select",
|
||||||
"request_all": "Request All",
|
"request_all": "Request All",
|
||||||
"request_seasons": "Request Seasons",
|
"request_seasons": "Request Seasons",
|
||||||
"select_seasons": "Select Seasons",
|
"select_seasons": "Select Seasons",
|
||||||
"request_selected": "Request Selected",
|
"request_selected": "Request Selected",
|
||||||
"n_selected": "{{count}} selezionati",
|
"n_selected": "{{count}} selected",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
|
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
|
||||||
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
|
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
|
||||||
@@ -785,39 +787,39 @@
|
|||||||
"library": "Libreria",
|
"library": "Libreria",
|
||||||
"custom_links": "Collegamenti personalizzati",
|
"custom_links": "Collegamenti personalizzati",
|
||||||
"favorites": "Preferiti",
|
"favorites": "Preferiti",
|
||||||
"settings": "Impostazioni"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musica",
|
"title": "Music",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "Suggerimenti",
|
"suggestions": "Suggestions",
|
||||||
"albums": "Album",
|
"albums": "Albums",
|
||||||
"artists": "Artisti",
|
"artists": "Artists",
|
||||||
"playlists": "Playlist",
|
"playlists": "Playlists",
|
||||||
"tracks": "tracks"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "Recently Played",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "Frequently Played",
|
"frequently_played": "Frequently Played",
|
||||||
"top_tracks": "Top Tracks",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "Riproduci",
|
"play": "Play",
|
||||||
"shuffle": "Riproduzione casuale",
|
"shuffle": "Shuffle",
|
||||||
"play_top_tracks": "Play Top Tracks",
|
"play_top_tracks": "Play Top Tracks",
|
||||||
"no_suggestions": "Nessun suggerimento disponibile",
|
"no_suggestions": "No suggestions available",
|
||||||
"no_albums": "Nessun album trovato",
|
"no_albums": "No albums found",
|
||||||
"no_artists": "Artista non trovato",
|
"no_artists": "No artists found",
|
||||||
"no_playlists": "Nessuna playlist trovata",
|
"no_playlists": "No playlists found",
|
||||||
"album_not_found": "Album non trovato",
|
"album_not_found": "Album not found",
|
||||||
"artist_not_found": "Artista non trovato",
|
"artist_not_found": "Artist not found",
|
||||||
"playlist_not_found": "Playlist non trovata",
|
"playlist_not_found": "Playlist not found",
|
||||||
"track_options": {
|
"track_options": {
|
||||||
"play_next": "Play Next",
|
"play_next": "Play Next",
|
||||||
"add_to_queue": "Add to Queue",
|
"add_to_queue": "Add to Queue",
|
||||||
"add_to_playlist": "Add to Playlist",
|
"add_to_playlist": "Add to Playlist",
|
||||||
"download": "Scarica",
|
"download": "Download",
|
||||||
"downloaded": "Scaricato",
|
"downloaded": "Downloaded",
|
||||||
"downloading": "Scaricamento...",
|
"downloading": "Downloading...",
|
||||||
"cached": "Memorizzato nella cache",
|
"cached": "Cached",
|
||||||
"delete_download": "Delete Download",
|
"delete_download": "Delete Download",
|
||||||
"delete_cache": "Remove from Cache",
|
"delete_cache": "Remove from Cache",
|
||||||
"go_to_artist": "Go to Artist",
|
"go_to_artist": "Go to Artist",
|
||||||
@@ -829,112 +831,112 @@
|
|||||||
"playlists": {
|
"playlists": {
|
||||||
"create_playlist": "Create Playlist",
|
"create_playlist": "Create Playlist",
|
||||||
"playlist_name": "Playlist Name",
|
"playlist_name": "Playlist Name",
|
||||||
"enter_name": "Inserisci il nome della playlist",
|
"enter_name": "Enter playlist name",
|
||||||
"create": "Crea",
|
"create": "Create",
|
||||||
"search_playlists": "Cerca playlist...",
|
"search_playlists": "Search playlists...",
|
||||||
"added_to": "Aggiunto a {{name}}",
|
"added_to": "Added to {{name}}",
|
||||||
"added": "Aggiunto alla playlist",
|
"added": "Added to playlist",
|
||||||
"removed_from": "Rimosso da {{name}}",
|
"removed_from": "Removed from {{name}}",
|
||||||
"removed": "Rimosso dalla playlist",
|
"removed": "Removed from playlist",
|
||||||
"created": "Playlist creata",
|
"created": "Playlist created",
|
||||||
"create_new": "Create New Playlist",
|
"create_new": "Create New Playlist",
|
||||||
"failed_to_add": "Impossibile aggiungere alla playlist",
|
"failed_to_add": "Failed to add to playlist",
|
||||||
"failed_to_remove": "Impossibile rimuovere dalla playlist",
|
"failed_to_remove": "Failed to remove from playlist",
|
||||||
"failed_to_create": "Impossibile creare la playlist",
|
"failed_to_create": "Failed to create playlist",
|
||||||
"delete_playlist": "Delete Playlist",
|
"delete_playlist": "Delete Playlist",
|
||||||
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"deleted": "Playlist eliminata",
|
"deleted": "Playlist deleted",
|
||||||
"failed_to_delete": "Impossibile eliminare la playlist"
|
"failed_to_delete": "Failed to delete playlist"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"title": "Sort By",
|
"title": "Sort By",
|
||||||
"alphabetical": "Alfabetico",
|
"alphabetical": "Alphabetical",
|
||||||
"date_created": "Date Created"
|
"date_created": "Date Created"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "Da vedere",
|
"title": "Watchlists",
|
||||||
"my_watchlists": "My Watchlists",
|
"my_watchlists": "My Watchlists",
|
||||||
"public_watchlists": "Public Watchlists",
|
"public_watchlists": "Public Watchlists",
|
||||||
"create_title": "Create Watchlist",
|
"create_title": "Create Watchlist",
|
||||||
"edit_title": "Edit Watchlist",
|
"edit_title": "Edit Watchlist",
|
||||||
"create_button": "Create Watchlist",
|
"create_button": "Create Watchlist",
|
||||||
"save_button": "Save Changes",
|
"save_button": "Save Changes",
|
||||||
"delete_button": "Cancella",
|
"delete_button": "Delete",
|
||||||
"remove_button": "Rimuovi",
|
"remove_button": "Remove",
|
||||||
"cancel_button": "Annulla",
|
"cancel_button": "Cancel",
|
||||||
"name_label": "Nome",
|
"name_label": "Name",
|
||||||
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
|
"name_placeholder": "Enter watchlist name",
|
||||||
"description_label": "Descrizione",
|
"description_label": "Description",
|
||||||
"description_placeholder": "Inserisci descrizione (opzionale)",
|
"description_placeholder": "Enter description (optional)",
|
||||||
"is_public_label": "Public Watchlist",
|
"is_public_label": "Public Watchlist",
|
||||||
"is_public_description": "Permetti ad altri di vedere questa lista",
|
"is_public_description": "Allow others to view this watchlist",
|
||||||
"allowed_type_label": "Content Type",
|
"allowed_type_label": "Content Type",
|
||||||
"sort_order_label": "Default Sort Order",
|
"sort_order_label": "Default Sort Order",
|
||||||
"empty_title": "No Watchlists",
|
"empty_title": "No Watchlists",
|
||||||
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
|
"empty_description": "Create your first watchlist to start organizing your media",
|
||||||
"empty_watchlist": "Questa lista è vuota",
|
"empty_watchlist": "This watchlist is empty",
|
||||||
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
|
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||||
"not_configured_title": "Streamystats Not Configured",
|
"not_configured_title": "Streamystats Not Configured",
|
||||||
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
|
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||||
"go_to_settings": "Vai alle impostazioni",
|
"go_to_settings": "Go to Settings",
|
||||||
"add_to_watchlist": "Add to Watchlist",
|
"add_to_watchlist": "Add to Watchlist",
|
||||||
"remove_from_watchlist": "Remove from Watchlist",
|
"remove_from_watchlist": "Remove from Watchlist",
|
||||||
"select_watchlist": "Select Watchlist",
|
"select_watchlist": "Select Watchlist",
|
||||||
"create_new": "Create New Watchlist",
|
"create_new": "Create New Watchlist",
|
||||||
"item": "elemento",
|
"item": "item",
|
||||||
"items": "elementi",
|
"items": "items",
|
||||||
"public": "Pubblico",
|
"public": "Public",
|
||||||
"private": "Privato",
|
"private": "Private",
|
||||||
"you": "Tu",
|
"you": "You",
|
||||||
"by_owner": "Da un altro utente",
|
"by_owner": "By another user",
|
||||||
"not_found": "\"Da vedere\" non trovata",
|
"not_found": "Watchlist not found",
|
||||||
"delete_confirm_title": "Delete Watchlist",
|
"delete_confirm_title": "Delete Watchlist",
|
||||||
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"remove_item_title": "Remove from Watchlist",
|
"remove_item_title": "Remove from Watchlist",
|
||||||
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
|
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||||
"loading": "Caricamento liste...",
|
"loading": "Loading watchlists...",
|
||||||
"no_compatible_watchlists": "Nessuna lista compatibile",
|
"no_compatible_watchlists": "No compatible watchlists",
|
||||||
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
|
"create_one_first": "Create a watchlist that accepts this content type"
|
||||||
},
|
},
|
||||||
"playback_speed": {
|
"playback_speed": {
|
||||||
"title": "Playback Speed",
|
"title": "Playback Speed",
|
||||||
"apply_to": "Apply To",
|
"apply_to": "Apply To",
|
||||||
"speed": "Velocità",
|
"speed": "Speed",
|
||||||
"scope": {
|
"scope": {
|
||||||
"media": "Solo questo media",
|
"media": "This media only",
|
||||||
"show": "Questo show",
|
"show": "This show",
|
||||||
"all": "Tutti i media (predefinito)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Associa con la TV",
|
"title": "Pair with TV",
|
||||||
"align_qr": "Allinea il QR code all'interno del riquadro",
|
"align_qr": "Align the QR code within the frame",
|
||||||
"enter_code_manually": "Inserisci il codice manualmente",
|
"enter_code_manually": "Enter code manually",
|
||||||
"pairing_enter_credentials": "Inserire le credenziali per la TV",
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
"pairing_code_label": "Codice di associazione",
|
"pairing_code_label": "Pairing code",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"authorize_button": "Autorizza",
|
"authorize_button": "Authorize",
|
||||||
"authorizing": "Autorizzando...",
|
"authorizing": "Authorizing...",
|
||||||
"scan_again": "Scan Again",
|
"scan_again": "Scan Again",
|
||||||
"done": "Fatto",
|
"done": "Done",
|
||||||
"success_title": "Authorization Sent",
|
"success_title": "Authorization Sent",
|
||||||
"pairing_tv_connecting": "La TV si sta collegando al tuo account",
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
"error_title": "Authorization Failed",
|
"error_title": "Authorization Failed",
|
||||||
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
"error_generic": "Si è verificato un errore. Riprova.",
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
"login_as": "Accedi come {{username}}?",
|
"login_as": "Log in as {{username}}?",
|
||||||
"on_server": "su {{server}}",
|
"on_server": "on {{server}}",
|
||||||
"use_different_user": "Usa un altro utente",
|
"use_different_user": "Use a different user",
|
||||||
"open_settings": "Apri le impostazioni"
|
"open_settings": "Open Settings"
|
||||||
},
|
},
|
||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
"waiting_for_phone": "In attesa del telefono...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Accesso in corso...",
|
"logging_in": "Logging in...",
|
||||||
"logging_in_description": "Sto connettendo al server"
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "選択",
|
||||||
"no_trailer_available": "トレーラーがありません",
|
"no_trailer_available": "トレーラーがありません",
|
||||||
"video": "映像",
|
"video": "映像",
|
||||||
"audio": "音声",
|
"audio": "音声",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Select",
|
||||||
"no_trailer_available": "No trailer available",
|
"no_trailer_available": "No trailer available",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Selecteren",
|
||||||
"no_trailer_available": "Geen trailer beschikbaar",
|
"no_trailer_available": "Geen trailer beschikbaar",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Velg",
|
||||||
"no_trailer_available": "Ingen trailer tilgjengelig",
|
"no_trailer_available": "Ingen trailer tilgjengelig",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Lyd",
|
"audio": "Lyd",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Wybierz",
|
||||||
"no_trailer_available": "Brak dostępnego zwiastunu",
|
"no_trailer_available": "Brak dostępnego zwiastunu",
|
||||||
"video": "Wideo",
|
"video": "Wideo",
|
||||||
"audio": "Dźwięk",
|
"audio": "Dźwięk",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
|
"searching": "Searching...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Search failed",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "No subtitles found",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user