Compare commits

..

10 Commits

Author SHA1 Message Date
Alex Kim
261f7cc0cd Merge branch 'develop' into feature/sync-play 2026-05-31 19:05:32 +10:00
Alex Kim
d06daef933 feat(sync-play): squash feature/sync-play 2026-05-31 19:04:14 +10:00
Fredrik Burmester
feca1d7e9c fix(android): resolve mpv-player Kotlin smart-cast build error (#1614) 2026-05-31 10:50:06 +02:00
Fredrik Burmester
6b6bfd1a89 fix(player): remove white blob artifacts on vertical sliders 2026-05-31 10:48:24 +02:00
Fredrik Burmester
d585b20f49 chore: version 2026-05-31 09:44:05 +02:00
Fredrik Burmester
692ccfdb2c fix(tvos): add arm64 UIRequiredDeviceCapabilities to Top Shelf extension
App Store Connect rejected TestFlight submissions because the Top Shelf
extension binary has a 64-bit slice but did not declare arm64 under
UIRequiredDeviceCapabilities in its Info.plist.
2026-05-31 09:42:09 +02:00
Fredrik Burmester
86e39c444c fix(ios): SDK 56 / iOS 26 EAS build fixes (SwiftUICore autolink + patch-package) (#1613) 2026-05-31 09:37:08 +02:00
Fredrik Burmester
ed7928b4d3 Merge remote-tracking branch 'origin/feat/tv-interface' into develop
Some checks failed
🌐 Translation Sync / sync-translations (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-30 22:47:33 +02:00
lance chant
27dc7b5664 fix: android pip (#1605)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-30 22:45:19 +02:00
Alex
a205c75895 chore(mpv-player): Update to MPVKIT 0.41 (#1604) 2026-05-30 22:45:07 +02:00
70 changed files with 6323 additions and 6236 deletions

View File

@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.54.0",
"version": "0.54.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,7 @@
"appleTeamId": "MWD5K362T8"
},
"android": {
"versionCode": 93,
"versionCode": 94,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -144,8 +144,8 @@
[
"./plugins/withGitPod.js",
{
"podName": "MPVKit-GPL",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
}
]
],

View File

@@ -7,6 +7,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
const SyncPlayButtonComponent = Platform.isTV
? null
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
@@ -33,6 +36,7 @@ export default function IndexLayout() {
{!Platform.isTV && (
<>
<Chromecast.Chromecast background='transparent' />
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>

View File

@@ -49,7 +49,8 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { PlayerControls } from "@/providers/SyncPlay/types";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -128,6 +129,7 @@ export default function DirectPlayerPage() {
bitrateValue: bitrateValueStr,
offline: offlineStr,
playbackPosition: playbackPositionFromUrl,
syncPlay: syncPlayStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
@@ -137,9 +139,24 @@ export default function DirectPlayerPage() {
offline: string;
/** Playback position in ticks. */
playbackPosition?: string;
/** Whether playback was initiated by SyncPlay */
syncPlay?: string;
}>();
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
const openedViaSyncPlay = syncPlayStr === "true";
const { lockOrientation, unlockOrientation } = useOrientation();
// SyncPlay integration
const syncPlay = useSyncPlay();
const {
isEnabled: isSyncPlayEnabled,
controller: syncPlayController,
setPlayerControls,
notifyReady,
notifyBuffering,
} = syncPlay;
const offline = offlineStr === "true";
// Audio index: use URL param if provided, otherwise use stored index for offline playback
@@ -403,8 +420,102 @@ export default function DirectPlayerPage() {
reportPlaybackStart();
}, [stream, api, offline]);
// SyncPlay: Connect player controls when video is ready
useEffect(() => {
if (!isVideoLoaded || !videoRef.current || offline) {
setPlayerControls(null);
return;
}
const controls: PlayerControls = {
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seekTo: (positionMs: number) => {
const positionSec = positionMs / 1000;
console.log(
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
);
videoRef.current?.seekTo(positionSec);
},
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
getSpeed: () => currentPlaybackSpeed,
getCurrentPosition: () => progress.get(),
isPlaying: () => isPlaying,
isBuffering: () => isBuffering,
};
setPlayerControls(controls);
return () => {
setPlayerControls(null);
};
}, [
isVideoLoaded,
offline,
isPlaying,
isBuffering,
currentPlaybackSpeed,
progress,
setPlayerControls,
]);
// SyncPlay: Report buffering/ready state to server.
//
// CRITICAL: We must report `buffering` to the server *during* initial
// load (before `isVideoLoaded`), otherwise the server treats us as ready
// and proceeds without waiting for us. jellyfin-web reports this for
// free via the HTML5 video element's `waiting` event; for us, the
// initial load itself is the buffering window.
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const isLocallyReady = isVideoLoaded && !isBuffering;
if (isLocallyReady) {
notifyReady();
} else {
notifyBuffering();
}
}, [
isSyncPlayEnabled,
isVideoLoaded,
isBuffering,
notifyReady,
notifyBuffering,
]);
// SyncPlay: Pause playback when group is waiting
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const groupState = syncPlay.groupInfo?.State;
const isLocalReady = isVideoLoaded && !isBuffering;
const isWaitingForGroup = groupState === "Waiting";
// Pause playback when waiting for group
if (isLocalReady && isWaitingForGroup && isPlaying) {
videoRef.current?.pause();
}
}, [
isSyncPlayEnabled,
syncPlay.groupInfo?.State,
isVideoLoaded,
isBuffering,
isPlaying,
]);
const togglePlay = async () => {
lightHapticFeedback();
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.playPause();
return;
}
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
@@ -638,10 +749,12 @@ export default function DirectPlayerPage() {
const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
const shouldAutoplay = !openedViaSyncPlay;
const source: MpvVideoSource = {
url: stream.url,
startPosition: startPos,
autoplay: true,
autoplay: shouldAutoplay,
initialSubtitleId,
initialAudioId,
// Pass cache/buffer settings from user preferences
@@ -825,12 +938,10 @@ export default function DirectPlayerPage() {
],
);
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent;
setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) {
_setShowControls(false);
}
@@ -848,6 +959,9 @@ export default function DirectPlayerPage() {
// Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => {
// Hide controls BEFORE entering PiP so the window captures a clean view
_setShowControls(false);
setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.();
}, []);
@@ -859,10 +973,20 @@ export default function DirectPlayerPage() {
videoRef.current?.pause?.();
}, []);
const seek = useCallback((position: number) => {
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
}, []);
const seek = useCallback(
(position: number) => {
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: seek requested via SyncPlay", position);
syncPlayController.seekMs(position);
return;
}
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
},
[isSyncPlayEnabled, syncPlayController],
);
// TV audio track change handler
const handleAudioIndexChange = useCallback(
@@ -1253,6 +1377,7 @@ export default function DirectPlayerPage() {
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange}
onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent);

View File

@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { SyncPlayProvider } from "@/providers/SyncPlay";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
@@ -409,133 +410,136 @@ function Layout() {
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
<SyncPlayProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</SyncPlayProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "streamyfin",
@@ -114,11 +113,6 @@
},
},
},
"patchedDependencies": {
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
},
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],

View File

