Compare commits

..

10 Commits

Author SHA1 Message Date
Gauvain
71b82a75ee Merge branch 'develop' into fix/ui-headers-and-logs 2026-07-17 17:30:56 +02:00
Gauvain
e0cf1559bc fix(ui): keep hitSlop invariant over caller pressableProps 2026-07-17 17:29:50 +02:00
Gauvain
610158efbd fix(ui): keep back button touch area at 44dp with hitSlop 2026-07-17 17:25:49 +02:00
Gauvain
e79ae53e4e feat(appearance): episode images toggle for Next Up & Continue Watching (#1817)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🌐 Translation Sync / sync-translations (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
2026-07-17 15:13:27 +02:00
Gauvain
6dd98ccae8 fix(home): black episode thumbnails in Next Up & Continue Watching (#1815) 2026-07-17 12:00:23 +02:00
kevbot
b4eb853048 fix(livetv): release live streams so the tuner doesn't wedge (#1799)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Gauvino <contact@uruk.dev>
2026-07-17 11:27:32 +02:00
github-actions[bot]
76d9b3177b feat: New Crowdin Translations (#1810)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-15 20:47:34 +02:00
Gauvain
8b02cfcb65 fix(logs): pin the filter bar on iOS, scroll/expand/copy log entries on Android 2026-07-15 18:44:09 +02:00
Gauvain
3020bf5903 fix(ui): unify the header back icon and fix its Android alignment 2026-07-15 18:44:05 +02:00
Gauvain
1f76119082 fix(settings): align the Settings/Plugins back chevron with the edge 2026-07-15 18:43:54 +02:00
54 changed files with 734 additions and 1128 deletions

View File

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

View File

@@ -854,6 +854,13 @@ export default function SettingsTV() {
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.use_episode_images_next_up")}
value={settings.useEpisodeImagesForNextUp}
onToggle={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { Feather } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler";
@@ -23,7 +23,7 @@ export const HeaderBackButton: React.FC<Props> = ({
className='flex items-center justify-center w-9 h-9'
{...pressableProps}
>
<Ionicons name='arrow-back' size={24} color='white' />
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
);
}
@@ -36,10 +36,10 @@ export const HeaderBackButton: React.FC<Props> = ({
intensity={100}
className='overflow-hidden rounded-full p-2'
>
<Ionicons
<Feather
className='drop-shadow-2xl'
name='arrow-back'
size={24}
name='chevron-left'
size={28}
color='white'
/>
</BlurView>
@@ -49,13 +49,18 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<Pressable
onPress={() => router.back()}
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. hitSlop
// keeps the touch area at ~44dp now that the padding is gone.
style={{ marginRight: 16 }}
{...pressableProps}
hitSlop={8}
>
<Ionicons
<Feather
className='drop-shadow-2xl'
name='arrow-back'
size={24}
name='chevron-left'
size={28}
color='white'
/>
</Pressable>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
const posterUrl = useMemo(() => {
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.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
}
if (item.SeriesId) {
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;

View File

@@ -27,6 +27,7 @@ export const AppearanceSettings: React.FC = () => {
<ListGroup title={t("home.settings.appearance.title")} className=''>
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
@@ -44,6 +45,9 @@ export const AppearanceSettings: React.FC = () => {
</ListItem>
<ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")}
subtitle={t(
"home.settings.appearance.merge_next_up_continue_watching_hint",
)}
>
<Switch
value={settings.mergeNextUpAndContinueWatching}
@@ -52,15 +56,32 @@ export const AppearanceSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.use_episode_images_next_up")}
subtitle={t(
"home.settings.appearance.use_episode_images_next_up_hint",
)}
>
<Switch
value={settings.useEpisodeImagesForNextUp}
onValueChange={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
</ListItem>
<ListItem
onPress={() =>
router.push("/settings/appearance/hide-libraries/page")
}
title={t("home.settings.other.hide_libraries")}
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
showArrow
/>
<ListItem
title={t("home.settings.appearance.hide_remote_session_button")}
subtitle={t(
"home.settings.appearance.hide_remote_session_button_hint",
)}
>
<Switch
value={settings.hideRemoteSessionButton}

View File

@@ -72,6 +72,9 @@ export interface TVPosterCardProps {
/** Custom image URL getter - if not provided, uses smart URL logic */
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
/** For horizontal episodes, prefer the episode's own image over the series thumb */
preferEpisodeImage?: boolean;
}
/**
@@ -108,6 +111,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
glowColor = "white",
scaleAmount = 1.05,
imageUrlGetter,
preferEpisodeImage = false,
}) => {
const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes();
@@ -139,9 +143,14 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
if (orientation === "horizontal") {
// Episode: prefer series thumb image for consistent look (like hero section)
if (item.Type === "Episode") {
// First try parent/series thumb (horizontal series artwork)
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
// Opt-in: use the episode's own image instead of the series thumb.
if (preferEpisodeImage && item.ImageTags?.Primary) {
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
}
// First try parent/series thumb (horizontal series artwork).
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
}
// Fall back to episode's own primary image
if (item.ImageTags?.Primary) {
@@ -173,7 +182,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
item,
width: width * 2, // 2x for quality on large screens
});
}, [api, item, orientation, width, imageUrlGetter]);
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
// Progress calculation
const progress = useMemo(() => {

View File

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

View File

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

View File

@@ -16,14 +16,14 @@
"got_it": "حسنًا",
"connection_failed": "فشل الاتصال",
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
"an_unexpected_error_occured": "حدث خطأ غير متوقع",
"an_unexpected_error_occurred": "حدث خطأ غير متوقع",
"change_server": "تغيير الخادم",
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صَلاحِيَة تسجيل الدخول",
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا",
"server_received_too_many_requests_try_again_later": "تلقى الخادم عددًا كبيرًا جدًا من الطلبات، يرجى المحاولة مرة أخرى لاحقًا.",
"there_is_a_server_error": "هناك خطأ في الخادم",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
"too_old_server_text": "تم اكتشاف خادم Jellyfin غير مدعوم",
"too_old_server_description": "يرجى تحديث Jellyfin إلى أحدث إصدار"
},
@@ -188,7 +188,7 @@
"authorize_button": "تفويض الدخول السريع",
"enter_the_quick_connect_code": "أدخل رمز الدخول السريع...",
"success": "نجاح",
"quick_connect_autorized": "تم تفويض الدخول السريع",
"quick_connect_authorized": "تم تفويض الدخول السريع",
"error": "خطأ",
"invalid_code": "رمز غير صالح",
"authorize": "تفويض"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
"hide_libraries": "إخفاء المكتبات",
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"select_libraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية",
"default_playback_speed": "سرعة التشغيل الافتراضية",
@@ -384,6 +384,8 @@
"device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}",
"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_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
"an_error_occurred_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
"client_error": "خطأ في المشغّل",
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
"message_from_server": "رسالة من الخادم: {{message}}",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -137,7 +137,11 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
"hide_remote_session_button": "Hide remote session button",
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
"show_home_backdrop": "Dynamic home backdrop",
"show_hero_carousel": "Hero carousel",
"show_series_poster_on_episode": "Show series poster on episodes",
@@ -296,6 +300,7 @@
},
"safe_area_in_controls": "Safe area in controls",
"show_custom_menu_links": "Show custom menu links",
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
"show_large_home_carousel": "Show large home carousel (beta)",
"hide_libraries": "Hide libraries",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
@@ -410,7 +415,9 @@
"click_for_more_info": "Click for more info",
"level": "Level",
"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": {
"title": "Languages",

View File

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

View File

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

View File

@@ -16,14 +16,14 @@
"got_it": "D'accord",
"connection_failed": "La connexion a échoué",
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier lURL et votre connexion réseau.",
"an_unexpected_error_occured": "Une erreur inattendue s'est produite",
"an_unexpected_error_occurred": "Une erreur inattendue s'est produite",
"change_server": "Changer de serveur",
"invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
"user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter",
"server_is_taking_too_long_to_respond_try_again_later": "Le serveur prend trop de temps à répondre, réessayez plus tard",
"server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard.",
"there_is_a_server_error": "Il y a une erreur de serveur",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue sest produite. Avez-vous correctement saisi lURL du serveur ?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Une erreur inattendue sest produite. Avez-vous correctement saisi lURL du serveur ?",
"too_old_server_text": "Serveur Jellyfin non pris en charge découvert",
"too_old_server_description": "Veuillez mettre à jour Jellyfin vers la dernière version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autoriser une connexion rapide",
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
"success": "Succès",
"quick_connect_autorized": "Connexion rapide autorisée",
"quick_connect_authorized": "Connexion rapide autorisée",
"error": "Erreur",
"invalid_code": "Code invalide",
"authorize": "Autoriser"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Afficher les liens personnalisés",
"show_large_home_carousel": "Afficher le grand carrousel daccueil (bêta)",
"hide_libraries": "Masquer les bibliothèques",
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
"select_libraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
"disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut",
"default_playback_speed": "Vitesse de lecture par défaut",
@@ -384,6 +384,8 @@
"device_usage": "Appareil {{availableSpace}}%",
"size_used": "{{used}} de {{total}} utilisés",
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés",
"delete_all_downloaded_files_confirm": "Supprimer tous les fichiers téléchargés ?",
"delete_all_downloaded_files_confirm_desc": "Êtes-vous sûr de vouloir supprimer tous les fichiers téléchargés ? Cette action est irréversible.",
"music_cache_title": "Mise en cache de la musique",
"music_cache_description": "Mettez automatiquement en cache les chansons au fur et à mesure que vous écoutez pour une lecture plus fluide et une prise en charge hors ligne",
"clear_music_cache": "Vider le cache de la musique",
@@ -598,7 +600,7 @@
"mpv_player_title": "Lecteur MPV",
"error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
"an_error_occurred_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
"client_error": "Erreur du client",
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
"message_from_server": "Message du serveur : {{message}}",

View File

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

View File

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

View File

@@ -16,14 +16,14 @@
"got_it": "Capito",
"connection_failed": "Connessione fallita",
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
"an_unexpected_error_occured": "Si è verificato un errore inaspettato",
"an_unexpected_error_occurred": "Si è verificato un errore inaspettato",
"change_server": "Cambiare il server",
"invalid_username_or_password": "Nome utente o password non validi",
"user_does_not_have_permission_to_log_in": "L'utente non ha il permesso di accedere",
"server_is_taking_too_long_to_respond_try_again_later": "Il server sta impiegando troppo tempo per rispondere, riprovare più tardi",
"server_received_too_many_requests_try_again_later": "Il server ha ricevuto troppe richieste, riprovare più tardi.",
"there_is_a_server_error": "Si è verificato un errore del server",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?",
"too_old_server_text": "Scoperto Server Jellyfin non supportato",
"too_old_server_description": "Aggiorna Jellyfin all'ultima versione"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autorizza Connessione Rapida",
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
"success": "Successo",
"quick_connect_autorized": "Connessione Rapida autorizzata",
"quick_connect_authorized": "Connessione Rapida autorizzata",
"error": "Errore",
"invalid_code": "Codice invalido",
"authorize": "Autorizza"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostra i link del menu personalizzato",
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
"hide_libraries": "Nascondi Librerie",
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"select_libraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
"an_error_occurred_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
"client_error": "Errore del client",
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
"message_from_server": "Messaggio dal server",

View File

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

View File

@@ -16,14 +16,14 @@
"got_it": "성공",
"connection_failed": "연결 실패",
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
"an_unexpected_error_occured": "예기치 않은 오류가 발생했습니다",
"an_unexpected_error_occurred": "예기치 않은 오류가 발생했습니다",
"change_server": "서버 변경",
"invalid_username_or_password": "잘못된 아이디 혹은 비밀번호입니다",
"user_does_not_have_permission_to_log_in": "로그인 하기 위한 권한이 없습니다",
"server_is_taking_too_long_to_respond_try_again_later": "서버 응답이 너무 느립니다. 나중에 다시 시도하세요",
"server_received_too_many_requests_try_again_later": "서버가 너무 많은 요청을 받았습니다. 나중에 다시 시도하세요.",
"there_is_a_server_error": "서버 에러",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "퀵 커넥트 승인",
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
"success": "성공",
"quick_connect_autorized": "퀵 커넥트 승인됨",
"quick_connect_authorized": "퀵 커넥트 승인됨",
"error": "오류",
"invalid_code": "유효하지 않은 코드",
"authorize": "승인"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
"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",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "디바이스 {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"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",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,14 +16,14 @@
"got_it": "Am înţeles",
"connection_failed": "Conectare eșuată",
"could_not_connect_to_server": "Nu s-a putut conecta la server. Verificați adresa URL și conexiunea la internet.",
"an_unexpected_error_occured": "A apărut o eroare neașteptată",
"an_unexpected_error_occurred": "A apărut o eroare neașteptată",
"change_server": "Schimba",
"invalid_username_or_password": "Nume de utilizator sau parolă greșită",
"user_does_not_have_permission_to_log_in": "Utilizatorul nu are permisiunea de a se conecta",
"server_is_taking_too_long_to_respond_try_again_later": "Serverul răspunde prea greu, încearcă din nou mai târziu.",
"server_received_too_many_requests_try_again_later": "Serverul a primit prea multe solicitări, încercați din nou mai târziu.",
"there_is_a_server_error": "Eroare de server",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "A apărut o eroare neașteptată. Ați introdus corect adresa URL a serverului?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "A apărut o eroare neașteptată. Ați introdus corect adresa URL a serverului?",
"too_old_server_text": "Serverul Jellyfin Neacceptat Descoperit",
"too_old_server_description": "Vă rugăm să actualizați Jellyfin la cea mai recentă versiune"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autorizare conectare rapidă",
"enter_the_quick_connect_code": "Introduceți codul pt. conectare rapidă...",
"success": "Succes",
"quick_connect_autorized": "Conectare rapidă autorizată",
"quick_connect_authorized": "Conectare rapidă autorizată",
"error": "Eroare",
"invalid_code": "Cod invalid.",
"authorize": "Autorizează"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Afișează link-uri personalizate în meniu",
"show_large_home_carousel": "Arată Caruselul Media Mare (beta)",
"hide_libraries": "Ascunde bibliotecile",
"select_liraries_you_want_to_hide": "Selectează bibliotecile pe care dorești să le ascunzi din fila Bibliotecă și din secțiunile paginii principale.",
"select_libraries_you_want_to_hide": "Selectează bibliotecile pe care dorești să le ascunzi din fila Bibliotecă și din secțiunile paginii principale.",
"disable_haptic_feedback": "Dezactivează vibrațiile tactile",
"default_quality": "Calitate implicită",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "Dispozitiv {{availableSpace}}%",
"size_used": "{{used}} din {{total}} folosit",
"delete_all_downloaded_files": "Ștergeți toate fișierele descărcate",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Eroare",
"failed_to_get_stream_url": "Nu s-a putut obține adresa URL a fluxului",
"an_error_occured_while_playing_the_video": "A apărut o eroare la redarea videoclipului. Verificați jurnalele în setări.",
"an_error_occurred_while_playing_the_video": "A apărut o eroare la redarea videoclipului. Verificați jurnalele în setări.",
"client_error": "Eroare client",
"could_not_create_stream_for_chromecast": "Nu s-a putut crea un flux pentru Chromecast",
"message_from_server": "Mesaj de la server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Принято",
"connection_failed": "Соединение не удалось",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста, проверьте URL и ваше интернет-соединение.",
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
"an_unexpected_error_occurred": "Возникла непредвиденная ошибка",
"change_server": "Поменять сервер",
"invalid_username_or_password": "Неправильное имя пользователя или пароль",
"user_does_not_have_permission_to_log_in": "Пользователь не имеет прав на вход",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже",
"server_received_too_many_requests_try_again_later": "Сервер получил слишком много запросов, попробуйте позже.",
"there_is_a_server_error": "Возникла ошибка сервера",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?",
"too_old_server_text": "Обнаружен неподдерживаемый сервер Jellyfin",
"too_old_server_description": "Пожалуйста, обновите Jellyfin до последней версии"
},
@@ -188,7 +188,7 @@
"authorize_button": "Авторизовать через быстрое подключение",
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
"success": "Успех",
"quick_connect_autorized": "Быстрое подключение авторизовано",
"quick_connect_authorized": "Быстрое подключение авторизовано",
"error": "Ошибка",
"invalid_code": "Неверный код",
"authorize": "Авторизовать"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Показать ссылки пользовательского меню",
"show_large_home_carousel": "Показывать большую карусель (beta)",
"hide_libraries": "Скрыть библиотеки",
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
"select_libraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
"disable_haptic_feedback": "Отключить тактильную обратную связь",
"default_quality": "Качество по умолчанию",
"default_playback_speed": "Скорость воспроизведения по умолчанию",
@@ -384,6 +384,8 @@
"device_usage": "Устройство {{availableSpace}}%",
"size_used": "{{used}} из {{total}} использовано",
"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_description": "Автоматически кешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
"clear_music_cache": "Очистить кеш музыки",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Ошибка",
"failed_to_get_stream_url": "Не удалось получить URL потока",
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
"an_error_occurred_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
"client_error": "Ошибка клиента",
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
"message_from_server": "Сообщение от сервера: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Koden har mottagits",
"connection_failed": "Anslutningen Misslyckades",
"could_not_connect_to_server": "Det gick inte att ansluta till servern. Kontrollera webbadressen och din nätverksanslutning.",
"an_unexpected_error_occured": "Ett Oväntat Fel Uppstod",
"an_unexpected_error_occurred": "Ett Oväntat Fel Uppstod",
"change_server": "Byt Server",
"invalid_username_or_password": "Ogiltigt användarnamn eller lösenord",
"user_does_not_have_permission_to_log_in": "Användaren har inte behörighet att logga in",
"server_is_taking_too_long_to_respond_try_again_later": "Servern svarar för långsamt, försök igen senare",
"server_received_too_many_requests_try_again_later": "Servern har fått för många förfrågningar, försök igen senare.",
"there_is_a_server_error": "Ett serverfel uppstod",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ett oväntat fel uppstod. Har du angett serverns URL korrekt?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Ett oväntat fel uppstod. Har du angett serverns URL korrekt?",
"too_old_server_text": "Jellyfin Servern Stöds Inte",
"too_old_server_description": "Var God Uppdatera Jellyfin Till Den Senaste Versionen"
},
@@ -188,7 +188,7 @@
"authorize_button": "Godkänn snabbanslutning",
"enter_the_quick_connect_code": "Ange snabbanslutningskod...",
"success": "Snabbanslutning lyckades",
"quick_connect_autorized": "Snabbanslutning Godkänd",
"quick_connect_authorized": "Snabbanslutning Godkänd",
"error": "Fel",
"invalid_code": "Ogiltig kod",
"authorize": "Autentisera"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Visa anpassade menylänkar",
"show_large_home_carousel": "Visa toppbanner (beta)",
"hide_libraries": "Dölj bibliotek",
"select_liraries_you_want_to_hide": "Välj de bibliotek du vill dölja på fliken Bibliotek och på startsidan.",
"select_libraries_you_want_to_hide": "Välj de bibliotek du vill dölja på fliken Bibliotek och på startsidan.",
"disable_haptic_feedback": "Stäng av vibrationer",
"default_quality": "Förvald Kvalitet",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "Telefon {{availableSpace}}%",
"size_used": "{{used}} av {{total}} används",
"delete_all_downloaded_files": "Ta bort alla nerladdade filer",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Musikcache",
"music_cache_description": "Cacha automatiskt låtar när du lyssnar för smidigare uppspelning och offline-stöd",
"clear_music_cache": "Rensa musikcache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Fel",
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
"an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
"an_error_occurred_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
"client_error": "Klientfel",
"could_not_create_stream_for_chromecast": "Kunde inte skapa stream för Chromecast",
"message_from_server": "Meddelande från servern: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Got It",
"connection_failed": "Connection Failed",
"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",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "ผู้ใช้ไม่มีสิทธิ์ในการเข้าสู่ระบบ",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect Authorized",
"quick_connect_authorized": "Quick Connect authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"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",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"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",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "jIyaj",
"connection_failed": "ngoQlaHbe'",
"could_not_connect_to_server": "SeHlaw veS Ho'Do'laHbe'. URL 'ej ret ghun mej.",
"an_unexpected_error_occured": "num ghIq Doch",
"an_unexpected_error_occurred": "num ghIq Doch",
"change_server": "Ho'Do' veS yIghoS",
"invalid_username_or_password": "tlhIngan pagh ngoq De' law'be'",
"user_does_not_have_permission_to_log_in": "tlhIngan lut 'el je'laHbe'",
"server_is_taking_too_long_to_respond_try_again_later": "Ho'Do' veS jachrup. pItlh yIHaD",
"server_received_too_many_requests_try_again_later": "Ho'Do' veS lutlh ngeb petlh law'. pItlh yIHaD.",
"there_is_a_server_error": "Ho'Do' veS ghIq maS",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "**Jellyfin chu'qu' Dotlh yIchoHmoH**"
},
@@ -188,7 +188,7 @@
"authorize_button": "parmaq ngoQ yIje'",
"enter_the_quick_connect_code": "parmaq ngoQ De' yI'el...",
"success": "Qapla'",
"quick_connect_autorized": "parmaq ngoQ je'laH",
"quick_connect_authorized": "parmaq ngoQ je'laH",
"error": "ghIq",
"invalid_code": "De' law'be'",
"authorize": "yIje'"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "menuDaq ret teqlu' yInej",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "De'wI' bom yIQIj",
"select_liraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
"select_libraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
"disable_haptic_feedback": "Qub quvHa' yIQIj",
"default_quality": "wa' luj",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "naDev {{availableSpace}}%",
"size_used": "{{used}} / {{total}} ram",
"delete_all_downloaded_files": "Hoch Qaw' Doch yIQaw'",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "ghIq",
"failed_to_get_stream_url": "tlhol ret URL tu'laHbe'",
"an_error_occured_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
"an_error_occurred_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
"client_error": "lut 'el ghIq",
"could_not_create_stream_for_chromecast": "Chromecast tlhol ret qonlaHbe'",
"message_from_server": "Ho'Do' veS jach: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Anlaşıldı",
"connection_failed": "Bağlantı başarısız",
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin.",
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
"an_unexpected_error_occurred": "Beklenmedik bir hata oluştu",
"change_server": "Sunucu değiştir",
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
"user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok",
"server_is_taking_too_long_to_respond_try_again_later": "Sunucunun yanıt vermesi çok uzun sürüyor, lütfen daha sonra tekrar deneyin",
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen daha sonra tekrar deneyin.",
"there_is_a_server_error": "Sunucu hatası var",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?",
"too_old_server_text": "Desteklenmeyen Jellyfin Sunucu sürümü bulundu.",
"too_old_server_description": "Lütfen Jellyfin'i en son sürüme güncelleyin."
},
@@ -188,7 +188,7 @@
"authorize_button": "Hızlı Bağlantıyı Yetkilendir",
"enter_the_quick_connect_code": "Hızlı bağlantı kodunu girin...",
"success": "Başarılı",
"quick_connect_autorized": "Hızlı Bağlantı Yetkilendirildi",
"quick_connect_authorized": "Hızlı Bağlantı Yetkilendirildi",
"error": "Hata",
"invalid_code": "Geçersiz kod",
"authorize": "Yetkilendir"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Kütüphaneleri Gizle",
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
"select_libraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
"default_quality": "Varsayılan kalite",
"default_playback_speed": "Varsayılan Oynatma Hızı",
@@ -384,6 +384,8 @@
"device_usage": "Cihaz {{availableSpace}}%",
"size_used": "{{used}} / {{total}} kullanıldı",
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil",
"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": "Müzik Ön Belleği",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Müzik Ön Belleğini Temizle",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Hata",
"failed_to_get_stream_url": "Yayın URL'si alınamadı",
"an_error_occured_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
"an_error_occurred_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
"client_error": "İstemci hatası",
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
"message_from_server": "Sunucudan mesaj: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Готово",
"connection_failed": "Помилка зʼєднання",
"could_not_connect_to_server": "Неможливо підʼєднатися до серверу. Будь ласка перевірте URL і ваше зʼєднання з мережею",
"an_unexpected_error_occured": "Сталася несподівана помилка",
"an_unexpected_error_occurred": "Сталася несподівана помилка",
"change_server": "Змінити сервер",
"invalid_username_or_password": "Неправильні імʼя користувача або пароль",
"user_does_not_have_permission_to_log_in": "Користувач не маю дозволу на вхід",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер відповідає занадто довго, будь-ласка спробуйте пізніше",
"server_received_too_many_requests_try_again_later": "Сервер отримав забагато запитів, будь ласка спробуйте пізніше.",
"there_is_a_server_error": "Відбулася помилка на стороні сервера",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Авторизуйте Швидке Зʼєднання",
"enter_the_quick_connect_code": "Введіть код для швидкого зʼєднання...",
"success": "Успіх",
"quick_connect_autorized": "Швидке Зʼєднання авторизовано",
"quick_connect_authorized": "Швидке Зʼєднання авторизовано",
"error": "Помилка",
"invalid_code": "Не правильний код",
"authorize": "Авторизувати"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Показати користувацькі посилання меню",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Сховати медіатеки",
"select_liraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
"select_libraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
"disable_haptic_feedback": "Вимкнути тактильний зворотний зв'язок",
"default_quality": "Якість за замовченням",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "Гаджет {{availableSpace}}%",
"size_used": "{{used}} з {{total}} використано",
"delete_all_downloaded_files": "Видалити усі завантаженні файли",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Помилка",
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
"an_error_occurred_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
"client_error": "Помилка клієнту",
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
"message_from_server": "Повідомлення від серверу: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Đã hiểu",
"connection_failed": "Kết nối thất bại",
"could_not_connect_to_server": "Không thể kết nối tới máy chủ. Vui lòng kiểm tra URL và kết nối mạng.",
"an_unexpected_error_occured": "Đã xảy ra lỗi không mong muốn",
"an_unexpected_error_occurred": "Đã xảy ra lỗi không mong muốn",
"change_server": "Đổi máy chủ",
"invalid_username_or_password": "Tên đăng nhập hoặc mật khẩu không đúng",
"user_does_not_have_permission_to_log_in": "Người dùng không có quyền đăng nhập",
"server_is_taking_too_long_to_respond_try_again_later": "Máy chủ phản hồi quá lâu, vui lòng thử lại sau",
"server_received_too_many_requests_try_again_later": "Máy chủ nhận quá nhiều yêu cầu, vui lòng thử lại sau.",
"there_is_a_server_error": "Đã xảy ra lỗi ở máy chủ",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Đã xảy ra lỗi không mong muốn. Bạn có chắc đã nhập đúng URL máy chủ?",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Đã xảy ra lỗi không mong muốn. Bạn có chắc đã nhập đúng URL máy chủ?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Cho phép Kết nối nhanh",
"enter_the_quick_connect_code": "Nhập mã kết nối nhanh...",
"success": "Thành công",
"quick_connect_autorized": "Kết nối nhanh đã được cho phép",
"quick_connect_authorized": "Kết nối nhanh đã được cho phép",
"error": "Lỗi",
"invalid_code": "Mã không hợp lệ",
"authorize": "Cho phép"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Hiện liên kết tùy chỉnh",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Ẩn thư viện",
"select_liraries_you_want_to_hide": "Chọn các thư viện muốn ẩn khỏi mục Thư viện và Trang chủ.",
"select_libraries_you_want_to_hide": "Chọn các thư viện muốn ẩn khỏi mục Thư viện và Trang chủ.",
"disable_haptic_feedback": "Tắt phản hồi rung",
"default_quality": "Chất lượng mặc định",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "Thiết bị sử dụng {{availableSpace}}%",
"size_used": "{{used}} / {{total}} đã dùng",
"delete_all_downloaded_files": "Xóa toàn bộ tập tin đã tải",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Lỗi",
"failed_to_get_stream_url": "Không thể lấy URL phát trực tiếp",
"an_error_occured_while_playing_the_video": "Có lỗi khi phát video. Xem nhật ký trong cài đặt.",
"an_error_occurred_while_playing_the_video": "Có lỗi khi phát video. Xem nhật ký trong cài đặt.",
"client_error": "Lỗi phía máy khách",
"could_not_create_stream_for_chromecast": "Không thể tạo luồng cho Chromecast",
"message_from_server": "Thông báo từ máy chủ: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Got It",
"connection_failed": "Connection Failed",
"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",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the 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_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect Authorized",
"quick_connect_authorized": "Quick Connect authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"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",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
@@ -384,6 +384,8 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -598,7 +600,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"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",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",

View File

@@ -1,5 +1,6 @@
import { atom } from "jotai";
import { useMemo } from "react";
import { atomWithStorage } from "jotai/utils";
import { storage } from "../mmkv";
import { useSettings } from "./settings";
export enum SortByOption {
@@ -58,36 +59,32 @@ export const sortOptions: {
export const useFilterOptions = () => {
const { settings } = useSettings();
// Memoized so the array identity stays stable across renders. A fresh array
// each render cascades into ListHeaderComponent re-creation and, under heavy
// re-rendering (active downloads), trips React's max-update-depth guard.
// We only show the watchlist option if someone has ticked that setting.
return useMemo(
() =>
settings?.useKefinTweaks
? [
{
key: FilterByOption.IsFavoriteOrLiked,
value: "Is Favorite Or Liked",
},
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
{ key: FilterByOption.IsPlayed, value: "Is Played" },
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
{ key: FilterByOption.Likes, value: "Watchlist" },
]
: [
{
key: FilterByOption.IsFavoriteOrLiked,
value: "Is Favorite Or Liked",
},
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
{ key: FilterByOption.IsPlayed, value: "Is Played" },
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
],
[settings?.useKefinTweaks],
);
// We want to only show the watchlist option if someone has ticked that setting.
const filterOptions = settings?.useKefinTweaks
? [
{
key: FilterByOption.IsFavoriteOrLiked,
value: "Is Favorite Or Liked",
},
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
{ key: FilterByOption.IsPlayed, value: "Is Played" },
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
{ key: FilterByOption.Likes, value: "Watchlist" },
]
: [
{
key: FilterByOption.IsFavoriteOrLiked,
value: "Is Favorite Or Liked",
},
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
{ key: FilterByOption.IsPlayed, value: "Is Played" },
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
];
console.log("filterOptions");
console.log(filterOptions);
return filterOptions;
};
export const sortOrderOptions: {
@@ -123,28 +120,57 @@ const defaultSortPreference: SortPreference = {};
const defaultSortOrderPreference: SortOrderPreference = {};
const defaultFilterPreference: FilterPreference = {};
// Per-library filter memory is intentionally in-memory (NOT atomWithStorage):
// each library keeps its own filters for the session, and everything resets
// when the app is fully closed.
export const sortByPreferenceAtom = atom<SortPreference>(defaultSortPreference);
export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
"sortByPreference",
defaultSortPreference,
{
getItem: (key) => {
const value = storage.getString(key);
return value ? JSON.parse(value) : null;
},
setItem: (key, value) => {
storage.set(key, JSON.stringify(value));
},
removeItem: (key) => {
storage.remove(key);
},
},
);
export const FilterByPreferenceAtom = atom<FilterPreference>(
export const FilterByPreferenceAtom = atomWithStorage<FilterPreference>(
"filterByPreference",
defaultFilterPreference,
{
getItem: (key) => {
const value = storage.getString(key);
return value ? JSON.parse(value) : null;
},
setItem: (key, value) => {
storage.set(key, JSON.stringify(value));
},
removeItem: (key) => {
storage.remove(key);
},
},
);
export const sortOrderPreferenceAtom = atom<SortOrderPreference>(
export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
"sortOrderPreference",
defaultSortOrderPreference,
{
getItem: (key) => {
const value = storage.getString(key);
return value ? JSON.parse(value) : null;
},
setItem: (key, value) => {
storage.set(key, JSON.stringify(value));
},
removeItem: (key) => {
storage.remove(key);
},
},
);
// Genres / years / tags are multi-select, so each library remembers an array.
export interface MultiFilterPreference {
[libraryId: string]: string[];
}
export const genrePreferenceAtom = atom<MultiFilterPreference>({});
export const yearPreferenceAtom = atom<MultiFilterPreference>({});
export const tagPreferenceAtom = atom<MultiFilterPreference>({});
export const getSortByPreference = (
libraryId: string,
preferences: SortPreference,
@@ -165,8 +191,3 @@ export const getFilterByPreference = (
) => {
return preferences?.[libraryId] || null;
};
export const getMultiFilterPreference = (
libraryId: string,
preferences: MultiFilterPreference,
) => preferences?.[libraryId] ?? [];

View File

@@ -274,6 +274,9 @@ export type Settings = {
hideBrightnessSlider: boolean;
usePopularPlugin: boolean;
mergeNextUpAndContinueWatching: boolean;
// Use the episode's own image (instead of the series thumb) for the
// "Next Up" and "Continue Watching" home rows.
useEpisodeImagesForNextUp: boolean;
// TV-specific settings
showHomeBackdrop: boolean;
showTVHeroCarousel: boolean;
@@ -382,6 +385,7 @@ export const defaultValues: Settings = {
hideBrightnessSlider: false,
usePopularPlugin: true,
mergeNextUpAndContinueWatching: false,
useEpisodeImagesForNextUp: false,
// TV-specific settings
showHomeBackdrop: true,
showTVHeroCarousel: true,