@@ -30,6 +30,7 @@ import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -67,6 +68,14 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
// then performs the navigation uniformly for everyone, matching
// jellyfin-web's playbackManager intercept (Controller.play).
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
@@ -94,6 +103,37 @@ export const PlayButton: React.FC<Props> = ({
const handleNormalPlayFlow = useCallback(async () => {
if (!item) return;
// SyncPlay intercept: in a group, route playback through sthe server so
// every member gets the same PlayQueue: NewPlaylist update and navigates
// together. Skips local navigation and the Chromecast prompt entirely —
// SyncPlay + Chromecast isn't a supported combination yet, same as
// jellyfin-web.
if (isSyncPlayEnabled && syncPlayController && item.Id) {
try {
// Pass the full `item` (not just the ID) so the SyncPlay controller
// can run `translateItemsForPlayback` with full context — this is
// what jellyfin-web does, and it lets us expand Series / Season /
// BoxSet into real episode/track IDs before broadcasting the queue.
// Without expansion, receivers (jellyfin-web in particular) get
// container IDs they can't play and silently fail to open the
// player.
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
} catch (error) {
console.error("SyncPlay: failed to start group playback", error);
Alert.alert(
t("player.client_error"),
t("syncplay.failed_to_start", {
defaultValue: "Failed to start SyncPlay group playback",
}),
);
}
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -290,6 +330,8 @@ export const PlayButton: React.FC<Props> = ({
goToPlayer,
isOffline,
t,
isSyncPlayEnabled,
syncPlayController,
]);
const onPress = useCallback(async () => {

View File

@@ -0,0 +1,235 @@
/**
* GroupSelectionMenu
*
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
* owned by SyncPlayButton). Calls `onClose` after successful actions to
* dismiss the parent sheet.
*/
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
interface GroupSelectionMenuProps {
onClose: () => void;
}
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const {
isEnabled,
groupInfo,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
} = useSyncPlay();
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
try {
const fetchedGroups = await getGroups();
if (!cancelled) {
setGroups(fetchedGroups);
}
} catch (error) {
console.error("Failed to fetch groups", error);
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [getGroups]);
const handleJoinGroup = useCallback(
async (groupId: string) => {
try {
await joinGroup(groupId);
onClose();
} catch (error) {
console.error("Failed to join group", error);
}
},
[joinGroup, onClose],
);
const handleCreateGroup = useCallback(async () => {
setIsCreating(true);
try {
await createGroup();
onClose();
} catch (error) {
console.error("Failed to create group", error);
} finally {
setIsCreating(false);
}
}, [createGroup, onClose]);
const handleLeaveGroup = useCallback(async () => {
try {
await leaveGroup();
onClose();
} catch (error) {
console.error("Failed to leave group", error);
}
}, [leaveGroup, onClose]);
const containerStyle = {
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
paddingTop: 8,
};
if (isEnabled && groupInfo) {
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people' size={24} color='#00a4dc' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
</View>
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
<View className='flex-row items-center justify-between mb-3'>
<Text className='text-neutral-100 font-semibold text-lg'>
{groupInfo.GroupName}
</Text>
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
<Text className='text-white text-xs font-medium'>
{groupInfo.State}
</Text>
</View>
</View>
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
<View className='flex-row items-center'>
<Ionicons name='person' size={16} color='#9ca3af' />
<Text className='text-neutral-400 ml-2'>
{groupInfo.Participants.length} {t("syncplay.members")}
</Text>
</View>
)}
</View>
<Button onPress={handleLeaveGroup} color='red'>
<View className='flex-row items-center justify-center'>
<Ionicons name='exit-outline' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.leave_group")}
</Text>
</View>
</Button>
</View>
);
}
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people-outline' size={24} color='white' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
</View>
{isLoading && (
<View className='py-8 items-center'>
<ActivityIndicator color='#00a4dc' />
</View>
)}
{!isLoading && groups.length > 0 && (
<View className='mb-4'>
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
{t("syncplay.available_groups")}
</Text>
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
{groups.map((group, index) => (
<TouchableOpacity
key={group.GroupId ?? index}
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
className={`flex-row items-center p-4 ${
index < groups.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
<Ionicons name='people' size={20} color='#00a4dc' />
</View>
<View className='flex-1'>
<Text className='text-neutral-100 font-medium'>
{group.GroupName}
</Text>
<Text className='text-neutral-500 text-sm'>
{group.Participants?.length ?? 0} {t("syncplay.members")} {" "}
{group.State}
</Text>
</View>
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
</TouchableOpacity>
))}
</View>
</View>
)}
{!isLoading && groups.length === 0 && (
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
<Ionicons name='people-outline' size={40} color='#6b7280' />
<Text className='text-neutral-400 text-center mt-3'>
{t("syncplay.available_groups")}: 0{"\n"}
{t("syncplay.create_new_group")}
</Text>
</View>
)}
{canCreateGroups && (
<Button
onPress={handleCreateGroup}
color='purple'
disabled={isCreating}
>
<View className='flex-row items-center justify-center'>
{isCreating ? (
<ActivityIndicator size='small' color='white' />
) : (
<>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.create_new_group")}
</Text>
</>
)}
</View>
</Button>
)}
</View>
);
}

View File

@@ -0,0 +1,97 @@
/**
* SyncPlayButton
*
* Header button for accessing SyncPlay functionality.
* Shows group status and opens the group selection sheet.
*
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
* Compose ModalBottomSheet on Android). Because it presents natively, it
* works correctly even when triggered from `headerRight` — no portal or
* provider context is required (unlike @gorhom/bottom-sheet, which fails
* silently from detached UINavigationItem subtrees).
*
* Safe to import statically: this whole module is lazy-required only on
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
*/
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@expo/ui/community/bottom-sheet";
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useRef } from "react";
import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useCastDevice } from "react-native-google-cast";
import { toast } from "sonner-native";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import { GroupSelectionMenu } from "./GroupSelectionMenu";
interface SyncPlayButtonProps {
size?: number;
}
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
const { isEnabled, canJoinGroups } = useSyncPlay();
const { isConnected } = useNetworkStatus();
const castDevice = useCastDevice();
const sheetRef = useRef<BottomSheetMethods>(null);
const isCasting = !!castDevice;
const handlePress = useCallback(() => {
if (isCasting) {
toast("SyncPlay not available while casting");
return;
}
sheetRef.current?.present();
}, [isCasting]);
const handleDismiss = useCallback(() => {
sheetRef.current?.dismiss();
}, []);
if (Platform.isTV) return null;
if (!canJoinGroups) return null;
if (!isConnected) return null;
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
return (
<>
<Pressable
className='mr-4'
onPress={handlePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<View className='relative'>
<Ionicons
name={isEnabled ? "people" : "people-outline"}
size={size}
color={iconColor}
/>
{isEnabled && !isCasting && (
<View
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
style={{
borderWidth: 1,
borderColor: "#171717",
}}
/>
)}
</View>
</Pressable>
<BottomSheetModal
ref={sheetRef}
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
enablePanDownToClose
>
<BottomSheetView>
<GroupSelectionMenu onClose={handleDismiss} />
</BottomSheetView>
</BottomSheetModal>
</>
);
}

View File

@@ -0,0 +1,165 @@
/**
* SyncPlayIndicator
*
* Visual indicator shown during SyncPlay operations.
* Only appears when user's stream is ready but waiting for other group members.
*
* Key principle: SyncPlay indicator = "You're ready, waiting on others"
*/
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface SyncPlayIndicatorProps {
/**
* Whether the indicator should be visible.
* Should only be true when:
* 1. User's stream has loaded
* 2. Waiting for other group members
*/
visible: boolean;
/**
* Optional message to display
*/
message?: string;
}
export function SyncPlayIndicator({
visible,
message,
}: SyncPlayIndicatorProps) {
const { t } = useTranslation();
const displayMessage = message ?? t("syncplay.waiting_for_group");
const opacity = useSharedValue(0);
const scale = useSharedValue(1);
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
scale.value = withRepeat(
withTiming(1.15, {
duration: 800,
easing: Easing.inOut(Easing.ease),
}),
-1,
true,
);
} else {
opacity.value = withTiming(0, { duration: 200 });
scale.value = 1;
}
}, [visible, opacity, scale]);
const containerStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
if (!visible) {
return null;
}
return (
<Animated.View style={[styles.container, containerStyle]}>
<View style={styles.content}>
{/* Pulsing icon container */}
<Animated.View style={[styles.iconContainer, pulseStyle]}>
<View style={styles.iconCircle}>
<Ionicons name='people' size={28} color='white' />
</View>
</Animated.View>
{/* Message */}
<Text style={styles.message}>{displayMessage}</Text>
{/* SyncPlay badge */}
<View style={styles.badge}>
<Ionicons name='sync' size={12} color='white' />
<Text style={styles.badgeText}>SyncPlay</Text>
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFill,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
zIndex: 100,
},
content: {
alignItems: "center",
},
iconContainer: {
marginBottom: 16,
},
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: SYNC_PLAY_COLOR,
justifyContent: "center",
alignItems: "center",
// Glow effect
shadowColor: SYNC_PLAY_COLOR,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 16,
elevation: 8,
},
message: {
color: "white",
fontSize: 16,
fontWeight: "500",
marginBottom: 8,
},
badge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 164, 220, 0.2)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: SYNC_PLAY_COLOR,
},
badgeText: {
color: SYNC_PLAY_COLOR,
fontSize: 12,
fontWeight: "600",
marginLeft: 4,
},
});
/**
* Hook-compatible version that reads SyncPlay state directly
*/
export function useSyncPlayIndicatorState(
isLocalReady: boolean,
isGroupWaiting: boolean,
): boolean {
// Show indicator only when:
// 1. User's local stream has loaded (isLocalReady)
// 2. Group is still waiting for others (isGroupWaiting)
return isLocalReady && isGroupWaiting;
}

View File

@@ -0,0 +1,53 @@
/**
* SyncPlaySpinner
*
* Compact rotating SyncPlay icon shown in place of the play/pause button
* while a play/pause command is in flight to the server (the "schedule-play"
* indicator from jellyfin-web).
*/
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
// SyncPlay cyan color (matches jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface SyncPlaySpinnerProps {
size: number;
color?: string;
}
export function SyncPlaySpinner({
size,
color = SYNC_PLAY_COLOR,
}: SyncPlaySpinnerProps) {
const rotation = useSharedValue(0);
useEffect(() => {
rotation.value = withRepeat(
withTiming(360, {
duration: 1200,
easing: Easing.linear,
}),
-1,
false,
);
}, [rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View style={animatedStyle}>
<Ionicons name='sync' size={size} color={color} />
</Animated.View>
);
}

View File

@@ -0,0 +1,10 @@
/**
* SyncPlay UI Components
*/
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayButton } from "./SyncPlayButton";
export {
SyncPlayIndicator,
useSyncPlayIndicatorState,
} from "./SyncPlayIndicator";

View File

@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -4,11 +4,16 @@ import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ICON_SIZES } from "./constants";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface CenterControlsProps {
showControls: boolean;
isPlaying: boolean;
@@ -44,6 +49,18 @@ export const CenterControls: FC<CenterControlsProps> = ({
const { settings } = useSettings();
const insets = useSafeAreaInsets();
// SyncPlay state from global provider
const {
isEnabled: isSyncPlayEnabled,
groupInfo,
pendingPlaybackCommand,
} = useSyncPlay();
const isSyncPlayWaiting = isSyncPlayEnabled && groupInfo?.State === "Waiting";
// Show the rotating SyncPlay icon ("schedule-play" in jellyfin-web) while a
// play/pause request is in flight to the server.
const isSyncPlayScheduling =
isSyncPlayEnabled && pendingPlaybackCommand !== null;
return (
<View
style={{
@@ -121,7 +138,17 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}>
{!isBuffering ? (
{isSyncPlayScheduling ? (
// SyncPlay command in flight - rotating spinner ("schedule-play")
<SyncPlaySpinner size={ICON_SIZES.CENTER} />
) : isSyncPlayWaiting ? (
// SyncPlay waiting indicator - clock icon, still pressable to toggle
<Ionicons
name='time'
size={ICON_SIZES.CENTER}
color={SYNC_PLAY_COLOR}
/>
) : !isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={ICON_SIZES.CENTER}

View File

@@ -52,14 +52,14 @@
},
"production": {
"environment": "production",
"channel": "0.54.0",
"channel": "0.54.1",
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.54.0",
"channel": "0.54.1",
"android": {
"buildType": "apk",
"image": "latest"
@@ -67,7 +67,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.54.0",
"channel": "0.54.1",
"android": {
"buildType": "apk",
"image": "latest"
@@ -78,7 +78,7 @@
},
"production_tv": {
"environment": "production",
"channel": "0.54.0",
"channel": "0.54.1",
"env": {
"EXPO_TV": "1"
},

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps {
@@ -80,9 +81,9 @@ export const useWebSocket = ({
playTrailers,
}: UseWebSocketProps) => {
const router = useRouter();
const { lastMessage } = useWebSocketContext();
const { lastMessage, clearLastMessage } = useWebSocketContext();
const { t } = useTranslation();
const { clearLastMessage } = useWebSocketContext();
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
useEffect(() => {
if (!lastMessage) return;
@@ -96,6 +97,25 @@ export const useWebSocket = ({
| Record<string, string>
| undefined; // Arguments are Dictionary<string, string>
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
const isSyncPlayCommand =
lastMessage.MessageType === "SyncPlayCommand" ||
lastMessage.MessageType === "SyncPlayGroupUpdate";
const isPlaybackCommand = [
"PlayPause",
"Pause",
"Unpause",
"Stop",
"Seek",
"NextTrack",
"PreviousTrack",
].includes(command ?? "");
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
return;
}
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
togglePlay();

View File

@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
/**
* Attach surface and re-enable video output.
* Based on Findroid's implementation.
* Attach surface and ensure video output is active.
*
* During PiP transitions, the surface is destroyed and recreated by Android.
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
* resumes immediately when the new surface is attached — avoiding the black
* screen that occurs when the VO is fully re-initialized via setOptionString.
*/
fun attachSurface(surface: Surface) {
this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) {
MPVLib.attachSurface(surface)
// Re-enable video output after attaching surface (Findroid approach)
MPVLib.setOptionString("force-window", "yes")
MPVLib.setOptionString("vo", voDriver)
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
// Read back vo to confirm it's still active
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
}
}
/**
* Detach surface and disable video output.
* Based on Findroid's implementation.
* Detach surface without killing the VO pipeline.
*
* The previous approach (vo=null / force-window=no) destroyed the entire video
* output pipeline on every surface transition. During PiP mode, the rapid
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
* did not properly re-initialize rendering into the new PiP surface.
*
* By keeping the VO alive, frames are simply dropped while no surface is
* attached, and rendering resumes immediately when the new surface arrives.
*/
fun detachSurface() {
this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) {
try {
// Disable video output before detaching surface (Findroid approach)
MPVLib.setOptionString("vo", "null")
MPVLib.setOptionString("force-window", "no")
Log.i(TAG, "Video output disabled before surface detach")
} catch (e: Exception) {
Log.e(TAG, "Failed to disable video output: ${e.message}")
}
MPVLib.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
}
}
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "Surface size updated: ${width}x$height")
Log.i(TAG, "[PiP] updateSurfaceSize ${width}x${height}")
} else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
}
}
/**
* Force mpv to render a frame to the current surface.
* Steps forward one frame then seeks back to the original position.
* Used after PiP entry to work around mpv stopping pixel output.
*/
fun forceRedraw() {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
MPVLib.command(arrayOf("frame-step"))
if (pos > 0) {
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
}
}
@@ -692,9 +715,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection.
if (initialAudioId != null && initialAudioId > 0) {
setAudioTrack(initialAudioId)
}
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
if (!isReadyToSeek) {

View File

@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
}
// Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}

View File

@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.widget.FrameLayout
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
* This mirrors the iOS MpvPlayerView implementation.
* Uses TextureView for reliable Picture-in-Picture support.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object {
private const val TAG = "MpvPlayerView"
}
// Event dispatchers
val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher()
val onError by EventDispatcher()
val onTracksReady by EventDispatcher()
private var surfaceView: SurfaceView
val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
private var currentUrl: String? = null
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
init {
setBackgroundColor(Color.BLACK)
// Create SurfaceView for video rendering
surfaceView = SurfaceView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerView)
surfaceTextureListener = this@MpvPlayerView
}
addView(surfaceView)
addView(textureView)
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView)
pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onSeekBy(seconds: Double) {
seekBy(seconds)
}
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) {
if (!isWaitingForPiPTransition) {
isWaitingForPiPTransition = true
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
}
// Renderer is created lazily in loadVideo once we have the voDriver setting
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
try {
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
Log.i(TAG, "Renderer started with vo=$voDriver")
// If surface was created before renderer started, attach it now
pendingSurface?.let { surface ->
renderer?.attachSurface(surface)
pendingSurface = null
Log.i(TAG, "Attached pending surface after renderer start")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
}
}
// MARK: - SurfaceHolder.Callback
override fun surfaceCreated(holder: SurfaceHolder) {
Log.i(TAG, "Surface created")
// MARK: - TextureView.SurfaceTextureListener
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true
if (rendererStarted) {
renderer?.attachSurface(holder.surface)
renderer?.attachSurface(surface)
} else {
// Renderer not started yet - store surface to attach after start
pendingSurface = holder.surface
Log.i(TAG, "Surface created before renderer started, storing as pending")
pendingSurface = surface
}
// If we have a pending load, execute it now
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pendingConfig = null
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.i(TAG, "Surface changed: ${width}x${height}")
// Update MPV with the new surface size (Findroid approach)
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.i(TAG, "Surface destroyed")
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
surfaceReady = false
renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
}
// MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) {
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
loadVideoInternal(config)
}
private fun loadVideoInternal(config: VideoLoadConfig) {
currentUrl = config.url
renderer?.load(
url = config.url,
headers = config.headers,
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId
)
if (config.autoplay) {
play()
}
onLoad(mapOf("url" to config.url))
}
// Convenience method for simple loads
fun loadVideo(url: String, headers: Map<String, String>? = null) {
loadVideo(VideoLoadConfig(url = url, headers = headers))
}
// MARK: - Playback Controls
fun play() {
intendedPlayState = true
renderer?.play()
pipController?.setPlaybackRate(1.0)
}
fun pause() {
intendedPlayState = false
renderer?.pause()
pipController?.setPlaybackRate(0.0)
}
fun seekTo(position: Double) {
renderer?.seekTo(position)
}
fun seekBy(offset: Double) {
renderer?.seekBy(offset)
}
fun setSpeed(speed: Double) {
renderer?.setSpeed(speed)
}
fun getSpeed(): Double {
return renderer?.getSpeed() ?: 1.0
}
fun isPaused(): Boolean {
return renderer?.isPausedState ?: true
}
fun getCurrentPosition(): Double {
return cachedPosition
}
fun getDuration(): Double {
return cachedDuration
}
// MARK: - Picture in Picture
fun startPictureInPicture() {
Log.i(TAG, "startPictureInPicture called")
isWaitingForPiPTransition = true
pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
}
fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
}
fun isPictureInPictureSupported(): Boolean {
return pipController?.isPictureInPictureSupported() ?: false
}
fun isPictureInPictureActive(): Boolean {
return pipController?.isPictureInPictureActive() ?: false
}
// MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> {
return renderer?.getSubtitleTracks() ?: emptyList()
}
fun setSubtitleTrack(trackId: Int) {
renderer?.setSubtitleTrack(trackId)
}
fun disableSubtitles() {
renderer?.disableSubtitles()
}
fun getCurrentSubtitleTrack(): Int {
return renderer?.getCurrentSubtitleTrack() ?: 0
}
fun addSubtitleFile(url: String, select: Boolean = true) {
renderer?.addSubtitleFile(url, select)
}
// MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) {
renderer?.setSubtitlePosition(position)
}
fun setSubtitleScale(scale: Double) {
renderer?.setSubtitleScale(scale)
}
fun setSubtitleMarginY(margin: Int) {
renderer?.setSubtitleMarginY(margin)
}
fun setSubtitleAlignX(alignment: String) {
renderer?.setSubtitleAlignX(alignment)
}
fun setSubtitleAlignY(alignment: String) {
renderer?.setSubtitleAlignY(alignment)
}
fun setSubtitleFontSize(size: Int) {
renderer?.setSubtitleFontSize(size)
}
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> {
return renderer?.getAudioTracks() ?: emptyList()
}
fun setAudioTrack(trackId: Int) {
renderer?.setAudioTrack(trackId)
}
fun getCurrentAudioTrack(): Int {
return renderer?.getCurrentAudioTrack() ?: 0
}
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position
cachedDuration = duration
// Update PiP progress
if (pipController?.isPictureInPictureActive() == true) {
pipController?.setCurrentTime(position, duration)
}
onProgress(mapOf(
"position" to position,
"duration" to duration,
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"cacheSeconds" to cacheSeconds
))
}
override fun onPauseChanged(isPaused: Boolean) {
// Sync PiP playback rate
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
onPlaybackStateChange(mapOf(
"isPaused" to isPaused,
"isPlaying" to !isPaused
))
}
override fun onLoadingChanged(isLoading: Boolean) {
onPlaybackStateChange(mapOf(
"isLoading" to isLoading
))
}
override fun onReadyToSeek() {
onPlaybackStateChange(mapOf(
"isReadyToSeek" to true
))
}
override fun onTracksReady() {
onTracksReady(emptyMap<String, Any>())
}
override fun onVideoDimensionsChanged(width: Int, height: Int) {
// Update PiP controller with video dimensions for proper aspect ratio
pipController?.setVideoDimensions(width, height)
}
override fun onError(message: String) {
onError(mapOf("error" to message))
}
// MARK: - Cleanup
fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
renderer?.stop()
surfaceView.holder.removeCallback(this)
surfaceTexture = null
surfaceReady = false
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cleanup()

View File

@@ -1,51 +1,62 @@
package expo.modules.mpvplayer
import android.app.Activity
import android.app.Application
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.annotation.RequiresApi
import expo.modules.kotlin.AppContext
/**
* Picture-in-Picture controller for Android.
* This mirrors the iOS PiPController implementation.
*/
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
companion object {
private const val TAG = "PiPController"
private const val DEFAULT_ASPECT_WIDTH = 16
private const val DEFAULT_ASPECT_HEIGHT = 9
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
}
interface Delegate {
fun onPlay()
fun onPause()
fun onSeekBy(seconds: Double)
fun onPictureInPictureModeChanged(isInPiP: Boolean)
}
var delegate: Delegate? = null
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Video dimensions for proper aspect ratio
private var videoWidth: Int = 0
private var videoHeight: Int = 0
// Reference to the player view for source rect
private var playerView: View? = null
/**
* Check if Picture-in-Picture is supported on this device
*/
// PiP state tracking
private var isInPiPMode: Boolean = false
private var pipEntryNotified: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
private var lifecycleRegistered = false
private var pipBroadcastReceiver: BroadcastReceiver? = null
fun isPictureInPictureSupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
false
}
}
/**
* Check if Picture-in-Picture is currently active
*/
fun isPictureInPictureActive(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
return false
}
/**
* Start Picture-in-Picture mode
*/
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity == null) {
Log.e(TAG, "Cannot start PiP: no activity found")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val activity = getActivity() ?: run {
Log.e(TAG, "Cannot start PiP: no activity")
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
val result = activity.enterPictureInPictureMode(params)
if (!result) {
Log.e(TAG, "enterPictureInPictureMode rejected by system")
isInPiPMode = false
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
activity.enterPictureInPictureMode(params)
Log.i(TAG, "Entered PiP mode")
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
} else {
Log.w(TAG, "PiP requires Android O or higher")
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
registerLifecycleCallbacks()
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
}
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() {
// On Android, exiting PiP is typically done by the user
// or by finishing the activity. We can request to move task to back.
isInPiPMode = false
pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
// Move task to back which will exit PiP
activity.moveTaskToBack(false)
}
}
}
/**
* Update the current playback position and duration
* Note: We don't update PiP params here as we're not using progress in PiP controls
*/
fun isCurrentlyInPiP(): Boolean = isInPiPMode
fun setCurrentTime(position: Double, duration: Double) {
currentPosition = position
currentDuration = duration
}
/**
* Set the playback rate (0.0 for paused, 1.0 for playing)
*/
fun setPlaybackRate(rate: Double) {
playbackRate = rate
// Update PiP params to reflect play/pause state
if (rate > 0) {
registerLifecycleCallbacks()
}
// Update PiP params so autoEnterEnabled and action icons track play/pause state
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
if (activity != null) {
try {
activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) {
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
}
}
/**
* Set the video dimensions for proper aspect ratio calculation
*/
fun setVideoDimensions(width: Int, height: Int) {
if (width > 0 && height > 0) {
videoWidth = width
videoHeight = height
Log.i(TAG, "Video dimensions set: ${width}x${height}")
// Update PiP params if active
updatePiPParamsIfNeeded()
}
}
/**
* Set the player view reference for source rect hint
*/
fun setPlayerView(view: View?) {
playerView = view
}
private fun updatePiPParamsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
}
}
/**
* Build Picture-in-Picture params for the current player state.
* Calculates proper aspect ratio and source rect based on video and view dimensions.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
val view = playerView
val viewWidth = view?.width ?: 0
val viewHeight = view?.height ?: 0
// Display aspect ratio from view (exactly like Findroid)
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
// Video aspect ratio with 2.39:1 clamping
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
Rational(
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
} else {
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
}
// Source rect hint calculation (exactly like Findroid)
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
if (displayAspectRatio < aspectRatio) {
// Letterboxing - black bars top/bottom
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect(
0,
space,
viewWidth,
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
)
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
} else {
// Pillarboxing - black bars left/right
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect(
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
}
} else {
null
}
val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
sourceRectHint?.let { builder.setSourceRectHint(it) }
// On Android 12+, enable auto-enter (like Findroid)
ensurePiPReceiverRegistered()
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true)
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
}
return builder.build()
}
private fun getActivity(): Activity? {
// First try Expo's AppContext (preferred in React Native)
appContext?.currentActivity?.let { return it }
// Fallback: Try to get from context wrapper chain
var ctx = context
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) {
return ctx
}
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
/**
* Handle PiP action (called from activity when user taps PiP controls)
*/
fun handlePiPAction(action: String) {
when (action) {
"play" -> delegate?.onPlay()
"pause" -> delegate?.onPause()
"skip_forward" -> delegate?.onSeekBy(10.0)
"skip_backward" -> delegate?.onSeekBy(-10.0)
// MARK: - Lifecycle-based PiP Detection
private fun registerLifecycleCallbacks() {
if (lifecycleRegistered) return
val app = context.applicationContext as? Application ?: run {
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
startFallbackPolling()
return
}
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
if (!isInPiPMode) return
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}
override fun onActivityPaused(activity: Activity) {
// Proactively hide controls when user leaves while playing,
// before the PiP window captures the UI. onActivityStopped
// will restore if PiP didn't actually enter.
if (playbackRate > 0 && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
}
}
override fun onActivityStopped(activity: Activity) {
pipHandler.postDelayed({
val inPip = activity.isInPictureInPictureMode
if (inPip && !isInPiPMode) {
isInPiPMode = true
pipEntryNotified = true
delegate?.onPictureInPictureModeChanged(true)
return@postDelayed
}
if (!isInPiPMode) return@postDelayed
if (inPip) return@postDelayed
// Not in PiP after 1s — check again to avoid false positive during transition
pipHandler.postDelayed({
if (!isInPiPMode) return@postDelayed
if (!activity.isInPictureInPictureMode) {
isInPiPMode = false
pipEntryNotified = false
delegate?.onPictureInPictureModeChanged(false)
}
}, 1500)
}, 1000)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
isInPiPMode = false
}
}
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
lifecycleRegistered = true
}
private fun unregisterLifecycleCallbacks() {
if (!lifecycleRegistered) return
lifecycleCallbacks?.let {
(context.applicationContext as? Application)
?.unregisterActivityLifecycleCallbacks(it)
}
lifecycleCallbacks = null
lifecycleRegistered = false
pipHandler.removeCallbacksAndMessages(null)
unregisterPiPBroadcastReceiver()
}
private fun startFallbackPolling() {
var falseReadCount = 0
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed(object : Runnable {
override fun run() {
if (!isInPiPMode) return
var ctx = context
var activity: Activity? = null
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) { activity = ctx; break }
ctx = ctx.baseContext
}
val stillInPip = activity?.isInPictureInPictureMode == true
if (!stillInPip) {
falseReadCount++
if (falseReadCount >= 3) {
isInPiPMode = false
delegate?.onPictureInPictureModeChanged(false)
return
}
pipHandler.postDelayed(this, 500)
return
}
falseReadCount = 0
pipHandler.postDelayed(this, 1000)
}
}, 3000)
}
// MARK: - PiP Remote Actions
private fun ensurePiPReceiverRegistered() {
if (pipBroadcastReceiver != null) return
pipBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_PIP_PLAY_PAUSE -> {
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
}
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
}
}
}
val filter = IntentFilter().apply {
addAction(ACTION_PIP_PLAY_PAUSE)
addAction(ACTION_PIP_SKIP_FORWARD)
addAction(ACTION_PIP_SKIP_BACKWARD)
}
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else {
0
}
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
}
private fun unregisterPiPBroadcastReceiver() {
pipBroadcastReceiver?.let {
try {
context.applicationContext.unregisterReceiver(it)
} catch (_: Exception) {}
}
pipBroadcastReceiver = null
}
private fun buildPiPActions(): List<RemoteAction> {
val isPlaying = playbackRate > 0
return listOf(
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
"Rewind", "Skip backward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
),
RemoteAction(
Icon.createWithResource(
context,
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
),
if (isPlaying) "Pause" else "Play",
if (isPlaying) "Pause playback" else "Resume playback",
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
),
RemoteAction(
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
"Fast Forward", "Skip forward 10 seconds",
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
)
)
}
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
android.app.PendingIntent.FLAG_IMMUTABLE
} else {
0
}
return android.app.PendingIntent.getBroadcast(
context.applicationContext, 0, Intent(action), flags
)
}
}

View File

@@ -1,32 +1,19 @@
Pod::Spec.new do |s|
s.name = 'MpvPlayer'
s.version = '1.0.0'
s.summary = 'MPVKit for Expo'
s.description = 'MPVKit for Expo'
s.author = 'mpvkit'
s.homepage = 'https://github.com/mpvkit/MPVKit'
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
s.name = 'MpvPlayer'
s.version = '1.0.0'
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
s.author = 'Streamyfin'
s.homepage = 'https://github.com/streamyfin/streamyfin'
s.platforms = { :ios => '15.1', :tvos => '15.1' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit-GPL'
s.dependency 'MPVKit'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'VALID_ARCHS' => 'arm64',
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.user_target_xcconfig = {
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"

View File

@@ -76,6 +76,7 @@ class MpvPlayerView: ExpoView {
super.init(appContext: appContext)
setupNotifications()
setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
}
private func setupView() {

View File

@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>;
export type OnPictureInPictureChangePayload = {
isActive: boolean;
};
export type NowPlayingMetadata = {
title?: string;
artist?: string;
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload;
}) => void;
};
export interface MpvPlayerViewRef {

View File

@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
requireNativeView("MpvPlayer");
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
function MpvPlayerView(props, ref) {
const nativeRef = useRef<any>(null);
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
return await nativeRef.current?.getDuration();
},
startPictureInPicture: async () => {
console.log(PIP_LOG, "startPictureInPicture → native");
await nativeRef.current?.startPictureInPicture();
console.log(PIP_LOG, "startPictureInPicture ← native returned");
},
stopPictureInPicture: async () => {
console.log(PIP_LOG, "stopPictureInPicture → native");
await nativeRef.current?.stopPictureInPicture();
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
},
isPictureInPictureSupported: async () => {
return await nativeRef.current?.isPictureInPictureSupported();
const result = await nativeRef.current?.isPictureInPictureSupported();
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
return result;
},
isPictureInPictureActive: async () => {
return await nativeRef.current?.isPictureInPictureActive();
const result = await nativeRef.current?.isPictureInPictureActive();
console.log(PIP_LOG, "isPictureInPictureActive =", result);
return result;
},
getSubtitleTracks: async () => {
return await nativeRef.current?.getSubtitleTracks();

View File

@@ -162,10 +162,5 @@
},
"trustedDependencies": [
"unrs-resolver"
],
"patchedDependencies": {
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
}
]
}

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate
}
@@ -14,10 +11,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
@available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none"
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/ios/TabView/NewTabView.swift
+++ b/ios/TabView/NewTabView.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
@@ -56,10 +53,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
}
#endif
+
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View {
@ViewBuilder
@@ -69,10 +66,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
if #available(iOS 26.0, macOS 26.0, *) {
if let behavior {
self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/ios/TabViewProps.swift
+++ b/ios/TabViewProps.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp
case onScrollDown

View File

@@ -1,7 +1,7 @@
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
@@ -25,15 +25,14 @@ public extension RCTView {
return rootView.recursivelyFindSubview(whereType: targetType);
};

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/react-native-udp.podspec
+++ b/react-native-udp.podspec
--- a/node_modules/react-native-udp/react-native-udp.podspec
+++ b/node_modules/react-native-udp/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"]
s.license = package_json["license"]

View File

@@ -39,6 +39,28 @@ function buildPatch() {
" end",
" end",
"",
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
" # files, so the pods-only flag above is not enough — the app's link still fails with",
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
" if ENV['EXPO_TV'] != '1'",
" installer.aggregate_targets.each do |agg|",
" next unless agg.user_project",
" agg.user_project.native_targets.each do |target|",
" target.build_configurations.each do |cfg|",
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
" existing = existing.join(' ') if existing.is_a?(Array)",
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
" end",
" end",
" end",
" agg.user_project.save",
" end",
" end",
"",
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
" if File.exist?(filepath)",

View File

@@ -53,7 +53,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.0" },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -128,7 +128,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.0" },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -162,7 +162,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.54.0"`,
}, DeviceId="${deviceId}", Version="0.54.1"`,
};
}, [deviceId]);

View File

@@ -0,0 +1,402 @@
/**
* SyncPlay Controller
*
* Exposes SyncPlay API calls to external modules.
* Provides methods for controlling synchronized playback.
*
* Based on jellyfin-web's Controller.js
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import {
getItemsForPlayback,
msToTicks,
translateItemsForPlayback,
} from "./Helper";
import type { SyncPlayManager } from "./Manager";
import type { QueueCore } from "./QueueCore";
import type { GroupRepeatMode, GroupShuffleMode, PlayOptions } from "./types";
/**
* SyncPlay Controller - External API for controlling SyncPlay
*/
export class SyncPlayController {
private api: Api;
private manager: SyncPlayManager;
private queueCore: QueueCore;
constructor(api: Api, manager: SyncPlayManager, queueCore: QueueCore) {
this.api = api;
this.manager = manager;
this.queueCore = queueCore;
}
// ============================================================================
// Playback Control
// ============================================================================
/**
* Toggle play/pause
*/
playPause(): void {
// Use server group state (with pending in-flight command preferred) as
// the source of truth. The local player can lag the group by hundreds of
// ms while a scheduled command is pending, so reading `playerControls`
// here would cause rapid taps to send duplicate / wrong commands and
// desync other clients.
const state = this.manager.getEffectivePlayState();
console.log(`SyncPlay Controller: playPause - effectiveState=${state}`);
if (state === "Playing") {
console.log("SyncPlay Controller: requesting PAUSE");
this.pause();
} else {
console.log("SyncPlay Controller: requesting UNPAUSE");
this.unpause();
}
}
/**
* Request unpause (play)
*/
async unpause(): Promise<void> {
// Drop duplicate rapid taps while a previous request is still in flight
// (cleared when the server broadcasts back via SyncPlayCommand, or after
// a safety timeout).
if (this.manager.getPendingPlaybackCommand() === "Unpause") {
console.debug("SyncPlay Controller: unpause ignored — already pending");
return;
}
this.manager.markPendingPlaybackCommand("Unpause");
try {
console.log("SyncPlay Controller: sending syncPlayUnpause to server");
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayUnpause();
console.log("SyncPlay Controller: syncPlayUnpause sent successfully");
} catch (error) {
console.error("SyncPlay Controller: failed to unpause", error);
}
}
/**
* Request pause
*/
async pause(): Promise<void> {
if (this.manager.getPendingPlaybackCommand() === "Pause") {
console.debug("SyncPlay Controller: pause ignored — already pending");
return;
}
this.manager.markPendingPlaybackCommand("Pause");
try {
console.log("SyncPlay Controller: sending syncPlayPause to server");
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPause();
console.log("SyncPlay Controller: syncPlayPause sent successfully");
// Also pause locally for immediate feedback
this.manager.getPlayerControls()?.pause();
} catch (error) {
console.error("SyncPlay Controller: failed to pause", error);
}
}
/**
* Request seek to position
*/
async seek(positionTicks: number): Promise<void> {
try {
console.log(
`SyncPlay Controller: sending syncPlaySeek to ${positionTicks} ticks`,
);
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySeek({
seekRequestDto: {
PositionTicks: positionTicks,
},
});
console.log("SyncPlay Controller: syncPlaySeek sent successfully");
// Also seek locally for immediate feedback
const positionMs = positionTicks / 10000;
this.manager.getPlayerControls()?.seekTo(positionMs);
} catch (error) {
console.error("SyncPlay Controller: failed to seek", error);
}
}
/**
* Request seek to position in milliseconds
*/
async seekMs(positionMs: number): Promise<void> {
console.log(`SyncPlay Controller: seekMs to ${positionMs}ms`);
await this.seek(msToTicks(positionMs));
}
/**
* Request stop
*/
async stop(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayStop();
} catch (error) {
console.error("SyncPlay Controller: failed to stop", error);
}
}
// ============================================================================
// Queue Control
// ============================================================================
/**
* Start playback with a new SyncPlay group queue.
*
* Mirrors jellyfin-web's `Controller.play`:
*
* - If the caller passed full `items` objects, translate them directly
* (Series → episodes, BoxSet → children, etc.).
* - Otherwise fetch the items by ID first (`getItemsForPlayback`), then
* translate.
* - Send the translated, real playable IDs to
* `syncPlaySetNewQueue` so every group member receives a queue of
* playable items — not container IDs (Series / Season / BoxSet) that
* receivers like jellyfin-web silently drop.
*
* `startIndex` / `startPositionTicks` default to 0, same as jellyfin-web.
*/
async play(options: PlayOptions): Promise<void> {
const { items, ids, startIndex = 0, startPositionTicks = 0 } = options;
if ((!ids || ids.length === 0) && (!items || items.length === 0)) {
console.error("SyncPlay Controller: no items or ids to play");
return;
}
try {
// Step 1 — resolve to a list of BaseItemDto. Prefer the caller-supplied
// items (no extra round trip), fall back to a fetch by IDs.
const sourceItems: BaseItemDto[] =
items && items.length > 0
? items
: await getItemsForPlayback(this.api, ids ?? []);
if (!sourceItems.length) {
console.error(
"SyncPlay Controller: getItemsForPlayback returned no items",
);
return;
}
// Step 2 — expand Series / Season / BoxSet / Playlist / single-Episode
// into the real playable queue.
const translated = await translateItemsForPlayback(
this.api,
sourceItems,
{ ids, queryOptions: {} },
);
const queueIds = translated
.map((item) => item.Id)
.filter((id): id is string => !!id);
if (!queueIds.length) {
console.error(
"SyncPlay Controller: translateItemsForPlayback produced empty queue",
);
return;
}
console.log(
`SyncPlay Controller: sending syncPlaySetNewQueue (${queueIds.length} item${queueIds.length === 1 ? "" : "s"}, startIndex=${startIndex})`,
);
// Step 3 — broadcast the resolved queue to the group.
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetNewQueue({
playRequestDto: {
PlayingQueue: queueIds,
PlayingItemPosition: startIndex,
StartPositionTicks: startPositionTicks,
},
});
} catch (error) {
// Surface the server response body when available — a SetNewQueue
// that 4xx's silently is the most common "why didn't the other
// client start?" cause. Without the body we'd just see a generic
// axios error and have no way to tell whether it was a permission
// problem, an unknown item ID, or the server rejecting the queue.
const err = error as {
response?: { status?: number; data?: unknown };
message?: string;
};
console.error("SyncPlay Controller: failed to set new queue", {
status: err?.response?.status,
data: err?.response?.data,
message: err?.message,
});
}
}
/**
* Set current item in playlist
*/
async setCurrentPlaylistItem(playlistItemId: string): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetPlaylistItem({
setPlaylistItemRequestDto: {
PlaylistItemId: playlistItemId,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set playlist item", error);
}
}
/**
* Play next item
*/
async nextItem(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayNextItem({
nextItemRequestDto: {
PlaylistItemId:
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to play next", error);
}
}
/**
* Play previous item
*/
async previousItem(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPreviousItem({
previousItemRequestDto: {
PlaylistItemId:
this.queueCore.getCurrentPlaylistItemId() ?? undefined,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to play previous", error);
}
}
/**
* Add items to queue
*/
async queue(
itemIds: string[],
mode: "Queue" | "QueueNext" = "Queue",
): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayQueue({
queueRequestDto: {
ItemIds: itemIds,
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to queue items", error);
}
}
/**
* Add items to play next
*/
async queueNext(itemIds: string[]): Promise<void> {
await this.queue(itemIds, "QueueNext");
}
/**
* Remove items from playlist
*/
async removeFromPlaylist(playlistItemIds: string[]): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayRemoveFromPlaylist({
removeFromPlaylistRequestDto: {
PlaylistItemIds: playlistItemIds,
},
});
} catch (error) {
console.error(
"SyncPlay Controller: failed to remove from playlist",
error,
);
}
}
/**
* Move item in playlist
*/
async movePlaylistItem(
playlistItemId: string,
newIndex: number,
): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayMovePlaylistItem({
movePlaylistItemRequestDto: {
PlaylistItemId: playlistItemId,
NewIndex: newIndex,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to move playlist item", error);
}
}
// ============================================================================
// Playback Settings
// ============================================================================
/**
* Set repeat mode
*/
async setRepeatMode(mode: GroupRepeatMode): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetRepeatMode({
setRepeatModeRequestDto: {
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set repeat mode", error);
}
}
/**
* Set shuffle mode
*/
async setShuffleMode(mode: GroupShuffleMode): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetShuffleMode({
setShuffleModeRequestDto: {
Mode: mode,
},
});
} catch (error) {
console.error("SyncPlay Controller: failed to set shuffle mode", error);
}
}
/**
* Toggle shuffle mode
*/
async toggleShuffleMode(): Promise<void> {
const currentMode = this.queueCore.getShuffleMode();
const newMode: GroupShuffleMode =
currentMode === "Sorted" ? "Shuffle" : "Sorted";
await this.setShuffleMode(newMode);
}
}

View File

@@ -0,0 +1,448 @@
/**
* SyncPlay Helper
*
* Utility functions for SyncPlay functionality.
* Based on jellyfin-web's Helper.js
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getItemsApi,
getTvShowsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { TicksPerMillisecond } from "./types";
/**
* Wait for an event to be triggered, with optional timeout.
*/
export function waitForEvent<T>(
eventEmitter: {
addEventListener: (event: string, handler: (data: T) => void) => void;
removeEventListener: (event: string, handler: (data: T) => void) => void;
},
eventType: string,
timeout?: number,
rejectEvents?: string[],
): Promise<T> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
eventEmitter.removeEventListener(eventType, handler);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (rejectEvents) {
for (const event of rejectEvents) {
eventEmitter.removeEventListener(event, rejectHandler);
}
}
};
const handler = (data: T) => {
cleanup();
resolve(data);
};
const rejectHandler = () => {
cleanup();
reject(new Error("Rejected by event"));
};
eventEmitter.addEventListener(eventType, handler);
if (rejectEvents) {
for (const event of rejectEvents) {
eventEmitter.addEventListener(event, rejectHandler);
}
}
if (timeout) {
timeoutId = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for event"));
}, timeout);
}
});
}
/**
* Wait for a promise-based callback, with timeout.
*/
export function waitWithTimeout<T>(
promise: Promise<T>,
timeout: number,
): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("Operation timed out"));
}, timeout);
promise
.then((result) => {
clearTimeout(timeoutId);
resolve(result);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
/**
* Convert ticks to milliseconds.
*/
export function ticksToMs(ticks: number): number {
return ticks / TicksPerMillisecond;
}
/**
* Convert milliseconds to ticks.
*/
export function msToTicks(ms: number): number {
return Math.round(ms * TicksPerMillisecond);
}
/**
* Convert a GUID string to standard format.
*/
export function stringToGuid(input: string): string {
return input.replace(
/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/,
"$1-$2-$3-$4-$5",
);
}
/**
* Parse a date string to Date object.
*/
export function parseDate(dateString: string): Date {
return new Date(dateString);
}
/**
* Get current time as ISO string for API requests.
*/
export function nowAsIsoString(): string {
return new Date().toISOString();
}
/**
* Clamp a value between min and max.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Simple event emitter for internal use.
*/
export class EventEmitter {
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
on(event: string, callback: (...args: unknown[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
off(event: string, callback: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(callback);
}
emit(event: string, ...args: unknown[]): void {
this.listeners.get(event)?.forEach((callback) => {
try {
callback(...args);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
removeAllListeners(event?: string): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}
/**
* Wait for the next emission of an event on our internal {@link EventEmitter},
* or reject after `timeoutMs`. Auto-cleans the listener.
*/
export function waitForOwnEvent(
emitter: EventEmitter,
event: string,
timeoutMs = 5000,
): Promise<unknown[]> {
return new Promise((resolve, reject) => {
const handler = (...args: unknown[]) => {
clearTimeout(timer);
emitter.off(event, handler);
resolve(args);
};
const timer = setTimeout(() => {
emitter.off(event, handler);
reject(new Error(`Timed out waiting for "${event}"`));
}, timeoutMs);
emitter.on(event, handler);
});
}
// ============================================================================
// Item fetching / queue translation
//
// Faithful port of jellyfin-web's `getItemsForPlayback` and
// `translateItemsForPlayback` from `src/plugins/syncPlay/core/Helper.js`.
//
// Why this matters for SyncPlay:
// - The server takes the queue we send via `syncPlaySetNewQueue` and
// broadcasts it verbatim to every group member. If we send a Series /
// Season / BoxSet ID, every receiver tries to load that container as a
// playable item, which silently fails on jellyfin-web (it never opens
// the player). Sending an Episode ID without sibling expansion breaks
// next-episode auto-advance for everyone in the group.
// - jellyfin-web's `playbackManager.play` runs the same translation
// locally; SyncPlay's `Controller.play` runs it before the SetNewQueue
// request so the broadcast carries real playable item IDs.
// - We replicate the same translation here so a mobile sender produces
// the same broadcast a jellyfin-web sender would.
// ============================================================================
/** Options bag accepted by `translateItemsForPlayback`. */
export interface TranslateOptions {
ids?: string[];
shuffle?: boolean;
queryOptions?: Record<string, unknown>;
}
/** Fields jellyfin-web requests for any playback queue. */
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
/** Resolve the current user. Cached only for the duration of one call. */
async function getCurrentUser(api: Api) {
const user = (await getUserApi(api).getCurrentUser()).data;
if (!user?.Id) {
throw new Error("SyncPlay Helper: no authenticated user");
}
return user;
}
/**
* Generic `getItems` wrapper with the playback defaults jellyfin-web uses
* (`Chapters,Trickplay` fields, `ExcludeLocationTypes: Virtual`,
* `CollapseBoxSetItems: false`, `EnableTotalRecordCount: false`, `Limit: 300`).
*
* Callers pass camelCase params straight to the SDK — no PascalCase shim.
*/
async function queryItems(
api: Api,
userId: string,
params: Record<string, unknown>,
): Promise<BaseItemDto[]> {
const res = await getItemsApi(api).getItems({
limit: 300,
fields: PLAYBACK_FIELDS as unknown as never,
excludeLocationTypes: ["Virtual"] as unknown as never,
enableTotalRecordCount: false,
collapseBoxSetItems: false,
...params,
userId,
});
return res.data.Items ?? [];
}
/**
* Recursive "fetch children/tracks under X" — the shape MusicArtist /
* MusicGenre / Photo / PhotoAlbum / IsFolder all share.
*/
function fetchSiblings(
api: Api,
userId: string,
params: Record<string, unknown>,
): Promise<BaseItemDto[]> {
return queryItems(api, userId, {
filters: ["IsNotFolder"],
recursive: true,
...params,
});
}
/**
* Resolve item IDs into full `BaseItemDto`s.
* Mirrors jellyfin-web's `Helper.getItemsForPlayback`:
* - single ID → `getUserLibraryApi.getItem` (cheap)
* - multi ID → `getItemsApi.getItems` with playback defaults
*/
export async function getItemsForPlayback(
api: Api,
ids: string[],
): Promise<BaseItemDto[]> {
if (!ids.length) return [];
const userId = (await getCurrentUser(api)).Id as string;
if (ids.length === 1) {
const res = await getUserLibraryApi(api).getItem({
userId,
itemId: ids[0],
});
return res.data ? [res.data] : [];
}
return queryItems(api, userId, { ids });
}
/**
* Expand a "first item" into a real playable queue.
*
* Mirrors jellyfin-web's `Helper.translateItemsForPlayback`:
* - Program → channel items
* - Playlist → playlist children
* - MusicArtist → artist tracks
* - MusicGenre → genre tracks
* - Photo / PhotoAlbum → sibling photos
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
* - single Episode w/ `EnableNextEpisodeAutoPlay` → remaining series episodes
* - anything else → passthrough (Movies, Audio, single Episodes when autoplay off)
*
* Preserves the original `options.ids` order so the receiver sees the same
* queue order the sender intended.
*/
export async function translateItemsForPlayback(
api: Api,
items: BaseItemDto[],
options: TranslateOptions = {},
): Promise<BaseItemDto[]> {
if (!items.length) return [];
const workingItems =
items.length > 1 && options.ids
? [...items].sort(
(a, b) =>
(options.ids ?? []).indexOf(a.Id ?? "") -
(options.ids ?? []).indexOf(b.Id ?? ""),
)
: items;
const firstItem = workingItems[0];
const defaultSortBy = options.shuffle ? "Random" : "SortName";
// Program → channel's playable items. Doesn't need a user lookup.
if (firstItem.Type === "Program" && firstItem.ChannelId) {
return getItemsForPlayback(api, [firstItem.ChannelId]);
}
// Resolve the user once for every branch that follows. Saves 1-2 round
// trips vs. each helper resolving independently.
const user = await getCurrentUser(api);
const userId = user.Id as string;
if (firstItem.Type === "Playlist") {
return queryItems(api, userId, {
parentId: firstItem.Id,
sortBy: options.shuffle ? ["Random"] : undefined,
});
}
if (firstItem.Type === "MusicArtist") {
return fetchSiblings(api, userId, {
artistIds: firstItem.Id ? [firstItem.Id] : undefined,
mediaTypes: ["Audio"],
sortBy: options.shuffle
? ["Random"]
: ["Album", "ParentIndexNumber", "IndexNumber", "SortName"],
});
}
if (firstItem.MediaType === "Photo") {
const siblings = await fetchSiblings(api, userId, {
parentId: firstItem.ParentId,
recursive: false,
mediaTypes: ["Photo", "Video"],
sortBy: [defaultSortBy],
});
// Re-anchor startIndex to the chosen photo, same as jellyfin-web.
// SyncPlay doesn't currently consume startIndex from queryOptions,
// but we keep parity for any future caller.
if (siblings.length && options.queryOptions) {
const idx = siblings.findIndex((i) => i.Id === firstItem.Id);
if (idx >= 0) options.queryOptions.startIndex = idx;
}
return siblings;
}
if (firstItem.Type === "PhotoAlbum") {
return fetchSiblings(api, userId, {
parentId: firstItem.Id,
recursive: false,
mediaTypes: ["Photo", "Video"],
sortBy: [defaultSortBy],
limit: 1000,
});
}
if (firstItem.Type === "MusicGenre") {
return fetchSiblings(api, userId, {
genreIds: firstItem.Id ? [firstItem.Id] : undefined,
mediaTypes: ["Audio"],
sortBy: [defaultSortBy],
});
}
if (firstItem.IsFolder) {
// Series, Season, BoxSet, MusicAlbum, etc. jellyfin-web only sets
// SortBy for shuffle or BoxSet — everything else inherits server-side
// sort order (typically index/premiere date).
const sortBy = options.shuffle
? ["Random"]
: firstItem.Type === "BoxSet"
? ["SortName"]
: undefined;
return fetchSiblings(api, userId, {
parentId: firstItem.Id,
mediaTypes: ["Audio", "Video"],
sortBy,
});
}
if (firstItem.Type === "Episode" && workingItems.length === 1) {
// Single-episode auto-next: drop everything before this episode so
// playback starts here and auto-advances through the rest of the
// series. Gated on the user's `EnableNextEpisodeAutoPlay` like
// jellyfin-web does.
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
return workingItems;
}
try {
const res = await getTvShowsApi(api).getEpisodes({
seriesId: firstItem.SeriesId,
userId,
isMissing: false,
fields: PLAYBACK_FIELDS as unknown as never,
});
const all = res.data.Items ?? [];
const foundIdx = Math.max(
0,
all.findIndex((e) => e.Id === firstItem.Id),
);
return all.slice(foundIdx);
} catch (error) {
// Don't block playback on a translation failure — fall back to the
// single-item queue the caller already supplied.
console.warn(
"SyncPlay Helper: Episode translation failed, falling back to single item",
error,
);
return workingItems;
}
}
// Everything else (Movie, Audio, ...) plays as-is.
return workingItems;
}

View File

@@ -0,0 +1,797 @@
/**
* SyncPlay Manager
*
* Main orchestrator for SyncPlay functionality.
* Manages group state, coordinates time sync, playback, and queue.
*
* Based on jellyfin-web's Manager.js
*/
import type { Api } from "@jellyfin/sdk";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { toast } from "sonner-native";
import i18n from "@/i18n";
import { EventEmitter, msToTicks } from "./Helper";
import { TimeSyncCore } from "./TimeSyncCore";
import type {
GroupInfoDto,
PlayerControls,
PlayQueueUpdate,
SendCommand,
SyncPlayStats,
} from "./types";
/**
* SyncPlay Manager - Main orchestrator
*/
export class SyncPlayManager extends EventEmitter {
private api: Api;
private timeSyncCore: TimeSyncCore;
// Group state
private groupInfo: GroupInfoDto | null = null;
private syncPlayEnabledAt: Date | null = null;
private syncPlayReady = false;
private queuedCommand: SendCommand | null = null;
private followingGroupPlayback = true;
private lastPlaybackCommand: SendCommand | null = null;
// Pending play/pause request awaiting server broadcast.
// Used to (1) ignore duplicate rapid taps and (2) treat the server's
// upcoming state as the source of truth while a request is in flight.
private pendingPlaybackCommand: "Unpause" | "Pause" | null = null;
private pendingPlaybackTimeout: ReturnType<typeof setTimeout> | null = null;
// Safety: drop the pending guard after this long if no broadcast arrives.
private static readonly PENDING_PLAYBACK_TIMEOUT_MS = 1500;
// Player state
private playerControls: PlayerControls | null = null;
private syncMethod = "None";
// Callbacks
private onPlaybackCoreCommand: ((command: SendCommand) => void) | null = null;
private onQueueUpdate: ((update: PlayQueueUpdate) => void) | null = null;
private onGetPlaylistItemId: (() => string | null) | null = null;
// Fired when SyncPlay is disabled — PlaybackCore wires up here to reset its
// own scheduled timers / cached command so we don't carry stale state into
// the next group.
private onDisable: (() => void) | null = null;
// Fired when SyncPlay is disabled — QueueCore wires up here to clear its
// last PlayQueue snapshot. Without this, re-joining the same group later
// causes the first PlayQueue echo (which can have a `LastUpdate` equal to
// or older than the snapshot we cached before the disable) to be dropped
// by the stale-update guard in `QueueCore.updatePlayQueue`.
private onQueueClear: (() => void) | null = null;
constructor(api: Api) {
super();
this.api = api;
this.timeSyncCore = new TimeSyncCore(api);
// Listen for time sync updates
this.timeSyncCore.onUpdate((error, timeOffset, ping) => {
if (error) {
console.debug("SyncPlay Manager: time sync error", error);
return;
}
this.emit("time-sync-update", timeOffset, ping);
// Report ping to server when enabled
if (this.isSyncPlayEnabled() && ping !== null) {
this.sendPing(ping);
}
});
}
/**
* Initialize the manager
*/
init(): void {
this.timeSyncCore.startPing();
}
/**
* Update the API client
*/
updateApiClient(api: Api): void {
this.api = api;
}
/**
* Get the API client
*/
getApiClient(): Api {
return this.api;
}
/**
* Get the time sync core
*/
getTimeSyncCore(): TimeSyncCore {
return this.timeSyncCore;
}
/**
* Set player controls for playback management
*/
setPlayerControls(controls: PlayerControls | null): void {
this.playerControls = controls;
// When player controls are connected and SyncPlay is active, sync to group state
if (controls && this.isSyncPlayEnabled() && this.syncPlayReady) {
const state = this.groupInfo?.State;
console.log(
`SyncPlay: player controls connected, group state is ${state}`,
);
// CRITICAL: Tell server we're following group playback
// This ensures the server sends us SyncPlayCommand messages
this.followGroupPlayback();
// Reconcile position: if we know the last command and group is playing,
// estimate where the group is *now* and seek there before resuming. This
// fixes the case where the player attaches mid-stream and would
// otherwise resume from 0 or the last-known local position.
const last = this.lastPlaybackCommand;
if (
last &&
(last.Command === "Unpause" || last.Command === "Pause") &&
last.When &&
last.PositionTicks != null
) {
try {
const commandWhen = new Date(last.When);
let targetTicks = last.PositionTicks;
if (last.Command === "Unpause") {
const remoteNow = this.timeSyncCore.localDateToRemote(new Date());
targetTicks +=
(remoteNow.getTime() - commandWhen.getTime()) * 10000;
}
const targetMs = Math.max(0, targetTicks / 10000);
const currentMs = controls.getCurrentPosition();
if (Math.abs(currentMs - targetMs) > 500) {
console.log(
`SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
);
controls.seekTo(targetMs);
}
} catch (error) {
console.warn(
"SyncPlay: failed to estimate group position on attach",
error,
);
}
}
if (state === "Playing" && !controls.isPlaying()) {
console.log("SyncPlay: starting playback to match group");
controls.play();
} else if (state === "Paused" && controls.isPlaying()) {
console.log("SyncPlay: pausing to match group");
controls.pause();
}
}
}
/**
* Get current player controls
*/
getPlayerControls(): PlayerControls | null {
return this.playerControls;
}
/**
* Set callback for playback commands
*/
setPlaybackCommandHandler(
handler: ((command: SendCommand) => void) | null,
): void {
this.onPlaybackCoreCommand = handler;
}
/**
* Set callback for queue updates
*/
setQueueUpdateHandler(
handler: ((update: PlayQueueUpdate) => void) | null,
): void {
this.onQueueUpdate = handler;
}
/**
* Set callback for getting current playlist item ID
*/
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
this.onGetPlaylistItemId = getter;
}
/**
* Set a callback invoked when SyncPlay is disabled. PlaybackCore registers
* here so it can flush scheduled commands and stale state.
*/
setDisableHandler(handler: (() => void) | null): void {
this.onDisable = handler;
}
/**
* Set a callback invoked when SyncPlay is disabled. QueueCore registers
* here so it can drop the cached PlayQueue snapshot and treat the next
* server update as fresh.
*/
setQueueClearHandler(handler: (() => void) | null): void {
this.onQueueClear = handler;
}
// ============================================================================
// Group Management
// ============================================================================
/**
* Check if SyncPlay is enabled (user is in a group)
*/
isSyncPlayEnabled(): boolean {
return this.syncPlayEnabledAt !== null;
}
/**
* Check if SyncPlay is ready (time sync complete)
*/
isSyncPlayReady(): boolean {
return this.syncPlayReady;
}
/**
* Get current group info
*/
getGroupInfo(): GroupInfoDto | null {
return this.groupInfo;
}
/**
* Get the last playback command
*/
getLastPlaybackCommand(): SendCommand | null {
return this.lastPlaybackCommand;
}
/**
* Check if currently playing
*/
isPlaying(): boolean {
// First check actual player state
if (this.playerControls) {
return this.playerControls.isPlaying();
}
// Fall back to group state
if (this.groupInfo?.State) {
return this.groupInfo.State === "Playing";
}
// Last resort: check last command
return this.lastPlaybackCommand?.Command === "Unpause";
}
/**
* Effective play state for SyncPlay routing decisions.
*
* Prefers (1) a pending in-flight command we just sent, (2) the server's
* group state, and only falls back to the local player. This avoids the
* race where a rapid second tap reads the local player (which hasn't
* applied the scheduled command yet) and sends a duplicate request that
* either re-broadcasts with a new `When` or flips the group the wrong way.
*/
getEffectivePlayState(): "Playing" | "Paused" {
if (this.pendingPlaybackCommand === "Unpause") return "Playing";
if (this.pendingPlaybackCommand === "Pause") return "Paused";
if (this.groupInfo?.State === "Playing") return "Playing";
if (this.groupInfo?.State === "Paused") return "Paused";
return this.playerControls?.isPlaying() ? "Playing" : "Paused";
}
/**
* Returns the in-flight play/pause request, if any.
*/
getPendingPlaybackCommand(): "Unpause" | "Pause" | null {
return this.pendingPlaybackCommand;
}
/**
* Mark a play/pause request as in flight. Auto-clears on a safety timeout
* in case the server broadcast is missed.
*/
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
this.pendingPlaybackCommand = command;
if (this.pendingPlaybackTimeout) {
clearTimeout(this.pendingPlaybackTimeout);
}
this.pendingPlaybackTimeout = setTimeout(() => {
console.debug(
"SyncPlay Manager: pending playback command timed out",
command,
);
this.pendingPlaybackCommand = null;
this.pendingPlaybackTimeout = null;
this.emit("pending-playback-change", null);
}, SyncPlayManager.PENDING_PLAYBACK_TIMEOUT_MS);
this.emit("pending-playback-change", command);
}
private clearPendingPlaybackCommand(): void {
if (this.pendingPlaybackTimeout) {
clearTimeout(this.pendingPlaybackTimeout);
this.pendingPlaybackTimeout = null;
}
if (this.pendingPlaybackCommand !== null) {
this.pendingPlaybackCommand = null;
this.emit("pending-playback-change", null);
}
}
/**
* Check if following group playback
*/
isFollowingGroupPlayback(): boolean {
return this.followingGroupPlayback;
}
/**
* Enable SyncPlay (join a group)
*/
enableSyncPlay(groupInfo: GroupInfoDto, showMessage = false): void {
if (this.isSyncPlayEnabled()) {
if (groupInfo.GroupId === this.groupInfo?.GroupId) {
console.debug(
`SyncPlay: group ${this.groupInfo?.GroupId} already joined.`,
);
return;
}
console.warn(
`SyncPlay: switching from group ${this.groupInfo?.GroupId} to ${groupInfo.GroupId}`,
);
this.disableSyncPlay(false);
}
this.groupInfo = groupInfo;
this.syncPlayEnabledAt = groupInfo.LastUpdatedAt
? new Date(groupInfo.LastUpdatedAt)
: new Date();
this.followingGroupPlayback = true;
this.syncPlayReady = false;
console.log(`SyncPlay: enableSyncPlay - group state is ${groupInfo.State}`);
this.emit("enabled", true);
// Wait for time sync to be ready
const checkReady = () => {
if (this.timeSyncCore.isReady()) {
this.syncPlayReady = true;
// CRITICAL: Tell server we're following group playback
// This ensures the server sends us SyncPlayCommand messages
this.followGroupPlayback();
if (this.queuedCommand) {
this.processCommand(this.queuedCommand);
this.queuedCommand = null;
}
// Act on initial group state if player is connected
if (this.playerControls && groupInfo.State) {
console.log(`SyncPlay: applying initial state ${groupInfo.State}`);
if (groupInfo.State === "Playing") {
this.playerControls.play();
} else if (groupInfo.State === "Paused") {
this.playerControls.pause();
}
}
} else {
setTimeout(checkReady, 100);
}
};
this.timeSyncCore.forceUpdate();
checkReady();
if (showMessage) {
toast(i18n.t("syncplay.enabled"));
}
}
/**
* Disable SyncPlay (leave group)
*/
disableSyncPlay(showMessage = false): void {
this.syncPlayEnabledAt = null;
this.syncPlayReady = false;
this.followingGroupPlayback = true;
this.lastPlaybackCommand = null;
this.queuedCommand = null;
this.groupInfo = null;
this.clearPendingPlaybackCommand();
// Tell PlaybackCore (or whoever subscribed) to flush any scheduled
// commands / cached state so a future re-enable starts clean.
try {
this.onDisable?.();
} catch (error) {
console.warn("SyncPlay: onDisable handler threw", error);
}
// Drop the cached PlayQueue snapshot so a future re-join doesn't get
// its first PlayQueue update silently dropped as "older than what we
// already have".
try {
this.onQueueClear?.();
} catch (error) {
console.warn("SyncPlay: onQueueClear handler threw", error);
}
this.emit("enabled", false);
if (showMessage) {
toast(i18n.t("syncplay.disabled"));
}
}
// ============================================================================
// Server Communication
// ============================================================================
/**
* Send ping to server
*/
private async sendPing(ping: number): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlayPing({
pingRequestDto: { Ping: Math.round(ping) },
});
} catch (error) {
console.debug("SyncPlay: failed to send ping", error);
}
}
/**
* Report that we're ready (not buffering)
*/
async reportReady(): Promise<void> {
try {
const syncPlayApi = getSyncPlayApi(this.api);
const now = new Date();
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
const currentPositionTicks = msToTicks(currentPosition);
console.log(
"SyncPlay Manager: reporting READY at position",
currentPositionTicks,
);
await syncPlayApi.syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: this.playerControls?.isPlaying() ?? false,
PlaylistItemId:
this.onGetPlaylistItemId?.() ??
"00000000-0000-0000-0000-000000000000",
},
});
console.log("SyncPlay Manager: READY sent successfully");
} catch (error) {
console.error("SyncPlay Manager: failed to report ready", error);
}
}
/**
* Follow group playback
*/
async followGroupPlayback(): Promise<void> {
this.followingGroupPlayback = true;
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetIgnoreWait({
ignoreWaitRequestDto: { IgnoreWait: false },
});
} catch (error) {
console.error("SyncPlay: failed to follow group playback", error);
}
}
/**
* Halt group playback (stop following)
*/
async haltGroupPlayback(): Promise<void> {
this.followingGroupPlayback = false;
try {
const syncPlayApi = getSyncPlayApi(this.api);
await syncPlayApi.syncPlaySetIgnoreWait({
ignoreWaitRequestDto: { IgnoreWait: true },
});
// Stop local playback
this.playerControls?.pause();
} catch (error) {
console.error("SyncPlay: failed to halt group playback", error);
}
}
// ============================================================================
// Message Processing
// ============================================================================
/**
* Process a group update from the server
* Uses generic type to handle all possible update types from server
*/
processGroupUpdate(update: { Type?: string; Data?: unknown }): void {
const { Type, Data } = update;
switch (Type) {
case "PlayQueue": {
const playQueueData = Data as PlayQueueUpdate;
console.log(
"SyncPlay: received PlayQueue update - position:",
playQueueData.StartPositionTicks,
"reason:",
playQueueData.Reason,
);
this.onQueueUpdate?.(playQueueData);
break;
}
case "UserJoined":
toast(i18n.t("syncplay.user_joined", { username: Data }));
if (this.groupInfo) {
if (!this.groupInfo.Participants) {
this.groupInfo.Participants = [Data as string];
} else {
this.groupInfo.Participants.push(Data as string);
}
}
break;
case "UserLeft":
toast(i18n.t("syncplay.user_left", { username: Data }));
if (this.groupInfo?.Participants) {
this.groupInfo.Participants = this.groupInfo.Participants.filter(
(user: string) => user !== Data,
);
}
break;
case "GroupJoined": {
const groupData = Data as GroupInfoDto;
this.enableSyncPlay(groupData, true);
break;
}
case "SyncPlayIsDisabled":
toast(i18n.t("syncplay.permission_required"));
break;
case "NotInGroup":
case "GroupLeft":
this.disableSyncPlay(true);
break;
case "GroupUpdate": {
const updatedData = Data as GroupInfoDto;
this.groupInfo = updatedData;
this.emit("group-info-change", updatedData);
break;
}
case "StateUpdate": {
// Log full state data to see if position is included
console.log("SyncPlay: StateUpdate full data:", JSON.stringify(Data));
const stateData = Data as {
State: string;
Reason: string;
PositionTicks?: number;
};
// CRITICAL: Update the stored group state so subsequent checks use the correct value
if (this.groupInfo) {
this.groupInfo.State = stateData.State as any;
// Emit a fresh object so React state subscribers re-render —
// mutating in place would not trigger re-renders.
this.emit("group-info-change", { ...this.groupInfo });
}
this.emit("group-state-update", stateData.State, stateData.Reason);
console.log(
`SyncPlay: state changed to ${stateData.State} because ${stateData.Reason}`,
);
// Handle seek from StateUpdate if position is included
if (stateData.Reason === "Seek" && stateData.PositionTicks != null) {
console.log(
"SyncPlay: StateUpdate contains seek position:",
stateData.PositionTicks,
);
this.emit("seek-from-state-update", stateData.PositionTicks);
}
// Use StateUpdate as a fallback to control playback when SyncPlayCommand isn't received
// This ensures we stay in sync even if the server doesn't send commands
if (this.playerControls) {
const currentlyPlaying = this.playerControls.isPlaying();
console.log(
`SyncPlay: StateUpdate handler - state=${stateData.State}, currentlyPlaying=${currentlyPlaying}`,
);
if (stateData.State === "Paused" && currentlyPlaying) {
console.log("SyncPlay: StateUpdate -> PAUSING player");
this.playerControls.pause();
} else if (stateData.State === "Playing" && !currentlyPlaying) {
console.log("SyncPlay: StateUpdate -> PLAYING");
this.playerControls.play();
} else if (stateData.State === "Waiting") {
console.log("SyncPlay: StateUpdate -> Waiting for other members");
// Pause player when waiting
if (currentlyPlaying) {
this.playerControls.pause();
}
// Emit event so PlaybackCore can report ready
this.emit("waiting-for-ready");
}
} else {
console.warn("SyncPlay: StateUpdate but no playerControls!");
}
break;
}
case "GroupDoesNotExist":
toast(i18n.t("syncplay.group_does_not_exist"));
break;
case "CreateGroupDenied":
toast(i18n.t("syncplay.create_denied"));
break;
case "JoinGroupDenied":
toast(i18n.t("syncplay.join_denied"));
break;
case "LibraryAccessDenied":
toast(i18n.t("syncplay.library_access_denied"));
break;
default:
console.warn(`SyncPlay: unrecognized group update type: ${Type}`);
}
}
/**
* Process a playback command from the server
*/
processCommand(command: SendCommand): void {
console.log(`SyncPlay Manager: processCommand called - ${command.Command}`);
if (!this.isSyncPlayEnabled()) {
console.warn(
"SyncPlay Manager: not enabled, ignoring command",
command.Command,
);
return;
}
const emittedAt = command.EmittedAt ? new Date(command.EmittedAt) : null;
if (this.syncPlayEnabledAt && emittedAt) {
if (emittedAt.getTime() < this.syncPlayEnabledAt.getTime()) {
console.debug("SyncPlay Manager: ignoring old command", command);
return;
}
}
// Reject commands targeted at a different playlist item than the one we
// currently have loaded. Stop is always honored (it may be a teardown
// before a queue swap). This prevents (e.g.) seeking the wrong episode
// when a queue change is racing a command.
if (command.Command !== "Stop" && command.PlaylistItemId) {
const currentItemId = this.onGetPlaylistItemId?.();
if (currentItemId && currentItemId !== command.PlaylistItemId) {
console.debug(
`SyncPlay Manager: ignoring command for playlist item ${command.PlaylistItemId} (current is ${currentItemId})`,
);
return;
}
}
if (!this.syncPlayReady) {
console.log(
"SyncPlay Manager: not ready, queuing command",
command.Command,
);
this.queuedCommand = command;
return;
}
// Remember the command even if we can't act on it yet. When the player
// attaches (setPlayerControls), the reconcile-on-attach path uses
// `lastPlaybackCommand` to seek to the estimated group position and
// resume/pause to match the group. Without this assignment, a command
// that arrives during the join→navigate→load window is lost.
this.lastPlaybackCommand = command;
// Clear pending guard once the matching broadcast arrives. We treat any
// Unpause/Pause arrival as satisfying the pending request (the server
// may coalesce or override our intent — either way we trust its decision).
if (command.Command === "Unpause" || command.Command === "Pause") {
this.clearPendingPlaybackCommand();
}
if (!this.playerControls) {
// Expected when a command arrives between joining the group and the
// player finishing its initial load. The reconciliation in
// setPlayerControls will replay this command from `lastPlaybackCommand`
// once controls attach.
console.debug(
`SyncPlay Manager: ${command.Command} stored for replay (player not attached yet)`,
);
return;
}
console.log(
`SyncPlay Manager: delegating ${command.Command} to playback core`,
);
// Delegate to playback handler
if (this.onPlaybackCoreCommand) {
this.onPlaybackCoreCommand(command);
} else {
console.error("SyncPlay Manager: no playback command handler set!");
}
}
// ============================================================================
// Stats
// ============================================================================
/**
* Get SyncPlay stats for display
*/
getStats(): SyncPlayStats {
return {
timeSyncDevice: this.timeSyncCore.getActiveDeviceName(),
timeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2),
playbackDiff: "0.00",
syncMethod: this.syncMethod,
};
}
/**
* Show sync icon
*/
showSyncIcon(method: string): void {
this.syncMethod = method;
this.emit("syncing", true, method);
}
/**
* Clear sync icon
*/
clearSyncIcon(): void {
this.syncMethod = "None";
this.emit("syncing", false, "None");
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Destroy the manager
*/
destroy(): void {
this.timeSyncCore.destroy();
this.disableSyncPlay(false);
this.removeAllListeners();
this.playerControls = null;
this.onPlaybackCoreCommand = null;
this.onQueueUpdate = null;
}
}

View File

@@ -0,0 +1,881 @@
/**
* PlaybackCore
*
* Manages synchronized playback for SyncPlay.
* Handles scheduling commands at precise times and sync correction.
*
* Based on jellyfin-web's PlaybackCore.js
*/
import type { Api } from "@jellyfin/sdk";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { EventEmitter, msToTicks, ticksToMs, waitForOwnEvent } from "./Helper";
import type { TimeSyncCore } from "./TimeSyncCore";
import type {
PlayerControls,
SendCommand,
SyncPlayOsdAction,
SyncPlaySettings,
} from "./types";
import { TicksPerMillisecond } from "./types";
// Random offset added when re-issuing a duplicate Seek to force the player
// off-position so the next sync correction has something to chew on. Matches
// jellyfin-web's behavior (server tolerates a range, so we deliberately land
// just outside it).
const SEEK_FORCE_RANGE_MS = 100;
// Timeout for waiting on the local player's "ready" event after seek.
// Matches jellyfin-web's Helper.WaitForEventDefaultTimeout.
const WAIT_FOR_READY_TIMEOUT_MS = 30000;
// How close player position must be to command position to consider it
// already in the correct place (fuzz to account for player imprecision).
const POSITION_MATCH_TOLERANCE_MS = 500;
/**
* PlaybackCore - Handles synchronized playback
*/
export class PlaybackCore extends EventEmitter {
private api: Api;
private timeSyncCore: TimeSyncCore;
private playerControls: PlayerControls | null = null;
// Sync state
private syncEnabled = false;
private playbackDiffMillis = 0;
private syncAttempts = 0;
private lastSyncTime = new Date();
private playerIsBuffering = false;
// Command tracking
private lastCommand: SendCommand | null = null;
private scheduledCommandTimeout: ReturnType<typeof setTimeout> | null = null;
private syncTimeout: ReturnType<typeof setTimeout> | null = null;
// Last buffering state we reported to the server. Used to dedupe
// sendBufferingRequest so we only send on actual transitions —
// jellyfin-web gets this for free from the HTML5 `waiting`/`canplay`
// events, but our player exposes state, not events, and the React
// effect that drives notifyReady/notifyBuffering can re-run many times
// per second during normal playback. Without this guard we flood the
// server with redundant Ready/Buffering requests.
private lastBufferingSent: boolean | null = null;
private inflightBufferingRequest: Promise<void> | null = null;
// Debounce buffering notifications, matching jellyfin-web's
// `minBufferingThresholdMillis = 3000` in HtmlVideoPlayer.js. A short
// re-buffer blip (<3s) shouldn't notify the server at all — there's no
// reason to pause the whole group for a hiccup that resolves itself.
// Going Ready cancels any pending buffering notification.
private notifyBufferingTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly minBufferingThresholdMillis = 3000;
// Set to true by `scheduleReadyRequestOnPlaybackStart` whenever a new
// SyncPlay queue starts loading (NewPlaylist / SetCurrentItem / NextItem
// / PreviousItem). On the next `onReady` we pause the player BEFORE
// sending SyncPlayReady so the server sees us as `IsPlaying:false`,
// parked at the start position, awaiting an Unpause command. Mirrors
// jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart` which
// registers a one-shot `playbackstart` listener that does the same.
// Without this the receiver's player auto-plays the moment it loads and
// the group's Unpause command arrives to an already-playing player —
// which leaves the receiver subtly out of sync with the sender (or, on
// slower devices, stuck on a blank loading screen because the early
// play attempt races the media load and never recovers).
private pendingInitialPause = false;
// Settings
private minDelaySpeedToSync = 60.0;
private maxDelaySpeedToSync = 3000.0;
private speedToSyncDuration = 1000.0;
private minDelaySkipToSync = 400.0;
private useSpeedToSync = true;
private useSkipToSync = true;
private enableSyncCorrection = false;
// Callbacks
private onNotifyOsd: ((action: SyncPlayOsdAction) => void) | null = null;
private getCurrentPlaylistItemId: (() => string | null) | null = null;
constructor(api: Api, timeSyncCore: TimeSyncCore) {
super();
this.api = api;
this.timeSyncCore = timeSyncCore;
}
/**
* Set player controls
*/
setPlayerControls(controls: PlayerControls | null): void {
this.playerControls = controls;
// A new (or detached) player means the server's view of our ready
// state is stale — drop the dedupe latch so the next notifyReady /
// notifyBuffering actually reaches the server.
this.lastBufferingSent = null;
}
/**
* Set OSD notification handler
*/
setOsdHandler(handler: ((action: SyncPlayOsdAction) => void) | null): void {
this.onNotifyOsd = handler;
}
/**
* Set playlist item ID getter
*/
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
this.getCurrentPlaylistItemId = getter;
}
/**
* Load settings
*/
loadSettings(settings: Partial<SyncPlaySettings>): void {
if (settings.minDelaySpeedToSync !== undefined) {
this.minDelaySpeedToSync = settings.minDelaySpeedToSync;
}
if (settings.maxDelaySpeedToSync !== undefined) {
this.maxDelaySpeedToSync = settings.maxDelaySpeedToSync;
}
if (settings.speedToSyncDuration !== undefined) {
this.speedToSyncDuration = settings.speedToSyncDuration;
}
if (settings.minDelaySkipToSync !== undefined) {
this.minDelaySkipToSync = settings.minDelaySkipToSync;
}
if (settings.useSpeedToSync !== undefined) {
this.useSpeedToSync = settings.useSpeedToSync;
}
if (settings.useSkipToSync !== undefined) {
this.useSkipToSync = settings.useSkipToSync;
}
if (settings.enableSyncCorrection !== undefined) {
this.enableSyncCorrection = settings.enableSyncCorrection;
}
}
// ============================================================================
// Player Events
// ============================================================================
/**
* Called when playback starts
*/
onPlaybackStart(): void {
this.emit("playbackstart");
}
/**
* Called when playback stops
*/
onPlaybackStop(): void {
this.lastCommand = null;
this.emit("playbackstop");
}
/**
* Called when player is ready to play
*/
onReady(): void {
this.playerIsBuffering = false;
// Cancel any pending buffering notification — we're ready before the
// 3s threshold fired, so the server never needs to know we hiccupped.
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
this.notifyBufferingTimeout = null;
}
// If we're handling the first ready event after a queue change,
// pause the player BEFORE reporting ready. The subsequent
// `sendBufferingRequest(false)` will then read `isPlaying() === false`
// and the server will hold the group until we receive an Unpause.
if (this.pendingInitialPause) {
this.pendingInitialPause = false;
if (this.playerControls?.isPlaying()) {
console.log(
"SyncPlay PlaybackCore: pausing on initial ready (awaiting group Unpause)",
);
this.playerControls.pause();
}
}
this.sendBufferingRequest(false);
this.emit("ready");
}
/**
* Mark the next `onReady` call as the initial ready for a new queue
* item. The player will be paused before SyncPlayReady is sent so the
* server keeps the group in `Waiting` until our Unpause arrives.
*
* Mirrors jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart`.
* Called by the provider when a PlayQueue update is `NewPlaylist`,
* `SetCurrentItem`, `NextItem`, or `PreviousItem`.
*/
scheduleReadyRequestOnPlaybackStart(): void {
this.pendingInitialPause = true;
}
/**
* Called when player is buffering
*/
onBuffering(): void {
// Debounce: only flip into "buffering" state (and notify the server)
// if the stall lasts longer than minBufferingThresholdMillis. Mirrors
// jellyfin-web's HtmlVideoPlayer.js `_onWaiting` handler, which only
// calls `onBuffering()` after the 3s timeout elapses. Keeping
// playerIsBuffering=false during brief blips lets sync correction
// continue to run normally.
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
}
this.notifyBufferingTimeout = setTimeout(() => {
this.notifyBufferingTimeout = null;
this.playerIsBuffering = true;
this.sendBufferingRequest(true);
this.emit("buffering");
}, this.minBufferingThresholdMillis);
}
/**
* Check if player is buffering
*/
isBuffering(): boolean {
return this.playerIsBuffering;
}
/**
* Get playback difference in milliseconds
*/
getPlaybackDiff(): number {
return this.playbackDiffMillis;
}
// ============================================================================
// Server Communication
// ============================================================================
/**
* Send buffering/ready request to server.
*
* NOTE: This must work even before player controls are bound, so that we
* can signal "I'm not ready yet, hold the group" while the video is still
* loading. jellyfin-web's HTML5 player gets this for free via the
* `waiting` event firing during initial buffering; we don't bind controls
* until the video is loaded, so we synthesize a position=0 buffering
* signal in the pre-bind window.
*/
async sendBufferingRequest(isBuffering: boolean): Promise<void> {
if (!this.api) {
console.warn("SyncPlay PlaybackCore: no api for buffering request");
return;
}
// Skip if the desired state matches what we last sent. Without this,
// the React effect that drives notifyReady/notifyBuffering will flood
// the server every time the video player's isBuffering momentarily
// toggles during normal playback.
if (this.lastBufferingSent === isBuffering) {
return;
}
// Coalesce: if a request is already in flight, wait for it. This
// prevents racing two requests when state flips rapidly.
if (this.inflightBufferingRequest) {
await this.inflightBufferingRequest;
// Re-check after the in-flight request settled — the new state may
// already match.
if (this.lastBufferingSent === isBuffering) {
return;
}
}
const request = this.doSendBufferingRequest(isBuffering);
this.inflightBufferingRequest = request;
try {
await request;
this.lastBufferingSent = isBuffering;
} finally {
if (this.inflightBufferingRequest === request) {
this.inflightBufferingRequest = null;
}
}
}
private async doSendBufferingRequest(isBuffering: boolean): Promise<void> {
if (!this.api) return;
try {
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
const currentPositionTicks = msToTicks(currentPosition);
const isPlaying = this.playerControls?.isPlaying() ?? false;
const now = this.timeSyncCore.localDateToRemote(new Date());
const playlistItemId = this.getCurrentPlaylistItemId?.() ?? null;
const syncPlayApi = getSyncPlayApi(this.api);
console.log(
`SyncPlay PlaybackCore: sending ${isBuffering ? "BUFFERING" : "READY"} to server`,
{
position: currentPositionTicks,
playlistItemId,
hasPlayerControls: !!this.playerControls,
},
);
if (isBuffering) {
await syncPlayApi.syncPlayBuffering({
bufferRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId ?? undefined,
},
});
} else {
await syncPlayApi.syncPlayReady({
readyRequestDto: {
When: now.toISOString(),
PositionTicks: currentPositionTicks,
IsPlaying: isPlaying,
PlaylistItemId: playlistItemId ?? undefined,
},
});
}
console.log(
`SyncPlay PlaybackCore: ${isBuffering ? "BUFFERING" : "READY"} sent successfully`,
);
} catch (error) {
console.error("SyncPlay: failed to send buffering request", error);
// On failure, clear the dedupe latch so the next attempt actually
// re-sends rather than getting stuck thinking the server knows.
throw error;
}
}
// ============================================================================
// Command Handling
// ============================================================================
/**
* Apply a playback command
*/
async applyCommand(command: SendCommand): Promise<void> {
console.log(`SyncPlay PlaybackCore: applyCommand - ${command.Command}`);
// Parse the When time from string
const commandWhen = command.When ? new Date(command.When) : new Date();
const positionTicks = command.PositionTicks ?? 0;
// Duplicate command handling — don't blindly skip. Match jellyfin-web:
// if the duplicate's scheduled time has already passed and local player
// state doesn't match, re-apply (with a force-offset for seek). This
// self-heals after a missed broadcast, reconnect, or local drift.
if (this.lastCommand?.When) {
const lastWhen = new Date(this.lastCommand.When);
if (
lastWhen.getTime() === commandWhen.getTime() &&
this.lastCommand.PositionTicks === command.PositionTicks &&
this.lastCommand.Command === command.Command &&
this.lastCommand.PlaylistItemId === command.PlaylistItemId
) {
const whenLocal = this.timeSyncCore.remoteDateToLocal(commandWhen);
if (whenLocal > new Date()) {
// Still in the future — already scheduled, nothing to do.
console.debug(
"SyncPlay PlaybackCore: duplicate (still scheduled), skipping",
);
return;
}
if (!this.playerControls) {
console.debug(
"SyncPlay PlaybackCore: duplicate past command but no player",
);
return;
}
const currentPositionMs = this.playerControls.getCurrentPosition();
const isPlaying = this.playerControls.isPlaying();
const targetMs = ticksToMs(positionTicks);
const positionMatches =
Math.abs(currentPositionMs - targetMs) <= POSITION_MATCH_TOLERANCE_MS;
switch (command.Command) {
case "Unpause":
if (!isPlaying) {
console.debug("SyncPlay PlaybackCore: dup Unpause — reconciling");
await this.scheduleUnpause(commandWhen, positionTicks);
}
return;
case "Pause":
if (isPlaying || !positionMatches) {
console.debug("SyncPlay PlaybackCore: dup Pause — reconciling");
this.schedulePause(commandWhen, positionTicks);
}
return;
case "Stop":
if (isPlaying) {
console.debug("SyncPlay PlaybackCore: dup Stop — reconciling");
this.scheduleStop(commandWhen);
}
return;
case "Seek": {
if (!isPlaying && positionMatches) {
// Already paused at target — just confirm ready.
this.sendBufferingRequest(false);
return;
}
// Force a re-seek with a small random offset so the player
// actually moves (server tolerates a range).
const randomOffsetTicks =
Math.round((Math.random() - 0.5) * SEEK_FORCE_RANGE_MS) *
TicksPerMillisecond;
console.debug(
`SyncPlay PlaybackCore: dup Seek — reconciling with offset ${randomOffsetTicks} ticks`,
);
this.scheduleSeek(commandWhen, positionTicks + randomOffsetTicks);
return;
}
default:
console.error(
"SyncPlay PlaybackCore: unrecognized duplicate command",
command,
);
return;
}
}
}
this.lastCommand = command;
if (!this.playerControls) {
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS!");
return;
}
console.log(
`SyncPlay PlaybackCore: executing ${command.Command} scheduled for ${commandWhen.toISOString()}`,
);
switch (command.Command) {
case "Unpause":
await this.scheduleUnpause(commandWhen, positionTicks);
break;
case "Pause":
this.schedulePause(commandWhen, positionTicks);
break;
case "Stop":
this.scheduleStop(commandWhen);
break;
case "Seek":
this.scheduleSeek(commandWhen, positionTicks);
break;
default:
console.error("SyncPlay PlaybackCore: unrecognized command", command);
}
}
/**
* Schedule an unpause at a specific time
*/
private async scheduleUnpause(
playAtTime: Date,
positionTicks: number,
): Promise<void> {
this.clearScheduledCommand();
const currentTime = new Date();
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
const positionMs = ticksToMs(positionTicks);
if (playAtTimeLocal > currentTime) {
// Future command - schedule it
const playTimeout = playAtTimeLocal.getTime() - currentTime.getTime();
// Pre-seek only when we're AHEAD of the target by more than the skip
// threshold. If we're behind, the unpause itself plays forward and
// SkipToSync/SpeedToSync will catch us up — forward-seeking now would
// just cause needless buffering. (Matches jellyfin-web.)
const currentPositionMs = this.playerControls?.getCurrentPosition() ?? 0;
const aheadByMs = currentPositionMs - positionMs;
console.log(
`SyncPlay: pre-seek check - current=${currentPositionMs}ms, target=${positionMs}ms, aheadBy=${aheadByMs}ms, threshold=${this.minDelaySkipToSync}ms`,
);
if (aheadByMs > this.minDelaySkipToSync) {
console.log(`SyncPlay: PRE-SEEKING back to ${positionMs}ms`);
this.localSeek(positionMs);
}
this.scheduledCommandTimeout = setTimeout(() => {
this.localUnpause();
this.onNotifyOsd?.("unpause");
// Enable sync after a delay
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, this.maxDelaySpeedToSync / 2);
}, playTimeout);
console.debug(`SyncPlay: scheduled unpause in ${playTimeout}ms`);
} else {
// Past command - play immediately and seek to estimated position
const elapsed = currentTime.getTime() - playAtTimeLocal.getTime();
const serverPositionTicks = positionTicks + elapsed * TicksPerMillisecond;
const serverPositionMs = ticksToMs(serverPositionTicks);
this.localUnpause();
this.localSeek(serverPositionMs);
setTimeout(() => {
this.onNotifyOsd?.("unpause");
}, 100);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
}, this.maxDelaySpeedToSync / 2);
console.debug(`SyncPlay: immediate unpause at ${serverPositionMs}ms`);
}
}
/**
* Schedule a pause at a specific time
*/
private schedulePause(pauseAtTime: Date, positionTicks: number): void {
console.log("SyncPlay PlaybackCore: schedulePause called");
this.clearScheduledCommand();
const currentTime = new Date();
const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime);
const positionMs = ticksToMs(positionTicks);
const callback = () => {
console.log("SyncPlay PlaybackCore: EXECUTING PAUSE NOW");
// If we're already paused at the target position, do nothing.
// jellyfin-web gets this for free because HTML5 video's seekTo is a
// no-op when the target equals currentTime, and pause() is a no-op
// when already paused. Our PlayerControls.seekTo always actually
// seeks, which triggers waiting→canplay and a notifyBuffering →
// notifyReady cycle. The server reacts by re-sending Pause, which
// re-enters this callback → infinite feedback loop. Guarding here
// breaks the loop while preserving normal pause behaviour.
if (this.playerControls) {
const isPlaying = this.playerControls.isPlaying();
const currentPositionMs = this.playerControls.getCurrentPosition();
const positionMatches =
positionMs <= 100 ||
Math.abs(currentPositionMs - positionMs) <=
POSITION_MATCH_TOLERANCE_MS;
if (!isPlaying && positionMatches) {
console.debug(
"SyncPlay PlaybackCore: already paused at target position, skipping",
);
this.onNotifyOsd?.("pause");
return;
}
}
this.localPause();
// Only seek if we have a valid position (not 0 or very small)
if (positionMs > 100) {
this.localSeek(positionMs);
} else {
console.log("SyncPlay PlaybackCore: skipping seek (no valid position)");
}
this.onNotifyOsd?.("pause");
};
if (pauseAtTimeLocal > currentTime) {
const pauseTimeout = pauseAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout);
console.log(
`SyncPlay PlaybackCore: scheduled pause in ${pauseTimeout}ms`,
);
} else {
console.log("SyncPlay PlaybackCore: immediate pause (past time)");
callback();
}
}
/**
* Schedule a stop at a specific time
*/
private scheduleStop(stopAtTime: Date): void {
this.clearScheduledCommand();
const currentTime = new Date();
const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime);
const callback = () => {
this.localStop();
};
if (stopAtTimeLocal > currentTime) {
const stopTimeout = stopAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, stopTimeout);
console.debug(`SyncPlay: scheduled stop in ${stopTimeout}ms`);
} else {
callback();
console.debug("SyncPlay: immediate stop");
}
}
/**
* Schedule a seek at a specific time.
*
* Flow (matches jellyfin-web): unpause -> seek -> wait for local "ready"
* (player finished buffering at the new position) -> pause and report ready
* to the server so the group can resume. This handles the common case
* where the player must rebuffer after the seek.
*/
private scheduleSeek(seekAtTime: Date, positionTicks: number): void {
this.clearScheduledCommand();
const currentTime = new Date();
const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime);
const positionMs = ticksToMs(positionTicks);
const callback = () => {
this.localUnpause();
this.localSeek(positionMs);
this.onNotifyOsd?.("seek");
// Wait for the local player to report ready ("onReady" fires this),
// then pause and tell the server we're ready at the new position.
waitForOwnEvent(this, "ready", WAIT_FOR_READY_TIMEOUT_MS)
.then(() => {
this.localPause();
this.sendBufferingRequest(false);
})
.catch((error) => {
console.warn(
`SyncPlay: seek ready-wait timed out, seeking to ${positionMs}ms anyway`,
error,
);
this.localSeek(positionMs);
});
};
if (seekAtTimeLocal > currentTime) {
const seekTimeout = seekAtTimeLocal.getTime() - currentTime.getTime();
this.scheduledCommandTimeout = setTimeout(callback, seekTimeout);
console.debug(`SyncPlay: scheduled seek in ${seekTimeout}ms`);
} else {
callback();
console.debug("SyncPlay: immediate seek");
}
}
/**
* Clear scheduled command
*/
private clearScheduledCommand(): void {
if (this.scheduledCommandTimeout) {
clearTimeout(this.scheduledCommandTimeout);
this.scheduledCommandTimeout = null;
}
if (this.syncTimeout) {
clearTimeout(this.syncTimeout);
this.syncTimeout = null;
}
this.syncEnabled = false;
// Reset playback rate
if (this.playerControls && this.playerControls.getSpeed() !== 1.0) {
this.playerControls.setSpeed(1.0);
}
this.emit("syncing", false, "None");
}
// ============================================================================
// Local Playback Control
// ============================================================================
private localUnpause(): void {
this.playerControls?.play();
}
private localPause(): void {
this.playerControls?.pause();
}
private localSeek(positionMs: number): void {
console.log(`SyncPlay PlaybackCore: localSeek to ${positionMs}ms`);
if (this.playerControls) {
this.playerControls.seekTo(positionMs);
console.log("SyncPlay PlaybackCore: seekTo called on playerControls");
} else {
console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS for seek!");
}
}
private localStop(): void {
this.playerControls?.pause();
}
// ============================================================================
// Time Sync
// ============================================================================
/**
* Estimate current position ticks given a past state
*/
estimateCurrentTicks(
ticks: number,
when: Date,
currentTime: Date = new Date(),
): number {
const remoteTime = this.timeSyncCore.localDateToRemote(currentTime);
return (
ticks + (remoteTime.getTime() - when.getTime()) * TicksPerMillisecond
);
}
/**
* Sync playback time during playback
*/
syncPlaybackTime(currentPositionMs: number): void {
if (!this.playerControls || !this.lastCommand) return;
// Only sync during unpause
if (this.lastCommand.Command !== "Unpause" || this.isBuffering()) return;
// Don't apply sync corrections if the active player isn't on the same
// playlist item that the group is playing (e.g. user switched item
// locally, or queue update in flight). Prevents seeking the wrong item.
const currentItemId = this.getCurrentPlaylistItemId?.();
if (
currentItemId &&
this.lastCommand.PlaylistItemId &&
this.lastCommand.PlaylistItemId !== currentItemId
) {
return;
}
const currentTime = new Date();
const currentPositionTicks = msToTicks(currentPositionMs);
const lastCommandWhen = this.lastCommand.When
? new Date(this.lastCommand.When)
: new Date();
// Estimate server position
const serverPositionTicks = this.estimateCurrentTicks(
this.lastCommand.PositionTicks ?? 0,
lastCommandWhen,
currentTime,
);
// Calculate difference
const diffMillis =
(serverPositionTicks - currentPositionTicks) / TicksPerMillisecond;
this.playbackDiffMillis = diffMillis;
this.emit("playback-diff", diffMillis);
// Rate-limit sync attempts
const elapsed = currentTime.getTime() - this.lastSyncTime.getTime();
if (elapsed < this.maxDelaySpeedToSync / 2) return;
this.lastSyncTime = currentTime;
if (!this.syncEnabled || !this.enableSyncCorrection) return;
const absDiffMillis = Math.abs(diffMillis);
// SpeedToSync
if (
this.useSpeedToSync &&
absDiffMillis >= this.minDelaySpeedToSync &&
absDiffMillis < this.maxDelaySpeedToSync
) {
let speedToSyncTime = this.speedToSyncDuration;
// Prevent negative speed
const MinSpeed = 0.2;
if (diffMillis <= -speedToSyncTime * MinSpeed) {
speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed);
}
const speed = 1 + diffMillis / speedToSyncTime;
if (speed > 0) {
this.playerControls.setSpeed(speed);
this.syncEnabled = false;
this.syncAttempts++;
this.emit("syncing", true, `SpeedToSync (x${speed.toFixed(2)})`);
this.syncTimeout = setTimeout(() => {
this.playerControls?.setSpeed(1.0);
this.syncEnabled = true;
this.emit("syncing", false, "None");
}, speedToSyncTime);
console.debug(`SyncPlay: SpeedToSync x${speed.toFixed(2)}`);
}
}
// SkipToSync
else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) {
const serverPositionMs = ticksToMs(serverPositionTicks);
this.localSeek(serverPositionMs);
this.syncEnabled = false;
this.syncAttempts++;
this.emit("syncing", true, `SkipToSync (${this.syncAttempts})`);
this.syncTimeout = setTimeout(() => {
this.syncEnabled = true;
this.emit("syncing", false, "None");
}, this.maxDelaySpeedToSync / 2);
console.debug(`SyncPlay: SkipToSync to ${serverPositionMs}ms`);
} else {
// Synced
if (this.syncAttempts > 0) {
console.debug(`SyncPlay: synced after ${this.syncAttempts} attempts`);
}
this.syncAttempts = 0;
}
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Reset PlaybackCore state — used when SyncPlay is disabled so we don't
* carry stale commands, scheduled timers, or sync state into the next
* session.
*/
reset(): void {
this.clearScheduledCommand();
this.lastCommand = null;
this.lastSyncTime = new Date();
this.syncAttempts = 0;
this.playbackDiffMillis = 0;
this.playerIsBuffering = false;
// Forget what we last told the server so the next session starts fresh.
this.lastBufferingSent = null;
this.inflightBufferingRequest = null;
if (this.notifyBufferingTimeout) {
clearTimeout(this.notifyBufferingTimeout);
this.notifyBufferingTimeout = null;
}
// Drop a pending pause-before-ready flag so it can't leak into the
// next group.
this.pendingInitialPause = false;
}
/**
* Destroy the playback core
*/
destroy(): void {
this.clearScheduledCommand();
this.removeAllListeners();
this.playerControls = null;
this.onNotifyOsd = null;
this.getCurrentPlaylistItemId = null;
}
}

View File

@@ -0,0 +1,292 @@
/**
* QueueCore
*
* Manages the shared playlist/queue for SyncPlay.
* Handles queue updates from the server.
*
* Based on jellyfin-web's QueueCore.js
*/
import { EventEmitter } from "./Helper";
import type {
GroupRepeatMode,
GroupShuffleMode,
PlayQueueUpdate,
SyncPlayQueueItem,
} from "./types";
/**
* QueueCore - Manages the shared playlist
*/
export class QueueCore extends EventEmitter {
// Queue state
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
private playlist: SyncPlayQueueItem[] = [];
// Callbacks
private onStartPlayback: (() => void) | null = null;
private estimateCurrentTicks: ((ticks: number, when: Date) => number) | null =
null;
/**
* Set the start playback callback
*/
setStartPlaybackHandler(handler: (() => void) | null): void {
this.onStartPlayback = handler;
}
/**
* Set the ticks estimator function
*/
setTicksEstimator(
estimator: ((ticks: number, when: Date) => number) | null,
): void {
this.estimateCurrentTicks = estimator;
}
// ============================================================================
// Queue State
// ============================================================================
/**
* Get the current playlist
*/
getPlaylist(): SyncPlayQueueItem[] {
return [...this.playlist];
}
/**
* Check if playlist is empty
*/
isPlaylistEmpty(): boolean {
return this.playlist.length === 0;
}
/**
* Get current playing index
*/
getCurrentPlaylistIndex(): number {
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
}
/**
* Get current playlist item ID
*/
getCurrentPlaylistItemId(): string | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index]?.PlaylistItemId ?? null;
}
/**
* Get current item's Jellyfin ID (the actual media item ID)
*/
getCurrentItemId(): string | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index]?.ItemId ?? null;
}
/**
* Get the current item from the playlist
*/
getCurrentItem(): SyncPlayQueueItem | null {
if (!this.lastPlayQueueUpdate) return null;
const index = this.lastPlayQueueUpdate.PlayingItemIndex;
if (index === undefined || index === -1 || index >= this.playlist.length) {
return null;
}
return this.playlist[index] ?? null;
}
/**
* Get the last update time
*/
getLastUpdate(): Date | null {
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
return lastUpdate ? new Date(lastUpdate) : null;
}
/**
* Get the last update time as timestamp
*/
getLastUpdateTime(): number {
const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
return lastUpdate ? new Date(lastUpdate).getTime() : 0;
}
/**
* Get start position ticks
*/
getStartPositionTicks(): number {
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
}
/**
* Get repeat mode
*/
getRepeatMode(): GroupRepeatMode {
return this.lastPlayQueueUpdate?.RepeatMode ?? "RepeatNone";
}
/**
* Get shuffle mode
*/
getShuffleMode(): GroupShuffleMode {
return this.lastPlayQueueUpdate?.ShuffleMode ?? "Sorted";
}
/**
* Get playlist as item IDs
*/
getPlaylistAsItemIds(): (string | undefined)[] {
return this.playlist.map((item) => item.ItemId);
}
// ============================================================================
// Queue Updates
// ============================================================================
/**
* Update the play queue from server
*/
async updatePlayQueue(update: PlayQueueUpdate): Promise<void> {
// Parse the last update time
const updateTime = update.LastUpdate
? new Date(update.LastUpdate).getTime()
: 0;
// Ignore old updates
if (updateTime <= this.getLastUpdateTime()) {
console.debug("SyncPlay QueueCore: ignoring old update", update);
return;
}
console.log("SyncPlay QueueCore: processing update", {
reason: update.Reason,
position: update.StartPositionTicks,
index: update.PlayingItemIndex,
});
// Check for position change (seek)
const oldPosition = this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
const newPosition = update.StartPositionTicks ?? 0;
const positionChanged = Math.abs(newPosition - oldPosition) > 10000000; // > 1 second difference
// Store the update
this.lastPlayQueueUpdate = update;
this.playlist = update.Playlist ?? [];
// Emit update event
this.emit("queue-update", update);
// Handle different update reasons
switch (update.Reason) {
case "NewPlaylist":
// Start playback with new playlist
this.onStartPlayback?.();
break;
case "SetCurrentItem":
case "NextItem":
case "PreviousItem":
// Item changed
this.emit("item-change", this.getCurrentPlaylistItemId());
break;
case "RemoveItems":
case "MoveItem":
case "Queue":
case "QueueNext":
// Playlist modified
this.emit("playlist-change", this.playlist);
break;
case "RepeatMode":
this.emit("repeat-mode-change", update.RepeatMode);
break;
case "ShuffleMode":
this.emit("shuffle-mode-change", update.ShuffleMode);
break;
default:
console.debug(
"SyncPlay QueueCore: unhandled update reason",
update.Reason,
);
break;
}
// Emit seek if position changed significantly (likely a seek from another device)
if (positionChanged && update.Reason !== "NewPlaylist") {
console.log(
`SyncPlay QueueCore: position changed ${oldPosition} -> ${newPosition}, emitting seek`,
);
this.emit("seek", newPosition);
}
}
/**
* Get estimated start position based on last command
*/
getEstimatedStartPosition(
lastCommandPositionTicks: number | null,
lastCommandWhen: Date | null,
): number {
if (lastCommandPositionTicks !== null && lastCommandWhen !== null) {
// Use playback command if recent enough
if (lastCommandWhen.getTime() >= this.getLastUpdateTime()) {
return (
this.estimateCurrentTicks?.(
lastCommandPositionTicks,
lastCommandWhen,
) ?? lastCommandPositionTicks
);
}
}
// Fall back to queue update position
const startTicks = this.getStartPositionTicks();
const lastUpdate = this.getLastUpdate();
if (lastUpdate) {
return this.estimateCurrentTicks?.(startTicks, lastUpdate) ?? startTicks;
}
return startTicks;
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Clear the queue
*/
clear(): void {
this.lastPlayQueueUpdate = null;
this.playlist = [];
}
/**
* Destroy the queue core
*/
destroy(): void {
this.clear();
this.removeAllListeners();
this.onStartPlayback = null;
this.estimateCurrentTicks = null;
}
}

View File

@@ -0,0 +1,594 @@
/**
* SyncPlayProvider
*
* React context provider for SyncPlay functionality.
* Manages the SyncPlay manager and exposes hooks for components.
*/
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import { toast } from "sonner-native";
import { useAppRouter } from "@/hooks/useAppRouter";
import i18n from "@/i18n";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { SyncPlayController } from "./Controller";
import { ticksToMs } from "./Helper";
import { SyncPlayManager } from "./Manager";
import { PlaybackCore } from "./PlaybackCore";
import { QueueCore } from "./QueueCore";
import type {
GroupInfoDto,
PlayerControls,
PlayQueueUpdate,
SendCommand,
SyncPlayOsdAction,
SyncPlayStats,
} from "./types";
import { useSyncPlayWebSocket } from "./useSyncPlayWebSocket";
// ============================================================================
// Context Types
// ============================================================================
interface SyncPlayContextValue {
// State
isEnabled: boolean;
isReady: boolean;
groupInfo: GroupInfoDto | null;
canJoinGroups: boolean;
canCreateGroups: boolean;
// Group management
joinGroup: (groupId: string) => Promise<void>;
createGroup: (groupName?: string) => Promise<void>;
leaveGroup: () => Promise<void>;
getGroups: () => Promise<GroupInfoDto[]>;
// Playback control delegation
controller: SyncPlayController | null;
// Player integration
setPlayerControls: (controls: PlayerControls | null) => void;
notifyReady: () => void;
notifyBuffering: () => void;
// Stats
getStats: () => SyncPlayStats;
// OSD state
osdAction: SyncPlayOsdAction | null;
isSyncing: boolean;
syncMethod: string;
/** In-flight Unpause/Pause request, before the server has echoed back. */
pendingPlaybackCommand: "Unpause" | "Pause" | null;
}
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
// ============================================================================
// Provider Component
// ============================================================================
interface SyncPlayProviderProps {
children: ReactNode;
}
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const router = useAppRouter();
// Core modules - use state for manager so WebSocket hook re-runs when ready
const [manager, setManager] = useState<SyncPlayManager | null>(null);
const playbackCoreRef = useRef<PlaybackCore | null>(null);
const queueCoreRef = useRef<QueueCore | null>(null);
const controllerRef = useRef<SyncPlayController | null>(null);
// Track if we're already on the player page to avoid duplicate navigations
const isNavigatingToPlayerRef = useRef(false);
// State
const [isEnabled, setIsEnabled] = useState(false);
const [isReady, setIsReady] = useState(false);
const [groupInfo, setGroupInfoDto] = useState<GroupInfoDto | null>(null);
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncMethod, setSyncMethod] = useState("None");
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
"Unpause" | "Pause" | null
>(null);
// Permission checks
const canJoinGroups = useMemo(() => {
const access = user?.Policy?.SyncPlayAccess;
return access !== "None" && access !== undefined;
}, [user?.Policy?.SyncPlayAccess]);
const canCreateGroups = useMemo(() => {
return user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups";
}, [user?.Policy?.SyncPlayAccess]);
// Initialize manager
useEffect(() => {
if (!api) return;
// Create manager and cores
const manager = new SyncPlayManager(api);
const playbackCore = new PlaybackCore(api, manager.getTimeSyncCore());
const queueCore = new QueueCore();
const controller = new SyncPlayController(api, manager, queueCore);
setManager(manager);
playbackCoreRef.current = playbackCore;
queueCoreRef.current = queueCore;
controllerRef.current = controller;
// Wire up manager callbacks
manager.setPlaybackCommandHandler((command: SendCommand) => {
playbackCore.applyCommand(command);
});
manager.setQueueUpdateHandler((update: PlayQueueUpdate) => {
queueCore.updatePlayQueue(update);
});
manager.setPlaylistItemIdGetter(() => {
return queueCore.getCurrentPlaylistItemId();
});
// When SyncPlay is disabled, flush PlaybackCore's scheduled commands and
// cached state so we don't carry ghost commands into the next group.
manager.setDisableHandler(() => {
playbackCore.reset();
});
// Also clear the cached PlayQueue snapshot on disable. If we don't, then
// when the user later re-joins the same group, the server's first
// PlayQueue echo (which can carry the same LastUpdate as the snapshot we
// saw last session) gets dropped by QueueCore's stale-update guard, and
// the receiver never auto-navigates to the group's content.
manager.setQueueClearHandler(() => {
queueCore.clear();
});
// Wire up playback core callbacks
playbackCore.setPlaylistItemIdGetter(() => {
return queueCore.getCurrentPlaylistItemId();
});
playbackCore.setOsdHandler((action) => {
setOsdAction(action);
// Clear after display
setTimeout(() => setOsdAction(null), 1500);
});
// Wire up queue core
queueCore.setTicksEstimator((ticks, when) => {
return playbackCore.estimateCurrentTicks(ticks, when);
});
// Navigate to player when group starts playing new content
queueCore.setStartPlaybackHandler(async () => {
const itemId = queueCore.getCurrentItemId();
const startPositionTicks = queueCore.getStartPositionTicks();
if (!itemId) {
console.warn("SyncPlay: new playlist but no current item ID");
return;
}
// Avoid duplicate navigations
if (isNavigatingToPlayerRef.current) {
console.debug("SyncPlay: already navigating to player");
return;
}
console.log("SyncPlay: navigating to player for item", itemId);
isNavigatingToPlayerRef.current = true;
// Mirror jellyfin-web's `QueueCore.startPlayback` ordering:
// 1. followGroupPlayback (IgnoreWait:false) — tell server we follow
// 2. scheduleReadyRequestOnPlaybackStart — arm initial pause
// 3. playerWrapper.localPlay (== our router navigation) — start loading
// The arm-then-navigate order matters: scheduling must happen BEFORE
// navigation so the flag is set when the player attaches and fires
// its first `notifyReady`. Otherwise we race the player and the
// initial SyncPlayReady reports `IsPlaying:true`, defeating the
// server's "hold the group until everyone is parked" semantics.
await manager.followGroupPlayback();
playbackCore.scheduleReadyRequestOnPlaybackStart();
// Show toast notification
toast(i18n.t("syncplay.joining_playback"));
// Navigate to the player with the item. Use `replace` so repeated
// queue updates don't stack player screens on the history.
const queryParams = new URLSearchParams({
itemId: itemId,
playbackPosition: startPositionTicks.toString(),
syncPlay: "true", // Mark this as a SyncPlay-initiated playback
}).toString();
router.push(`/player/direct-player?${queryParams}` as any);
// Reset navigation flag after a short delay
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
});
// Also handle item changes (next/previous in playlist)
queueCore.on("item-change", () => {
const newItemId = queueCore.getCurrentItemId();
const startPositionTicks = queueCore.getStartPositionTicks();
if (!newItemId) {
console.warn("SyncPlay: item change but no current item ID");
return;
}
// Avoid duplicate navigations
if (isNavigatingToPlayerRef.current) {
return;
}
console.log("SyncPlay: item changed, navigating to", newItemId);
isNavigatingToPlayerRef.current = true;
// Same pause-before-ready dance as NewPlaylist — the new item's
// player needs to park at the start position and report
// IsPlaying:false so the server holds the group until everyone is
// ready for the next Unpause. Mirrors jellyfin-web's
// `QueueCore.setCurrentPlaylistItem`.
playbackCore.scheduleReadyRequestOnPlaybackStart();
const queryParams = new URLSearchParams({
itemId: newItemId,
playbackPosition: startPositionTicks.toString(),
syncPlay: "true",
}).toString();
router.push(`/player/direct-player?${queryParams}`);
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
});
// Handle seek events from other devices - pause first, then seek (like Jellyfin-web)
queueCore.on("seek", (...args: unknown[]) => {
const positionTicks = args[0] as number;
const positionMs = ticksToMs(positionTicks);
console.log(
"SyncPlay: seek event received, pausing then seeking to",
positionMs,
"ms",
);
const playerControls = manager.getPlayerControls();
if (playerControls) {
playerControls.pause();
playerControls.seekTo(positionMs);
}
});
// Subscribe to manager events
manager.on("enabled", (...args: unknown[]) => {
const enabled = args[0] as boolean;
setIsEnabled(enabled);
if (!enabled) {
setIsReady(false);
setGroupInfoDto(null);
}
});
manager.on("syncing", (...args: unknown[]) => {
const syncing = args[0] as boolean;
const method = args[1] as string;
setIsSyncing(syncing);
setSyncMethod(method);
});
// Keep React-side groupInfo in sync with Manager mutations. Without this,
// CenterControls' `groupInfo.State === 'Waiting'` check is stale because
// Manager mutates the existing object reference rather than emitting a
// fresh one.
manager.on("group-info-change", (...args: unknown[]) => {
setGroupInfoDto(args[0] as GroupInfoDto);
});
// Expose pending Unpause/Pause to consumers (e.g. CenterControls renders
// a spinner instead of the play/pause button while a request is in
// flight — mirrors jellyfin-web's "schedule-play" indicator).
manager.on("pending-playback-change", (...args: unknown[]) => {
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
});
// When entering Waiting state, report ready through PlaybackCore
manager.on("waiting-for-ready", () => {
console.log(
"SyncPlay: waiting-for-ready event, calling PlaybackCore.onReady()",
);
playbackCore.onReady();
});
// Handle seek from StateUpdate (when SyncPlayCommand doesn't include seek)
manager.on("seek-from-state-update", (...args: unknown[]) => {
const positionTicks = args[0] as number;
const positionMs = ticksToMs(positionTicks);
console.log(
"SyncPlay: seek from StateUpdate, seeking to",
positionMs,
"ms",
);
const playerControls = manager.getPlayerControls();
if (playerControls) {
playerControls.pause();
playerControls.seekTo(positionMs);
}
});
// Initialize
manager.init();
return () => {
manager.destroy();
playbackCore.destroy();
queueCore.destroy();
setManager(null);
playbackCoreRef.current = null;
queueCoreRef.current = null;
controllerRef.current = null;
};
}, [api]);
// Update group info when enabled
useEffect(() => {
if (isEnabled && manager) {
setGroupInfoDto(manager.getGroupInfo());
setIsReady(manager.isSyncPlayReady());
}
}, [isEnabled, manager]);
// Connect to WebSocket messages - manager is now state so hook re-runs when ready
useSyncPlayWebSocket(manager);
// ============================================================================
// Group Management
// ============================================================================
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
if (!api) return [];
try {
const syncPlayApi = getSyncPlayApi(api);
const response = await syncPlayApi.syncPlayGetGroups();
return (response.data as unknown as GroupInfoDto[]) ?? [];
} catch (error) {
console.error("SyncPlay: failed to get groups", error);
return [];
}
}, [api]);
const joinGroup = useCallback(
async (groupId: string): Promise<void> => {
if (!api) return;
try {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.syncPlayJoinGroup({
joinGroupRequestDto: {
GroupId: groupId,
},
});
} catch (error) {
console.error("SyncPlay: failed to join group", error);
throw error;
}
},
[api],
);
const createGroup = useCallback(
async (groupName?: string): Promise<void> => {
if (!api || !user) return;
const name = groupName || `${user.Name}'s Group`;
try {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.syncPlayCreateGroup({
newGroupRequestDto: {
GroupName: name,
},
});
} catch (error) {
console.error("SyncPlay: failed to create group", error);
throw error;
}
},
[api, user],
);
const leaveGroup = useCallback(async (): Promise<void> => {
if (!api) return;
try {
const syncPlayApi = getSyncPlayApi(api);
await syncPlayApi.syncPlayLeaveGroup();
} catch (error) {
console.error("SyncPlay: failed to leave group", error);
throw error;
}
}, [api]);
// Re-join the SyncPlay group when the app returns from background.
//
// Backgrounding tears down our WebSocket (see WebSocketProvider) and the
// server may drop us from the group after its inactivity timeout. Even
// when it doesn't, we likely missed any commands/state-updates broadcast
// while we were suspended. Re-issuing the join is idempotent on the
// server and gets us a fresh GroupJoined snapshot.
const lastGroupIdRef = useRef<string | null>(null);
useEffect(() => {
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
}, [groupInfo?.GroupId]);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
if (!api) return;
const subscription = AppState.addEventListener("change", (nextAppState) => {
const previousAppState = appStateRef.current;
appStateRef.current = nextAppState;
const becameActive =
(previousAppState === "background" ||
previousAppState === "inactive") &&
nextAppState === "active";
if (!becameActive) return;
const groupId = lastGroupIdRef.current;
if (!groupId) return;
// Give the WebSocket a moment to reconnect (handled by
// WebSocketProvider on the same 'active' transition) so the
// server's GroupJoined broadcast actually reaches us.
setTimeout(() => {
console.log(`SyncPlay: app foregrounded, rejoining group ${groupId}`);
getSyncPlayApi(api)
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
.catch((error) => {
console.error("SyncPlay: failed to rejoin group", error);
});
}, 1000);
});
return () => {
subscription.remove();
};
}, [api]);
// ============================================================================
// Player Integration
// ============================================================================
const setPlayerControls = useCallback(
(controls: PlayerControls | null) => {
manager?.setPlayerControls(controls);
playbackCoreRef.current?.setPlayerControls(controls);
},
[manager],
);
const notifyReady = useCallback(() => {
console.log("SyncPlay: notifyReady called");
playbackCoreRef.current?.onReady();
}, []);
const notifyBuffering = useCallback(() => {
console.log("SyncPlay: notifyBuffering called");
playbackCoreRef.current?.onBuffering();
}, []);
// ============================================================================
// Stats
// ============================================================================
const getStats = useCallback((): SyncPlayStats => {
return (
manager?.getStats() ?? {
timeSyncDevice: "None",
timeSyncOffset: "0.00",
playbackDiff: "0.00",
syncMethod: "None",
}
);
}, [manager]);
// ============================================================================
// Context Value
// ============================================================================
const contextValue: SyncPlayContextValue = useMemo(
() => ({
isEnabled,
isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
controller: controllerRef.current,
setPlayerControls,
notifyReady,
notifyBuffering,
getStats,
osdAction,
isSyncing,
syncMethod,
pendingPlaybackCommand,
}),
[
isEnabled,
isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
setPlayerControls,
notifyReady,
notifyBuffering,
getStats,
osdAction,
isSyncing,
syncMethod,
pendingPlaybackCommand,
],
);
return (
<SyncPlayContext.Provider value={contextValue}>
{children}
</SyncPlayContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to access SyncPlay state and actions
*/
export function useSyncPlay(): SyncPlayContextValue {
const context = useContext(SyncPlayContext);
if (!context) {
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
}
return context;
}
/**
* Hook to access the SyncPlay controller
*/
export function useSyncPlayController(): SyncPlayController | null {
const { controller } = useSyncPlay();
return controller;
}

View File

@@ -0,0 +1,284 @@
/**
* TimeSyncCore
*
* Manages time synchronization with the Jellyfin server.
* Uses NTP-like algorithm to calculate clock offset between client and server.
*
* Based on jellyfin-web's TimeSyncCore.js and TimeSync.js
*/
import type { Api } from "@jellyfin/sdk";
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
import type { TimeSyncMeasurement } from "./types";
// Time estimation constants
const NumberOfTrackedMeasurements = 8;
const PollingIntervalGreedy = 1000; // ms - fast polling initially
const PollingIntervalLowProfile = 60000; // ms - slow polling once synced
const GreedyPingCount = 3;
/**
* Stores a single time sync measurement
*/
class Measurement {
requestSent: number;
requestReceived: number;
responseSent: number;
responseReceived: number;
constructor(data: TimeSyncMeasurement) {
this.requestSent = data.requestSent;
this.requestReceived = data.requestReceived;
this.responseSent = data.responseSent;
this.responseReceived = data.responseReceived;
}
/**
* Calculate time offset from server, in milliseconds.
* Offset = (t1 - t0 + t2 - t3) / 2
* where t0 = request sent, t1 = request received, t2 = response sent, t3 = response received
*/
getOffset(): number {
return (
(this.requestReceived -
this.requestSent +
(this.responseSent - this.responseReceived)) /
2
);
}
/**
* Get round-trip delay, in milliseconds.
*/
getDelay(): number {
return (
this.responseReceived -
this.requestSent -
(this.responseSent - this.requestReceived)
);
}
/**
* Get ping time (half of round-trip), in milliseconds.
*/
getPing(): number {
return this.getDelay() / 2;
}
}
export type TimeSyncEventCallback = (
error: Error | null,
timeOffset: number | null,
ping: number | null,
) => void;
/**
* TimeSyncCore - Manages time synchronization with the server
*/
export class TimeSyncCore {
private api: Api;
private poller: ReturnType<typeof setTimeout> | null = null;
private pingStop = true;
private pollingInterval = PollingIntervalGreedy;
private pings = 0;
private measurement: Measurement | null = null;
private measurements: Measurement[] = [];
private extraTimeOffset = 0;
private onUpdateCallback: TimeSyncEventCallback | null = null;
constructor(api: Api) {
this.api = api;
}
/**
* Set callback for time sync updates
*/
onUpdate(callback: TimeSyncEventCallback): void {
this.onUpdateCallback = callback;
}
/**
* Check if time sync is ready (has at least one measurement)
*/
isReady(): boolean {
return this.measurement !== null;
}
/**
* Get the current time offset with server, in milliseconds.
*/
getTimeOffset(): number {
return (this.measurement?.getOffset() ?? 0) + this.extraTimeOffset;
}
/**
* Get current ping time to server, in milliseconds.
*/
getPing(): number {
return this.measurement?.getPing() ?? 0;
}
/**
* Set extra time offset for manual adjustment
*/
setExtraTimeOffset(offset: number): void {
this.extraTimeOffset = offset;
}
/**
* Convert server time to local time.
*/
remoteDateToLocal(remote: Date): Date {
// remote - local = offset, so local = remote - offset
return new Date(remote.getTime() - this.getTimeOffset());
}
/**
* Convert local time to server time.
*/
localDateToRemote(local: Date): Date {
// remote - local = offset, so remote = local + offset
return new Date(local.getTime() + this.getTimeOffset());
}
/**
* Get the display name of the sync device
*/
getActiveDeviceName(): string {
return "Server";
}
/**
* Make a ping request to the server to measure time offset
*/
private async requestPing(): Promise<TimeSyncMeasurement> {
const requestSent = Date.now();
const timeSyncApi = getTimeSyncApi(this.api);
const response = await timeSyncApi.getUtcTime();
const responseReceived = Date.now();
const data = response.data;
const requestReceived = new Date(data.RequestReceptionTime!).getTime();
const responseSent = new Date(data.ResponseTransmissionTime!).getTime();
return {
requestSent,
requestReceived,
responseSent,
responseReceived,
};
}
/**
* Update time offset with a new measurement
*/
private updateTimeOffset(measurement: Measurement): void {
this.measurements.push(measurement);
if (this.measurements.length > NumberOfTrackedMeasurements) {
this.measurements.shift();
}
// Pick measurement with minimum delay (most accurate)
const sortedMeasurements = [...this.measurements].sort(
(a, b) => a.getDelay() - b.getDelay(),
);
this.measurement = sortedMeasurements[0];
}
/**
* Internal poller for ping requests
*/
private internalRequestPing(): void {
if (this.poller !== null || this.pingStop) {
return;
}
this.poller = setTimeout(async () => {
this.poller = null;
try {
const result = await this.requestPing();
this.onPingSuccess(result);
} catch (error) {
this.onPingError(error as Error);
}
// Schedule next ping
this.internalRequestPing();
}, this.pollingInterval);
}
/**
* Handle successful ping response
*/
private onPingSuccess(result: TimeSyncMeasurement): void {
const measurement = new Measurement(result);
this.updateTimeOffset(measurement);
// Slow down polling after initial greedy phase
if (this.pings >= GreedyPingCount) {
this.pollingInterval = PollingIntervalLowProfile;
} else {
this.pings++;
}
this.onUpdateCallback?.(null, this.getTimeOffset(), this.getPing());
}
/**
* Handle ping error
*/
private onPingError(error: Error): void {
console.error("SyncPlay TimeSyncCore: ping error", error);
this.onUpdateCallback?.(error, null, null);
}
/**
* Start the time sync poller
*/
startPing(): void {
this.pingStop = false;
this.internalRequestPing();
}
/**
* Stop the time sync poller
*/
stopPing(): void {
this.pingStop = true;
if (this.poller !== null) {
clearTimeout(this.poller);
this.poller = null;
}
}
/**
* Force an immediate update (reset to greedy mode)
*/
forceUpdate(): void {
this.stopPing();
this.pollingInterval = PollingIntervalGreedy;
this.pings = 0;
this.startPing();
}
/**
* Drop all accumulated measurements
*/
resetMeasurements(): void {
this.measurement = null;
this.measurements = [];
}
/**
* Clean up resources
*/
destroy(): void {
this.stopPing();
this.resetMeasurements();
this.onUpdateCallback = null;
}
}

View File

@@ -0,0 +1,25 @@
/**
* SyncPlay Module
*
* Synchronized playback for Jellyfin.
* Allows multiple users to watch content together in sync.
*/
export { SyncPlayController } from "./Controller";
// Helpers
export * from "./Helper";
// Core modules
export { SyncPlayManager } from "./Manager";
export { PlaybackCore } from "./PlaybackCore";
export { QueueCore } from "./QueueCore";
// Provider and hooks
export {
SyncPlayProvider,
useSyncPlay,
useSyncPlayController,
} from "./SyncPlayProvider";
export { TimeSyncCore } from "./TimeSyncCore";
// Types
export * from "./types";

139
providers/SyncPlay/types.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* SyncPlay Types
*
* Re-exports Jellyfin SDK types and defines app-specific types.
* Following the pattern used in offline downloads.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
// ============================================================================
// Re-export SDK Types
// ============================================================================
// Group types
// Playback command types
// Queue types
// User access type
// Group update message types
export type {
GroupInfoDto,
GroupRepeatMode,
GroupShuffleMode,
GroupStateType,
GroupUpdate,
GroupUpdateType,
PlayQueueUpdate,
PlayQueueUpdateReason,
SendCommand,
SendCommandType,
SyncPlayGroupJoinedUpdate,
SyncPlayGroupLeftUpdate,
SyncPlayPlayQueueUpdate,
SyncPlayQueueItem,
SyncPlayStateUpdate,
SyncPlayUserAccessType,
SyncPlayUserJoinedUpdate,
SyncPlayUserLeftUpdate,
} from "@jellyfin/sdk/lib/generated-client/models";
// ============================================================================
// Constants
// ============================================================================
export const TicksPerMillisecond = 10000;
export const WaitForEventDefaultTimeout = 30000; // milliseconds
export const WaitForPlayerEventTimeout = 500; // milliseconds
// ============================================================================
// App-Specific Types (not in SDK)
// ============================================================================
/**
* Time sync measurement for NTP-like synchronization
*/
export interface TimeSyncMeasurement {
requestSent: number;
requestReceived: number;
responseSent: number;
responseReceived: number;
}
/**
* Player controls interface for integrating with MPV player
*/
export interface PlayerControls {
play: () => void;
pause: () => void;
seekTo: (positionMs: number) => void;
setSpeed: (speed: number) => void;
getSpeed: () => number;
getCurrentPosition: () => number;
isPlaying: () => boolean;
isBuffering: () => boolean;
}
/**
* OSD action types for visual feedback
*/
export type SyncPlayOsdAction =
| "schedule-play"
| "unpause"
| "pause"
| "seek"
| "buffering"
| "wait-pause"
| "wait-unpause";
/**
* SyncPlay settings for sync correction algorithms
*/
export interface SyncPlaySettings {
// SpeedToSync settings
minDelaySpeedToSync: number;
maxDelaySpeedToSync: number;
speedToSyncDuration: number;
// SkipToSync settings
minDelaySkipToSync: number;
// Feature toggles
useSpeedToSync: boolean;
useSkipToSync: boolean;
enableSyncCorrection: boolean;
// Time sync
extraTimeOffset: number;
}
export const DEFAULT_SYNC_PLAY_SETTINGS: SyncPlaySettings = {
minDelaySpeedToSync: 60.0,
maxDelaySpeedToSync: 3000.0,
speedToSyncDuration: 1000.0,
minDelaySkipToSync: 400.0,
useSpeedToSync: true,
useSkipToSync: true,
enableSyncCorrection: false,
extraTimeOffset: 0.0,
};
/**
* Stats for debugging/display
*/
export interface SyncPlayStats {
timeSyncDevice: string;
timeSyncOffset: string;
playbackDiff: string;
syncMethod: string;
}
/**
* Play options for starting playback
*/
export interface PlayOptions {
ids?: string[];
items?: BaseItemDto[];
startIndex?: number;
startPositionTicks?: number;
serverId?: string;
}

View File

@@ -0,0 +1,87 @@
/**
* useSyncPlayWebSocket
*
* Hook that connects the SyncPlay manager to WebSocket messages.
* Listens for SyncPlayCommand and SyncPlayGroupUpdate messages.
*
* IMPORTANT: We subscribe directly to the WebSocket via `addEventListener`
* rather than reading WebSocketProvider's `lastMessage` state. That state
* only holds the most recent message, so when the server emits bursts
* after a join (GroupJoined + StateUpdate + UserJoined + PlayQueue, all
* within a few ms), React's batching causes earlier messages to be
* overwritten before our effect can read them — most notably the
* GroupJoined message, which left the joining client thinking it hadn't
* joined while other members already saw it as a participant.
*
* Listening on the raw socket guarantees we see every frame in order.
*/
import { useEffect } from "react";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
import type { SyncPlayManager } from "./Manager";
import type { SendCommand } from "./types";
/**
* Hook to connect SyncPlay manager to WebSocket
*/
export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void {
const { ws } = useWebSocketContext();
useEffect(() => {
if (!ws || !manager) return;
const handleMessage = (event: WebSocketMessageEvent) => {
let parsed: { MessageType?: string; Data?: unknown };
try {
parsed = JSON.parse(event.data as string);
} catch (error) {
console.error("SyncPlay: failed to parse WebSocket message", error);
return;
}
const { MessageType, Data } = parsed;
// Only handle SyncPlay messages here; everything else is handled
// elsewhere via WebSocketProvider's lastMessage.
if (!MessageType?.startsWith("SyncPlay")) return;
console.log(
`SyncPlay WebSocket [${MessageType}]:`,
JSON.stringify(Data).substring(0, 300),
);
switch (MessageType) {
case "SyncPlayCommand": {
const command = Data as SendCommand;
console.log(
`SyncPlay: COMMAND received - ${command.Command} at ${command.When}`,
command.Command === "Seek"
? `position=${command.PositionTicks}`
: "",
);
// Note: it's normal for controls to be missing here during the
// join → navigate → load window. Manager stashes the command and
// replays it on attach.
manager.processCommand(command);
break;
}
case "SyncPlayGroupUpdate": {
const update = Data as { Type?: string; Data?: unknown };
console.debug("SyncPlay: group update -", update.Type);
manager.processGroupUpdate(update);
break;
}
default:
break;
}
};
ws.addEventListener("message", handleMessage);
return () => {
ws.removeEventListener("message", handleMessage);
};
}, [ws, manager]);
}

View File

@@ -29,6 +29,10 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -4,9 +4,6 @@
"error_title": "خطأ",
"login_title": "تسجيل الدخول",
"login_to_title": "تسجيل الدخول إلى",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "اسم المستخدم",
"password_placeholder": "كلمة المرور",
"login_button": "تسجيل الدخول",
@@ -33,54 +30,48 @@
"connect_button": "اتصل",
"previous_servers": "الخوادم السابقة",
"clear_button": "مسح",
"swipe_to_remove": "مرر للإزالة",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "البحث عن الخوادم المحلية",
"searching": "جاري البحث...",
"servers": "الخوادم",
"saved": "تم الحفظ",
"session_expired": "انتهت الجلسة",
"please_login_again": "انتهت مدة صلاحية جلستك. الرجاء تسجيل الدخول مرة أخرى.",
"remove_saved_login": "إزالة تسجيل دخول محفوظ",
"remove_saved_login_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول الخاص بك المحفوظة لهذا الخادم. ستحتاج إلى إدخال اسم المستخدم وكلمة المرور مرة أخرى في المرة القادمة.",
"accounts_count": "الحسابات {{count}}",
"select_account": "اختر الحساب",
"add_account": "إضافة حساب",
"remove_account_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول لـ {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "حفظ الحساب",
"save_for_later": "حفظ هذا الحساب",
"security_option": "‮خيارات الأمان",
"no_protection": "بدون حماية",
"no_protection_desc": "تسجيل دخول سريع بدون مصادقة",
"pin_code": "رمز PIN",
"pin_code_desc": "رمز PIN مكون من 4 أرقام مطلوب عند التبديل",
"password": "أعد إدخال كلمة المرور",
"password_desc": "كلمة المرور مطلوبة عند التبديل",
"save_button": "حفظ",
"cancel_button": "إلغاء"
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
},
"pin": {
"enter_pin": "‏أدخل رمز PIN",
"enter_pin_for": "أدخل رمز PIN لـ {{username}}",
"enter_4_digits": "ادخل 4 أرقام",
"invalid_pin": "PIN غير صالح",
"setup_pin": "تعيين رمز PIN",
"confirm_pin": "تأكيد رمز PIN",
"pins_dont_match": "رموز PIN غير متطابقة",
"forgot_pin": "نسيت رمز PIN؟",
"forgot_pin_desc": "سيتم إزالة بيانات تسجيل الدخول المحفوظة الخاصة بك"
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
},
"password": {
"enter_password": "أدخل كلمة المرور",
"enter_password_for": "أدخل كلمة المرور لـ {{username}}",
"invalid_password": "كلمة المرور غير صحيحة"
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
},
"home": {
"checking_server_connection": "التحقق من اتصال الخادم...",
@@ -95,9 +86,8 @@
"oops": "عفوًا!",
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
"continue_watching": "متابعة المشاهدة",
"continue": "Continue",
"next_up": "التالي",
"continue_and_next_up": "تابع و التالي",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
"suggested_movies": "أفلام مقترحة",
"suggested_episodes": "حلقات مقترحة",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "الإعدادات",
"log_out_button": "تسجيل الخروج",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "الأقسام"
},
@@ -136,45 +120,36 @@
},
"appearance": {
"title": "المظهر",
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
"hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "الشبكة",
"local_network": "الشبكة المحلية",
"auto_switch_enabled": "التبديل التلقائي عند المنزل",
"auto_switch_description": "التبديل تلقائياً إلى رابط URL محلي عند الاتصال بشبكة WiFi المنزلية",
"local_url": "رابط محلي",
"local_url_hint": "أدخل عنوان الخادم المحلي الخاص بك (على سبيل المثال http://192.168.1.100:8096)",
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "شبكات WiFi المنزل",
"add_current_network": "إضافة \"{{ssid}}\"",
"not_connected_to_wifi": "غير متصل بشبكة WiFi",
"no_networks_configured": "لا توجد شبكات مكونة",
"add_network_hint": "إضافة شبكة WiFi المنزلية الخاصة بك لتمكين التبديل التلقائي",
"current_wifi": "شبكة WiFi الحالية",
"using_url": "استخدام",
"local": "رابط محلي",
"remote": "الـ URL الخارجي",
"not_connected": "غير متصل",
"current_server": "الخادم الحالي",
"remote_url": "الـ URL الخارجي",
"active_url": "الرابط النشط",
"not_configured": "لم يتم تكوينه",
"network_added": "تمت إضافة الشبكة",
"network_already_added": "الشبكة مضافة مسبقاً",
"no_wifi_connected": "غير متصل بشبكة WiFi",
"permission_denied": "تم رفض إذن الوصول إلى الموقع",
"permission_denied_explanation": "يتطلب التعرف على شبكة WiFi للتبديل التلقائي الحصول على إذن الوصول إلى الموقع. يرجى تفعيله من الإعدادات."
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
},
"user_info": {
"user_info_title": "معلومات المستخدم",
@@ -199,22 +174,6 @@
"rewind_length": "مدة الترجيع",
"seconds_unit": "ث"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "التحكم بالإيماءات",
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
@@ -223,10 +182,10 @@
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
"hide_volume_slider": "إخفاء شريط مستوى الصوت",
"hide_volume_slider_description": "إخفاء شريط التحكم في مستوى الصوت في مشغل الفيديو",
"hide_brightness_slider": "إخفاء شريط السطوع",
"hide_brightness_slider_description": "إخفاء شريط التحكم في السطوع في مشغل الفيديو"
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
},
"audio": {
"audio_title": "الصوت",
@@ -236,12 +195,12 @@
"none": "لا شيء",
"language": "اللغة",
"transcode_mode": {
"title": "تحويل ترميز الصوت",
"description": "يتحكم في كيفية التعامل مع الصوت المحيطي (7.1، TrueHD، DTS-HD)",
"auto": "تلقائي",
"stereo": "إجبار تشغيل ستيريو",
"5_1": "السماح بـ 5.1",
"passthrough": "تمرير الصوت"
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"passthrough": "Passthrough"
}
},
"subtitles": {
@@ -292,45 +251,29 @@
"Normal": "عادي",
"Thick": "سميك"
},
"subtitle_color": "لون الترجمة",
"subtitle_background_color": "لون الخلفية",
"subtitle_font": "خط الترجمة",
"ksplayer_title": "إعدادات KSPlayer",
"hardware_decode": "فك الترميز بواسطة الجهاز",
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "إعدادات ترجمة VLC",
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
"text_color": "لون النص",
"background_color": "لون الخلفية",
"background_opacity": "شفافية الخلفية",
"outline_color": "لون إطار الخط",
"outline_opacity": "شفافية إطار الخط",
"outline_thickness": "سمك إطار الخط",
"bold": "خط عريض",
"margin": "الهامش السفلي"
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
},
"video_player": {
"title": "مشغل الفيديو",
"video_player": "مشغل الفيديو",
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -362,8 +305,8 @@
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية",
"default_playback_speed": "سرعة التشغيل الافتراضية",
"auto_play_next_episode": "تشغيل الحلقة التالية تلقائياً",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
"disabled": "معطل"
},
@@ -371,15 +314,15 @@
"downloads_title": "التنزيلات"
},
"music": {
"title": "الموسيقى",
"playback_title": "التشغيل",
"playback_description": "ضبط كيفية تشغيل الموسيقى.",
"prefer_downloaded": "تفضيل الأغاني التي تم تنزيلها",
"caching_title": "التخزين المؤقت",
"caching_description": "تخزين الأغاني التالية مؤقتاً تلقائياً لضمان تشغيل أكثر سلاسة.",
"lookahead_enabled": "تفعيل التخزين المؤقت الاستباقي",
"lookahead_count": "عدد الأغاني المراد تخزينها مسبقاً",
"max_cache_size": "الحد الأقصى لحجم التخزين المؤقت"
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
"lookahead_enabled": "Enable Look-Ahead Caching",
"lookahead_count": "Tracks to Pre-cache",
"max_cache_size": "Max Cache Size"
},
"plugins": {
"plugins_title": "الإضافات",
@@ -414,39 +357,39 @@
"save_button": "حفظ",
"toasts": {
"saved": "تم الحفظ",
"refreshed": "تم تحديث الإعدادات من الخادم"
"refreshed": "Settings refreshed from server"
},
"refresh_from_server": "تحديث الإعدادات من الخادم"
"refresh_from_server": "Refresh Settings from Server"
},
"streamystats": {
"enable_streamystats": "تفعيل Streamystats",
"disable_streamystats": "تعطيل Streamystats",
"enable_search": "استخدم للبحث",
"url": "الرابط",
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
"save_button": "حفظ",
"save": "حفظ",
"features_title": "المميزات",
"home_sections_title": "أقسام الرئيسية",
"enable_movie_recommendations": "توصيات الأفلام",
"enable_series_recommendations": "توصيات المسلسلات",
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
"hide_watchlists_tab": "إخفاء تبويب قوائم المشاهدة",
"home_sections_hint": "إظهار التوصيات المخصصة وقوائم المشاهدة المختارة من Streamystats في الصفحة الرئيسية.",
"recommended_movies": "أفلام موصى بها",
"recommended_series": "مسلسلات موصى بها",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read More About Streamystats.",
"save_button": "Save",
"save": "Save",
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"toasts": {
"saved": "تم الحفظ",
"refreshed": "تم تحديث الإعدادات من الخادم",
"disabled": "تم تعطيل Streamystats"
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
},
"refresh_from_server": "تحديث الإعدادات من الخادم"
"refresh_from_server": "Refresh Settings from Server"
},
"kefinTweaks": {
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
}
},
"storage": {
@@ -455,21 +398,15 @@
"device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}",
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
"music_cache_title": "التخزين المؤقت للموسيقى",
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
"delete_all_downloaded_songs": "حذف جميع الأغاني التي تم تنزيلها",
"downloaded_songs_size": "تم تنزيل {{size}}",
"downloaded_songs_deleted": "تم حذف الأغاني التي تم تنزيلها",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "المقدمة",
@@ -493,21 +430,6 @@
"error_deleting_files": "خطأ في حذف الملفات",
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو",
@@ -578,18 +499,15 @@
"subtitle": "الترجمة",
"play": "تشغيل",
"none": "لا شيء",
"track": "أغنية",
"cancel": "إلغاء",
"stop": "Stop",
"delete": "حذف",
"ok": "حسناً",
"remove": "إزالة",
"next": "التالي",
"back": "رجوع",
"continue": "متابعة",
"verifying": "جارٍ التحقق...",
"login": "Login",
"refresh": "Refresh"
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
},
"search": {
"search": "بحث...",
@@ -603,10 +521,10 @@
"episodes": "حلقات",
"collections": "مجموعات",
"actors": "ممثلون",
"artists": "الفنانون",
"albums": "الألبومات",
"songs": "الأغاني",
"playlists": "قوائم التشغيل",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"request_movies": "طلب أفلام",
"request_series": "طلب مسلسلات",
"recently_added": "أضيف مؤخرًا",
@@ -638,7 +556,6 @@
"movies": "أفلام",
"series": "مسلسلات",
"boxsets": "مجموعات",
"playlists": "Playlists",
"items": "عناصر"
},
"options": {
@@ -655,13 +572,9 @@
"genres": "الأنواع",
"years": "السنوات",
"sort_by": "ترتيب حسب",
"filter_by": "تصفية حسب",
"filter_by": "Filter By",
"sort_order": "اتجاه الترتيب",
"tags": "الوسوم",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "الوسوم"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "لا توجد روابط"
},
"player": {
"live": "LIVE",
"error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
@@ -692,39 +604,11 @@
"index": "الفِهْرِس:",
"continue_watching": "متابعة المشاهدة",
"go_back": "رجوع",
"downloaded_file_title": "تم تنزيل هذا الملف",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
"downloaded_file_yes": "نعم",
"downloaded_file_no": "لا",
"downloaded_file_cancel": "إلغاء",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "التالي",
@@ -733,11 +617,6 @@
"series": "مسلسلات",
"seasons": "مواسم",
"season": "موسم",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
"overview": "نظرة عامة",
"more_with": "المزيد مع {{name}}",
@@ -745,24 +624,13 @@
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
"video": "فيديو",
"more_details": "المزيد من التفاصيل",
"media_options": "خيارات الوسائط",
"media_options": "Media Options",
"quality": "الجودة",
"audio": "الصوت",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "الترجمة",
"show_more": "عرض المزيد",
"show_less": "عرض أقل",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "ظهر في",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "تعذر تحميل العنصر",
"none": "لا شيء",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "تنزيل {{item_count}} عناصر",
"download_unwatched_only": "غير المشاهدة فقط",
"download_button": "تنزيل"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "التالي",
@@ -790,18 +652,7 @@
"movies": "أفلام",
"sports": "رياضة",
"for_kids": "للأطفال",
"news": "أخبار",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "أخبار"
},
"jellyseerr": {
"confirm": "تأكيد",
@@ -846,12 +697,6 @@
"decline": "رفض",
"requested_by": "مطلوب من {{user}}",
"unknown_user": "مستخدم غير معروف",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
@@ -871,162 +716,130 @@
"search": "بحث",
"library": "المكتبة",
"custom_links": "روابط مخصصة",
"favorites": "المفضلة",
"settings": "Settings"
"favorites": "المفضلة"
},
"music": {
"title": "الموسيقى",
"title": "Music",
"tabs": {
"suggestions": "الإقتراحات",
"albums": "الألبومات",
"artists": "الفنانون",
"playlists": "قوائم التشغيل",
"tracks": "الأغاني"
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
},
"filters": {
"all": "الكل"
"all": "All"
},
"recently_added": "أضيف مؤخرًا",
"recently_played": "تم تشغيله مؤخرًا",
"frequently_played": "الأكثر تشغيلاً",
"explore": "اكتشف",
"top_tracks": "أفضل الأغاني",
"play": "تشغيل",
"shuffle": "ترتيب عشوائي",
"play_top_tracks": "تشغيل أفضل الأغاني",
"no_suggestions": "لا توجد مقترحات متاحة",
"no_albums": "لا توجد ألبومات",
"no_artists": "لا يوجد فنانون",
"no_playlists": "لا توجد قوائم تشغيل",
"album_not_found": "الألبوم غير موجود",
"artist_not_found": "الفنان غير موجود",
"playlist_not_found": "قائمة التشغيل غير موجودة",
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"explore": "Explore",
"top_tracks": "Top Tracks",
"play": "Play",
"shuffle": "Shuffle",
"play_top_tracks": "Play Top Tracks",
"no_suggestions": "No suggestions available",
"no_albums": "No albums found",
"no_artists": "No artists found",
"no_playlists": "No playlists found",
"album_not_found": "Album not found",
"artist_not_found": "Artist not found",
"playlist_not_found": "Playlist not found",
"track_options": {
"play_next": "تشغيل التالي",
"add_to_queue": "إضافة إلى قائمة الانتظار",
"add_to_playlist": "أضف إلى قائمة التشغيل",
"download": "تنزيل",
"downloaded": "تم التنزيل",
"downloading": "جارٍ التنزيل...",
"cached": "تم التخزين مؤقتاً",
"delete_download": "حذف ملف التنزيل",
"delete_cache": "إزالة من التخزين المؤقت",
"go_to_artist": "انتقال إلى الفنان",
"go_to_album": "انتقال إلى الألبوم",
"add_to_favorites": "إضافة إلى المفضلة",
"remove_from_favorites": "إزالة من المفضلة",
"remove_from_playlist": "إزالة من قائمة التشغيل"
"play_next": "Play Next",
"add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist",
"download": "Download",
"downloaded": "Downloaded",
"downloading": "Downloading...",
"cached": "Cached",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist",
"go_to_album": "Go to Album",
"add_to_favorites": "Add to Favorites",
"remove_from_favorites": "Remove from Favorites",
"remove_from_playlist": "Remove from Playlist"
},
"playlists": {
"create_playlist": "إنشاء قائمة التشغيل",
"playlist_name": "اسم قائمة التشغيل",
"enter_name": "أدخل اسم قائمة التشغيل",
"create": "إنشاء",
"search_playlists": "البحث عن قوائم التشغيل...",
"added_to": "تمت الإضافة إلى {{name}}",
"added": "تمت الإضافة إلى قائمة التشغيل",
"removed_from": "تمت الإزالة من {{name}}",
"removed": "تمت الازالة من قائمة التشغيل",
"created": "تم إنشاء قائمة التشغيل",
"create_new": "إنشاء قائمة تشغيل جديدة",
"failed_to_add": "فشلت الإضافة إلى قائمة التشغيل",
"failed_to_remove": "فشلت الإزالة من قائمة التشغيل",
"failed_to_create": "فشل إنشاء قائمة التشغيل",
"delete_playlist": "حذف قائمة التشغيل",
"delete_confirm": "هل أنت متأكد من رغبتك في حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.",
"deleted": "تم حذف قائمة التشغيل",
"failed_to_delete": "فشل إنشاء قائمة التشغيل"
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"enter_name": "Enter playlist name",
"create": "Create",
"search_playlists": "Search playlists...",
"added_to": "Added to {{name}}",
"added": "Added to playlist",
"removed_from": "Removed from {{name}}",
"removed": "Removed from playlist",
"created": "Playlist created",
"create_new": "Create New Playlist",
"failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Failed to create playlist",
"delete_playlist": "Delete Playlist",
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist deleted",
"failed_to_delete": "Failed to delete playlist"
},
"sort": {
"title": "ترتيب حسب",
"alphabetical": "أبجدي",
"date_created": "تاريخ الإنشاء"
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
}
},
"watchlists": {
"title": "قوائم المشاهدة",
"my_watchlists": "قوائم المشاهدة الخاصة بي",
"public_watchlists": "قوائم مشاهدة عامة",
"create_title": "إنشاء قائمة مشاهدة",
"edit_title": "تعديل قائمة المشاهدة",
"create_button": "إنشاء قائمة مشاهدة",
"save_button": "حفظ التغييرات",
"delete_button": "حذف",
"remove_button": "إزالة",
"cancel_button": "إلغاء",
"name_label": "الاسم",
"name_placeholder": "أدخل اسم قائمة المشاهدة",
"description_label": "الوصف",
"description_placeholder": "أدخل الوصف (اختياري)",
"is_public_label": "قائمة مشاهدة عامة",
"is_public_description": "السماح للآخرين بعرض قائمة المشاهدة هذه",
"allowed_type_label": "نوع المحتوى",
"sort_order_label": "الترتيب الافتراضي",
"empty_title": "لا توجد قوائم مشاهدة",
"empty_description": "قم بإنشاء أول قائمة مشاهدة لبدء تنظيم الوسائط الخاصة بك",
"empty_watchlist": "قائمة المشاهدة هذه فارغة",
"empty_watchlist_hint": "إضافة عناصر من مكتبتك إلى قائمة المشاهدة هذه",
"not_configured_title": "لم يتم ضبط Streamystats",
"not_configured_description": "اضبط Streamystats في الإعدادات لاستخدام قوائم المشاهدة",
"go_to_settings": "الذهاب إلى الإعدادات",
"add_to_watchlist": "إضافة إلى قائمة المشاهدة",
"remove_from_watchlist": "إزالة من قائمة المشاهدة",
"select_watchlist": "تحديد قائمة المشاهدة",
"create_new": "إنشاء قائمة مشاهدة جديدة",
"item": "عنصر",
"items": "عناصر",
"public": "عامة",
"private": "خاصة",
"you": "أنت",
"by_owner": "بواسطة مستخدم آخر",
"not_found": "قائمة المشاهدة غير موجودة",
"delete_confirm_title": "حذف قائمة المشاهدة",
"delete_confirm_message": "هل أنت متأكد من رغبتك في حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
"remove_item_title": "إزالة من قائمة المشاهدة",
"remove_item_message": "إزالة \"{{name}}\" من قائمة المشاهدة هذه؟",
"loading": "تحميل قوائم المشاهدة...",
"no_compatible_watchlists": "لا توجد قوائم مشاهدة متوافقة",
"create_one_first": "إنشاء قائمة مشاهدة تقبل نوع المحتوى هذا"
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist",
"edit_title": "Edit Watchlist",
"create_button": "Create Watchlist",
"save_button": "Save Changes",
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
"allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists",
"empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Add items from your library to this watchlist",
"not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Go to Settings",
"add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
"delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"remove_item_title": "Remove from Watchlist",
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type"
},
"playback_speed": {
"title": "سرعة التشغيل",
"apply_to": "تطبيق على",
"speed": "السرعة",
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"scope": {
"media": "الوسائط هذه فقط",
"show": "هذا المسلسل",
"all": "جميع الوسائط (الافتراضي)"
"media": "This media only",
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Error",
"login_title": "Inicia sessió",
"login_to_title": "Inicia sessió a",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nom d'usuari",
"password_placeholder": "Contrasenya",
"login_button": "Inicia sessió",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Oops!",
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
"continue_watching": "Continua veient",
"continue": "Continue",
"next_up": "A continuació",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Afegit recentment a {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Configuració",
"log_out_button": "Tanca sessió",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Durada del rebobinat",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Error en suprimir fitxers",
"background_downloads_enabled": "Descàrregues en segon pla activades",
"background_downloads_disabled": "Descàrregues en segon pla desactivades"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Vídeo",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Cerca...",
@@ -638,7 +556,6 @@
"movies": "pel·lícules",
"series": "sèries",
"boxsets": "col·leccions",
"playlists": "Playlists",
"items": "elements"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Ordenar per",
"filter_by": "Filter By",
"sort_order": "Ordre",
"tags": "Etiquetes",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Etiquetes"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "No hi ha enllaços"
},
"player": {
"live": "LIVE",
"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ó.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "A continuació",
@@ -733,11 +617,6 @@
"series": "Sèries",
"seasons": "Temporades",
"season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada",
"overview": "Descripció general",
"more_with": "Més amb {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Qualitat",
"audio": "Àudio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Subtítols",
"show_more": "Mostra més",
"show_less": "Mostra menys",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Va aparèixer a",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No s'ha pogut carregar l'element",
"none": "Cap",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Descarrega {{item_count}} elements",
"download_unwatched_only": "Unwatched Only",
"download_button": "Descarrega"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Següent",
@@ -790,18 +652,7 @@
"movies": "Pel·lícules",
"sports": "Esports",
"for_kids": "Infantil",
"news": "Notícies",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Notícies"
},
"jellyseerr": {
"confirm": "Confirma",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0",
"jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.",
@@ -871,8 +716,7 @@
"search": "Cercar",
"library": "Biblioteca",
"custom_links": "Enllaços personalitzats",
"favorites": "Preferits",
"settings": "Settings"
"favorites": "Preferits"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Chyba",
"login_title": "Přihlásit se",
"login_to_title": "Přihlásit se do",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Uživatelské jméno",
"password_placeholder": "Heslo",
"login_button": "Přihlásit se",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Jejda!",
"error_message": "Něco se pokazilo.\nOdhlaste se a znovu se prosím.",
"continue_watching": "Pokračovat ve sledování",
"continue": "Continue",
"next_up": "Další nahoru",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Nedávno přidané v {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Nastavení",
"log_out_button": "Odhlásit se",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Délka zpětného větru",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Ovládání gest",
"horizontal_swipe_skip": "Horizontální přejetím přeskočit",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Chyba při mazání souborů",
"background_downloads_enabled": "Stahování na pozadí povoleno",
"background_downloads_disabled": "Stahování na pozadí zakázáno"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Hledat...",
@@ -638,7 +556,6 @@
"movies": "Filmy",
"series": "Série",
"boxsets": "Sada boxů",
"playlists": "Playlists",
"items": "Položky"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Seřadit podle",
"filter_by": "Filter By",
"sort_order": "Řazení",
"tags": "Štítky",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Štítky"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Žádné odkazy"
},
"player": {
"live": "LIVE",
"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í.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Další nahoru",
@@ -733,11 +617,6 @@
"series": "Série",
"seasons": "Série",
"season": "Sezóna",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Žádné epizody pro tuto sezónu",
"overview": "Přehled",
"more_with": "Více s {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Kvalita",
"audio": "Zvuk",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Podtitulek",
"show_more": "Zobrazit více",
"show_less": "Zobrazit méně",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Zobrazeno v",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nelze načíst položku",
"none": "Nic",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Stáhnout položky {{item_count}}",
"download_unwatched_only": "Pouze nezhlédnuté",
"download_button": "Stáhnout"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Další",
@@ -790,18 +652,7 @@
"movies": "Filmy",
"sports": "Sporty",
"for_kids": "Pro děti",
"news": "Novinky",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Novinky"
},
"jellyseerr": {
"confirm": "Potvrdit",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server nesplňuje minimální požadavky na verzi! Aktualizujte prosím alespoň na 2.0.0",
"jellyseerr_test_failed": "Seerr test se nezdařil. Zkuste to prosím znovu.",
@@ -871,8 +716,7 @@
"search": "Hledat",
"library": "Knihovna",
"custom_links": "Vlastní odkazy",
"favorites": "Oblíbené",
"settings": "Settings"
"favorites": "Oblíbené"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Fejl",
"login_title": "Log ind",
"login_to_title": "Log ind på",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Brugernavn",
"password_placeholder": "Adgangskode",
"login_button": "Log ind",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Ups!",
"error_message": "Noget gik galt.\nLog venligst ud og ind igen.",
"continue_watching": "Fortsæt med at se",
"continue": "Continue",
"next_up": "Næste",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Senest tilføjet i {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Indstillinger",
"log_out_button": "Log ud",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Spol tilbage længde",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Bevægelsesstyring",
"horizontal_swipe_skip": "Vandret Stryg for at springe over",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Fejl ved sletning af filer",
"background_downloads_enabled": "Baggrundsdownloads aktiveret",
"background_downloads_disabled": "Baggrundsdownloads deaktiveret"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Søg...",
@@ -638,7 +556,6 @@
"movies": "film",
"series": "serier",
"boxsets": "box sæt",
"playlists": "Playlists",
"items": "elementer"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sortér efter",
"filter_by": "Filter By",
"sort_order": "Sorteringsrækkefølge",
"tags": "Mærker",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Mærker"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Ingen links"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Næste",
@@ -733,11 +617,6 @@
"series": "Serier",
"seasons": "Sæsoner",
"season": "Sæson",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ingen episoder for denne sæson",
"overview": "Oversigt",
"more_with": "Mere med {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Kvalitet",
"audio": "Lyd",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Undertekster",
"show_more": "Vis mere",
"show_less": "Vis mindre",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Medvirket i",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kunne ikke indlæse elementet",
"none": "Ingen",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Download {{item_count}} elementer",
"download_unwatched_only": "Kun Usete",
"download_button": "Hent"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Næste",
@@ -790,18 +652,7 @@
"movies": "Film",
"sports": "Sport",
"for_kids": "For børn",
"news": "Nyheder",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Nyheder"
},
"jellyseerr": {
"confirm": "Bekræft",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr serveren opfylder ikke minimumskravene! Opdater venligst til mindst 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test mislykkedes. Prøv venligst igen.",
@@ -871,8 +716,7 @@
"search": "Søg",
"library": "Bibliotek",
"custom_links": "Tilpassede links",
"favorites": "Favoritter",
"settings": "Settings"
"favorites": "Favoritter"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Fehler",
"login_title": "Anmelden",
"login_to_title": "Anmelden bei",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Benutzername",
"password_placeholder": "Passwort",
"login_button": "Anmelden",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} Konten",
"select_account": "Konto auswählen",
"add_account": "Konto hinzufügen",
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt."
},
"save_account": {
"title": "Konto speichern",
@@ -95,7 +86,6 @@
"oops": "Ups!",
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
"continue_watching": "Weiterschauen",
"continue": "Continue",
"next_up": "Als nächstes",
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Einstellungen",
"log_out_button": "Abmelden",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategorien"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Aussehen",
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden"
},
"network": {
"title": "Netzwerk",
@@ -199,22 +174,6 @@
"rewind_length": "Rückspullänge",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gestensteuerung",
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
@@ -297,23 +256,7 @@
"subtitle_font": "Untertitel-Schriftart",
"ksplayer_title": "KSPlayer Einstellungen",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten."
},
"vlc_subtitles": {
"title": "VLC Untertitel-Einstellungen",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Musik-Cache geleert",
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
"downloaded_songs_size": "{{size}} heruntergeladen",
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht"
},
"intro": {
"title": "Einführung",
@@ -493,21 +430,6 @@
"error_deleting_files": "Fehler beim Löschen von Dateien",
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "Keine",
"track": "Spur",
"cancel": "Abbrechen",
"stop": "Stop",
"delete": "Löschen",
"ok": "OK",
"remove": "Entfernen",
"next": "Weiter",
"back": "Zurück",
"continue": "Fortsetzen",
"verifying": "Verifiziere...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifiziere..."
},
"search": {
"search": "Suchen...",
@@ -638,7 +556,6 @@
"movies": "Filme",
"series": "Serien",
"boxsets": "Boxsets",
"playlists": "Playlists",
"items": "Elemente"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sortieren nach",
"filter_by": "Filtern nach",
"sort_order": "Sortierreihenfolge",
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tags"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Keine Links"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Abbrechen"
},
"item_card": {
"next_up": "Als Nächstes",
@@ -733,11 +617,6 @@
"series": "Serien",
"seasons": "Staffeln",
"season": "Staffel",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Keine Episoden für diese Staffel",
"overview": "Überblick",
"more_with": "Mehr mit {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Medienoptionen",
"quality": "Qualität",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Untertitel",
"show_more": "Mehr anzeigen",
"show_less": "Weniger anzeigen",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Erschien in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Konnte Element nicht laden",
"none": "Keine",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "{{item_count}} Elemente herunterladen",
"download_unwatched_only": "Nur Ungesehene",
"download_button": "Herunterladen"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Nächste",
@@ -790,18 +652,7 @@
"movies": "Filme",
"sports": "Sport",
"for_kids": "Für Kinder",
"news": "Nachrichten",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Nachrichten"
},
"jellyseerr": {
"confirm": "Bestätigen",
@@ -846,12 +697,6 @@
"decline": "Ablehnen",
"requested_by": "Angefragt von {{user}}",
"unknown_user": "Unbekannter Nutzer",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
@@ -871,8 +716,7 @@
"search": "Suche",
"library": "Bibliothek",
"custom_links": "Links",
"favorites": "Favoriten",
"settings": "Settings"
"favorites": "Favoriten"
},
"music": {
"title": "Musik",
@@ -997,36 +841,5 @@
"show": "Nur diese Serie",
"all": "Alle (Standard)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Σφάλμα",
"login_title": "Σύνδεση",
"login_to_title": "Συνδεθείτε στο",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Όνομα Χρήστη",
"password_placeholder": "Κωδικός",
"login_button": "Σύνδεση",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Ωχ!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Συνέχεια Παρακολούθησης",
"continue": "Continue",
"next_up": "Επόμενο Επάνω",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Ρυθμίσεις",
"log_out_button": "Αποσύνδεση",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Επαναφορά Μήκους",
"seconds_unit": "ίνα"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Έλεγχοι Χειρονομιών",
"horizontal_swipe_skip": "Οριζόντια σάρωση για παράλειψη",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων",
"background_downloads_enabled": "Οι λήψεις στο παρασκήνιο ενεργοποιήθηκαν",
"background_downloads_disabled": "Οι λήψεις παρασκηνίου απενεργοποιήθηκαν"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Αναζήτηση...",
@@ -638,7 +556,6 @@
"movies": "Ταινίες",
"series": "Σειρά",
"boxsets": "Σύνολα Πλαισίων",
"playlists": "Playlists",
"items": "Στοιχεία"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Ταξινόμηση Κατά",
"filter_by": "Filter By",
"sort_order": "Σειρά Ταξινόμησης",
"tags": "Ετικέτες",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Ετικέτες"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Δεν Υπάρχουν Σύνδεσμοι"
},
"player": {
"live": "LIVE",
"error": "Σφάλμα",
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Επόμενο Επάνω",
@@ -733,11 +617,6 @@
"series": "Σειρά",
"seasons": "Περίοδοι",
"season": "Σεζόν",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Δεν υπάρχουν επεισόδια για αυτή τη σεζόν",
"overview": "Επισκόπηση",
"more_with": "More with {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Ποιότητα",
"audio": "Ήχος",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Υπότιτλος",
"show_more": "Εμφάνιση Περισσότερων",
"show_less": "Εμφάνιση Λιγότερων",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Εμφανίστηκε Σε",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Αδύνατη Η Φόρτωση Του Στοιχείου",
"none": "Κανένα",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Λήψη Αντικειμένων {{item_count}}",
"download_unwatched_only": "Μόνο Χωρίς Παρακολούθηση",
"download_button": "Λήψη"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Επόμενο",
@@ -790,18 +652,7 @@
"movies": "Ταινίες",
"sports": "Αθλητισμός",
"for_kids": "Για Παιδιά",
"news": "Νέα",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Νέα"
},
"jellyseerr": {
"confirm": "Επιβεβαίωση",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Ο διακομιστής Seerr δεν πληροί τις ελάχιστες απαιτήσεις έκδοσης! Παρακαλούμε ενημερώστε τουλάχιστον σε 2.0.0",
"jellyseerr_test_failed": "Ο έλεγχος Seerr απέτυχε. Παρακαλώ προσπαθήστε ξανά.",
@@ -871,8 +716,7 @@
"search": "Αναζήτηση",
"library": "Βιβλιοθήκη",
"custom_links": "Προσαρμοσμένοι Σύνδεσμοι",
"favorites": "Αγαπημένα",
"settings": "Settings"
"favorites": "Αγαπημένα"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -998,6 +998,30 @@
"all": "All media (default)"
}
},
"syncplay": {
"title": "SyncPlay",
"my_group": "My Group",
"join_group": "Join Group",
"leave_group": "Leave Group",
"create_group": "Create Group",
"create_new_group": "Create New Group",
"available_groups": "Available Groups",
"group_id": "Group ID",
"leader": "Leader",
"members": "members",
"enabled": "SyncPlay enabled",
"disabled": "SyncPlay disabled",
"user_joined": "{{username}} joined the group",
"user_left": "{{username}} left the group",
"permission_required": "Permission required to use SyncPlay",
"group_does_not_exist": "Group does not exist",
"create_denied": "Permission denied to create group",
"join_denied": "Permission denied to join group",
"library_access_denied": "Library access denied",
"waiting_for_group": "Waiting for group...",
"joining_playback": "Joining group playback...",
"failed_to_start": "Failed to start SyncPlay group playback"
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",

View File

@@ -4,9 +4,6 @@
"error_title": "Error",
"login_title": "Iniciar sesión",
"login_to_title": "Iniciar sesión en",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nombre de usuario",
"password_placeholder": "Contraseña",
"login_button": "Iniciar sesión",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} cuentas",
"select_account": "Seleccione una cuenta",
"add_account": "Añadir cuenta",
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "Esto eliminará las credenciales guardadas para {{username}}."
},
"save_account": {
"title": "Guardar Cuenta",
@@ -95,7 +86,6 @@
"oops": "¡Vaya!",
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
"continue_watching": "Seguir viendo",
"continue": "Continue",
"next_up": "A continuación",
"continue_and_next_up": "Continuar y siguiente",
"recently_added_in": "Recientemente añadido en {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Configuración",
"log_out_button": "Cerrar sesión",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorías"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Apariencia",
"merge_next_up_continue_watching": "Fusionar continuar viendo y siguiente",
"hide_remote_session_button": "Ocultar botón de sesión remota",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Ocultar botón de sesión remota"
},
"network": {
"title": "Cadena",
@@ -199,22 +174,6 @@
"rewind_length": "Longitud de retroceso",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controles de gestos",
"horizontal_swipe_skip": "Deslizar horizontal para omitir",
@@ -297,23 +256,7 @@
"subtitle_font": "Fuente de los subtítulos",
"ksplayer_title": "Ajustes de KSPlayer",
"hardware_decode": "Decodificación de hardware",
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción."
},
"vlc_subtitles": {
"title": "Configuración de subtítulos VLC",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Caché de música eliminado",
"delete_all_downloaded_songs": "Eliminar todas las descargas",
"downloaded_songs_size": "{{tamaño}} descargado",
"downloaded_songs_deleted": "Canciones descargadas eliminadas",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Canciones descargadas eliminadas"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Error al eliminar archivos",
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo",
@@ -580,16 +501,13 @@
"none": "Nada",
"track": "Pista",
"cancel": "Cancelar",
"stop": "Stop",
"delete": "Borrar",
"ok": "Aceptar",
"remove": "Eliminar",
"next": "Siguiente",
"back": "Atrás",
"continue": "Continuar",
"verifying": "Verificando...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verificando..."
},
"search": {
"search": "Buscar...",
@@ -638,7 +556,6 @@
"movies": "Películas",
"series": "Series",
"boxsets": "Colecciones",
"playlists": "Playlists",
"items": "Elementos"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Ordenar por",
"filter_by": "Filtrar por",
"sort_order": "Ordenar",
"tags": "Etiquetas",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Etiquetas"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Sin enlaces"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "¿Quieres reproducir el archivo descargado?",
"downloaded_file_yes": "Sí",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancelar",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancelar"
},
"item_card": {
"next_up": "A continuación",
@@ -733,11 +617,6 @@
"series": "Series",
"seasons": "Temporadas",
"season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No hay episodios para esta temporada",
"overview": "Resumen",
"more_with": "Más con {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Opciones de medios",
"quality": "Calidad",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Subtítulos",
"show_more": "Mostrar más",
"show_less": "Mostrar menos",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apareció en",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "No se pudo cargar el ítem",
"none": "Ninguno",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Descargar {{item_count}} ítems",
"download_unwatched_only": "No visto",
"download_button": "Descargar"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Siguiente",
@@ -790,18 +652,7 @@
"movies": "Películas",
"sports": "Deportes",
"for_kids": "Para niños",
"news": "Noticias",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Noticias"
},
"jellyseerr": {
"confirm": "Confirmar",
@@ -846,12 +697,6 @@
"decline": "Rechazar",
"requested_by": "Solicitado por {{user}}",
"unknown_user": "Usuario desconocido",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.",
"jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.",
@@ -871,8 +716,7 @@
"search": "Buscar",
"library": "Bibliotecas",
"custom_links": "Enlaces personalizados",
"favorites": "Favoritos",
"settings": "Settings"
"favorites": "Favoritos"
},
"music": {
"title": "Música",
@@ -997,36 +841,5 @@
"show": "Este programa",
"all": "Todos los medios (por defecto)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Virhe",
"login_title": "Kirjaudu sisään",
"login_to_title": "Kirjaudu sisään palveluun",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Käyttäjätunnus",
"password_placeholder": "Salasana",
"login_button": "Kirjaudu sisään",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Ups!",
"error_message": "Jotain meni pieleen.\nKirjaudu ulos ja takaisin sisään.",
"continue_watching": "Jatka katsomista",
"continue": "Continue",
"next_up": "Seuraavaksi",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Asetukset",
"log_out_button": "Kirjaudu ulos",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategoriat"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Ulkoasu",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Taaksepäin hyppäämisen pituus",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Ele Ohjaus",
"horizontal_swipe_skip": "Ohita vaakatasossa pyyhkäisemällä",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Esittely",
@@ -493,21 +430,6 @@
"error_deleting_files": "Virhe tiedostojen poistamisessa",
"background_downloads_enabled": "Taustalataukset käytössä",
"background_downloads_disabled": "Taustalataukset pois käytöstä"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "Ei mitään",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Haku...",
@@ -638,7 +556,6 @@
"movies": "elokuvat",
"series": "sarjat",
"boxsets": "bokset",
"playlists": "Playlists",
"items": "kohteet"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Lajittele",
"filter_by": "Filter By",
"sort_order": "Lajittelujärjestys",
"tags": "Tunnisteet",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tunnisteet"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Ei Linkkejä"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Haluatko toistaa ladatun tiedoston?",
"downloaded_file_yes": "Kyllä",
"downloaded_file_no": "Ei",
"downloaded_file_cancel": "Peruuta",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Peruuta"
},
"item_card": {
"next_up": "Seuraavaksi",
@@ -733,11 +617,6 @@
"series": "Sarjat",
"seasons": "Kaudet",
"season": "Kausi",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ei jaksoja tälle kaudelle",
"overview": "Yleiskatsaus",
"more_with": "Enemmän {{name}} kanssa",
@@ -748,21 +627,10 @@
"media_options": "Media-asetukset",
"quality": "Laatu",
"audio": "Ääni",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Tekstitys",
"show_more": "Näytä Lisää",
"show_less": "Näytä Vähemmän",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Esiintyy Sisään",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kohdetta Ei Voitu Ladata",
"none": "Ei mitään",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Lataa {{item_count}} Kohteita",
"download_unwatched_only": "Vain Katsomattomat",
"download_button": "Lataa"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Seuraava",
@@ -790,18 +652,7 @@
"movies": "Elokuvat",
"sports": "Urheilu",
"for_kids": "Lapsille",
"news": "Uutiset",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Uutiset"
},
"jellyseerr": {
"confirm": "Vahvista",
@@ -846,12 +697,6 @@
"decline": "Hylkää",
"requested_by": "Käyttäjän {{user}} pyynnöstä",
"unknown_user": "Tuntematon käyttäjä",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr-palvelin ei täytä vähimmäisversiovaatimuksia! Päivitä vähintään versioon 2.0.0",
"jellyseerr_test_failed": "Jellyseerr-testi epäonnistui. Yritä uudelleen.",
@@ -871,8 +716,7 @@
"search": "Haku",
"library": "Kirjasto",
"custom_links": "Mukautetut linkit",
"favorites": "Suosikit",
"settings": "Settings"
"favorites": "Suosikit"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Erreur",
"login_title": "Se connecter",
"login_to_title": "Se connecter à",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe",
"login_button": "Se connecter",
@@ -45,13 +42,7 @@
"accounts_count": "Comptes {{count}}",
"select_account": "Sélectionnez un compte",
"add_account": "Ajouter un compte",
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}."
},
"save_account": {
"title": "Sauvegarder le compte",
@@ -95,7 +86,6 @@
"oops": "Oups!",
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
"continue_watching": "Continuer à regarder",
"continue": "Continue",
"next_up": "À suivre",
"continue_and_next_up": "Continuer de regarder et à suivre",
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Paramètres",
"log_out_button": "Déconnexion",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Catégories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Apparence",
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
"hide_remote_session_button": "Masquer le bouton de session distante",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Masquer le bouton de session distante"
},
"network": {
"title": "Réseau",
@@ -199,22 +174,6 @@
"rewind_length": "Durée de retour en arrière",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Commandes gestuelles",
"horizontal_swipe_skip": "Glisser horizontalement pour passer",
@@ -297,23 +256,7 @@
"subtitle_font": "Police des sous-titres",
"ksplayer_title": "Paramètres de KSPlayer",
"hardware_decode": "Décodage matériel",
"hardware_decode_description": "Utilisez laccélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Utilisez laccélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture."
},
"vlc_subtitles": {
"title": "Paramètres des sous-titres VLC",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Cache de musique effacé",
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
"downloaded_songs_size": "{{size}} téléchargé",
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Chansons téléchargées supprimées"
},
"intro": {
"title": "Introduction",
@@ -493,21 +430,6 @@
"error_deleting_files": "Erreur lors de la suppression des fichiers",
"background_downloads_enabled": "Téléchargements en arrière-plan activés",
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Sélectionner",
"no_trailer_available": "Aucune bande-annonce disponible",
"video": "Vidéo",
@@ -580,16 +501,13 @@
"none": "Aucun",
"track": "Suivre",
"cancel": "Annuler",
"stop": "Stop",
"delete": "Supprimer",
"ok": "Ok",
"remove": "Retirer",
"next": "Suivant",
"back": "Précédent",
"continue": "Continuer",
"verifying": "Vérification...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Vérification..."
},
"search": {
"search": "Rechercher...",
@@ -638,7 +556,6 @@
"movies": "Films",
"series": "Séries",
"boxsets": "Coffrets ",
"playlists": "Playlists",
"items": "Médias"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Trier par",
"filter_by": "Filtrer par",
"sort_order": "Ordre de tri",
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tags"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Aucuns liens"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?",
"downloaded_file_yes": "Oui",
"downloaded_file_no": "Non",
"downloaded_file_cancel": "Annuler",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Annuler"
},
"item_card": {
"next_up": "À suivre",
@@ -733,11 +617,6 @@
"series": "Séries",
"seasons": "Saisons",
"season": "Saison",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
"overview": "Aperçu",
"more_with": "Plus avec {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Options média",
"quality": "Qualité",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Sous-titres",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparu dans",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossible de charger le média",
"none": "Aucun",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Télécharger {{item_count}} médias",
"download_unwatched_only": "Non visionné uniquement",
"download_button": "Télécharger"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Suivant",
@@ -790,18 +652,7 @@
"movies": "Films",
"sports": "Sports",
"for_kids": "Pour enfants",
"news": "Actualités",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Actualités"
},
"jellyseerr": {
"confirm": "Confirmer",
@@ -846,12 +697,6 @@
"decline": "Refuser",
"requested_by": "Demandé par {{user}}",
"unknown_user": "Utilisateur inconnu",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
@@ -871,8 +716,7 @@
"search": "Recherche",
"library": "Bibliothèque",
"custom_links": "Liens personnalisés",
"favorites": "Favoris",
"settings": "Settings"
"favorites": "Favoris"
},
"music": {
"title": "Musique",
@@ -944,8 +788,8 @@
}
},
"watchlists": {
"title": "Listes de lecture",
"my_watchlists": "Mes listes de lecture",
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"public_watchlists": "Watchlist publique",
"create_title": "Créer une Watchlist",
"edit_title": "Modifier la Watchlist",
@@ -958,7 +802,7 @@
"name_placeholder": "Entrer le nom de la playlist",
"description_label": "Description",
"description_placeholder": "Entrez la description (facultatif)",
"is_public_label": "Liste de lecture Publique",
"is_public_label": "Public Watchlist",
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
"allowed_type_label": "Type de contenu",
"sort_order_label": "Ordre de tri par défaut",
@@ -997,36 +841,5 @@
"show": "Cette série",
"all": "Tous les médias (par défaut)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "שגיאה",
"login_title": "התחבר",
"login_to_title": "התחבר אל",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "שם משתמש",
"password_placeholder": "סיסמה",
"login_button": "התחבר",
@@ -42,16 +39,10 @@
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} חשבונות",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "אופס!",
"error_message": "קרתה תקלה. אנא התנתק והתחבר מחדש.",
"continue_watching": "המשך לצפות",
"continue": "Continue",
"next_up": "הבא בתור",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "התווסף לאחרונה ב-{{libraryName}}",
@@ -119,34 +109,19 @@
"settings": {
"settings_title": "הגדרות",
"log_out_button": "התנתק",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "קטגוריות"
"title": "Categories"
},
"playback_controls": {
"title": "Playback & Controls"
},
"audio_subtitles": {
"title": "שמע וכתוביות"
"title": "Audio & Subtitles"
},
"appearance": {
"title": "מראה",
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "אורך הזזה אחורה",
"seconds_unit": "שנ'"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "פקדי מחוות",
"horizontal_swipe_skip": "החלקה אופקית לדילוג",
@@ -229,7 +188,7 @@
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
},
"audio": {
"audio_title": "שמע",
"audio_title": "אודיו",
"set_audio_track": "בחר רצועת שמע מהפריט הקודם",
"audio_language": "שפת שמע",
"audio_hint": "בחר שפת שמע אוטומטית.",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -328,8 +271,8 @@
"margin": "Bottom Margin"
},
"video_player": {
"title": "נגן וידאו",
"video_player": "נגן וידאו",
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
@@ -371,7 +314,7 @@
"downloads_title": "הורדות"
},
"music": {
"title": "מוזיקה",
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
@@ -463,16 +406,10 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "הקדמה",
"title": "Intro",
"show_intro": "הצג פתיח",
"reset_intro": "אפס פתיח"
},
@@ -493,21 +430,6 @@
"error_deleting_files": "שגיאה במחיקת קבצים",
"background_downloads_enabled": "הורדה ברקע מופעלת",
"background_downloads_disabled": "הורדה ברקע כבויה"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,26 +492,22 @@
}
},
"common": {
"no_results": "No Results",
"select": "בחר",
"no_trailer_available": "אין טריילר זמין",
"video": "וידאו",
"audio": "שמע",
"audio": "אודיו",
"subtitle": "כתובית",
"play": "נגן",
"none": "ללא",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "חפש...",
@@ -603,9 +521,9 @@
"episodes": "פרקים",
"collections": "אוספים",
"actors": "שחקנים",
"artists": "אומנים",
"albums": "אלבומים",
"songs": "שירים",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"request_movies": "סרטים מבוקשים",
"request_series": "סדרות מבוקשים",
@@ -638,7 +556,6 @@
"movies": "סרטים",
"series": "סדרות",
"boxsets": "אוסף",
"playlists": "Playlists",
"items": "פריטים"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "מיין לפי",
"filter_by": "Filter By",
"sort_order": "סדר מיון",
"tags": "תגים",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "תגים"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "אין קישורים"
},
"player": {
"live": "LIVE",
"error": "שגיאה",
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
@@ -694,37 +606,9 @@
"go_back": "חזור",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "כן",
"downloaded_file_no": "לא",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "הבא בתור",
@@ -733,11 +617,6 @@
"series": "סדרות",
"seasons": "עונות",
"season": "עונה",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "אין פרקים בעונה זו",
"overview": "סקירה",
"more_with": "עוד עם {{name}}",
@@ -747,22 +626,11 @@
"more_details": "פרטים נוספים",
"media_options": "Media Options",
"quality": "איכות",
"audio": "שמע",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"audio": "אודיו",
"subtitles": "כתובית",
"show_more": "הצג עוד",
"show_less": "הצג פחות",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "הופיע ב-",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "נכשל בטעינת פריט",
"none": "ללא",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "הורד {{item_count}} פריטים",
"download_unwatched_only": "רק שלא נצפו",
"download_button": "הורד"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "הבא",
@@ -790,18 +652,7 @@
"movies": "סרטים",
"sports": "ספורט",
"for_kids": "לילדים",
"news": "חדשות",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "חדשות"
},
"jellyseerr": {
"confirm": "אשר",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "שרת ה-Seerr לא תואם את הגרסה המינימלית הנרדת! אנא עדכן לפחות לגרסה 2.0.0",
"jellyseerr_test_failed": "בדיקת ה-Seerr נכשלה. אנא נסה שוב.",
@@ -871,14 +716,13 @@
"search": "חיפוש",
"library": "ספריה",
"custom_links": "קישורים מותאמים אישית",
"favorites": "מועדפים",
"settings": "Settings"
"favorites": "מועדפים"
},
"music": {
"title": "מוזיקה",
"title": "Music",
"tabs": {
"suggestions": "Suggestions",
"albums": "אלבומים",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
@@ -954,9 +798,9 @@
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "שם",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "תיאור",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
@@ -973,10 +817,10 @@
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "פריט",
"items": "פריטים",
"public": "ציבורי",
"private": "פרטי",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
@@ -991,42 +835,11 @@
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "מהירות",
"speed": "Speed",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Hiba",
"login_title": "Bejelentkezés",
"login_to_title": "Bejelentkezés ide",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Felhasználónév",
"password_placeholder": "Jelszó",
"login_button": "Bejelentkezés",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Hoppá!",
"error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.",
"continue_watching": "Nézd Tovább",
"continue": "Continue",
"next_up": "Következő",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Beállítások",
"log_out_button": "Kijelentkezés",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Visszatekerés Hossza",
"seconds_unit": "mp"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesztusvezérlés",
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Hiba a Fájlok Törlésekor",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Videó",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Keresés...",
@@ -638,7 +556,6 @@
"movies": "Filmek",
"series": "Sorozatok",
"boxsets": "Gyűjtemények",
"playlists": "Playlists",
"items": "Elemek"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Rendezés",
"filter_by": "Filter By",
"sort_order": "Rendezés Iránya",
"tags": "Címkék",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Címkék"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Nincsenek Linkek"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Következő",
@@ -733,11 +617,6 @@
"series": "Sorozat",
"seasons": "Évadok",
"season": "Évad",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód",
"overview": "Áttekintés",
"more_with": "További {{name}} Alkotások",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Minőség",
"audio": "Hang",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Felirat",
"show_more": "Több Megjelenítése",
"show_less": "Kevesebb Megjelenítése",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Megjelent:",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nem Sikerült Betölteni az Elemet",
"none": "Nincs",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "{{item_count}} Elem Letöltése",
"download_unwatched_only": "Csak Nem Megtekintett",
"download_button": "Letöltés"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Következő",
@@ -790,18 +652,7 @@
"movies": "Filmek",
"sports": "Sport",
"for_kids": "Gyerekeknek",
"news": "Hírek",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Hírek"
},
"jellyseerr": {
"confirm": "Megerősítés",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.",
"jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.",
@@ -871,8 +716,7 @@
"search": "Keresés",
"library": "Könyvtár",
"custom_links": "Egyéni Linkek",
"favorites": "Kedvencek",
"settings": "Settings"
"favorites": "Kedvencek"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Errore",
"login_title": "Accesso",
"login_to_title": "Accedi a",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nome utente",
"password_placeholder": "Password",
"login_button": "Accedi",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Ops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare",
"continue": "Continue",
"next_up": "Prossimo",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Impostazioni",
"log_out_button": "Esci",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorie"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Aspetto",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -161,7 +136,7 @@
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "WiFi Attuale",
"current_wifi": "Current WiFi",
"using_url": "Sta utilizzando",
"local": "Local URL",
"remote": "Remote URL",
@@ -199,22 +174,6 @@
"rewind_length": "Lunghezza del riavvolgimento",
"seconds_unit": "secondi"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controlli Gesture",
"horizontal_swipe_skip": "Scorrimento orizzontale per saltare",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Errore nella cancellazione dei file",
"background_downloads_enabled": "Scaricamento in background abilitato",
"background_downloads_disabled": "Scaricamento in background disabilitato"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Seleziona",
"no_trailer_available": "Nessun trailer disponibile",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "Nulla",
"track": "Traccia",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Cerca...",
@@ -638,7 +556,6 @@
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"playlists": "Playlists",
"items": "elementi"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Ordina per",
"filter_by": "Filter By",
"sort_order": "Criterio di ordinamento",
"tags": "Tag",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tag"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Nessun link"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Il prossimo",
@@ -733,11 +617,6 @@
"series": "Serie",
"seasons": "Stagioni",
"season": "Stagione",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Nessun episodio per questa stagione",
"overview": "Panoramica",
"more_with": "Altri con {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Opzioni Media",
"quality": "Qualità",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Sottotitoli",
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparso in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Scarica {{item_count}} elementi",
"download_unwatched_only": "Solo Non Visti",
"download_button": "Scarica"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Prossimo",
@@ -790,18 +652,7 @@
"movies": "Film",
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Notiziari"
},
"jellyseerr": {
"confirm": "Conferma",
@@ -846,12 +697,6 @@
"decline": "Rifiuta",
"requested_by": "Richiesto da {{user}}",
"unknown_user": "Utente Sconosciuto",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
@@ -871,8 +716,7 @@
"search": "Cerca",
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti",
"settings": "Settings"
"favorites": "Preferiti"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "エラー",
"login_title": "ログイン",
"login_to_title": "ログイン先",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "ユーザー名",
"password_placeholder": "パスワード",
"login_button": "ログイン",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "おっと!",
"error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。",
"continue_watching": "続きを見る",
"continue": "Continue",
"next_up": "次の動画",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "{{libraryName}}に最近追加された",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "設定",
"log_out_button": "ログアウト",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "カテゴリ"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "巻き戻しの長さ",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "ジェスチャーコントロール",
"horizontal_swipe_skip": "水平方向にスワイプしてスキップ",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "イントロ",
@@ -493,21 +430,6 @@
"error_deleting_files": "ファイルの削除エラー",
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "選択",
"no_trailer_available": "トレーラーがありません",
"video": "映像",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "検索...",
@@ -638,7 +556,6 @@
"movies": "映画",
"series": "シリーズ",
"boxsets": "ボックスセット",
"playlists": "Playlists",
"items": "アイテム"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "ソート",
"filter_by": "Filter By",
"sort_order": "ソート順",
"tags": "タグ",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "タグ"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "リンクがありません"
},
"player": {
"live": "LIVE",
"error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "次",
@@ -733,11 +617,6 @@
"series": "シリーズ",
"seasons": "シーズン",
"season": "シーズン",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "このシーズンのエピソードはありません",
"overview": "ストーリー",
"more_with": "{{name}}の詳細",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "画質",
"audio": "音声",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "字幕",
"show_more": "もっと見る",
"show_less": "少なく表示",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "出演作品",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "アイテムを読み込めませんでした",
"none": "なし",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "{{item_count}}のアイテムをダウンロード",
"download_unwatched_only": "未視聴のみ",
"download_button": "ダウンロード"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "次",
@@ -790,18 +652,7 @@
"movies": "映画",
"sports": "スポーツ",
"for_kids": "子供向け",
"news": "ニュース",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "ニュース"
},
"jellyseerr": {
"confirm": "確認",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。",
"jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。",
@@ -871,8 +716,7 @@
"search": "検索",
"library": "ライブラリ",
"custom_links": "カスタムリンク",
"favorites": "お気に入り",
"settings": "Settings"
"favorites": "お気に入り"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -1,228 +1,187 @@
{
"login": {
"username_required": "사용자 이름이 필요합니다",
"error_title": "오류",
"login_title": "로그인",
"login_to_title": "다음 서비스에 연결 중",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "사용자 이름",
"password_placeholder": "비밀번호",
"login_button": "로그인",
"quick_connect": "퀵 커넥트",
"enter_code_to_login": "로그인 하기 위해 코드{{code}}를 입력하세요",
"failed_to_initiate_quick_connect": "Quick Connect 연결을 시작하는 데 실패했습니다",
"got_it": "성공",
"connection_failed": "연결 실패",
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
"an_unexpected_error_occured": "예기치 않은 오류가 발생했습니다",
"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을 올바르게 입력하셨습니까?",
"username_required": "Username Is Required",
"error_title": "Error",
"login_title": "Log In",
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"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",
"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?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "연결",
"previous_servers": "이전 서버",
"clear_button": "모두 지우기",
"swipe_to_remove": "스와이프해서 지우기",
"search_for_local_servers": "로컬 서버 찾기",
"searching": "찾는 중...",
"servers": "서버",
"saved": "저장됨",
"session_expired": "세션 만료됨",
"please_login_again": "사용자 세션이 만료되었습니다. 다시 로그인하십시오.",
"remove_saved_login": "저장된 로그인 정보 삭제",
"remove_saved_login_description": "해당 서버에 저장된 자격 증명이 삭제됩니다. 다음에 접속할 때는 사용자 이름과 비밀번호를 다시 입력해야 합니다.",
"accounts_count": "{{count}} 계정",
"select_account": "계정 선택",
"add_account": "계정 추가",
"remove_account_description": "{{username}}에 저장된 자격 증명이 삭제됩니다.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"connect_button": "Connect",
"previous_servers": "Previous Servers",
"clear_button": "Clear all",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for Local Servers",
"searching": "Searching...",
"servers": "Servers",
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "계정 저장",
"save_for_later": "이 계정 저장",
"security_option": "보안 설정",
"no_protection": "보안 없음",
"no_protection_desc": "인증 없이 빠른 로그인",
"pin_code": "PIN 코드",
"pin_code_desc": "전환하려면 4자리 PIN 필요함",
"password": "암호 확인",
"password_desc": "전환하려면 비밀번호 필요함",
"save_button": "저장",
"cancel_button": "취소"
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
},
"pin": {
"enter_pin": "PIN 입력",
"enter_pin_for": "{{username}} PIN 입력",
"enter_4_digits": "4자리 입력",
"invalid_pin": "잘못된 PIN",
"setup_pin": "PIN 설정",
"confirm_pin": "PIN 확인",
"pins_dont_match": "PIN이 일치하지 않습니다",
"forgot_pin": "PIN을 잊으셨나요?",
"forgot_pin_desc": "저장된 계정 정보가 삭제됩니다"
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
},
"password": {
"enter_password": "비밀번호 입력",
"enter_password_for": "{{username}}의 비밀번호 입력",
"invalid_password": "잘못된 비밀번호"
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
},
"home": {
"checking_server_connection": "서버 연결 체크중...",
"no_internet": "인터넷에 연결되지 않음",
"no_items": "항목 없음",
"no_internet_message": "걱정마세요. 다운로드 된 컨텐츠는 여전히 볼 수 있습니다.",
"checking_server_connection": "Checking server connection...",
"no_internet": "No Internet",
"no_items": "No Items",
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
"checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "Go to Downloads",
"retry": "재시도",
"server_unreachable": "서버에 연결할 수 없음",
"server_unreachable_message": "서버에 연결할 수 없습니다. 네트워크 상태를 체크하세요.",
"oops": "이런!",
"error_message": "문제가 발생했습니다.\n로그아웃 후 다시 로그인해 주세요.",
"continue_watching": "이어서 보기",
"continue": "Continue",
"next_up": "다음 시청",
"continue_and_next_up": "이어서 보기 & 다음 시청",
"recently_added_in": "최근에 추가된 {{libraryName}}",
"suggested_movies": "추천 영화",
"suggested_episodes": "추천 에피소드",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
"suggested_movies": "Suggested Movies",
"suggested_episodes": "Suggested Episodes",
"intro": {
"welcome_to_streamyfin": "스트리미핀에 오신 것을 환영합니다",
"a_free_and_open_source_client_for_jellyfin": "젤리핀을 위한 무료 오픈소스 클라이언트입니다.",
"features_title": "기능",
"features_description": "스트리미핀은 다양한 기능을 제공하며 설정 메뉴에서 확인할 수 있는 여러 소프트웨어와 통합됩니다. 이러한 소프트웨어에는 다음이 포함됩니다:",
"jellyseerr_feature_description": "Seerr 인스턴스에 연결하여 앱에서 직접 영화를 요청할 수 있습니다.",
"downloads_feature_title": "다운로드된 컨텐츠",
"downloads_feature_description": "오프라인으로 보기위해 다운로드 하세요. 기본 다운로드 방식을 사용하거나, 백그라운드에서 파일을 다운로드하는 최적화 서버를 설치할 수 있습니다.",
"chromecast_feature_description": "영화와 TV 프로그램을 Chromecast 기기로 전송하기",
"centralised_settings_plugin_title": "중앙 설정 플러그인",
"centralised_settings_plugin_description": "Jellyfin 서버의 중앙 집중식 위치에서 설정을 구성합니다. 모든 사용자의 모든 클라이언트 설정이 자동으로 동기화됩니다.",
"done_button": "확인",
"go_to_settings_button": "설정으로 이동",
"read_more": "자세히 보기"
"welcome_to_streamyfin": "Welcome to Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
"features_title": "Features",
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
"done_button": "Done",
"go_to_settings_button": "Go to Settings",
"read_more": "Read More"
},
"settings": {
"settings_title": "설정",
"log_out_button": "로그아웃",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"settings_title": "Settings",
"log_out_button": "Log Out",
"categories": {
"title": "카테고리"
"title": "Categories"
},
"playback_controls": {
"title": "재생 & 컨트롤"
"title": "Playback & Controls"
},
"audio_subtitles": {
"title": "오디오 & 자막"
"title": "Audio & Subtitles"
},
"appearance": {
"title": "화면 스타일",
"merge_next_up_continue_watching": "[이어보기]와 [다음 보기] 합치기",
"hide_remote_session_button": "원격 세션 버튼 숨기기",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "네트워크",
"local_network": "로컬 네트워크",
"auto_switch_enabled": "홈 네트워크 자동 전환",
"auto_switch_description": "홈 WiFi에 연결되었을 때 로컬 URL로 자동 전환",
"local_url": "로컬 URL",
"local_url_hint": "로컬 서버 주소를 입력하세요 (e.g., http://192.168.1.100:8096)",
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": " WiFi 네트워크",
"add_current_network": "\"{{ssid}}\" 추가",
"not_connected_to_wifi": "WiFi에 연결되지 않음",
"no_networks_configured": "구성된 네트워크가 없습니다",
"add_network_hint": "자동 전환을 위한 홈 WiFi 추가",
"current_wifi": "현재 WiFi",
"using_url": "사용중",
"local": "로컬 URL",
"remote": "원격 URL",
"not_connected": "연결되지 않았습니다",
"current_server": "현재 서버",
"remote_url": "원격 URL",
"active_url": "현재 사용 중인 URL",
"not_configured": "설정되지 않음",
"network_added": "네트워크 추가됨",
"network_already_added": "네트워크 이미 추가됨",
"no_wifi_connected": "WiFi에 연결되지 않음",
"permission_denied": "위치 권한이 거부되었습니다",
"permission_denied_explanation": "자동 전환 Wi-Fi 네트워크를 감지하려면 위치 권한이 필요합니다. 설정에서 위치 권한을 활성화해 주세요."
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
},
"user_info": {
"user_info_title": "사용자 정보",
"user": "사용자",
"server": "서버",
"token": "토큰",
"app_version": "앱 버전"
"user_info_title": "User Info",
"user": "User",
"server": "Server",
"token": "Token",
"app_version": "App Version"
},
"quick_connect": {
"quick_connect_title": "퀵 커넥트",
"authorize_button": "퀵 커넥트 승인",
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
"success": "성공",
"quick_connect_autorized": "퀵 커넥트 승인됨",
"error": "오류",
"invalid_code": "유효하지 않은 코드",
"authorize": "승인"
"quick_connect_title": "Quick Connect",
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect Authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
},
"media_controls": {
"media_controls_title": "미디어 컨트롤",
"forward_skip_length": "앞으로 건너뛸 시간",
"rewind_length": "뒤로 되감을 시간",
"seconds_unit": ""
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
"media_controls_title": "Media Controls",
"forward_skip_length": "Forward Skip Length",
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"gesture_controls": {
"gesture_controls_title": "제스처 제어",
"horizontal_swipe_skip": "좌/우로 스와이프하여 건너뛰기",
"horizontal_swipe_skip_description": "컨트롤 숨김상태에서 좌/우로 스와이프하여 건너뛰기",
"left_side_brightness": "왼쪽 영역 밝기 조정 컨트롤",
"left_side_brightness_description": "왼쪽 영역을 위/아래 스와이프하여 밝기 조절",
"right_side_volume": "오른쪽 영역 볼륨 컨트롤",
"right_side_volume_description": "오른족 영역을 위/아래로 스와이프 하여 볼륨 조절",
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
@@ -237,7 +196,7 @@
"language": "Language",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "서라운드 오디오(7.1, TrueHD, DTS-HD)를 어떻게 처리할지 설정합니다",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
@@ -269,68 +228,52 @@
"outline_opacity": "Outline Opacity",
"bold_text": "Bold Text",
"colors": {
"Black": "검정색",
"Gray": "회색",
"Silver": "은색",
"White": "흰색",
"Maroon": "밤색",
"Red": "빨간색",
"Fuchsia": "분홍색",
"Yellow": "노란색",
"Olive": "올리브 색",
"Green": "녹색",
"Teal": "청록색",
"Lime": "라임색",
"Purple": "보라색",
"Navy": "남색",
"Blue": "파란색",
"Aqua": "아쿠아색"
"Black": "Black",
"Gray": "Gray",
"Silver": "Silver",
"White": "White",
"Maroon": "Maroon",
"Red": "Red",
"Fuchsia": "Fuchsia",
"Yellow": "Yellow",
"Olive": "Olive",
"Green": "Green",
"Teal": "Teal",
"Lime": "Lime",
"Purple": "Purple",
"Navy": "Navy",
"Blue": "Blue",
"Aqua": "Aqua"
},
"thickness": {
"None": "없음",
"Thin": "얇게",
"Normal": "보통",
"Thick": "굵게"
"None": "None",
"Thin": "Thin",
"Normal": "Normal",
"Thick": "Thick"
},
"subtitle_color": "자막 색상",
"subtitle_background_color": "배경 색상",
"subtitle_font": "자막 폰트",
"ksplayer_title": "KSPlayer 설정",
"hardware_decode": "하드웨어 디코딩",
"hardware_decode_description": "비디오 디코딩에 하드웨어 가속을 사용하십시오. 재생 문제가 발생하는 경우 비활성화하십시오.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC 자막 설정",
"hint": "VLC 플레이어의 자막 표시 방식을 설정하세요. 변경 사항은 다음 재생 시 적용됩니다.",
"text_color": "글자색",
"background_color": "배경 색상",
"background_opacity": "배경 투명도",
"outline_color": "외곽선 색상",
"outline_opacity": "외곽선 투명도",
"outline_thickness": "외곽선 굵기",
"bold": "굵은 글씨",
"margin": "아래쪽 여백"
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
},
"video_player": {
"title": "비디오 플레이어",
"video_player": "비디오 플레이어",
"video_player_description": "iOS 사용자는 비디오 플레이어를 선택하세요.",
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -345,20 +288,20 @@
"PORTRAIT_UP": "Portrait Up",
"PORTRAIT_DOWN": "Portrait Down",
"LANDSCAPE": "Landscape",
"LANDSCAPE_LEFT": "왼쪽 가로 모드",
"LANDSCAPE_RIGHT": "오른쪽 가로 모드",
"LANDSCAPE_LEFT": "Landscape Left",
"LANDSCAPE_RIGHT": "Landscape Right",
"OTHER": "Other",
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "컨트롤 안전 영역",
"safe_area_in_controls": "Safe Area in Controls",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
"hide_libraries": "라이브러리 숨기기",
"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.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
@@ -391,24 +334,24 @@
"password": "Password",
"password_placeholder": "Enter password for Jellyfin user {{username}}",
"login_button": "Login",
"total_media_requests": "전체 미디어 요청 수",
"movie_quota_limit": "영화 요청 한도",
"movie_quota_days": "영화 요청 제한 기간",
"total_media_requests": "Total Media Requests",
"movie_quota_limit": "Movie Quota Limit",
"movie_quota_days": "Movie Quota Days",
"tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV 요청 제한 기간",
"reset_jellyseerr_config_button": "Seerr 설정 초기화",
"tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Seerr Config",
"unlimited": "Unlimited",
"plus_n_more": "+{{n}}개 더",
"plus_n_more": "+{{n}} More",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "평균 평점 및 투표 수",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
"POPULARITY": "Popularity"
}
},
"marlin_search": {
"enable_marlin_search": "Marlin 검색 활성화",
"enable_marlin_search": "Enable Marlin Search",
"url": "URL",
"server_url_placeholder": "http(s)://도메인:포트",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
"read_more_about_marlin": "Read More About Marlin.",
"save_button": "Save",
@@ -431,28 +374,28 @@
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "시리즈 추천",
"enable_promoted_watchlists": "추천 관심 목록",
"hide_watchlists_tab": "관심 목록 탭 숨기기",
"home_sections_hint": "홈 페이지에서 Streamystats의 개인 맞춤 추천 및 추천 관심 목록을 표시합니다.",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "추천 시리즈",
"recommended_series": "Recommended Series",
"toasts": {
"saved": "Saved",
"refreshed": "서버에서 설정을 새로고침했습니다",
"disabled": "Streamystats 비활성화됨"
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
},
"refresh_from_server": "서버에서 설정 새로고침"
"refresh_from_server": "Refresh Settings from Server"
},
"kefinTweaks": {
"watchlist_enabler": "관심 목록 통합 기능 활성화",
"watchlist_button": "관심 목록 연동 켜기/끄기"
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
}
},
"storage": {
"storage_title": "Storage",
"app_usage": " {{usedSpace}}",
"device_usage": "디바이스 {{availableSpace}}%",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"music_cache_title": "Music Cache",
@@ -460,16 +403,10 @@
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "음악 캐시가 삭제되었습니다",
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "다운로드한 노래가 삭제되었습니다",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,26 +430,11 @@
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "세션 비활성화"
"no_active_sessions": "No Active Sessions"
},
"downloads": {
"downloads_title": "Downloads",
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Search...",
@@ -638,7 +556,6 @@
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"playlists": "Playlists",
"items": "Items"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tags"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Next Up",
@@ -733,11 +617,6 @@
"series": "Series",
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Subtitle",
"show_more": "Show More",
"show_less": "Show Less",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Appeared In",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Could Not Load Item",
"none": "None",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Next",
@@ -790,18 +652,7 @@
"movies": "Movies",
"sports": "Sports",
"for_kids": "For Kids",
"news": "News",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "News"
},
"jellyseerr": {
"confirm": "Confirm",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -871,8 +716,7 @@
"search": "Search",
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites",
"settings": "Settings"
"favorites": "Favorites"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Fout",
"login_title": "Aanmelden",
"login_to_title": "Aanmelden bij",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Gebruikersnaam",
"password_placeholder": "Wachtwoord",
"login_button": "Aanmelden",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Account selecteren",
"add_account": "Account toevoegen",
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "Hiermee worden de opgeslagen inloggegevens voor {{username}} verwijderd."
},
"save_account": {
"title": "Account opslaan",
@@ -95,7 +86,6 @@
"oops": "Oeps!",
"error_message": "Er ging iets fout\nProbeer opnieuw in te loggen.",
"continue_watching": "Verder Kijken",
"continue": "Continue",
"next_up": "Volgende",
"continue_and_next_up": "Doorgaan & Volgende",
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Instellingen",
"log_out_button": "Afmelden",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorieën"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Weergave",
"merge_next_up_continue_watching": "Doorgaan met kijken & Volgende samenvoegen",
"hide_remote_session_button": "Verberg Knop voor Externe Sessie",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Verberg Knop voor Externe Sessie"
},
"network": {
"title": "Netwerk",
@@ -199,22 +174,6 @@
"rewind_length": "Duur terugspoelen",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gebaar Bediening",
"horizontal_swipe_skip": "Horizontale Swipe om over te slaan",
@@ -297,23 +256,7 @@
"subtitle_font": "Lettertype ondertitels",
"ksplayer_title": "KSPlayer Instellingen",
"hardware_decode": "Hardware Acceleratie",
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt."
},
"vlc_subtitles": {
"title": "VLC ondertitel instellingen",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Muziek cache gewist",
"delete_all_downloaded_songs": "Verwijder alle gedownloade liedjes",
"downloaded_songs_size": "{{size}} gedownload",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Fout bij het verwijderen van bestanden",
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Selecteren",
"no_trailer_available": "Geen trailer beschikbaar",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "Geen",
"track": "Spoor",
"cancel": "Annuleren",
"stop": "Stop",
"delete": "Verwijderen",
"ok": "Oké",
"remove": "Verwijderen",
"next": "Volgende",
"back": "Terug",
"continue": "Doorgaan",
"verifying": "Verifiëren...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifiëren..."
},
"search": {
"search": "Zoek...",
@@ -638,7 +556,6 @@
"movies": "Films",
"series": "Series",
"boxsets": "Boxsets",
"playlists": "Playlists",
"items": "items"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sorteren op",
"filter_by": "Filteren op",
"sort_order": "Sorteer volgorde",
"tags": "Labels",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Labels"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Geen links"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Wil je het gedownloade bestand afspelen?",
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nee",
"downloaded_file_cancel": "Annuleren",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Annuleren"
},
"item_card": {
"next_up": "Volgende",
@@ -733,11 +617,6 @@
"series": "Series",
"seasons": "Seizoenen",
"season": "Seizoen",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Geen afleveringen voor dit seizoen",
"overview": "Overzicht",
"more_with": "Meer met {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media opties",
"quality": "Kwaliteit",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Ondertitel",
"show_more": "Toon meer",
"show_less": "Toon minder",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Verschenen in",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kon item niet laden",
"none": "Geen",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Download {{item_count}} items",
"download_unwatched_only": "Alleen niet bekeken",
"download_button": "Downloaden"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Volgende ",
@@ -790,18 +652,7 @@
"movies": "Films",
"sports": "Sport",
"for_kids": "Voor kinderen",
"news": "Nieuws",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Nieuws"
},
"jellyseerr": {
"confirm": "Bevestig",
@@ -846,12 +697,6 @@
"decline": "Weigeren",
"requested_by": "Aangevraagd door {{user}}",
"unknown_user": "Onbekende gebruiker",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr server voldoet niet aan de minimale versievereisten! Update naar minimaal 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test mislukt. Probeer opnieuw.",
@@ -871,8 +716,7 @@
"search": "Zoeken",
"library": "Bibliotheek",
"custom_links": "Aangepaste links",
"favorites": "Favorieten",
"settings": "Settings"
"favorites": "Favorieten"
},
"music": {
"title": "Muziek",
@@ -997,36 +841,5 @@
"show": "Deze serie",
"all": "Alle media (standaard)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Feil",
"login_title": "Logg inn",
"login_to_title": "Logg inn i",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Brukernavn",
"password_placeholder": "Passord",
"login_button": "Logg inn",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Oisann!",
"error_message": "Noe gikk galt.\nVennligst logg ut og inn igjen.",
"continue_watching": "Fortsett å se",
"continue": "Continue",
"next_up": "Neste opp",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Nylig lagt til i {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Innstillinger",
"log_out_button": "Logg ut",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Omspar lengde",
"seconds_unit": "S"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gest kontroller",
"horizontal_swipe_skip": "Vannrett sveip for å hoppe over",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Feil ved sletting av filer",
"background_downloads_enabled": "Nedlastinger av bakgrunn aktivert",
"background_downloads_disabled": "Bakgrunnsnedlastinger deaktivert"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Velg",
"no_trailer_available": "Ingen trailer tilgjengelig",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Søk...",
@@ -638,7 +556,6 @@
"movies": "Filmer",
"series": "Serier",
"boxsets": "Boks sett",
"playlists": "Playlists",
"items": "Elementer"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sorter etter",
"filter_by": "Filter By",
"sort_order": "Sorter etter",
"tags": "Tagger",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tagger"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Ingen lenke"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Neste opp",
@@ -733,11 +617,6 @@
"series": "Serier",
"seasons": "Sesonger",
"season": "Sesong",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Ingen episoder for denne sesongen",
"overview": "Oversikt",
"more_with": "Mer med {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Kvalitet",
"audio": "Lyd",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Undertittel",
"show_more": "Vis mer",
"show_less": "Vis mindre",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Ble brukt i",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Kan ikke laste inn produkt",
"none": "Ingen",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Last ned {{item_count}} Objekter",
"download_unwatched_only": "Bare usette",
"download_button": "Nedlasting"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Neste",
@@ -790,18 +652,7 @@
"movies": "Filmer",
"sports": "Sport",
"for_kids": "For barn",
"news": "Nyheter",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Nyheter"
},
"jellyseerr": {
"confirm": "Bekreft",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server oppfyller ikke minimumskravene til versjoner! Vennligst oppdater til minst 2.0.0",
"jellyseerr_test_failed": "Seerr-test mislyktes. Vennligst prøv på nytt.",
@@ -871,8 +716,7 @@
"search": "Søk",
"library": "Bibliotek",
"custom_links": "Egendefinerte lenker",
"favorites": "Favoritter",
"settings": "Settings"
"favorites": "Favoritter"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Błąd",
"login_title": "Zaloguj się",
"login_to_title": "Zaloguj się do",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Nazwa użytkownika",
"password_placeholder": "Hasło",
"login_button": "Zaloguj się",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} kont",
"select_account": "Wybierz konto",
"add_account": "Dodaj konto",
"remove_account_description": "Spowoduje to usunięcie zapisanych danych logowania dla {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "Spowoduje to usunięcie zapisanych danych logowania dla {{username}}."
},
"save_account": {
"title": "Zapisz konto",
@@ -95,7 +86,6 @@
"oops": "Ups!",
"error_message": "Coś poszło nie tak.\nWyloguj się i zaloguj ponownie.",
"continue_watching": "Kontynuuj oglądanie",
"continue": "Continue",
"next_up": "Następne w kolejce",
"continue_and_next_up": "Oglądaj dalej i Następne",
"recently_added_in": "Ostatnio dodano w {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Ustawienia",
"log_out_button": "Wyloguj się",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategorie"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Wygląd",
"merge_next_up_continue_watching": "Połącz Oglądaj dalej i Następne",
"hide_remote_session_button": "Ukryj przycisk Zdalnej Sesji",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Ukryj przycisk Zdalnej Sesji"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Długość przewijania do tyłu",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Sterowanie gestami",
"horizontal_swipe_skip": "Przesuń w poziomie, aby pominąć",
@@ -297,23 +256,7 @@
"subtitle_font": "Czcionka napisów",
"ksplayer_title": "Ustawienia KSPlayer",
"hardware_decode": "Dekodowanie sprzętowe",
"hardware_decode_description": "Używaj akceleracji sprzętowej dla dekodowania wideo. Wyłącz, jeśli doświadczasz problemów z odtwarzaniem.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Używaj akceleracji sprzętowej dla dekodowania wideo. Wyłącz, jeśli doświadczasz problemów z odtwarzaniem."
},
"vlc_subtitles": {
"title": "Ustawienia napisów VLC",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Wyczyszczono bufor muzyki",
"delete_all_downloaded_songs": "Usuń wszystkie pobrane piosenki",
"downloaded_songs_size": "Pobrano {{size}}",
"downloaded_songs_deleted": "Usunięto pobrane piosenki",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Usunięto pobrane piosenki"
},
"intro": {
"title": "Wstęp",
@@ -493,21 +430,6 @@
"error_deleting_files": "Błąd podczas usuwania plików",
"background_downloads_enabled": "Pobieranie w tle włączone",
"background_downloads_disabled": "Pobieranie w tle wyłączone"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Wybierz",
"no_trailer_available": "Brak dostępnego zwiastunu",
"video": "Wideo",
@@ -580,16 +501,13 @@
"none": "Nic",
"track": "Utwór",
"cancel": "Anuluj",
"stop": "Stop",
"delete": "Usuń",
"ok": "OK",
"remove": "Usuń",
"next": "Następne",
"back": "Poprzednie",
"continue": "Kontynuuj",
"verifying": "Weryfikacja...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Weryfikacja..."
},
"search": {
"search": "Szukaj...",
@@ -638,7 +556,6 @@
"movies": "filmy",
"series": "seriale",
"boxsets": "zestawy",
"playlists": "Playlists",
"items": "elementy"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sortuj według",
"filter_by": "Filtruj po",
"sort_order": "Kolejność sortowania",
"tags": "Tagi",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tagi"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Brak odnośników"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Chcesz odtworzyć pobrany plik?",
"downloaded_file_yes": "Tak",
"downloaded_file_no": "Nie",
"downloaded_file_cancel": "Anuluj",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Anuluj"
},
"item_card": {
"next_up": "Następne",
@@ -733,11 +617,6 @@
"series": "Serial",
"seasons": "Sezony",
"season": "Sezon",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Brak odcinków w tym sezonie",
"overview": "Opis",
"more_with": "Więcej z {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Ustawienia mediów",
"quality": "Jakość",
"audio": "Dźwięk",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Napisy",
"show_more": "Pokaż więcej",
"show_less": "Pokaż mniej",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Wystąpił w",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nie udało się wczytać elementu",
"none": "Brak",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Pobierz {{item_count}} elementów",
"download_unwatched_only": "Tylko nieobejrzane",
"download_button": "Pobierz"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Następny",
@@ -790,18 +652,7 @@
"movies": "Filmy",
"sports": "Sport",
"for_kids": "Dla dzieci",
"news": "Wiadomości",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Wiadomości"
},
"jellyseerr": {
"confirm": "Potwierdź",
@@ -846,12 +697,6 @@
"decline": "Odrzuć",
"requested_by": "Poproszone przez {{user}}",
"unknown_user": "Nieznany użytkownik",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Serwer Jellyseerr nie spełnia minimalnych wymagań wersji! Zaktualizuj go co najmniej do wersji 2.0.0",
"jellyseerr_test_failed": "Test Jellyseerr nie powiódł się. Spróbuj ponownie.",
@@ -871,8 +716,7 @@
"search": "Szukaj",
"library": "Biblioteka",
"custom_links": "Niestandardowe odnośniki",
"favorites": "Ulubione",
"settings": "Settings"
"favorites": "Ulubione"
},
"music": {
"title": "Muzyka",
@@ -997,36 +841,5 @@
"show": "Ten odcinek",
"all": "Wszystkie media (domyślne)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Erro",
"login_title": "Iniciar sessão",
"login_to_title": "Iniciar sessão em",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Usuário",
"password_placeholder": "Palavra-passe",
"login_button": "Iniciar sessão",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Opa!",
"error_message": "Algo deu errado.\nPor favor, saia e entre novamente.",
"continue_watching": "Continuar assistindo",
"continue": "Continue",
"next_up": "A Seguir",
"continue_and_next_up": "Continuar e Próximo",
"recently_added_in": "Adicionado recentemente em {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Confirgurações",
"log_out_button": "Encerrar Sessão",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorias"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Aparência",
"merge_next_up_continue_watching": "Mesclar Continuar Assistindo e Próximo",
"hide_remote_session_button": "Esconder botão de sessão remota",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Esconder botão de sessão remota"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Comprimento de Retroceder",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controles de Gestos",
"horizontal_swipe_skip": "Deslizar horizontalmente para pular",
@@ -297,23 +256,7 @@
"subtitle_font": "Fonte da legenda",
"ksplayer_title": "Configurações do KSPlayer",
"hardware_decode": "Decodificação por hardware",
"hardware_decode_description": "Use aceleração de hardware para decodificação de vídeo. Desative se você tiver problemas de reprodução.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use aceleração de hardware para decodificação de vídeo. Desative se você tiver problemas de reprodução."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Cache de música limpo",
"delete_all_downloaded_songs": "Excluir todas as músicas baixadas",
"downloaded_songs_size": "{{size}} baixado",
"downloaded_songs_deleted": "Músicas baixadas excluídas",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Músicas baixadas excluídas"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Erro ao excluir arquivos",
"background_downloads_enabled": "Downloads em segundo plano ativados",
"background_downloads_disabled": "Downloads em segundo plano desativados"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Selecionar",
"no_trailer_available": "Nenhum trailer disponível",
"video": "Vídeo",
@@ -580,16 +501,13 @@
"none": "Nenhum",
"track": "Faixa",
"cancel": "Cancelar",
"stop": "Stop",
"delete": "Apagar",
"ok": "OK",
"remove": "Remover",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Buscar...",
@@ -638,7 +556,6 @@
"movies": "Filmes",
"series": "Série",
"boxsets": "Conjuntos de caixas",
"playlists": "Playlists",
"items": "itens"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Classificar por",
"filter_by": "Filtrar Por",
"sort_order": "Ordem de classificação",
"tags": "Etiquetas",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Etiquetas"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Sem links"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Você quer reproduzir o arquivo baixado?",
"downloaded_file_yes": "SIm",
"downloaded_file_no": "Não",
"downloaded_file_cancel": "Cancelar",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancelar"
},
"item_card": {
"next_up": "A Seguir",
@@ -733,11 +617,6 @@
"series": "Série",
"seasons": "Estações",
"season": "Temporada",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Não há episódios para esta temporada",
"overview": "Geral",
"more_with": "Mais com {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Opções de Mídia",
"quality": "Qualidade",
"audio": "Áudio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Legenda",
"show_more": "Mostrar mais",
"show_less": "Mostrar menos",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Aparece em",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Não foi possível carregar o item",
"none": "Nenhuma",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Baixar itens de {{item_count}}",
"download_unwatched_only": "Apenas não assistidos",
"download_button": "BAIXAR"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Próximo",
@@ -790,18 +652,7 @@
"movies": "Filmes",
"sports": "Esportes",
"for_kids": "Para crianças",
"news": "Notícias",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Notícias"
},
"jellyseerr": {
"confirm": "Confirmar",
@@ -846,12 +697,6 @@
"decline": "Declinar",
"requested_by": "Solicitado por {{user}}",
"unknown_user": "Usuário desconhecido",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "O servidor do Seerr não atende ao mínimo aos requisitos de versão! Por favor, atualize para pelo menos 2.0.0",
"jellyseerr_test_failed": "Falha no teste do senhor. Por favor, tente novamente.",
@@ -871,8 +716,7 @@
"search": "Pesquisa",
"library": "Biblioteca",
"custom_links": "Links personalizados",
"favorites": "Atalhos",
"settings": "Settings"
"favorites": "Atalhos"
},
"music": {
"title": "Música",
@@ -997,36 +841,5 @@
"show": "Esta série",
"all": "Todas as mídias (Padrão)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Eroare",
"login_title": "Conectare",
"login_to_title": "Conectare la",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Utilizator",
"password_placeholder": "Parola",
"login_button": "Conectare",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Ups!",
"error_message": "Ceva nu e bine.\nAutentificați-vă din nou.",
"continue_watching": "Continuă vizionarea",
"continue": "Continue",
"next_up": "Urmează",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Adăugat recent în {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Setări",
"log_out_button": "Deconectare",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categorii"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Aspect",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Durata saltului înapoi",
"seconds_unit": "S"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Controale gesturi",
"horizontal_swipe_skip": "Glisați orizontal pentru a sări",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Introducere",
@@ -493,21 +430,6 @@
"error_deleting_files": "Eroare la ștergerea fișierelor",
"background_downloads_enabled": "Descărcări în fundal activate",
"background_downloads_disabled": "Descărcări în fundal dezactivate"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Selectare",
"no_trailer_available": "Nicio remorcă disponibilă",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "Nimic",
"track": "Limbă audio",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Caută...",
@@ -638,7 +556,6 @@
"movies": "filme",
"series": "seriale",
"boxsets": "box sets",
"playlists": "Playlists",
"items": "articole"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sortează după",
"filter_by": "Filter By",
"sort_order": "Ordine de sortare",
"tags": "Taguri",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Taguri"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Niciun link"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Doriți să redați fișierul descărcat?",
"downloaded_file_yes": "Da",
"downloaded_file_no": "Nu",
"downloaded_file_cancel": "Anulează",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Anulează"
},
"item_card": {
"next_up": "Urmează",
@@ -733,11 +617,6 @@
"series": "Seriale",
"seasons": "Sezoane",
"season": "Sezon",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Niciun episod pt acest sezon",
"overview": "Prezentare generală",
"more_with": "Mai multe cu {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Opțiuni Media",
"quality": "Calitate",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Subtitrare",
"show_more": "Arată mai mult",
"show_less": "Arată mai puțin",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apare în",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Nu s-a putut încărca elementul",
"none": "Nimic",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Descărcați {{item_count}} articole",
"download_unwatched_only": "Numai nevizionate",
"download_button": "Descarcă"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Următorul",
@@ -790,18 +652,7 @@
"movies": "Filme",
"sports": "Sport",
"for_kids": "Pt copii",
"news": "Știri",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Știri"
},
"jellyseerr": {
"confirm": "Confirmă",
@@ -846,12 +697,6 @@
"decline": "Respinge",
"requested_by": "Solicitat de {{user}}",
"unknown_user": "Utilizator necunoscut",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Serverul Jellyseerr nu îndeplinește cerințele minime de versiune! Vă rugăm să actualizați cel puțin la versiunea 2.0.0",
"jellyseerr_test_failed": "Testul Jellyseerr a eșuat. Vă rugăm să încercați din nou.",
@@ -871,8 +716,7 @@
"search": "Caută",
"library": "Bibiliotecă",
"custom_links": "Linkuri personalizate",
"favorites": "Favorite",
"settings": "Settings"
"favorites": "Favorite"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Ошибка",
"login_title": "Вход",
"login_to_title": "Вход в",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Имя пользователя",
"password_placeholder": "Пароль",
"login_button": "Войти",
@@ -15,25 +12,25 @@
"failed_to_initiate_quick_connect": "Не удалось инициировать быстрое подключение",
"got_it": "Принято",
"connection_failed": "Соединение не удалось",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста, проверьте URL и ваше интернет-соединение.",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста проверьте URL и ваше интернет соединение.",
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
"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_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?",
"too_old_server_text": "Обнаружен неподдерживаемый сервер Jellyfin",
"too_old_server_text": "Неподдерживаемый сервер Jellyfin обнаружен",
"too_old_server_description": "Пожалуйста, обновите Jellyfin до последней версии"
},
"server": {
"enter_url_to_jellyfin_server": "Укажите URL на ваш Jellyfin сервер",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Подключиться",
"previous_servers": "Предыдущие серверы",
"previous_servers": "предыдущие серверы",
"clear_button": "Очистить",
"swipe_to_remove": "Смахните для удаления",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Поиск локальных серверов",
"searching": "Поиск...",
"servers": "Сервера",
@@ -42,16 +39,10 @@
"please_login_again": "Ваша сессия истекла. Пожалуйста, войдите снова.",
"remove_saved_login": "Удалить сохраненный аккаунт",
"remove_saved_login_description": "Ваши сохранённые данные для входа от этого сервера будут удалены. Вам придётся ввести ваши логин и пароль ещё раз.",
"accounts_count": "Аккаунтов: {{count}}",
"accounts_count": "{{count}} аккаунтов",
"select_account": "Выбрать аккаунт",
"add_account": "Добавить аккаунт",
"remove_account_description": "Данные для входа {{username}} будут удалены.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "Данные для входа {{username}} будут удалены."
},
"save_account": {
"title": "Сохранить аккаунт",
@@ -67,14 +58,14 @@
"cancel_button": "Отмена"
},
"pin": {
"enter_pin": "Введите PIN-код",
"enter_pin_for": "Введите PIN-код для {{username}}",
"enter_pin": "Введите PIN",
"enter_pin_for": "Введите PIN для {{username}}",
"enter_4_digits": "Введите 4 цифры",
"invalid_pin": "Некорректный PIN-код",
"setup_pin": "Установить PIN-код",
"confirm_pin": "Подтвердите PIN-код",
"invalid_pin": "Некорректный PIN",
"setup_pin": "Установить PIN",
"confirm_pin": "Подтвердите PIN",
"pins_dont_match": "PIN-коды не совпадают",
"forgot_pin": "Забыли PIN-код?",
"forgot_pin": "Забыли PIN?",
"forgot_pin_desc": "Ваши данные для входа будут удалены"
},
"password": {
@@ -93,9 +84,8 @@
"server_unreachable": "Сервер недоступен",
"server_unreachable_message": "Не удалось соединиться с сервером.\nПожалуйста, проверьте настройки сети.",
"oops": "Упс!",
"error_message": "Что-то пошло не так.\nПожалуйста, выйдите и зайдите снова.",
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
"continue_watching": "Продолжить",
"continue": "Continue",
"next_up": "Далее",
"continue_and_next_up": "Продолжить и Далее",
"recently_added_in": "Недавно добавлено в {{libraryName}}",
@@ -103,13 +93,13 @@
"suggested_episodes": "Предложенные серии",
"intro": {
"welcome_to_streamyfin": "Добро пожаловать в Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом.",
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом",
"features_title": "Функции",
"features_description": "Streamyfin имеет множество функций и интегрируется с широким спектром программ, которое вы можете найти в меню настроек:",
"jellyseerr_feature_description": "Подключитесь к Jellyseerr и запрашивайте фильмы прямо в приложении.",
"downloads_feature_title": "Загрузки",
"downloads_feature_description": "Скачивайте фильмы и сериалы для просмотра без интернета. Используйте стандартный способ или установите сервер оптимизации для загрузки файлов в фоновом режиме.",
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройства с поддержкой Chromecast.",
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройста с поддержкой Chromecast.",
"centralised_settings_plugin_title": "Плагин для централизованной настройки",
"centralised_settings_plugin_description": "Настраивайте параметры из централизованного места на сервере Jellyfin. Все настройки клиента для всех пользователей будут синхронизированы автоматически.",
"done_button": "Готово",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Настройки",
"log_out_button": "Выйти",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Категории"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Внешний вид",
"merge_next_up_continue_watching": "Объединить «Продолжить» и «Далее»",
"hide_remote_session_button": "Скрыть кнопку «Удалённый сеанс»",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Скрыть кнопку «Удалённый сеанс»"
},
"network": {
"title": "Сеть",
@@ -154,7 +129,7 @@
"auto_switch_enabled": "Переключаться дома автоматически",
"auto_switch_description": "Автоматически переключаться на локальный URL при присоединении к домашней WiFi сети",
"local_url": "Локальный URL",
"local_url_hint": "Введите локальный URL вашего сервера (например, http://192.168.1.100:8096)",
"local_url_hint": "Введите локальный URL вашего сервера (e.g., http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Домашние WiFi сети",
"add_current_network": "Добавить \"{{ssid}}\"",
@@ -185,44 +160,28 @@
},
"quick_connect": {
"quick_connect_title": "Быстрое подключение",
"authorize_button": "Авторизовать через быстрое подключение",
"authorize_button": "Авторизировать через быстрое подключение",
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
"success": "Успех",
"quick_connect_autorized": "Быстрое подключение авторизовано",
"error": "Ошибка",
"invalid_code": "Неверный код",
"authorize": "Авторизовать"
"authorize": "Авторизировать"
},
"media_controls": {
"media_controls_title": "Управление воспроизведением",
"media_controls_title": "Медиа-контроль",
"forward_skip_length": "Шаг перемотки вперёд",
"rewind_length": "Шаг перемотки назад",
"seconds_unit": "c"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Управление жестами",
"horizontal_swipe_skip": "Проведите влево/вправо для перемотки",
"horizontal_swipe_skip_description": "Проведите влево/вправо, когда элементы управления скрыты, чтобы перемотать",
"left_side_brightness": "Управление яркостью слева",
"left_side_brightness_description": "Проведите вверх/вниз на левой стороне для настройки яркости",
"horizontal_swipe_skip": "Горизонтальный свайп для перемотки",
"horizontal_swipe_skip_description": "Проведите влево/вправо, когда элементы управления скрыты, чтобы пропустить",
"left_side_brightness": "Управление яркостью левой стороны",
"left_side_brightness_description": "Смахните вверх/вниз на левой стороне для настройки яркости",
"right_side_volume": "Управление громкостью справа",
"right_side_volume_description": "Проведите вверх/вниз с правой стороны для настройки громкости",
"right_side_volume_description": "Свайп вверх/вниз с правой стороны для настройки громкости",
"hide_volume_slider": "Скрыть индикатор громкости",
"hide_volume_slider_description": "Скрывает индикатор громкости в плеере",
"hide_brightness_slider": "Скрыть индикатор яркости",
@@ -246,7 +205,7 @@
},
"subtitles": {
"subtitle_title": "Субтитры",
"subtitle_hint": "Настройки отображения субтитров.",
"subtitle_hint": "Настройки отображения субтитров",
"subtitle_language": "Язык субтитров",
"subtitle_mode": "Режим субтитров",
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
@@ -297,23 +256,7 @@
"subtitle_font": "Шрифт субтитров",
"ksplayer_title": "Настройки KSPlayer",
"hardware_decode": "Аппаратное декодирование",
"hardware_decode_description": "Использовать аппаратное ускорение для декодирования видео. Выключите, если наблюдаете проблемы с воспроизведением.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Использовать аппаратное ускорение для декодирования видео. Выключите, если наблюдаете проблемы с воспроизведением."
},
"vlc_subtitles": {
"title": "Настройки субтитров в VLC",
@@ -328,9 +271,9 @@
"margin": "Отступ снизу"
},
"video_player": {
"title": "Видео плеер",
"video_player": "Видео плеер",
"video_player_description": "Выберите видео плеер в iOS.",
"title": "Видеоплеер",
"video_player": "Видеоплеер",
"video_player_description": "Выберите видеоплеер в iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
@@ -351,12 +294,12 @@
"UNKNOWN": "Неизвестное"
},
"safe_area_in_controls": "Безопасная зона в элементах управления",
"video_player": "Видео плеер",
"video_player": "Видеоплеер",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
},
"show_custom_menu_links": "Показать ссылки пользовательского меню",
"show_custom_menu_links": "Показать ссылки кастомного меню",
"show_large_home_carousel": "Показывать большую карусель (beta)",
"hide_libraries": "Скрыть библиотеки",
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
@@ -364,7 +307,7 @@
"default_quality": "Качество по умолчанию",
"default_playback_speed": "Скорость воспроизведения по умолчанию",
"auto_play_next_episode": "Автоматически воспроизводить следующий эпизод",
"max_auto_play_episode_count": "Максимальное количество авто воспроизводимых эпизодов",
"max_auto_play_episode_count": "Максимальное количество автовоспроизведения эпизодов",
"disabled": "Отключено"
},
"downloads": {
@@ -376,9 +319,9 @@
"playback_description": "Настройте воспроизведение музыки.",
"prefer_downloaded": "Предпочитать скачанные песни",
"caching_title": "Кеширование",
"caching_description": "Автоматически кешировать следующие треки для стабильного воспроизведения.",
"caching_description": "Автоматически предкешировать следующие треки для стабильного воспроизведения.",
"lookahead_enabled": "Включить предкеширование",
"lookahead_count": "Сколько треков предкешировать",
"lookahead_count": "Сколько предкешировать",
"max_cache_size": "Максимальное число предкешированных треков"
},
"plugins": {
@@ -386,8 +329,8 @@
"jellyseerr": {
"jellyseerr_warning": "Эта интеграция находится на ранней стадии. Ожидайте изменений.",
"server_url": "URL сервера",
"server_url_hint": "Пример: http(s)://your-host.url\n(добавьте порт если необходимо)",
"server_url_placeholder": "Seerr URL...",
"server_url_hint": "Пример: http(s)://your-host.url\n(Добавьте порт если необходимо)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Пароль",
"password_placeholder": "Введите пароль для пользователя Jellyfin {{username}}",
"login_button": "Войти",
@@ -406,7 +349,7 @@
}
},
"marlin_search": {
"enable_marlin_search": "Включить Marlin Search",
"enable_marlin_search": "Включить Marlin Search ",
"url": "URL-адрес",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Введите URL для Marlin сервера. URL должен включать http or https и опционально порт.",
@@ -456,20 +399,14 @@
"size_used": "{{used}} из {{total}} использовано",
"delete_all_downloaded_files": "Удалить все загруженные файлы",
"music_cache_title": "Кеш музыки",
"music_cache_description": "Автоматически кешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
"music_cache_description": "Автоматически прекешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
"enable_music_cache": "Кешировать музыку",
"clear_music_cache": "Очистить кеш музыки",
"music_cache_size": "Кешировано: {{size}}",
"music_cache_size": "{{size}} кешировано",
"music_cache_cleared": "Кеш музыки очищен",
"delete_all_downloaded_songs": "Удалить все скачанные песни",
"downloaded_songs_size": "Скачано: {{size}}",
"downloaded_songs_deleted": "Скачанные песни удалены",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_size": "{{size}} скачано",
"downloaded_songs_deleted": "Скачанные песни удалены"
},
"intro": {
"title": "Вступление",
@@ -478,7 +415,7 @@
},
"logs": {
"logs_title": "Логи",
"export_logs": "Сохранить логи",
"export_logs": "Экспорт журналов",
"click_for_more_info": "Нажмите для получения дополнительной информации",
"level": "Уровень",
"no_logs_available": "Логи не доступны",
@@ -493,21 +430,6 @@
"error_deleting_files": "Ошибка при удалении файлов",
"background_downloads_enabled": "Фоновая загрузка включена",
"background_downloads_disabled": "Фоновая загрузка отключена"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -531,7 +453,7 @@
"no_active_downloads": "Нет активных загрузок",
"active_downloads": "Активные",
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки контента. Пожалуйста, удалите весь скачанный контент и попробуйте заново.",
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
"back": "Назад",
"delete": "Удалить",
"something_went_wrong": "Что-то пошло не так",
@@ -543,34 +465,33 @@
"failed_to_delete_all_movies": "Возникла ошибка при удалении всех фильмов",
"deleted_all_tvseries_successfully": "Все сериалы были успешно удалены!",
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
"deleted_media_successfully": "Остальные медиафайлы успешно удалены!",
"failed_to_delete_media": "Не удалось удалить остальные медиафайлы",
"download_deleted": "Загруженный контент удалён",
"deleted_media_successfully": "Другие носители успешно удалены!",
"failed_to_delete_media": "Не удалось удалить другой файл",
"download_deleted": "Удалено",
"download_cancelled": "Загрузка отменена",
"could_not_delete_download": "Не удалось удалить загрузку",
"download_paused": "На паузе",
"could_not_pause_download": "Не удалось приостановить загрузку",
"download_resumed": "Продолжено",
"could_not_resume_download": "Не удалось возобновить загрузку",
"could_not_resume_download": "Не удалось продолжить загрузку",
"download_completed": "Завершено",
"download_failed": "Не удалось загрузить",
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
"download_completed_for_item": "{{item}} успешно загружен",
"download_started_for_item": "Загрузка {{item}} началась",
"download_started_for_item": "Загрузка началась для {{item}}",
"failed_to_start_download": "Не удалось начать загрузку",
"item_already_downloading": "{{item}} уже загружается",
"all_files_deleted": "Все загрузки удалены",
"files_deleted_by_type": "Удалено: {{count}} {{type}}",
"files_deleted_by_type": "{{count}} {{type}} удалён(о)",
"all_files_folders_and_jobs_deleted_successfully": "Все файлы, папки, и задачи были успешно удалены",
"failed_to_clean_cache_directory": "Не удалось очистить директорию кэша",
"could_not_get_download_url_for_item": "Не удалось получить URL для загрузки {{itemName}}",
"could_not_get_download_url_for_item": "Не удалось получить URL загрузки для {{itemName}}",
"go_to_downloads": "В загрузки",
"file_deleted": "Удалено: {{item}}"
"file_deleted": "{{item}} удалён"
}
}
},
"common": {
"no_results": "No Results",
"select": "Выбрать",
"no_trailer_available": "Трейлер недоступен",
"video": "Видео",
@@ -580,20 +501,17 @@
"none": "Отсутствует",
"track": "Трек",
"cancel": "Отмена",
"stop": "Stop",
"delete": "Удалить",
"ok": "ОК",
"remove": "Удалить",
"next": "Вперед",
"back": "Назад",
"continue": "Продолжить",
"verifying": "Проверка...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Проверка..."
},
"search": {
"search": "Поиск...",
"x_items": "Элементов: {{count}}",
"x_items": "{{count}} элементов",
"library": "Библиотека",
"discover": "Найти новое",
"no_results": "Ничего не найдено",
@@ -611,14 +529,14 @@
"request_series": "Запросить сериалы",
"recently_added": "Недавно добавлено",
"recent_requests": "Недавно запрошено",
"plex_watchlist": "Список просмотра Plex",
"plex_watchlist": "Список просмотра с Plex",
"trending": "В тренде",
"popular_movies": "Популярные фильмы",
"movie_genres": "Популярные жанры",
"upcoming_movies": "Предстоящие фильмы",
"studios": "Студии",
"popular_tv": "Популярные сериалы",
"tv_genres": "Жанры сериалов",
"tv_genres": "жанры сериалов",
"upcoming_tv": "Предстоящие сериалы",
"networks": "Сети",
"tmdb_movie_keyword": "TMDB Ключевые слова фильмов",
@@ -638,8 +556,7 @@
"movies": "Фильмы",
"series": "Сериалы",
"boxsets": "Коллекции",
"playlists": "Playlists",
"items": "Элементы"
"items": "элементы"
},
"options": {
"display": "Отображать",
@@ -648,7 +565,7 @@
"image_style": "Стиль изображения",
"poster": "Постер",
"cover": "Обложка",
"show_titles": "Показывать заголовки",
"show_titles": "Показывать загаловки",
"show_stats": "Показывать статистику"
},
"filters": {
@@ -657,11 +574,7 @@
"sort_by": "Сортировка",
"filter_by": "Фильтр",
"sort_order": "Порядок",
"tags": "Теги",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Тэги"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Нет ссылок"
},
"player": {
"live": "LIVE",
"error": "Ошибка",
"failed_to_get_stream_url": "Не удалось получить URL потока",
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Хотите воспроизвести скачанный файл?",
"downloaded_file_yes": "Да",
"downloaded_file_no": "Нет",
"downloaded_file_cancel": "Отмена",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Отмена"
},
"item_card": {
"next_up": "Далее",
@@ -733,11 +617,6 @@
"series": "Серии",
"seasons": "Сезоны",
"season": "Сезон",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "В этом сезоне нет серий",
"overview": "Обзор",
"more_with": "Больше с {{name}}",
@@ -745,24 +624,13 @@
"no_similar_items_found": "Похожие элементы не найдены",
"video": "Видео",
"more_details": "Больше деталей",
"media_options": "Опции медиа",
"media_options": "Media Options",
"quality": "Качество",
"audio": "Звук",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Субтитры",
"show_more": "Показать больше",
"show_less": "Показать меньше",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Появлялся в",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Не удалось загрузить элемент",
"none": "Отсутствует",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Загрузить {{item_count}} элементов",
"download_unwatched_only": "Только непросмотренные",
"download_button": "Загрузить"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Далее",
@@ -790,18 +652,7 @@
"movies": "Фильмы",
"sports": "Спорт",
"for_kids": "Для детей",
"news": "Новости",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Новости"
},
"jellyseerr": {
"confirm": "Подтвердить",
@@ -834,32 +685,26 @@
"currently_streaming_on": "Сейчас доступно на",
"advanced": "Продвинутое",
"request_as": "Запросить как",
"tags": "Теги",
"tags": "Тэги",
"quality_profile": "Профиль качества",
"root_folder": "Корневая папка",
"season_all": "Сезон (все)",
"season_number": "Сезон {{season_number}}",
"number_episodes": "Серий: {{episode_number}}",
"number_episodes": "{{episode_number}} серий",
"born": "Рожден",
"appearances": "Появления",
"approve": "Одобрить",
"decline": "Отклонить",
"requested_by": "Запрошено {{user}}",
"unknown_user": "Неизвестный пользователь",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера Seerr",
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера jellyseerr",
"issue_submitted": "Проблема отправлена!",
"requested_item": "Запрошено {{item}}!",
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиа!",
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!",
"request_approved": "Запрос одобрен!",
"request_declined": "Запрос отклонён!",
"failed_to_approve_request": "Не удалось одобрить запрос",
@@ -871,8 +716,7 @@
"search": "Поиск",
"library": "Библиотека",
"custom_links": "Ссылки",
"favorites": "Избранное",
"settings": "Settings"
"favorites": "Избранное"
},
"music": {
"title": "Музыка",
@@ -957,7 +801,7 @@
"name_label": "Название",
"name_placeholder": "Введите название списка",
"description_label": "Описание",
"description_placeholder": "Введите описание (необязательно)",
"description_placeholder": "Введите описание (не обязательно)",
"is_public_label": "Публичный",
"is_public_description": "Разрешить остальным пользователям видеть этот список",
"allowed_type_label": "Тип контента",
@@ -997,36 +841,5 @@
"show": "Ко всему сериалу",
"all": "Ко всем файлам (по умолчанию)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Fel",
"login_title": "Logga in",
"login_to_title": "Logga in till",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Användarnamn",
"password_placeholder": "Lösenord",
"login_button": "Logga in",
@@ -47,11 +44,7 @@
"add_account": "Lägg till konto",
"remove_account_description": "Detta kommer att ta bort de sparade uppgifterna för {{username}}.",
"remove_server": "Ta bort server",
"remove_server_description": "Detta kommer att ta bort {{server}} och alla sparade konton från din lista.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_server_description": "Detta kommer att ta bort {{server}} och alla sparade konton från din lista."
},
"save_account": {
"title": "Spara konto",
@@ -119,12 +112,6 @@
"settings": {
"settings_title": "Inställningar",
"log_out_button": "Logga ut",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategorier"
},
@@ -141,12 +128,12 @@
"show_home_backdrop": "Dynamisk hembakgrund",
"show_hero_carousel": "Hjältekarusell",
"show_series_poster_on_episode": "Visa serieaffisch på avsnitt",
"theme_music": "Temamusik",
"display_size": "Visningsstorlek",
"display_size_small": "Liten",
"display_size_default": "Standard",
"display_size_large": "Stor",
"display_size_extra_large": "Extra stor"
"display_size_extra_large": "Extra stor",
"theme_music": "Temamusik"
},
"network": {
"title": "Nätverk",
@@ -209,12 +196,6 @@
"max_cache_size": "Max cachestorlek",
"max_backward_cache": "Max bakåtcache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gestkontroller",
"horizontal_swipe_skip": "Horisontell Svepning för att Hoppa Fram/Bak",
@@ -390,7 +371,7 @@
"server_url_placeholder": "Seerr URL",
"password": "Lösenord",
"password_placeholder": "Ange lösenord för Jellyfin användare {{username}}",
"login_button": "Login",
"login_button": "Logga in",
"total_media_requests": "Totalt antal mediaförfrågningar",
"movie_quota_limit": "Gräns för filmkvot",
"movie_quota_days": "Filmkvot Dagar",
@@ -463,13 +444,7 @@
"music_cache_cleared": "Musikcache rensad",
"delete_all_downloaded_songs": "Ta bort alla nerladdade filer",
"downloaded_songs_size": "{{size}} nedladdad",
"downloaded_songs_deleted": "Nedladdade låtar raderade",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Nedladdade låtar raderade"
},
"intro": {
"title": "Introduktion",
@@ -493,21 +468,6 @@
"error_deleting_files": "Fel Vid Borttagning Av Filer",
"background_downloads_enabled": "Bakgrundsnedladdningar aktiverade",
"background_downloads_disabled": "Bakgrundsnedladdningar inaktiverade"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -589,7 +549,8 @@
"continue": "Fortsätt",
"verifying": "Verifierar...",
"login": "Logga in",
"refresh": "Uppdatera"
"refresh": "Uppdatera",
"seeAll": "Visa alla"
},
"search": {
"search": "Sök...",
@@ -720,12 +681,6 @@
"stopPlayingConfirm": "Är du säker på att du vill stoppa uppspelningen?",
"downloaded": "Nedladdad"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Näst på tur",
"no_items_to_display": "Inga Artiklar Att Visa",
@@ -838,7 +793,7 @@
"quality_profile": "Kvalitetsprofil",
"root_folder": "Rotkatalog",
"season_all": "Säsong (alla)",
"season_number": "Säsong {{seasonNumber}}",
"season_number": "Säsong {{season_number}}",
"number_episodes": "{{episode_number}} Avsnitt",
"born": "Född",
"appearances": "Framträdanden",
@@ -997,36 +952,5 @@
"show": "Denna serie",
"all": "Alla media (standard)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Error",
"login_title": "Log In",
"login_to_title": "Log in to",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"continue": "Continue",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "ปรับแต่งลักษณะภายนอก",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Search...",
@@ -638,7 +556,6 @@
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"playlists": "Playlists",
"items": "Items"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tags"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Next Up",
@@ -733,11 +617,6 @@
"series": "Series",
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Subtitle",
"show_more": "Show More",
"show_less": "Show Less",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Appeared In",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Could Not Load Item",
"none": "None",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Next",
@@ -790,18 +652,7 @@
"movies": "Movies",
"sports": "Sports",
"for_kids": "For Kids",
"news": "News",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "News"
},
"jellyseerr": {
"confirm": "Confirm",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -871,8 +716,7 @@
"search": "Search",
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites",
"settings": "Settings"
"favorites": "Favorites"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "ghIq",
"login_title": "lut 'el",
"login_to_title": "lut 'el",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "tlhIngan",
"password_placeholder": "ngoq De'",
"login_button": "yI'el!",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "QI'ya!",
"error_message": "Doch rurbe'.\nyIQo' 'ej yI'elqa'.",
"continue_watching": "tlhol yIHaDqa'",
"continue": "Continue",
"next_up": "wej",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "num tu'lu' {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "men",
"log_out_button": "yIQo'",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "bavHom vum",
"seconds_unit": "tera' rep"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "QavwI' 'ej Qap",
"horizontal_swipe_skip": "SaS mup loSmeH",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Qaw' ghIq",
"background_downloads_enabled": "tlhegh Qaw' chu'",
"background_downloads_disabled": "tlhegh Qaw' QIj"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "mu'tlhegh",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "yISam...",
@@ -638,7 +556,6 @@
"movies": "DIS",
"series": "Hem",
"boxsets": "Hem ghom",
"playlists": "Playlists",
"items": "Doch"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "yIwIv",
"filter_by": "Filter By",
"sort_order": "wIv mIw",
"tags": "De'Hom",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "De'Hom"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "ret pagh"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "wej",
@@ -733,11 +617,6 @@
"series": "Hem",
"seasons": "muv",
"season": "muv",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "muvvam HemHom pagh",
"overview": "Hoch Sov",
"more_with": "{{name}} latlh",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "luj",
"audio": "QoQ",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "De' chu'",
"show_more": "latlh yIHoch",
"show_less": "Hom yIHoch",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "tlholvam",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Doch tlha'laHbe'",
"none": "pagh",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "{{item_count}} Doch yIQaw'",
"download_unwatched_only": "Unwatched Only",
"download_button": "yIQaw'"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "wej",
@@ -790,18 +652,7 @@
"movies": "DIS",
"sports": "QI'",
"for_kids": "puqbeq",
"news": "De'",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "De'"
},
"jellyseerr": {
"confirm": "yInej",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr Ho'Do' veS wej law'be'! 2.0.0 yIchu'!",
"jellyseerr_test_failed": "Jellyseerr nejlaHbe'. yIHaDqa'.",
@@ -871,8 +716,7 @@
"search": "Sam",
"library": "De'wI' bom",
"custom_links": "teqlu' ret",
"favorites": "wIv Doch",
"settings": "Settings"
"favorites": "wIv Doch"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Hata",
"login_title": "Giriş yap",
"login_to_title": " 'e giriş yap",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Kullanıcı adı",
"password_placeholder": "Şifre",
"login_button": "Giriş yap",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} hesap",
"select_account": "Hesap Seç",
"add_account": "Hesap Ekle",
"remove_account_description": "{{username}} için kayıtlı bilgiler kaldırılacaktır.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "{{username}} için kayıtlı bilgiler kaldırılacaktır."
},
"save_account": {
"title": "Hesabı Kaydet",
@@ -95,7 +86,6 @@
"oops": "Hups!",
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapıp tekrar giriş yapın.",
"continue_watching": "İzlemeye Devam Et",
"continue": "Continue",
"next_up": "Sonraki",
"continue_and_next_up": "İzlemeye Devam Et & Sıradakiler",
"recently_added_in": "{{libraryName}} Kütüphanesine Son Eklenenler",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Ayarlar",
"log_out_button": ıkış Yap",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Kategoriler"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Görünüm",
"merge_next_up_continue_watching": "İzlemeye Devam Et & Sıradakiler'i birleştir",
"hide_remote_session_button": "Uzak Oturum Butonunu Gizle",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Uzak Oturum Butonunu Gizle"
},
"network": {
"title": "Ağ",
@@ -199,22 +174,6 @@
"rewind_length": "Geri Sarma Uzunluğu",
"seconds_unit": "sn"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Hareketle Kontrol",
"horizontal_swipe_skip": "Atlamak için yatay kaydırma",
@@ -297,23 +256,7 @@
"subtitle_font": "Altyazı Yazı Tipi",
"ksplayer_title": "KSPlayer Ayarları",
"hardware_decode": "Donanımsal Kod Çözme",
"hardware_decode_description": "Video kod çözme için donanımsal hızlandırma kullan. Oynatma sorunları yaşıyorsanız devre dışı bırakın.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Video kod çözme için donanımsal hızlandırma kullan. Oynatma sorunları yaşıyorsanız devre dışı bırakın."
},
"vlc_subtitles": {
"title": "VLC Altyazı Ayarları",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Müzik ön belleği temizlendi",
"delete_all_downloaded_songs": "Tüm İndirilen Müzikleri Sil",
"downloaded_songs_size": "{{size}} indirildi",
"downloaded_songs_deleted": "İndirilen müzikler silindi",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "İndirilen müzikler silindi"
},
"intro": {
"title": "Giriş",
@@ -493,21 +430,6 @@
"error_deleting_files": "Dosyalar silinirken hata oluştu",
"background_downloads_enabled": "Arka plan indirmeleri etkinleştirildi",
"background_downloads_disabled": "Arka plan indirmeleri devre dışı bırakıldı"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Seç",
"no_trailer_available": "Fragman mevcut değil",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "Hiçbiri",
"track": "Parça",
"cancel": "Vazgeç",
"stop": "Stop",
"delete": "Sil",
"ok": "Tamam",
"remove": "Kaldır",
"next": "Sonraki",
"back": "Geri",
"continue": "Devam",
"verifying": "Doğrulanıyor...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Doğrulanıyor..."
},
"search": {
"search": "Ara...",
@@ -638,7 +556,6 @@
"movies": "filmler",
"series": "diziler",
"boxsets": "koleksiyonlar",
"playlists": "Playlists",
"items": "ögeler"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sırala",
"filter_by": "Filtrele",
"sort_order": "Sıralama düzeni",
"tags": "Etiketler",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Etiketler"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Bağlantı yok"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "İndirilmiş dosyayı oynatmak ister misiniz?",
"downloaded_file_yes": "Evet",
"downloaded_file_no": "Hayır",
"downloaded_file_cancel": "Vazgeç",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Vazgeç"
},
"item_card": {
"next_up": "Sıradaki",
@@ -733,11 +617,6 @@
"series": "Dizi",
"seasons": "Sezonlar",
"season": "Sezon",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Bu sezona ait bölüm yok",
"overview": "Özet",
"more_with": "Daha fazla {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Medya Seçenekleri",
"quality": "Kalite",
"audio": "Ses",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Altyazı",
"show_more": "Daha fazla göster",
"show_less": "Daha az göster",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Şurada yer aldı",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Öge yüklenemedi",
"none": "Hiçbiri",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "{{item_count}} tane ögeyi indir",
"download_unwatched_only": "Yalnızca İzlenmemişler",
"download_button": "İndir"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Sonraki",
@@ -790,18 +652,7 @@
"movies": "Filmler",
"sports": "Spor",
"for_kids": "Çocuklar İçin",
"news": "Haberler",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Haberler"
},
"jellyseerr": {
"confirm": "Onayla",
@@ -846,12 +697,6 @@
"decline": "Reddet",
"requested_by": "{{user}} tarafından istendi",
"unknown_user": "Bilinmeyen Kullanıcı",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin",
"jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.",
@@ -871,8 +716,7 @@
"search": "Ara",
"library": "Kütüphane",
"custom_links": "Özel Bağlantılar",
"favorites": "Favoriler",
"settings": "Settings"
"favorites": "Favoriler"
},
"music": {
"title": "Müzik",
@@ -997,36 +841,5 @@
"show": "Bu dizide",
"all": "Bütün medyalarda (varsayılan)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Помилка",
"login_title": "Вхід",
"login_to_title": "Увійти в",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Імʼя користувача",
"password_placeholder": "Пароль",
"login_button": "Вхід",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Упс!",
"error_message": "Щось пішло не так.\nБудь ласка вийдіть і увійдіть знов.",
"continue_watching": "Продовжити перегляд",
"continue": "Continue",
"next_up": "Далі",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Нещодавно додане до \"{{libraryName}}\"",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Параметри",
"log_out_button": "Вихід",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Довжина перемотування назад",
"seconds_unit": "с"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Помилка при видалені файлів",
"background_downloads_enabled": "Завантаження в фоні увімкнене",
"background_downloads_disabled": "Завантаження в фоні вимкнене"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Шукати...",
@@ -638,7 +556,6 @@
"movies": "фільми",
"series": "серіали",
"boxsets": "бокс-сети",
"playlists": "Playlists",
"items": "елементи"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Відсортувати за",
"filter_by": "Filter By",
"sort_order": "Порядок сортування",
"tags": "Теги",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Теги"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Немає посилань"
},
"player": {
"live": "LIVE",
"error": "Помилка",
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Далі",
@@ -733,11 +617,6 @@
"series": "Серіали",
"seasons": "Сезони",
"season": "Сезон",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "У цьому сезоні немає епізодів",
"overview": "Огляд",
"more_with": "Більше з {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Якість",
"audio": "Аудіо",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Субтитри",
"show_more": "Показати більше",
"show_less": "Показати менше",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Зʼявлявся у",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Неможливо завантажити елемент",
"none": "Нічого",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Завантажено {{item_count}} елементів",
"download_unwatched_only": "Unwatched Only",
"download_button": "Завантажити"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Наступний",
@@ -790,18 +652,7 @@
"movies": "Фільми",
"sports": "Спорт",
"for_kids": "Для дітей",
"news": "Новини",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Новини"
},
"jellyseerr": {
"confirm": "Підтвердити",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Версія Jellyseerr не відповідає мінімальним вимогам! Будь ласка, оновіться принаймні до 2.0.0",
"jellyseerr_test_failed": "Тест Jellyseerr завершився невдало. Спробуйте ще раз.",
@@ -871,8 +716,7 @@
"search": "Пошук",
"library": "Медіатека",
"custom_links": "Ваші Посилання",
"favorites": "Улюблене",
"settings": "Settings"
"favorites": "Улюблене"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Lỗi",
"login_title": "Đăng nhập",
"login_to_title": "Đăng nhập vào",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Tên người dùng",
"password_placeholder": "Mật khẩu",
"login_button": "Đăng nhập",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Ối!",
"error_message": "Có lỗi xảy ra.\nVui lòng đăng xuất rồi đăng nhập lại.",
"continue_watching": "Tiếp tục xem",
"continue": "Continue",
"next_up": "Tiếp theo",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Mới thêm trong {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Cài đặt",
"log_out_button": "Đăng xuất",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Thời gian tua lui",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Lỗi khi xóa tập tin",
"background_downloads_enabled": "Tải trong nền đã bật",
"background_downloads_disabled": "Tải trong nền đã tắt"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Tìm...",
@@ -638,7 +556,6 @@
"movies": "phim",
"series": "chương trình",
"boxsets": "bộ sưu tập",
"playlists": "Playlists",
"items": "nội dung"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sắp xếp theo",
"filter_by": "Filter By",
"sort_order": "Thứ tự",
"tags": "Thẻ",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Thẻ"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "Chưa có liên kết"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Tiếp theo",
@@ -733,11 +617,6 @@
"series": "Chương trình",
"seasons": "Mùa",
"season": "Mùa",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "Không có tập cho mùa này",
"overview": "Giới thiệu",
"more_with": "Thêm với {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Chất lượng",
"audio": "Âm thanh",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Phụ đề",
"show_more": "Xem thêm",
"show_less": "Thu gọn",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Xuất hiện trong",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Không thể tải nội dung",
"none": "Không có",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Tải {{item_count}} nội dung",
"download_unwatched_only": "Unwatched Only",
"download_button": "Tải"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Tiếp theo",
@@ -790,18 +652,7 @@
"movies": "Phim",
"sports": "Thể thao",
"for_kids": "Dành cho trẻ em",
"news": "Tin tức",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "Tin tức"
},
"jellyseerr": {
"confirm": "Xác nhận",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Máy chủ Jellyseerr không đạt yêu cầu tối thiểu! Vui lòng cập nhật lên ít nhất 2.0.0",
"jellyseerr_test_failed": "Kiểm tra Jellyseerr thất bại. Vui lòng thử lại.",
@@ -871,8 +716,7 @@
"search": "Tìm kiếm",
"library": "Thư viện",
"custom_links": "Liên kết tùy chỉnh",
"favorites": "Yêu thích",
"settings": "Settings"
"favorites": "Yêu thích"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -4,9 +4,6 @@
"error_title": "Error",
"login_title": "Log In",
"login_to_title": "Log in to",
"select_user": "Select a user to log in",
"add_user_to_login": "Add a user to log in",
"add_user": "Add User",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
@@ -45,13 +42,7 @@
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}.",
"remove_server": "Remove Server",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
"select_your_server": "Select Your Server",
"add_server_to_get_started": "Add a server to get started",
"add_server": "Add Server",
"change_server": "Change Server"
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
@@ -95,7 +86,6 @@
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"continue": "Continue",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
@@ -119,12 +109,6 @@
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"switch_user": {
"title": "Switch User",
"account": "Account",
"switch_user": "Switch User on This Server",
"current": "current"
},
"categories": {
"title": "Categories"
},
@@ -137,16 +121,7 @@
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"theme_music": "Theme Music",
"display_size": "Display Size",
"display_size_small": "Small",
"display_size_default": "Default",
"display_size_large": "Large",
"display_size_extra_large": "Extra Large"
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
@@ -199,22 +174,6 @@
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"vo_driver": {
"title": "Video Output",
"vo_mode": "VO Driver",
"gpu_next": "gpu-next (Recommended)",
"gpu": "gpu"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
@@ -297,23 +256,7 @@
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.",
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
@@ -463,13 +406,7 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"title": "Intro",
@@ -493,21 +430,6 @@
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
},
"security": {
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"description": "Auto logout after inactivity",
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
"sessions": {
@@ -570,7 +492,6 @@
}
},
"common": {
"no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
@@ -580,16 +501,13 @@
"none": "None",
"track": "Track",
"cancel": "Cancel",
"stop": "Stop",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"verifying": "Verifying..."
},
"search": {
"search": "Search...",
@@ -638,7 +556,6 @@
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"playlists": "Playlists",
"items": "Items"
},
"options": {
@@ -657,11 +574,7 @@
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags",
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
"tags": "Tags"
}
},
"favorites": {
@@ -678,7 +591,6 @@
"no_links": "No Links"
},
"player": {
"live": "LIVE",
"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.",
@@ -696,35 +608,7 @@
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Next Up",
@@ -733,11 +617,6 @@
"series": "Series",
"seasons": "Seasons",
"season": "Season",
"from_this_series": "From This Series",
"more_from_this_season": "More from this Season",
"view_series": "View Series",
"view_season": "View Season",
"select_season": "Select Season",
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
@@ -748,21 +627,10 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"subtitles": "Subtitle",
"show_more": "Show More",
"show_less": "Show Less",
"left": "left",
"more_info": "More Info",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Appeared In",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Could Not Load Item",
"none": "None",
"download": {
@@ -773,13 +641,7 @@
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continue from {{time}}"
}
},
"live_tv": {
"next": "Next",
@@ -790,18 +652,7 @@
"movies": "Movies",
"sports": "Sports",
"for_kids": "For Kids",
"news": "News",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
"news": "News"
},
"jellyseerr": {
"confirm": "Confirm",
@@ -846,12 +697,6 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -871,8 +716,7 @@
"search": "Search",
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites",
"settings": "Settings"
"favorites": "Favorites"
},
"music": {
"title": "Music",
@@ -997,36 +841,5 @@
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}