mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
Compare commits
135 Commits
chore/i18n
...
refactor-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e35785a5c | ||
|
|
c7c3aa8a34 | ||
|
|
211923b2ab | ||
|
|
f4a68bca10 | ||
|
|
985cb0f337 | ||
|
|
46bd2a784e | ||
|
|
0a36fdfbec | ||
|
|
45d1f752d6 | ||
|
|
54ee507209 | ||
|
|
338fb9713b | ||
|
|
939fd2512d | ||
|
|
58f0877cfe | ||
|
|
2c2a7137d3 | ||
|
|
56e350891d | ||
|
|
d9e25135c4 | ||
|
|
84246e9dde | ||
|
|
57cfa5ce78 | ||
|
|
0ba3d19550 | ||
|
|
58e2418120 | ||
|
|
6c00a0348a | ||
|
|
276ba1e4c5 | ||
|
|
41ab4de833 | ||
|
|
abe4981126 | ||
|
|
a9d8f753d4 | ||
|
|
ee5c9ae19f | ||
|
|
d661a9ff7a | ||
|
|
4939d05e69 | ||
|
|
7201002dd5 | ||
|
|
03d2917ca0 | ||
|
|
74315a8b94 | ||
|
|
53c4f317cc | ||
|
|
335a373034 | ||
|
|
55595bea9b | ||
|
|
0cf6630af9 | ||
|
|
41f6116ba8 | ||
|
|
1e3311fea9 | ||
|
|
e400378684 | ||
|
|
21c0fb4b6c | ||
|
|
b9e87e51cc | ||
|
|
c3a9b451b6 | ||
|
|
418bd506c0 | ||
|
|
b0e92d8689 | ||
|
|
4ae656818c | ||
|
|
99527e1fae | ||
|
|
1ca6e0853b | ||
|
|
f99ce8210c | ||
|
|
674e252641 | ||
|
|
119b7ad937 | ||
|
|
788a3b7cfd | ||
|
|
8b94f491e4 | ||
|
|
e9f61a2f7c | ||
|
|
6ca1f63877 | ||
|
|
0cc3a8469d | ||
|
|
b38064e2da | ||
|
|
5b823a8efd | ||
|
|
750caba038 | ||
|
|
d3ee6c8239 | ||
|
|
7e2ef0f2da | ||
|
|
ca2e657eac | ||
|
|
288b390e5b | ||
|
|
c04924fe9e | ||
|
|
525a6b39fa | ||
|
|
1ea7f0f491 | ||
|
|
79c2829444 | ||
|
|
87e0b0006b | ||
|
|
3c71c08591 | ||
|
|
9f4f0fa7d1 | ||
|
|
0d922b75d6 | ||
|
|
0ee1d43d16 | ||
|
|
ec49d03cf1 | ||
|
|
02df2477d8 | ||
|
|
8c9506c7b5 | ||
|
|
b225286f57 | ||
|
|
23b4f20d18 | ||
|
|
88d96603e4 | ||
|
|
6e513b8f9e | ||
|
|
4f50ec6665 | ||
|
|
0e25a5936c | ||
|
|
e9fee79130 | ||
|
|
3d65c3bb7a | ||
|
|
e5d61bf3ea | ||
|
|
5eac91190e | ||
|
|
95d63e3c8a | ||
|
|
6d0ca44308 | ||
|
|
73214f5d45 | ||
|
|
5cfd110ad5 | ||
|
|
6e63afc61a | ||
|
|
bcf6b705e1 | ||
|
|
fb8c649f6f | ||
|
|
6ecadecb87 | ||
|
|
e3f105691b | ||
|
|
bcfa8c6d63 | ||
|
|
17450e3811 | ||
|
|
9759d84aa2 | ||
|
|
28d8b28c73 | ||
|
|
a4e47e5cb7 | ||
|
|
fcd7e46599 | ||
|
|
a841619d78 | ||
|
|
6c72a2803f | ||
|
|
6bf00abb9b | ||
|
|
ac405af3b2 | ||
|
|
9ec81cfa1d | ||
|
|
28bf1489c1 | ||
|
|
68d64fec9c | ||
|
|
9dcbcdc41d | ||
|
|
99775b353f | ||
|
|
7589ccd284 | ||
|
|
d4f730fc54 | ||
|
|
e002381706 | ||
|
|
838a248d28 | ||
|
|
a5c72011a8 | ||
|
|
51bd8a92da | ||
|
|
515e05015f | ||
|
|
7126564f72 | ||
|
|
6894decdba | ||
|
|
72c050b9a5 | ||
|
|
1da3d7cfc6 | ||
|
|
594a1d04aa | ||
|
|
9efe12637b | ||
|
|
9398f5f104 | ||
|
|
18600a4956 | ||
|
|
e54bdd048b | ||
|
|
e35623c46c | ||
|
|
25730a24d6 | ||
|
|
5a1fe51ad7 | ||
|
|
8dc0421e22 | ||
|
|
4125924aa6 | ||
|
|
eeb27cbaf6 | ||
|
|
88b79920bf | ||
|
|
203c6d59b0 | ||
|
|
bb491d4e86 | ||
|
|
0b6639fc4e | ||
|
|
71d922beeb | ||
|
|
5e60b6c2f8 | ||
|
|
da70541c8e |
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||
|
||||
## Main Technologies
|
||||
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
|
||||
## Coding Standards
|
||||
## Code Quality Standards
|
||||
|
||||
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
||||
|
||||
### Type Safety
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
||||
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
||||
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
||||
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
||||
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
||||
- Enable and respect strict TypeScript compiler options
|
||||
- Define explicit return types for functions
|
||||
- Use discriminated unions for complex state
|
||||
|
||||
### Code Reliability
|
||||
- Implement comprehensive error handling with try-catch blocks
|
||||
- Validate all external inputs (API responses, user input, query params)
|
||||
- Handle edge cases explicitly (empty arrays, null, undefined)
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
||||
- Add runtime checks for critical operations
|
||||
- Implement proper loading and error states in components
|
||||
|
||||
### Best Practices
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
@@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- Follow BiomeJS formatting and linting rules
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- Implement proper error boundaries
|
||||
- Use React.memo() for performance optimization
|
||||
- Use React.memo() for performance optimization when needed
|
||||
- Handle both mobile and TV navigation patterns
|
||||
- Write self-documenting code with clear intent
|
||||
- Add comments only when code complexity requires explanation
|
||||
|
||||
## API Integration
|
||||
|
||||
@@ -85,6 +108,18 @@ Exemples:
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `chore(deps): update Jellyfin SDK`
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
||||
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
||||
- **NEVER add or remove keys** - Crowdin manages the key structure
|
||||
- **Editing translation values is safe** - Bidirectional sync handles merges
|
||||
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
||||
- **Crowdin workflow**:
|
||||
- New keys added to `en.json` sync to Crowdin automatically
|
||||
- Approved translations sync back to language files via GitHub integration
|
||||
- The source of truth is `en.json` for structure, Crowdin for translations
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
|
||||
1
.github/workflows/linting.yml
vendored
1
.github/workflows/linting.yml
vendored
@@ -97,7 +97,6 @@ jobs:
|
||||
- "check"
|
||||
- "format"
|
||||
- "typecheck"
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
|
||||
@@ -59,17 +59,19 @@ function SettingsMobile() {
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
{Platform.OS !== "ios" && (
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
||||
};
|
||||
|
||||
const handleRefreshFromServer = useCallback(async () => {
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
// Update local state with new values
|
||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||
setUrl(newUrl);
|
||||
|
||||
238
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
238
app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { TFunction } from "i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Factory function to create skip options for a specific segment type
|
||||
* Reduces code duplication across all 5 segment types
|
||||
*/
|
||||
const useSkipOptions = (
|
||||
settingKey:
|
||||
| "skipIntro"
|
||||
| "skipOutro"
|
||||
| "skipRecap"
|
||||
| "skipCommercial"
|
||||
| "skipPreview",
|
||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
||||
t: TFunction<"translation", undefined>,
|
||||
) => {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
selected: option.value === settings?.[settingKey],
|
||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
||||
);
|
||||
};
|
||||
|
||||
export default function SegmentSkipPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.other.segment_skip_settings"),
|
||||
});
|
||||
}, [navigation, t]);
|
||||
|
||||
const skipIntroOptions = useSkipOptions(
|
||||
"skipIntro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipOutroOptions = useSkipOptions(
|
||||
"skipOutro",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipRecapOptions = useSkipOptions(
|
||||
"skipRecap",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipCommercialOptions = useSkipOptions(
|
||||
"skipCommercial",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
const skipPreviewOptions = useSkipOptions(
|
||||
"skipPreview",
|
||||
settings,
|
||||
updateSettings,
|
||||
t,
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={false} className='px-4'>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
subtitle={t("home.settings.other.skip_intro_description")}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipIntroOptions}
|
||||
disabled={pluginSettings?.skipIntro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_intro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
subtitle={t("home.settings.other.skip_outro_description")}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipOutroOptions}
|
||||
disabled={pluginSettings?.skipOutro?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_outro")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
subtitle={t("home.settings.other.skip_recap_description")}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipRecapOptions}
|
||||
disabled={pluginSettings?.skipRecap?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_recap")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipCommercialOptions}
|
||||
disabled={pluginSettings?.skipCommercial?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipCommercial}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_commercial")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
subtitle={t("home.settings.other.skip_preview_description")}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={skipPreviewOptions}
|
||||
disabled={pluginSettings?.skipPreview?.locked}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.other.segment_skip_${settings.skipPreview}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.skip_preview")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
|
||||
const SEGMENT_SKIP_OPTIONS = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): Array<{
|
||||
label: string;
|
||||
value: "none" | "ask" | "auto";
|
||||
}> => [
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_auto"),
|
||||
value: "auto",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_ask"),
|
||||
value: "ask",
|
||||
},
|
||||
{
|
||||
label: t("home.settings.other.segment_skip_none"),
|
||||
value: "none",
|
||||
},
|
||||
];
|
||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
trigger={
|
||||
<View className='pl-1.5'>
|
||||
<View>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
|
||||
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -139,6 +141,8 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
<CastingMiniPlayer />
|
||||
<CastAutoplayWatcher />
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
|
||||
768
app/(auth)/casting-player.tsx
Normal file
768
app/(auth)/casting-player.tsx
Normal file
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* Unified Casting Player Modal
|
||||
* Protocol-agnostic full-screen player for all supported casting protocols
|
||||
*/
|
||||
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastState,
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
|
||||
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
|
||||
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
|
||||
import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
|
||||
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
|
||||
import { CastPlayerTransportControls } from "@/components/casting/player/CastPlayerTransportControls";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
|
||||
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
|
||||
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
|
||||
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||
import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
|
||||
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
|
||||
import { useCasting } from "@/hooks/useCasting";
|
||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
|
||||
import { useCastSelection } from "@/hooks/useCastSelection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { detectCapabilities } from "@/utils/casting/capabilities";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { getPosterUrl } from "@/utils/casting/helpers";
|
||||
import { resolveSelection } from "@/utils/casting/selection";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
|
||||
export default function CastingPlayerScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Chromecast autoplay countdown — watcher hook drives this atom; we render
|
||||
// the overlay here when set, and handle Play-now / Cancel from the user.
|
||||
const castAutoplay = useAtomValue(castAutoplayAtom);
|
||||
const setCastAutoplay = useSetAtom(castAutoplayAtom);
|
||||
|
||||
// Get raw Chromecast state directly - same as old implementation
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const castDevice = useCastDevice();
|
||||
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
||||
useRemoteMediaClient();
|
||||
|
||||
// Fetch full item data from Jellyfin by ID and derive the effective item
|
||||
const { fetchedItem, currentItem } = useCastPlayerItem({
|
||||
api,
|
||||
user,
|
||||
mediaStatus,
|
||||
});
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const currentDevice = castDevice?.friendlyName ?? null;
|
||||
|
||||
// Progress/slider/trickplay cluster: slider shared values, scrub state,
|
||||
// live-progress interpolation, resume-position tracking, trickplay preview.
|
||||
const {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
progress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
|
||||
|
||||
// Only use casting controls if we have a current item to avoid "No session" errors
|
||||
const castingControls = useCasting(currentItem);
|
||||
const {
|
||||
togglePlayPause,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
setVolume,
|
||||
volume,
|
||||
remoteMediaClient,
|
||||
} = currentItem
|
||||
? castingControls
|
||||
: {
|
||||
togglePlayPause: async () => {},
|
||||
skipForward: async () => {},
|
||||
skipBackward: async () => {},
|
||||
setVolume: () => {},
|
||||
volume: 1,
|
||||
remoteMediaClient: null,
|
||||
};
|
||||
|
||||
// Modal states
|
||||
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
||||
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Chapter markers (shown for both episodes and movies).
|
||||
const chapters = currentItem?.Chapters;
|
||||
const hasChapters = chapterMarkers(chapters, duration * 1000).length > 1;
|
||||
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
|
||||
|
||||
// Reload the cast stream with a full selection; resolves true on success.
|
||||
const reloadWithSelection = useCallback(
|
||||
async (selection: CastSelection): Promise<boolean> => {
|
||||
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
||||
console.warn("[Casting Player] Cannot reload - missing required data");
|
||||
return false;
|
||||
}
|
||||
const currentPosition = resumePositionRef.current;
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: currentItem,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: {
|
||||
mediaSourceId: selection.mediaSourceId,
|
||||
audioStreamIndex: selection.audioStreamIndex,
|
||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
||||
maxBitrate: selection.maxBitrate,
|
||||
startPositionMs: currentPosition * 1000,
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to reload stream:",
|
||||
result.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
],
|
||||
);
|
||||
|
||||
const { currentSelection, applySelection } = useCastSelection({
|
||||
currentItem,
|
||||
mediaStatus,
|
||||
reload: reloadWithSelection,
|
||||
});
|
||||
|
||||
// Episode/season cluster: episode list, next episode, season data, loader
|
||||
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
|
||||
useCastEpisodes({
|
||||
api,
|
||||
user,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings,
|
||||
});
|
||||
|
||||
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
|
||||
// cast customData) still describes the previous episode. Used to suppress
|
||||
// episode-dependent secondary UI that would otherwise flash stale data.
|
||||
const isEpisodeTransitioning =
|
||||
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
|
||||
|
||||
// Expose this player to the app-wide remote-control surface while a cast
|
||||
// session is connected. The individual useCasting methods are each
|
||||
// useCallback-wrapped and stable, so depend on them directly rather than on
|
||||
// the whole `castingControls` object literal (rebuilt every render).
|
||||
const {
|
||||
togglePlayPause: castTogglePlayPause,
|
||||
pause: castPause,
|
||||
play: castPlay,
|
||||
stop: castStop,
|
||||
seek: castSeek,
|
||||
setVolume: castSetVolume,
|
||||
} = castingControls;
|
||||
// toggleMute reads the latest volume without making `volume` a useMemo dep.
|
||||
const volumeRef = useRef(volume);
|
||||
volumeRef.current = volume;
|
||||
|
||||
const castController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
playPause: () => {
|
||||
castTogglePlayPause();
|
||||
},
|
||||
pause: () => {
|
||||
castPause();
|
||||
},
|
||||
unpause: () => {
|
||||
castPlay();
|
||||
},
|
||||
stop: () => {
|
||||
castStop();
|
||||
},
|
||||
seek: (positionMs) => {
|
||||
castSeek(positionMs);
|
||||
},
|
||||
next: () => {
|
||||
if (nextEpisode) loadEpisode(nextEpisode);
|
||||
},
|
||||
previous: () => {
|
||||
const idx = episodes.findIndex((e) => e.Id === currentItem?.Id);
|
||||
if (idx > 0) loadEpisode(episodes[idx - 1]);
|
||||
},
|
||||
setVolume: (level) => {
|
||||
castSetVolume(level);
|
||||
},
|
||||
toggleMute: () => {
|
||||
castSetVolume(volumeRef.current > 0 ? 0 : 1);
|
||||
},
|
||||
}),
|
||||
[
|
||||
castTogglePlayPause,
|
||||
castPause,
|
||||
castPlay,
|
||||
castStop,
|
||||
castSeek,
|
||||
castSetVolume,
|
||||
episodes,
|
||||
nextEpisode,
|
||||
loadEpisode,
|
||||
currentItem?.Id,
|
||||
],
|
||||
);
|
||||
|
||||
useRegisterPlaybackController(
|
||||
castController,
|
||||
castState === CastState.CONNECTED,
|
||||
);
|
||||
|
||||
// The MediaSource currently selected, for deriving its tracks.
|
||||
// Derived from fetchedItem: the slim cast-customData item strips per-source
|
||||
// MediaStreams, so only the full fetched item yields correct track lists.
|
||||
const selectedSource = useMemo(
|
||||
() =>
|
||||
fetchedItem?.MediaSources?.find(
|
||||
(s) => s.Id === currentSelection?.mediaSourceId,
|
||||
) ??
|
||||
fetchedItem?.MediaSources?.[0] ??
|
||||
null,
|
||||
[fetchedItem?.MediaSources, currentSelection?.mediaSourceId],
|
||||
);
|
||||
|
||||
// Real alternate versions (multi-version items).
|
||||
const availableVersions = useMemo(
|
||||
() =>
|
||||
(fetchedItem?.MediaSources ?? []).map((s, i) => ({
|
||||
id: s.Id ?? `source-${i}`,
|
||||
name: s.Name || `${t("casting_player.version")} ${i + 1}`,
|
||||
})),
|
||||
[fetchedItem?.MediaSources, t],
|
||||
);
|
||||
|
||||
// Quality tiers from the shared ladder, capped to BOTH the device's
|
||||
// capability and the media's own bitrate — a tier above either ceiling
|
||||
// would behave identically to "Max", so it is not offered.
|
||||
const availableQualities = useMemo(() => {
|
||||
const caps = detectCapabilities(castDevice, {
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrate: settings.chromecastMaxBitrate,
|
||||
});
|
||||
const mediaBitrate =
|
||||
selectedSource?.Bitrate ??
|
||||
fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ??
|
||||
Number.POSITIVE_INFINITY;
|
||||
const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate);
|
||||
return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling);
|
||||
}, [
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
selectedSource,
|
||||
fetchedItem?.MediaStreams,
|
||||
]);
|
||||
|
||||
const availableAudioTracks = useMemo(() => {
|
||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||
if (!streams) return [];
|
||||
return streams
|
||||
.filter((stream) => stream.Type === "Audio")
|
||||
.map((stream) => ({
|
||||
index: stream.Index ?? 0,
|
||||
language: stream.Language || "Unknown",
|
||||
displayTitle:
|
||||
stream.DisplayTitle ||
|
||||
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
|
||||
codec: stream.Codec || "Unknown",
|
||||
channels: stream.Channels,
|
||||
bitrate: stream.BitRate,
|
||||
}));
|
||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||
|
||||
const availableSubtitleTracks = useMemo(() => {
|
||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
||||
if (!streams) return [];
|
||||
return streams
|
||||
.filter((stream) => stream.Type === "Subtitle")
|
||||
.map((stream) => ({
|
||||
index: stream.Index ?? 0,
|
||||
language: stream.Language || "Unknown",
|
||||
displayTitle:
|
||||
stream.DisplayTitle ||
|
||||
[
|
||||
stream.Language || "Unknown",
|
||||
stream.IsForced ? " (Forced)" : "",
|
||||
stream.Title ? ` - ${stream.Title}` : "",
|
||||
].join(""),
|
||||
codec: stream.Codec || "Unknown",
|
||||
isForced: stream.IsForced || false,
|
||||
isExternal: stream.IsExternal || false,
|
||||
}));
|
||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
||||
|
||||
// Autoplay overlay's "Play now" — load the queued next episode immediately.
|
||||
// Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape,
|
||||
// same start-position derivation) so the cast load is identical regardless
|
||||
// of whether it is triggered by the user or by the countdown timer.
|
||||
const onAutoplayPlayNow = useCallback(async () => {
|
||||
if (!castAutoplay) return;
|
||||
const episode = castAutoplay.nextEpisode;
|
||||
if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) {
|
||||
setCastAutoplay(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: episode,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load next episode (play now):",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reset the autoplay counter on explicit user action.
|
||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load next episode (play now):",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setCastAutoplay(null);
|
||||
}
|
||||
}, [
|
||||
castAutoplay,
|
||||
api,
|
||||
user?.Id,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
updateSettings,
|
||||
setCastAutoplay,
|
||||
]);
|
||||
|
||||
// Poster URL for the queued next episode (mirrors `posterUrl` for the
|
||||
// currently-playing item — same helper, same dimensions).
|
||||
const autoplayPosterUrl = useMemo(() => {
|
||||
if (!castAutoplay || !api?.basePath) return null;
|
||||
const ep = castAutoplay.nextEpisode;
|
||||
// `BaseItemDto.Id` is `string | undefined`; bail if missing so we never
|
||||
// call the helper with `undefined`. AutoplayCountdown handles null.
|
||||
if (!ep?.Id) return null;
|
||||
return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390);
|
||||
}, [castAutoplay, api?.basePath]);
|
||||
|
||||
// NOTE: Auto-navigation to casting-player is handled by higher-level
|
||||
// components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
|
||||
// do NOT call router.replace("/casting-player") here because this component
|
||||
// IS the casting-player screen — doing so would cause redundant navigation loops.
|
||||
|
||||
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
|
||||
const { currentSegment, skipIntro, skipCredits, skipSegment } =
|
||||
useChromecastSegments(currentItem, progress * 1000, false);
|
||||
|
||||
// Swipe down to dismiss gesture
|
||||
const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({
|
||||
router,
|
||||
});
|
||||
|
||||
// Memoize expensive calculations (before early return)
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api?.basePath || !currentItem?.Id) return null;
|
||||
|
||||
// For episodes, use SEASON poster instead of episode poster
|
||||
if (currentItem.Type === "Episode" && seasonData?.Id) {
|
||||
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
||||
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
||||
const seasonImageTag = seasonData.ImageTags?.Primary;
|
||||
return seasonImageTag
|
||||
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
||||
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
||||
}
|
||||
|
||||
// Fallback to item poster for non-episodes or if season data not loaded
|
||||
return getPosterUrl(
|
||||
api.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
260,
|
||||
390,
|
||||
);
|
||||
}, [
|
||||
api?.basePath,
|
||||
currentItem?.Id,
|
||||
currentItem?.Type,
|
||||
seasonData?.Id,
|
||||
seasonData?.ImageTags?.Primary,
|
||||
currentItem?.ImageTags?.Primary,
|
||||
]);
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
|
||||
// Redirect if not connected - check CastState like old implementation
|
||||
useEffect(() => {
|
||||
// Redirect immediately when disconnected or no devices
|
||||
if (
|
||||
castState === CastState.NOT_CONNECTED ||
|
||||
castState === CastState.NO_DEVICES_AVAILABLE
|
||||
) {
|
||||
// Use setTimeout to avoid state update during render
|
||||
const timer = setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState, router]);
|
||||
|
||||
// Also redirect if mediaStatus disappears (media ended or stopped)
|
||||
useEffect(() => {
|
||||
if (castState === CastState.CONNECTED && !mediaStatus) {
|
||||
const timer = setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 500); // Small delay to allow for media transitions
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [castState, mediaStatus, router]);
|
||||
|
||||
// Show loading while connecting
|
||||
if (castState === CastState.CONNECTING) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={{ color: "#fff", marginTop: 16 }}>
|
||||
{t("casting_player.connecting")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if not connected or no media playing
|
||||
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
presentation: "fullScreenModal",
|
||||
animation: "slide_from_bottom",
|
||||
}}
|
||||
/>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
{/* Header - Fixed at top */}
|
||||
<CastPlayerHeader
|
||||
insetTop={insets.top}
|
||||
protocolColor={protocolColor}
|
||||
currentDevice={currentDevice}
|
||||
t={t}
|
||||
onDismiss={dismissModal}
|
||||
onPressConnectionIndicator={() => setShowDeviceSheet(true)}
|
||||
onPressSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
|
||||
{/* Title Area — hidden during an episode change to avoid flashing
|
||||
the previous episode's title/season-episode numbers. */}
|
||||
{!isEpisodeTransitioning && (
|
||||
<CastPlayerTitle
|
||||
insetTop={insets.top}
|
||||
currentItem={currentItem}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: insets.top + 160,
|
||||
paddingBottom: insets.bottom + 500,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Poster with buffering overlay — force the overlay during an
|
||||
episode change so the loading state covers the stale poster. */}
|
||||
<CastPlayerPoster
|
||||
posterUrl={posterUrl}
|
||||
isBuffering={isBuffering || isEpisodeTransitioning}
|
||||
currentSegment={currentSegment}
|
||||
skipIntro={skipIntro}
|
||||
skipCredits={skipCredits}
|
||||
skipSegment={skipSegment}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
mediaStatus={mediaStatus}
|
||||
protocolColor={protocolColor}
|
||||
t={t}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed control row - positioned independently. Episode-specific
|
||||
buttons are conditional inside; Stop is always available. */}
|
||||
<CastPlayerEpisodeControls
|
||||
insetBottom={insets.bottom}
|
||||
currentItemId={currentItem.Id}
|
||||
episodes={episodes}
|
||||
nextEpisode={nextEpisode}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
onPressEpisodes={() => setShowEpisodeList(true)}
|
||||
hasChapters={hasChapters}
|
||||
onPressChapters={() => setChapterListVisible(true)}
|
||||
loadEpisode={loadEpisode}
|
||||
router={router}
|
||||
/>
|
||||
|
||||
{/* Fixed bottom controls area */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 10,
|
||||
left: 20,
|
||||
right: 20,
|
||||
zIndex: 98,
|
||||
}}
|
||||
>
|
||||
{/* Progress slider with trickplay preview + time display */}
|
||||
<CastPlayerProgressBar
|
||||
sliderProgress={sliderProgress}
|
||||
sliderMin={sliderMin}
|
||||
sliderMax={sliderMax}
|
||||
isScrubbing={isScrubbing}
|
||||
trickplayTime={trickplayTime}
|
||||
setTrickplayTime={setTrickplayTime}
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
calculateTrickplayUrl={calculateTrickplayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
progress={progress}
|
||||
duration={duration}
|
||||
remoteMediaClient={remoteMediaClient}
|
||||
protocolColor={protocolColor}
|
||||
chapters={currentItem?.Chapters}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Playback controls */}
|
||||
<CastPlayerTransportControls
|
||||
isPlaying={isPlaying}
|
||||
togglePlayPause={togglePlayPause}
|
||||
skipBackward={skipBackward}
|
||||
skipForward={skipForward}
|
||||
rewindSkipTime={settings?.rewindSkipTime}
|
||||
forwardSkipTime={settings?.forwardSkipTime}
|
||||
protocolColor={protocolColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Autoplay countdown overlay — bottom-centred above the episode
|
||||
control row and main controls. 320 wide card; centred via
|
||||
left/right:0 + alignItems:"center". */}
|
||||
{castAutoplay && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 280,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
zIndex: 99,
|
||||
}}
|
||||
pointerEvents='box-none'
|
||||
>
|
||||
<AutoplayCountdown
|
||||
nextEpisode={castAutoplay.nextEpisode}
|
||||
posterUrl={autoplayPosterUrl}
|
||||
secondsRemaining={castAutoplay.secondsRemaining}
|
||||
onPlayNow={onAutoplayPlayNow}
|
||||
onCancel={() => setCastAutoplay(null)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<ChromecastDeviceSheet
|
||||
visible={showDeviceSheet}
|
||||
onClose={() => setShowDeviceSheet(false)}
|
||||
device={
|
||||
currentDevice && castDevice
|
||||
? { friendlyName: currentDevice }
|
||||
: null
|
||||
}
|
||||
onDisconnect={async () => {
|
||||
try {
|
||||
// End the casting session and disconnect completely
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
setShowDeviceSheet(false);
|
||||
// Close player immediately after disconnecting
|
||||
setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Casting Player] Error disconnecting from Chromecast:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}}
|
||||
volume={volume}
|
||||
onVolumeChange={async (vol) => {
|
||||
try {
|
||||
setVolume(vol);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to set volume:", error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChromecastEpisodeList
|
||||
visible={showEpisodeList}
|
||||
onClose={() => setShowEpisodeList(false)}
|
||||
currentItem={currentItem}
|
||||
episodes={episodes}
|
||||
api={api}
|
||||
onSelectEpisode={async (episode) => {
|
||||
setShowEpisodeList(false);
|
||||
await loadEpisode(episode);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChapterList
|
||||
visible={chapterListVisible}
|
||||
chapters={chapters}
|
||||
currentPositionMs={progress * 1000}
|
||||
onSeek={(ms) => {
|
||||
remoteMediaClient?.seek({ position: ms / 1000 });
|
||||
}}
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
/>
|
||||
|
||||
<ChromecastSettingsMenu
|
||||
visible={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
versions={availableVersions}
|
||||
selectedVersionId={currentSelection?.mediaSourceId ?? ""}
|
||||
onVersionChange={(id) => {
|
||||
if (!fetchedItem) return;
|
||||
applySelection({
|
||||
...resolveSelection(fetchedItem, { mediaSourceId: id }),
|
||||
maxBitrate: currentSelection?.maxBitrate,
|
||||
});
|
||||
}}
|
||||
qualities={availableQualities}
|
||||
selectedMaxBitrate={currentSelection?.maxBitrate}
|
||||
onQualityChange={(value) => applySelection({ maxBitrate: value })}
|
||||
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
|
||||
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
|
||||
onAudioChange={(index) =>
|
||||
applySelection({ audioStreamIndex: index })
|
||||
}
|
||||
subtitleTracks={
|
||||
isEpisodeTransitioning ? [] : availableSubtitleTracks
|
||||
}
|
||||
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
|
||||
onSubtitleChange={(index) =>
|
||||
applySelection({ subtitleStreamIndex: index })
|
||||
}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
onPlaybackSpeedChange={(speed) => {
|
||||
setCurrentPlaybackSpeed(speed);
|
||||
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,6 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
@@ -60,6 +59,10 @@ import {
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
|
||||
@@ -403,26 +406,6 @@ export default function DirectPlayerPage() {
|
||||
reportPlaybackStart();
|
||||
}, [stream, api, offline]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (progressInfo) {
|
||||
playbackManager.reportPlaybackProgress(progressInfo);
|
||||
}
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (!offline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: progressInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||
|
||||
@@ -496,6 +479,35 @@ export default function DirectPlayerPage() {
|
||||
isMuted,
|
||||
]);
|
||||
|
||||
// Declared after currentPlayStateInfo so the dependency array can reference
|
||||
// it without hitting the temporal dead zone.
|
||||
const togglePlay = useCallback(async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (progressInfo) {
|
||||
playbackManager.reportPlaybackProgress(progressInfo);
|
||||
}
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
const progressInfo = currentPlayStateInfo();
|
||||
if (!offline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: progressInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
lightHapticFeedback,
|
||||
isPlaying,
|
||||
currentPlayStateInfo,
|
||||
playbackManager,
|
||||
offline,
|
||||
api,
|
||||
]);
|
||||
|
||||
const lastUrlUpdateTime = useSharedValue(0);
|
||||
const wasJustSeeking = useSharedValue(false);
|
||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||
@@ -924,6 +936,47 @@ export default function DirectPlayerPage() {
|
||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||
}, []);
|
||||
|
||||
// App-wide remote control: wrap the player's existing handlers so remote
|
||||
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
|
||||
const playbackController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
// togglePlay flips play/pause and reports progress to the server.
|
||||
playPause: () => {
|
||||
void togglePlay();
|
||||
},
|
||||
pause: () => {
|
||||
pause();
|
||||
},
|
||||
unpause: () => {
|
||||
play();
|
||||
},
|
||||
stop: () => {
|
||||
stop();
|
||||
},
|
||||
// PlaybackController seeks in ms; the player's seek already expects ms.
|
||||
seek: (positionMs: number) => {
|
||||
seek(positionMs);
|
||||
},
|
||||
// The player screen has no episode-loading path of its own — episode
|
||||
// navigation is handled inside <Controls> via router replacement — so
|
||||
// next/previous are honest no-ops here.
|
||||
next: () => {},
|
||||
previous: () => {},
|
||||
// Volume is device-level (react-native-volume-manager); the slider sends
|
||||
// 0-1 while setVolumeCb expects 0-100.
|
||||
setVolume: (level: number) => {
|
||||
void setVolumeCb(level * 100);
|
||||
},
|
||||
toggleMute: () => {
|
||||
void toggleMuteCb();
|
||||
},
|
||||
}),
|
||||
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
|
||||
);
|
||||
|
||||
// Active for the whole lifetime of the player screen; cleared on unmount.
|
||||
useRegisterPlaybackController(playbackController, true);
|
||||
|
||||
// Determine play method based on stream URL and media source
|
||||
const playMethod = useMemo<
|
||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||
@@ -1260,7 +1313,7 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected?: Bitrate | null;
|
||||
inverted?: boolean | null;
|
||||
}
|
||||
|
||||
export const BitrateSheet: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(a.value || Number.POSITIVE_INFINITY) -
|
||||
(b.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}, [inverted]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.quality")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={t("item_card.quality")}
|
||||
data={sorted}
|
||||
values={selected ? [selected] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).key || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as Bitrate | undefined;
|
||||
if (chosen) onChange(chosen);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -10,36 +10,31 @@ export type Bitrate = {
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
{ key: "Max", value: undefined },
|
||||
{ key: "200 Mb/s", value: 200000000 },
|
||||
{ key: "180 Mb/s", value: 180000000 },
|
||||
{ key: "140 Mb/s", value: 140000000 },
|
||||
{ key: "120 Mb/s", value: 120000000 },
|
||||
{ key: "110 Mb/s", value: 110000000 },
|
||||
{ key: "100 Mb/s", value: 100000000 },
|
||||
{ key: "90 Mb/s", value: 90000000 },
|
||||
{ key: "80 Mb/s", value: 80000000 },
|
||||
{ key: "70 Mb/s", value: 70000000 },
|
||||
{ key: "60 Mb/s", value: 60000000 },
|
||||
{ key: "50 Mb/s", value: 50000000 },
|
||||
{ key: "40 Mb/s", value: 40000000 },
|
||||
{ key: "30 Mb/s", value: 30000000 },
|
||||
{ key: "20 Mb/s", value: 20000000 },
|
||||
{ key: "15 Mb/s", value: 15000000 },
|
||||
{ key: "10 Mb/s", value: 10000000 },
|
||||
{ key: "8 Mb/s", value: 8000000 },
|
||||
{ key: "5 Mb/s", value: 5000000 },
|
||||
{ key: "4 Mb/s", value: 4000000 },
|
||||
{ key: "3 Mb/s", value: 3000000 },
|
||||
{ key: "2 Mb/s", value: 2000000 },
|
||||
{ key: "1 Mb/s", value: 1000000 },
|
||||
{ key: "720 Kb/s", value: 720000 },
|
||||
{ key: "420 Kb/s", value: 420000 },
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
export function Chromecast({
|
||||
@@ -18,23 +26,136 @@ export function Chromecast({
|
||||
background = "transparent",
|
||||
...props
|
||||
}) {
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
// Hooks called for their side effects (keep Chromecast session active)
|
||||
useRemoteMediaClient();
|
||||
useCastDevice();
|
||||
const castState = useCastState();
|
||||
useDevices();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Connection menu state
|
||||
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
|
||||
const isConnected = castState === CastState.CONNECTED;
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const lastReportedPlayerStateRef = useRef<string | null>(null);
|
||||
const playSessionIdRef = useRef<string | null>(null);
|
||||
const lastContentIdRef = useRef<string | null>(null);
|
||||
const discoveryAttempts = useRef(0);
|
||||
const maxDiscoveryAttempts = 3;
|
||||
|
||||
// Enhanced discovery with retry mechanism - runs once on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let isSubscribed = true;
|
||||
let retryTimeout: NodeJS.Timeout;
|
||||
|
||||
const startDiscoveryWithRetry = async () => {
|
||||
if (!discoveryManager) {
|
||||
console.warn("DiscoveryManager is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
try {
|
||||
// Stop any existing discovery first
|
||||
try {
|
||||
await discoveryManager.stopDiscovery();
|
||||
} catch {
|
||||
// Ignore errors when stopping
|
||||
}
|
||||
|
||||
// Start fresh discovery
|
||||
await discoveryManager.startDiscovery();
|
||||
discoveryAttempts.current = 0; // Reset on success
|
||||
} catch (error) {
|
||||
console.error("[Chromecast Discovery] Failed:", error);
|
||||
|
||||
// Retry on error
|
||||
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
|
||||
discoveryAttempts.current++;
|
||||
retryTimeout = setTimeout(() => {
|
||||
if (isSubscribed) {
|
||||
startDiscoveryWithRetry();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
startDiscoveryWithRetry();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
};
|
||||
}, [discoveryManager]); // Only re-run if discoveryManager changes
|
||||
|
||||
// Report video progress to Jellyfin server
|
||||
useEffect(() => {
|
||||
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamPosition = mediaStatus.streamPosition || 0;
|
||||
const playerState = mediaStatus.playerState || null;
|
||||
|
||||
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
||||
const positionChanged =
|
||||
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
||||
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
||||
if (!positionChanged && !stateChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentId = mediaStatus.mediaInfo.contentId;
|
||||
|
||||
// Generate a new PlaySessionId when the content changes
|
||||
if (contentId !== lastContentIdRef.current) {
|
||||
// Use Math.random()-based UUID v4 (React Native lacks global crypto)
|
||||
playSessionIdRef.current = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||
/[xy]/g,
|
||||
(c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
},
|
||||
);
|
||||
lastContentIdRef.current = contentId;
|
||||
}
|
||||
|
||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||
const isPaused = mediaStatus.playerState === "paused";
|
||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||
const isTranscoding = /m3u8/i.test(streamUrl);
|
||||
|
||||
const progressInfo: PlaybackProgressInfo = {
|
||||
ItemId: contentId,
|
||||
PositionTicks: positionTicks,
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||
PlaySessionId: playSessionIdRef.current || contentId,
|
||||
};
|
||||
|
||||
getPlaystateApi(api)
|
||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||
.then(() => {
|
||||
lastReportedProgressRef.current = streamPosition;
|
||||
lastReportedPlayerStateRef.current = playerState;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to report Chromecast progress:", error);
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.mediaInfo?.contentId,
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.mediaInfo?.contentUrl,
|
||||
]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
@@ -43,50 +164,92 @@ export function Chromecast({
|
||||
[Platform.OS],
|
||||
);
|
||||
|
||||
// Handle press - show connection menu when connected, otherwise show cast dialog
|
||||
const handlePress = useCallback(() => {
|
||||
if (isConnected) {
|
||||
if (mediaStatus?.currentItemId) {
|
||||
// Media is playing - navigate to full player
|
||||
router.push("/casting-player");
|
||||
} else {
|
||||
// Connected but no media - show connection menu
|
||||
setShowConnectionMenu(true);
|
||||
}
|
||||
} else {
|
||||
// Not connected - show cast dialog
|
||||
CastContext.showCastDialog();
|
||||
}
|
||||
}, [isConnected, mediaStatus?.currentItemId]);
|
||||
|
||||
// Handle disconnect from Chromecast
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
} catch (error) {
|
||||
console.error("[Chromecast] Disconnect error:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<Pressable
|
||||
className='mr-4'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</Pressable>
|
||||
<>
|
||||
<Pressable className='mr-4' onPress={handlePress} {...props}>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</Pressable>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<RoundButton
|
||||
size='large'
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
<>
|
||||
<RoundButton
|
||||
size='large'
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={handlePress}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</RoundButton>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundButton
|
||||
size='large'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
<>
|
||||
<RoundButton size='large' onPress={handlePress} {...props}>
|
||||
<AndroidCastButton />
|
||||
<Feather
|
||||
name='cast'
|
||||
size={22}
|
||||
color={isConnected ? "#a855f7" : "white"}
|
||||
/>
|
||||
</RoundButton>
|
||||
<ChromecastConnectionMenu
|
||||
visible={showConnectionMenu}
|
||||
onClose={() => setShowConnectionMenu(false)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ interface PlatformDropdownProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
disabled?: boolean;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
@@ -213,6 +214,9 @@ const PlatformDropdownComponent = ({
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
|
||||
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
|
||||
disabled: isDisabled,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
@@ -265,6 +269,13 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||
@@ -417,8 +428,14 @@ const PlatformDropdownComponent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,8 +8,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
MediaStreamType,
|
||||
MediaPlayerState,
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
@@ -32,12 +33,8 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { chromecast } from "../utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
@@ -59,6 +56,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
@@ -111,7 +109,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const options = [
|
||||
t("casting_player.chromecast"),
|
||||
t("casting_player.device"),
|
||||
t("casting_player.cancel"),
|
||||
];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -120,9 +122,14 @@ export const PlayButton: React.FC<Props> = ({
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
// Compare item IDs AND check if media is actually playing (not stopped/idle)
|
||||
const currentContentId = mediaStatus?.mediaInfo?.contentId;
|
||||
const isMediaActive =
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
|
||||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const isOpeningCurrentlyPlayingMedia =
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
isMediaActive && currentContentId && currentContentId === item?.Id;
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
@@ -130,30 +137,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
if (state && state !== PlayServicesState.SUCCESS) {
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
} else {
|
||||
// Check if user wants H265 for Chromecast
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
|
||||
// Validate required parameters before calling getStreamUrl
|
||||
if (!api) {
|
||||
console.warn("API not available for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!user?.Id) {
|
||||
console.warn(
|
||||
"User not authenticated for Chromecast streaming",
|
||||
);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!item?.Id) {
|
||||
console.warn("Item not available for Chromecast streaming");
|
||||
if (!api || !user?.Id || !item?.Id) {
|
||||
console.warn("Missing parameters for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
@@ -161,110 +146,37 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a new URL with the Chromecast device profile
|
||||
try {
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||
userId: user.Id,
|
||||
const startPositionMs =
|
||||
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client,
|
||||
device: castDevice,
|
||||
api,
|
||||
item,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: {
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
});
|
||||
maxBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
||||
startPositionMs,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("URL: ", data?.url, enableH265);
|
||||
if (!result.ok) {
|
||||
console.error("[PlayButton] cast load failed:", result.error);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate start time in seconds from playback position
|
||||
const startTimeSeconds =
|
||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||
|
||||
// Calculate stream duration in seconds from runtime
|
||||
const streamDurationSeconds = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: item.Id,
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: streamDurationSeconds,
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: startTimeSeconds,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
if (!isOpeningCurrentlyPlayingMedia) {
|
||||
router.push("/casting-player");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -280,6 +192,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}, [
|
||||
item,
|
||||
client,
|
||||
castDevice,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
|
||||
12
components/casting/CastAutoplayWatcher.tsx
Normal file
12
components/casting/CastAutoplayWatcher.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Always-mounted host for the Chromecast autoplay watcher. Renders nothing —
|
||||
* it exists only to run `useCastAutoplay` for the app's lifetime, so autoplay
|
||||
* fires regardless of which screen is open.
|
||||
*/
|
||||
|
||||
import { useCastAutoplay } from "@/hooks/useCastAutoplay";
|
||||
|
||||
export function CastAutoplayWatcher() {
|
||||
useCastAutoplay();
|
||||
return null;
|
||||
}
|
||||
358
components/casting/CastingMiniPlayer.tsx
Normal file
358
components/casting/CastingMiniPlayer.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Unified Casting Mini Player
|
||||
* Works with all supported casting protocols
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated, {
|
||||
SlideInDown,
|
||||
SlideOutDown,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export const CastingMiniPlayer: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const castDevice = useCastDevice();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
|
||||
const { currentItem } = useCastPlayerItem({ api, user, mediaStatus });
|
||||
|
||||
// Trickplay support - pass currentItem as BaseItemDto or null
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
currentItem || null,
|
||||
);
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Slider shared values
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
|
||||
// Live progress state that updates every second when playing
|
||||
const [liveProgress, setLiveProgress] = useState(
|
||||
mediaStatus?.streamPosition || 0,
|
||||
);
|
||||
|
||||
// Track baseline for elapsed-time computation
|
||||
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
|
||||
const baselineTimestampRef = useRef(Date.now());
|
||||
|
||||
// Sync live progress with mediaStatus and poll every second when playing
|
||||
useEffect(() => {
|
||||
// Resync baseline whenever mediaStatus reports a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||
baselineTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update based on elapsed real time when playing
|
||||
const interval = setInterval(() => {
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
|
||||
const elapsed =
|
||||
((Date.now() - baselineTimestampRef.current) *
|
||||
(mediaStatus.playbackRate || 1)) /
|
||||
1000;
|
||||
setLiveProgress(baselinePositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
||||
baselineTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.playbackRate,
|
||||
]);
|
||||
|
||||
const progress = liveProgress * 1000; // Convert to ms
|
||||
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
|
||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||
|
||||
// Update slider max value when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration;
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Sync slider progress with live progress (when not scrubbing)
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress >= 0) {
|
||||
sliderProgress.value = progress;
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
// For episodes, use series poster; for other content, use item poster
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api?.basePath || !currentItem) return null;
|
||||
|
||||
if (
|
||||
currentItem.Type === "Episode" &&
|
||||
currentItem.SeriesId &&
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.SeasonId
|
||||
) {
|
||||
// Build series poster URL using SeriesId and series-level image tag
|
||||
const imageTag = currentItem.SeriesPrimaryImageTag || "";
|
||||
const tagParam = imageTag ? `&tag=${imageTag}` : "";
|
||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
|
||||
}
|
||||
|
||||
// For non-episodes, use item's own poster
|
||||
return getPosterUrl(
|
||||
api.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
80,
|
||||
120,
|
||||
);
|
||||
}, [api?.basePath, currentItem]);
|
||||
|
||||
// Hide mini player when:
|
||||
// - No cast device connected
|
||||
// - No media info (currentItem)
|
||||
// - No media status
|
||||
// - Media is stopped (IDLE state)
|
||||
// - Media is unknown state
|
||||
const playerState = mediaStatus?.playerState;
|
||||
const isMediaStopped = playerState === MediaPlayerState.IDLE;
|
||||
|
||||
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
||||
|
||||
const handlePress = () => {
|
||||
router.push("/casting-player");
|
||||
};
|
||||
|
||||
const handleTogglePlayPause = () => {
|
||||
if (isPlaying) {
|
||||
remoteMediaClient?.pause()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Pause error:", error);
|
||||
});
|
||||
} else {
|
||||
remoteMediaClient?.play()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Play error:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#333",
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{/* Interactive progress slider with trickplay */}
|
||||
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 20 }}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isScrubbing.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
// Calculate trickplay preview
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
// Update time display for trickplay bubble
|
||||
const progressInSeconds = Math.floor(
|
||||
ticksToSeconds(progressInTicks),
|
||||
);
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTrickplayTime({ hours, minutes, seconds });
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isScrubbing.current = false;
|
||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||
const positionSeconds = value / 1000;
|
||||
if (remoteMediaClient && duration > 0) {
|
||||
remoteMediaClient
|
||||
.seek({ position: positionSeconds })
|
||||
.catch((error) => {
|
||||
console.error("[Mini Player] Seek error:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
renderBubble={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={190}
|
||||
/>
|
||||
)}
|
||||
bubbleMaxWidth={190}
|
||||
bubbleWidth={190}
|
||||
bubbleTranslateY={-20}
|
||||
sliderHeight={3}
|
||||
thumbWidth={14}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={handlePress}>
|
||||
{/* Content */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 12,
|
||||
paddingTop: 6,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 60,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.Name}
|
||||
</Text>
|
||||
{currentItem.SeriesName && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.SeriesName}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={12} color={protocolColor} />
|
||||
<Text
|
||||
style={{
|
||||
color: protocolColor,
|
||||
fontSize: 11,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{castDevice.friendlyName || "Chromecast"}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{formatTime(progress)} / {formatTime(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stop button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
remoteMediaClient?.stop()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Stop error:", error);
|
||||
});
|
||||
}}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<Ionicons name='stop' size={24} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTogglePlayPause();
|
||||
}}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
175
components/casting/player/CastPlayerEpisodeControls.tsx
Normal file
175
components/casting/player/CastPlayerEpisodeControls.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Casting Player Episode Controls
|
||||
* Fixed control row: episode list, previous, next, stop.
|
||||
* Episode-specific buttons (list / previous / next) are conditional;
|
||||
* Stop is always rendered so movies still get a Stop button.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { ImperativeRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerEpisodeControlsProps {
|
||||
/** Bottom safe-area inset, used to offset the fixed control row. */
|
||||
insetBottom: number;
|
||||
/** Id of the currently playing episode. */
|
||||
currentItemId: BaseItemDto["Id"];
|
||||
/** Full episode list for the series. */
|
||||
episodes: BaseItemDto[];
|
||||
/** Next episode in the list, or null if none. */
|
||||
nextEpisode: BaseItemDto | null;
|
||||
/** Remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Open the episode list modal. */
|
||||
onPressEpisodes: () => void;
|
||||
/** Whether the current item exposes chapter markers. */
|
||||
hasChapters: boolean;
|
||||
/** Open the chapter list modal. */
|
||||
onPressChapters: () => void;
|
||||
/** Load a different episode on the Chromecast. */
|
||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||
/** Expo Router instance for navigation on stop. */
|
||||
router: ImperativeRouter;
|
||||
}
|
||||
|
||||
export function CastPlayerEpisodeControls({
|
||||
insetBottom,
|
||||
currentItemId,
|
||||
episodes,
|
||||
nextEpisode,
|
||||
remoteMediaClient,
|
||||
onPressEpisodes,
|
||||
hasChapters,
|
||||
onPressChapters,
|
||||
loadEpisode,
|
||||
router,
|
||||
}: CastPlayerEpisodeControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasEpisodeList = episodes.length > 0;
|
||||
const hasPrevious = episodes.findIndex((ep) => ep.Id === currentItemId) > 0;
|
||||
const hasNext = nextEpisode != null;
|
||||
|
||||
// Count of buttons actually rendered (Stop is always rendered).
|
||||
const buttonCount =
|
||||
1 +
|
||||
(hasEpisodeList ? 1 : 0) +
|
||||
(hasChapters ? 1 : 0) +
|
||||
(hasPrevious ? 1 : 0) +
|
||||
(hasNext ? 1 : 0);
|
||||
|
||||
// When Stop is the only button (movies), render it full-width with a label.
|
||||
const isLoneStop = buttonCount === 1;
|
||||
|
||||
// Each button stretches evenly only when the row holds more than one;
|
||||
// a lone Stop button keeps its intrinsic size and stays centered.
|
||||
const buttonStyle = {
|
||||
...(buttonCount > 1 ? { flex: 1 } : {}),
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
flexDirection: "row" as const,
|
||||
justifyContent: "center" as const,
|
||||
alignItems: "center" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insetBottom + 200,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
{/* Episodes button - only rendered when an episode list exists (not for movies) */}
|
||||
{hasEpisodeList && (
|
||||
<Pressable onPress={onPressEpisodes} style={buttonStyle}>
|
||||
<Ionicons name='list' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Chapter list button - rendered for both episodes and movies when chapters exist */}
|
||||
{hasChapters && (
|
||||
<Pressable onPress={onPressChapters} style={buttonStyle}>
|
||||
<Ionicons name='bookmarks' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Previous episode button - only rendered when a previous episode exists */}
|
||||
{hasPrevious && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const currentIndex = episodes.findIndex(
|
||||
(ep) => ep.Id === currentItemId,
|
||||
);
|
||||
if (currentIndex > 0) {
|
||||
await loadEpisode(episodes[currentIndex - 1]);
|
||||
}
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<Ionicons name='play-skip-back' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Next episode button - only rendered when a next episode exists */}
|
||||
{hasNext && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (nextEpisode) {
|
||||
await loadEpisode(nextEpisode);
|
||||
}
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={22} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Stop playback button - always rendered; stops media but stays connected to Chromecast */}
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
// Stop the current media playback (don't disconnect from Chromecast)
|
||||
if (remoteMediaClient) {
|
||||
await remoteMediaClient.stop();
|
||||
}
|
||||
|
||||
// Navigate back/close the player (mini player will disappear since no media is playing)
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Error stopping playback:", error);
|
||||
// Navigate anyway
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={[buttonStyle, isLoneStop && { flex: 1, gap: 8 }]}
|
||||
>
|
||||
<Ionicons name='stop-circle' size={22} color='white' />
|
||||
{isLoneStop && (
|
||||
<Text style={{ color: "white", fontSize: 15, fontWeight: "600" }}>
|
||||
{t("casting_player.stop")}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
94
components/casting/player/CastPlayerHeader.tsx
Normal file
94
components/casting/player/CastPlayerHeader.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Casting Player Header
|
||||
* Fixed top bar: dismiss button, connection indicator, settings button.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerHeaderProps {
|
||||
/** Top safe-area inset, used to offset the fixed header. */
|
||||
insetTop: number;
|
||||
/** Streamyfin protocol accent color. */
|
||||
protocolColor: string;
|
||||
/** Friendly name of the connected cast device, or null. */
|
||||
currentDevice: string | null;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
/** Dismiss the casting player modal. */
|
||||
onDismiss: () => void;
|
||||
/** Open the device sheet (connection indicator press). */
|
||||
onPressConnectionIndicator: () => void;
|
||||
/** Open the settings menu. */
|
||||
onPressSettings: () => void;
|
||||
}
|
||||
|
||||
export function CastPlayerHeader({
|
||||
insetTop,
|
||||
protocolColor,
|
||||
currentDevice,
|
||||
t,
|
||||
onDismiss,
|
||||
onPressConnectionIndicator,
|
||||
onPressSettings,
|
||||
}: CastPlayerHeaderProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insetTop + 8,
|
||||
left: 20,
|
||||
right: 20,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={onDismiss} style={{ padding: 8, marginLeft: -8 }}>
|
||||
<Ionicons name='chevron-down' size={32} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Connection indicator */}
|
||||
<Pressable
|
||||
onPress={onPressConnectionIndicator}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: protocolColor,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: protocolColor,
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{currentDevice || t("casting_player.unknown_device")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={onPressSettings}
|
||||
style={{ padding: 8, marginRight: -8 }}
|
||||
>
|
||||
<Ionicons name='settings-outline' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
176
components/casting/player/CastPlayerPoster.tsx
Normal file
176
components/casting/player/CastPlayerPoster.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Casting Player Poster
|
||||
* Poster image with empty-state fallback, skip intro/credits bar, and buffering overlay.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import type { TFunction } from "i18next";
|
||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import {
|
||||
MediaPlayerState,
|
||||
type MediaStatus,
|
||||
type RemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import type { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
type ChromecastSegments = ReturnType<typeof useChromecastSegments>;
|
||||
|
||||
interface CastPlayerPosterProps {
|
||||
/** Poster image URL, or null when unavailable. */
|
||||
posterUrl: string | null;
|
||||
/** Whether the cast media is currently buffering. */
|
||||
isBuffering: boolean;
|
||||
/** The current playback segment (intro/credits/etc.), or null. */
|
||||
currentSegment: ChromecastSegments["currentSegment"];
|
||||
/** Skip the intro segment. */
|
||||
skipIntro: ChromecastSegments["skipIntro"];
|
||||
/** Skip the credits segment. */
|
||||
skipCredits: ChromecastSegments["skipCredits"];
|
||||
/** Skip the current generic segment. */
|
||||
skipSegment: ChromecastSegments["skipSegment"];
|
||||
/** The remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Raw Chromecast media status. */
|
||||
mediaStatus: MediaStatus | null;
|
||||
/** Theme accent color. */
|
||||
protocolColor: string;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerPoster({
|
||||
posterUrl,
|
||||
isBuffering,
|
||||
currentSegment,
|
||||
skipIntro,
|
||||
skipCredits,
|
||||
skipSegment,
|
||||
remoteMediaClient,
|
||||
mediaStatus,
|
||||
protocolColor,
|
||||
t,
|
||||
}: CastPlayerPosterProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 280,
|
||||
height: 420,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={64} color='#333' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skip intro/credits bar at bottom of poster */}
|
||||
{currentSegment && (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (!remoteMediaClient) return;
|
||||
try {
|
||||
const seekFn = async (positionMs: number) => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
||||
mediaStatus?.playerState === MediaPlayerState.PAUSED
|
||||
) {
|
||||
await remoteMediaClient.seek({
|
||||
position: positionMs / 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (currentSegment.type === "intro") {
|
||||
await skipIntro(seekFn);
|
||||
} else if (currentSegment.type === "credits") {
|
||||
await skipCredits(seekFn);
|
||||
} else {
|
||||
await skipSegment(seekFn);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Skip error:", error);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: protocolColor,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={18} color='white' />
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
`player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`,
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Buffering overlay */}
|
||||
{isBuffering && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='large' color={protocolColor} />
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{t("casting_player.buffering")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
163
components/casting/player/CastPlayerProgressBar.tsx
Normal file
163
components/casting/player/CastPlayerProgressBar.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Casting Player Progress Bar
|
||||
* Progress slider with trickplay preview bubble and current/end time display.
|
||||
*/
|
||||
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { calculateEndingTime, formatTime } from "@/utils/casting/helpers";
|
||||
import { chapterMarkers } from "@/utils/chapters";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface CastPlayerProgressBarProps {
|
||||
/** Shared value tracking the slider progress, in milliseconds. */
|
||||
sliderProgress: SharedValue<number>;
|
||||
/** Shared value for the slider minimum, in milliseconds. */
|
||||
sliderMin: SharedValue<number>;
|
||||
/** Shared value for the slider maximum, in milliseconds. */
|
||||
sliderMax: SharedValue<number>;
|
||||
/** Mutable ref flag set true while the user is scrubbing. */
|
||||
isScrubbing: { current: boolean };
|
||||
/** Trickplay time display state for the bubble. */
|
||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||
/** Updates the trickplay time display state. */
|
||||
setTrickplayTime: (time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}) => void;
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Computes the trickplay URL for a given progress in ticks. */
|
||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
/** Current playback progress, in seconds. */
|
||||
progress: number;
|
||||
/** Total media duration, in seconds. */
|
||||
duration: number;
|
||||
/** Remote media client, or null when no session. */
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
/** Theme color used for the slider track and bubbles. */
|
||||
protocolColor: string;
|
||||
/** Chapter markers for the current item, or null/undefined if none. */
|
||||
chapters?: ChapterInfo[] | null;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerProgressBar({
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
progress,
|
||||
duration,
|
||||
remoteMediaClient,
|
||||
protocolColor,
|
||||
chapters,
|
||||
t,
|
||||
}: CastPlayerProgressBarProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Progress slider with trickplay preview */}
|
||||
<View style={{ marginTop: 8, height: 40 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isScrubbing.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
// Calculate trickplay preview
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
|
||||
// Update time display for trickplay bubble
|
||||
const progressInSeconds = Math.floor(
|
||||
ticksToSeconds(progressInTicks),
|
||||
);
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTrickplayTime({ hours, minutes, seconds });
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isScrubbing.current = false;
|
||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
||||
const positionSeconds = value / 1000;
|
||||
if (remoteMediaClient && duration > 0) {
|
||||
remoteMediaClient
|
||||
.seek({ position: positionSeconds })
|
||||
.catch((error) => {
|
||||
console.error("[Casting Player] Seek error:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
renderBubble={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={220}
|
||||
/>
|
||||
)}
|
||||
bubbleMaxWidth={220}
|
||||
bubbleWidth={220}
|
||||
bubbleTranslateY={-20}
|
||||
sliderHeight={6}
|
||||
thumbWidth={16}
|
||||
panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }}
|
||||
/>
|
||||
<ChapterTicks
|
||||
markers={chapterMarkers(chapters, duration * 1000)}
|
||||
height={4}
|
||||
color='#cccccc'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time display */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(progress * 1000)}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{t("casting_player.ending_at", {
|
||||
time: calculateEndingTime(progress * 1000, duration * 1000),
|
||||
})}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(duration * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
components/casting/player/CastPlayerTitle.tsx
Normal file
72
components/casting/player/CastPlayerTitle.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Casting Player Title Area
|
||||
* Fixed title bar: item title and optional grey episode/season info.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { TFunction } from "i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { truncateTitle } from "@/utils/casting/helpers";
|
||||
|
||||
interface CastPlayerTitleProps {
|
||||
/** Top safe-area inset, used to offset the fixed title area. */
|
||||
insetTop: number;
|
||||
/** The currently playing item. */
|
||||
currentItem: BaseItemDto;
|
||||
/** Translation function. */
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function CastPlayerTitle({
|
||||
insetTop,
|
||||
currentItem,
|
||||
t,
|
||||
}: CastPlayerTitleProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insetTop + 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 95,
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{truncateTitle(currentItem.Name || t("casting_player.unknown"), 50)}
|
||||
</Text>
|
||||
|
||||
{/* Grey episode/season info */}
|
||||
{currentItem.Type === "Episode" &&
|
||||
currentItem.ParentIndexNumber !== undefined &&
|
||||
currentItem.IndexNumber !== undefined && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{t("casting_player.season_episode_format", {
|
||||
season: currentItem.ParentIndexNumber,
|
||||
episode: currentItem.IndexNumber,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
122
components/casting/player/CastPlayerTransportControls.tsx
Normal file
122
components/casting/player/CastPlayerTransportControls.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Casting Player Transport Controls
|
||||
* Playback transport row: rewind, play/pause, forward.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface CastPlayerTransportControlsProps {
|
||||
/** Whether playback is currently playing. */
|
||||
isPlaying: boolean;
|
||||
/** Toggle play/pause on the Chromecast. */
|
||||
togglePlayPause: () => Promise<void>;
|
||||
/** Skip backward by the given number of seconds. */
|
||||
skipBackward: (seconds: number) => Promise<void>;
|
||||
/** Skip forward by the given number of seconds. */
|
||||
skipForward: (seconds: number) => Promise<void>;
|
||||
/** Configured rewind skip time in seconds, shown on the rewind button. */
|
||||
rewindSkipTime: number | null | undefined;
|
||||
/** Configured forward skip time in seconds, shown on the forward button. */
|
||||
forwardSkipTime: number | null | undefined;
|
||||
/** Accent color used for the play/pause button background. */
|
||||
protocolColor: string;
|
||||
}
|
||||
|
||||
export function CastPlayerTransportControls({
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
skipBackward,
|
||||
skipForward,
|
||||
rewindSkipTime,
|
||||
forwardSkipTime,
|
||||
protocolColor,
|
||||
}: CastPlayerTransportControlsProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 32,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{/* Rewind (use settings) */}
|
||||
<Pressable
|
||||
onPress={() => skipBackward(rewindSkipTime ?? 10)}
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={48}
|
||||
color='white'
|
||||
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
|
||||
/>
|
||||
{rewindSkipTime != null && (
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{rewindSkipTime}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<Pressable
|
||||
onPress={togglePlayPause}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: protocolColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={36}
|
||||
color='white'
|
||||
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Forward (use settings) */}
|
||||
<Pressable
|
||||
onPress={() => skipForward(forwardSkipTime ?? 10)}
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='refresh-outline' size={48} color='white' />
|
||||
{forwardSkipTime != null && (
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{forwardSkipTime}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
110
components/casting/player/CastTrickplayBubble.tsx
Normal file
110
components/casting/player/CastTrickplayBubble.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Shared scrub-preview bubble for the casting progress bars.
|
||||
*
|
||||
* The slider (`react-native-awesome-slider`) sizes, centres and clamps this
|
||||
* bubble on the thumb via its `bubbleMaxWidth` / `bubbleWidth` props. This
|
||||
* component therefore does NO horizontal positioning — it only anchors itself
|
||||
* vertically (`bottom: 0`, growing upward) so it sits above the progress bar.
|
||||
*/
|
||||
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { formatTrickplayTime } from "@/utils/casting/helpers";
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface CastTrickplayBubbleProps {
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
/** Scrub time to display. */
|
||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
||||
/** Trickplay tile width in px (220 main player, 140 mini-player). */
|
||||
tileWidth: number;
|
||||
}
|
||||
|
||||
export function CastTrickplayBubble({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
trickplayTime,
|
||||
tileWidth,
|
||||
}: CastTrickplayBubbleProps) {
|
||||
const timeText = (
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
textShadowColor: "rgba(0, 0, 0, 0.85)",
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
}}
|
||||
>
|
||||
{formatTrickplayTime(trickplayTime)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Anchored to the bottom of the slider-positioned container, growing upward,
|
||||
// and filling the container width (left/right: 0) so it stays centred on the
|
||||
// thumb. No horizontal maths here — the slider owns horizontal placement.
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{timeText}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{timeText}
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
cachePolicy='memory-disk'
|
||||
style={{
|
||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
||||
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
321
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
321
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Chromecast Connection Menu
|
||||
* Shows device info, volume control, and disconnect option
|
||||
* Simple menu for when connected but not actively controlling playback
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useCastDevice, useCastSession } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface ChromecastConnectionMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDisconnect?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastConnectionMenu: React.FC<
|
||||
ChromecastConnectionMenuProps
|
||||
> = ({ visible, onClose, onDisconnect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const castDevice = useCastDevice();
|
||||
const castSession = useCastSession();
|
||||
|
||||
// Volume state - use refs to avoid triggering re-renders during sliding
|
||||
const [displayVolume, setDisplayVolume] = useState(50);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isMutedRef = useRef(false);
|
||||
const volumeValue = useSharedValue(50);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(50);
|
||||
|
||||
const protocolColor = "#a855f7";
|
||||
|
||||
// Get initial volume and mute state when menu opens
|
||||
useEffect(() => {
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Get initial states
|
||||
const fetchInitialState = async () => {
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
fetchInitialState();
|
||||
|
||||
// Poll for external volume changes (physical buttons) - only when not sliding
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
// Only update if external change detected (not our own change)
|
||||
if (Math.abs(percent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
if (muted !== isMutedRef.current) {
|
||||
isMutedRef.current = muted;
|
||||
setIsMuted(muted);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, 1000); // Poll less frequently
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue]);
|
||||
|
||||
// Volume change during sliding - update display only, don't call API
|
||||
const handleVolumeChange = useCallback((value: number) => {
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
}, []);
|
||||
|
||||
// Volume change complete - call API
|
||||
const handleVolumeComplete = useCallback(
|
||||
async (value: number) => {
|
||||
isSliding.current = false;
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
lastSetVolume.current = rounded;
|
||||
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(rounded / 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Volume error:", error);
|
||||
}
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMute = !isMuted;
|
||||
await castSession.setMute(newMute);
|
||||
isMutedRef.current = newMute;
|
||||
setIsMuted(newMute);
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Mute error:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
// Disconnect
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
if (onDisconnect) {
|
||||
await onDisconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Disconnect error:", error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}, [onDisconnect, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with device name */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: protocolColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={20} color='white' />
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{castDevice?.friendlyName || t("casting_player.chromecast")}
|
||||
</Text>
|
||||
<Text style={{ color: protocolColor, fontSize: 12 }}>
|
||||
{t("casting_player.connected")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Volume Control */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("casting_player.volume")}
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? protocolColor : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isSliding.current = true;
|
||||
}}
|
||||
onValueChange={async (value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
// Unmute when adjusting volume - use ref to avoid
|
||||
// stale closure and prevent repeated async calls
|
||||
if (isMutedRef.current) {
|
||||
isMutedRef.current = false;
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession?.setMute(false);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
"[ChromecastConnectionMenu] Failed to unmute:",
|
||||
error,
|
||||
);
|
||||
isMutedRef.current = true;
|
||||
setIsMuted(true); // Rollback on failure
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSlidingComplete={handleVolumeComplete}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<View style={{ paddingHorizontal: 16 }}>
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
style={{
|
||||
backgroundColor: protocolColor,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='power' size={20} color='white' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
|
||||
>
|
||||
{t("casting_player.disconnect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
348
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
348
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Chromecast Device Info Sheet
|
||||
* Shows device details, volume control, and disconnect option
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useCastSession } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface ChromecastDeviceSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
device: { friendlyName?: string } | null;
|
||||
onDisconnect: () => Promise<void>;
|
||||
volume?: number;
|
||||
onVolumeChange?: (volume: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
device,
|
||||
onDisconnect,
|
||||
volume = 0.5,
|
||||
onVolumeChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
|
||||
const volumeValue = useSharedValue(volume * 100);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const castSession = useCastSession();
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(Math.round(volume * 100));
|
||||
|
||||
// Sync volume slider with prop changes (updates from physical buttons)
|
||||
// Skip updates while user is actively sliding to avoid overwriting drag
|
||||
useEffect(() => {
|
||||
if (isSliding.current) return;
|
||||
volumeValue.value = volume * 100;
|
||||
setDisplayVolume(Math.round(volume * 100));
|
||||
}, [volume, volumeValue]);
|
||||
|
||||
// Poll for volume and mute updates when sheet is visible to catch physical button changes
|
||||
useEffect(() => {
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Get initial mute state
|
||||
castSession
|
||||
.isMute()
|
||||
.then(setIsMuted)
|
||||
.catch(() => {});
|
||||
|
||||
// Poll CastSession for device volume and mute state (only when not sliding)
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const deviceVolume = await castSession.getVolume();
|
||||
if (deviceVolume !== undefined) {
|
||||
const volumePercent = Math.round(deviceVolume * 100);
|
||||
// Only update if external change (physical buttons)
|
||||
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(volumePercent);
|
||||
volumeValue.value = volumePercent;
|
||||
lastSetVolume.current = volumePercent;
|
||||
}
|
||||
}
|
||||
|
||||
// Check mute state
|
||||
const muteState = await castSession.isMute();
|
||||
setIsMuted(muteState);
|
||||
} catch {
|
||||
// Ignore errors - device might be disconnected
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue]);
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
await onDisconnect();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeComplete = async (value: number) => {
|
||||
const newVolume = value / 100;
|
||||
setDisplayVolume(Math.round(value));
|
||||
try {
|
||||
// Use CastSession.setVolume for DEVICE volume control
|
||||
// This works even when no media is playing, unlike setStreamVolume
|
||||
if (castSession) {
|
||||
await castSession.setVolume(newVolume);
|
||||
} else if (onVolumeChange) {
|
||||
// Fallback to prop method if session not available
|
||||
await onVolumeChange(newVolume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Volume] Error setting volume:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced volume update during sliding for smooth live feedback
|
||||
const handleVolumeChange = useCallback(
|
||||
(value: number) => {
|
||||
setDisplayVolume(Math.round(value));
|
||||
|
||||
// Debounce the API call to avoid too many requests
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
|
||||
volumeDebounceRef.current = setTimeout(async () => {
|
||||
const newVolume = value / 100;
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(newVolume);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during sliding
|
||||
}
|
||||
}, 150); // 150ms debounce
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute state
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMuteState = !isMuted;
|
||||
await castSession.setMute(newMuteState);
|
||||
setIsMuted(newMuteState);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Error toggling mute:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
|
||||
>
|
||||
{t("casting_player.chromecast")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device info */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
{t("casting_player.device_name")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
||||
>
|
||||
{device?.friendlyName || t("casting_player.unknown_device")}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Volume control */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("casting_player.volume")}
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Mute button */}
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? "#a855f7" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
|
||||
bubbleBackgroundColor: "#a855f7",
|
||||
}}
|
||||
onSlidingStart={async () => {
|
||||
isSliding.current = true;
|
||||
// Auto-unmute when user starts adjusting volume
|
||||
if (isMuted && castSession) {
|
||||
setIsMuted(false);
|
||||
try {
|
||||
await castSession.setMute(false);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Failed to unmute:", error);
|
||||
setIsMuted(true); // Rollback on failure
|
||||
}
|
||||
}
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isSliding.current = false;
|
||||
lastSetVolume.current = Math.round(value);
|
||||
handleVolumeComplete(value);
|
||||
}}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
style={{
|
||||
backgroundColor: "#a855f7",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: isDisconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='power'
|
||||
size={20}
|
||||
color='white'
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{isDisconnecting
|
||||
? t("casting_player.disconnecting")
|
||||
: t("casting_player.stop_casting")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
356
components/chromecast/ChromecastEpisodeList.tsx
Normal file
356
components/chromecast/ChromecastEpisodeList.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Episode List for Chromecast Player
|
||||
* Displays list of episodes for TV shows with thumbnails
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { truncateTitle } from "@/utils/casting/helpers";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface ChromecastEpisodeListProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentItem: BaseItemDto | null;
|
||||
episodes: BaseItemDto[];
|
||||
onSelectEpisode: (episode: BaseItemDto) => void;
|
||||
api: Api | null;
|
||||
}
|
||||
|
||||
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
currentItem,
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
api,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const scrollRetryCountRef = useRef(0);
|
||||
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const MAX_SCROLL_RETRIES = 3;
|
||||
|
||||
// Cleanup pending retry timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
scrollRetryTimeoutRef.current = null;
|
||||
}
|
||||
scrollRetryCountRef.current = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get unique seasons from episodes
|
||||
const seasons = useMemo(() => {
|
||||
const seasonSet = new Set<number>();
|
||||
for (const ep of episodes) {
|
||||
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
|
||||
seasonSet.add(ep.ParentIndexNumber);
|
||||
}
|
||||
}
|
||||
return Array.from(seasonSet).sort((a, b) => a - b);
|
||||
}, [episodes]);
|
||||
|
||||
// Filter episodes by selected season and exclude virtual episodes
|
||||
const filteredEpisodes = useMemo(() => {
|
||||
let eps = episodes;
|
||||
|
||||
// Filter by season if selected
|
||||
if (selectedSeason !== null) {
|
||||
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
|
||||
}
|
||||
|
||||
// Filter out virtual episodes (episodes without actual video files)
|
||||
// LocationType === "Virtual" means the episode doesn't have a media file
|
||||
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
|
||||
|
||||
return eps;
|
||||
}, [episodes, selectedSeason]);
|
||||
|
||||
// Set initial season to current episode's season
|
||||
useEffect(() => {
|
||||
if (currentItem?.ParentIndexNumber !== undefined) {
|
||||
setSelectedSeason(currentItem.ParentIndexNumber);
|
||||
}
|
||||
}, [currentItem]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset retry counter when visibility or data changes
|
||||
scrollRetryCountRef.current = 0;
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (visible && currentItem && filteredEpisodes.length > 0) {
|
||||
const currentIndex = filteredEpisodes.findIndex(
|
||||
(ep) => ep.Id === currentItem.Id,
|
||||
);
|
||||
if (currentIndex !== -1 && flatListRef.current) {
|
||||
// Delay to ensure FlatList is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: currentIndex,
|
||||
animated: true,
|
||||
viewPosition: 0.5, // Center the item
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [visible, currentItem, filteredEpisodes]);
|
||||
|
||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSelectEpisode(item);
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
padding: 12,
|
||||
// Translucent (not solid) purple so the dark base shows through and
|
||||
// the row's text — incl. the purple S:E label — stays readable. The
|
||||
// play-circle icon also marks the current episode.
|
||||
backgroundColor: isCurrentEpisode
|
||||
? "rgba(168, 85, 247, 0.25)"
|
||||
: "transparent",
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 68,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const imageUrl =
|
||||
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={32} color='#333' />
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Episode info */}
|
||||
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
|
||||
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
|
||||
</Text>
|
||||
{item.Overview && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
||||
{item.ParentIndexNumber !== undefined &&
|
||||
item.IndexNumber !== undefined && (
|
||||
<Text
|
||||
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
|
||||
>
|
||||
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
|
||||
{String(item.IndexNumber).padStart(2, "0")}
|
||||
</Text>
|
||||
)}
|
||||
{item.ProductionYear && (
|
||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
)}
|
||||
{item.RunTimeTicks && (
|
||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
||||
{Math.round(item.RunTimeTicks / 600000000)}{" "}
|
||||
{t("casting_player.minutes_short")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isCurrentEpisode && (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-circle' size={24} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: insets.top,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: seasons.length > 1 ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
{t("casting_player.episodes")}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Season selector */}
|
||||
{seasons.length > 1 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8 }}
|
||||
>
|
||||
{seasons.map((season) => (
|
||||
<Pressable
|
||||
key={season}
|
||||
onPress={() => setSelectedSeason(season)}
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor:
|
||||
selectedSeason === season ? "#a855f7" : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: selectedSeason === season ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{t("casting_player.season", { number: season })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Episode list */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={filteredEpisodes}
|
||||
renderItem={renderEpisode}
|
||||
keyExtractor={(item, index) => item.Id || `episode-${index}`}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScrollToIndexFailed={(info) => {
|
||||
// Bounded retry for scroll failures
|
||||
if (
|
||||
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
|
||||
info.index >= filteredEpisodes.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scrollRetryCountRef.current += 1;
|
||||
if (scrollRetryTimeoutRef.current) {
|
||||
clearTimeout(scrollRetryTimeoutRef.current);
|
||||
}
|
||||
scrollRetryTimeoutRef.current = setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: info.index,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
304
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
304
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Chromecast Settings Menu
|
||||
* Configure version, quality (bitrate cap), audio, subtitles, and playback speed.
|
||||
* Every "selected" row is driven by the active CastSelection — no [0] fallbacks.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
|
||||
|
||||
export interface VersionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface QualityOption {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
}
|
||||
|
||||
interface ChromecastSettingsMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
versions: VersionOption[];
|
||||
selectedVersionId: string;
|
||||
onVersionChange: (id: string) => void;
|
||||
qualities: QualityOption[];
|
||||
selectedMaxBitrate: number | undefined;
|
||||
onQualityChange: (value: number | undefined) => void;
|
||||
audioTracks: AudioTrack[];
|
||||
selectedAudioIndex: number;
|
||||
onAudioChange: (index: number) => void;
|
||||
subtitleTracks: SubtitleTrack[];
|
||||
/** -1 = subtitles off. */
|
||||
selectedSubtitleIndex: number;
|
||||
onSubtitleChange: (index: number) => void;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
const ACCENT = "#a855f7";
|
||||
|
||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
versions,
|
||||
selectedVersionId,
|
||||
onVersionChange,
|
||||
qualities,
|
||||
selectedMaxBitrate,
|
||||
onQualityChange,
|
||||
audioTracks,
|
||||
selectedAudioIndex,
|
||||
onAudioChange,
|
||||
subtitleTracks,
|
||||
selectedSubtitleIndex,
|
||||
onSubtitleChange,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSection(expandedSection === section ? null : section);
|
||||
};
|
||||
|
||||
const renderSectionHeader = (
|
||||
title: string,
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
sectionKey: string,
|
||||
) => (
|
||||
<Pressable
|
||||
onPress={() => toggleSection(sectionKey)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<Ionicons name={icon} size={20} color='white' />
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color='#999'
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const renderRow = (
|
||||
key: string | number,
|
||||
label: string,
|
||||
sublabel: string | null,
|
||||
selected: boolean,
|
||||
onPress: () => void,
|
||||
) => (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => {
|
||||
onPress();
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor: selected ? "#2a2a2a" : "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
|
||||
{sublabel ? (
|
||||
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
|
||||
{sublabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "80%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
{t("casting_player.playback_settings")}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
{/* Version — only when the item has more than one MediaSource */}
|
||||
{versions.length > 1 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.version"),
|
||||
"albums-outline",
|
||||
"version",
|
||||
)}
|
||||
{versions.length > 1 && expandedSection === "version" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{versions.map((v) =>
|
||||
renderRow(
|
||||
v.id,
|
||||
v.name,
|
||||
null,
|
||||
v.id === selectedVersionId,
|
||||
() => onVersionChange(v.id),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Quality (bitrate cap) */}
|
||||
{renderSectionHeader(
|
||||
t("casting_player.quality"),
|
||||
"film-outline",
|
||||
"quality",
|
||||
)}
|
||||
{expandedSection === "quality" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{qualities.map((q) =>
|
||||
renderRow(
|
||||
q.key,
|
||||
q.key,
|
||||
null,
|
||||
q.value === selectedMaxBitrate,
|
||||
() => onQualityChange(q.value),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio — only when more than one track */}
|
||||
{audioTracks.length > 1 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.audio"),
|
||||
"musical-notes",
|
||||
"audio",
|
||||
)}
|
||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{audioTracks.map((track) =>
|
||||
renderRow(
|
||||
track.index,
|
||||
track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown"),
|
||||
track.codec ? track.codec.toUpperCase() : null,
|
||||
track.index === selectedAudioIndex,
|
||||
() => onAudioChange(track.index),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Subtitles */}
|
||||
{subtitleTracks.length > 0 &&
|
||||
renderSectionHeader(
|
||||
t("casting_player.subtitles"),
|
||||
"text",
|
||||
"subtitles",
|
||||
)}
|
||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{renderRow(
|
||||
"off",
|
||||
t("casting_player.none"),
|
||||
null,
|
||||
selectedSubtitleIndex < 0,
|
||||
() => onSubtitleChange(-1),
|
||||
)}
|
||||
{subtitleTracks.map((track) =>
|
||||
renderRow(
|
||||
track.index,
|
||||
track.displayTitle ||
|
||||
track.language ||
|
||||
t("casting_player.unknown"),
|
||||
[
|
||||
track.codec ? track.codec.toUpperCase() : "",
|
||||
track.isForced ? t("casting_player.forced") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ") || null,
|
||||
track.index === selectedSubtitleIndex,
|
||||
() => onSubtitleChange(track.index),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Playback speed */}
|
||||
{renderSectionHeader(
|
||||
t("casting_player.playback_speed"),
|
||||
"speedometer",
|
||||
"speed",
|
||||
)}
|
||||
{expandedSection === "speed" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{PLAYBACK_SPEEDS.map((speed) =>
|
||||
renderRow(
|
||||
speed,
|
||||
speed === 1 ? t("casting_player.normal") : `${speed}x`,
|
||||
null,
|
||||
Math.abs(playbackSpeed - speed) < 0.01,
|
||||
() => onPlaybackSpeedChange(speed),
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
171
components/chromecast/hooks/useChromecastSegments.ts
Normal file
171
components/chromecast/hooks/useChromecastSegments.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
|
||||
* Integrates with autoskip API for segment detection
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { isWithinSegment } from "@/utils/casting/helpers";
|
||||
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
|
||||
export const useChromecastSegments = (
|
||||
item: BaseItemDto | null,
|
||||
currentProgressMs: number,
|
||||
isOffline = false,
|
||||
) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Fetch segments from autoskip API
|
||||
const { data: segmentData } = useSegments(
|
||||
item?.Id || "",
|
||||
isOffline,
|
||||
undefined, // downloadedFiles parameter
|
||||
api,
|
||||
);
|
||||
|
||||
// Parse segments into usable format
|
||||
const segments = useMemo<ChromecastSegmentData>(() => {
|
||||
if (!segmentData) {
|
||||
return {
|
||||
intro: null,
|
||||
credits: null,
|
||||
recap: null,
|
||||
commercial: [],
|
||||
preview: [],
|
||||
};
|
||||
}
|
||||
|
||||
const intro =
|
||||
segmentData.introSegments && segmentData.introSegments.length > 0
|
||||
? {
|
||||
start: segmentData.introSegments[0].startTime,
|
||||
end: segmentData.introSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const credits =
|
||||
segmentData.creditSegments && segmentData.creditSegments.length > 0
|
||||
? {
|
||||
start: segmentData.creditSegments[0].startTime,
|
||||
end: segmentData.creditSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const recap =
|
||||
segmentData.recapSegments && segmentData.recapSegments.length > 0
|
||||
? {
|
||||
start: segmentData.recapSegments[0].startTime,
|
||||
end: segmentData.recapSegments[0].endTime,
|
||||
}
|
||||
: null;
|
||||
|
||||
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
|
||||
start: seg.startTime,
|
||||
end: seg.endTime,
|
||||
}));
|
||||
|
||||
const preview = (segmentData.previewSegments || []).map((seg) => ({
|
||||
start: seg.startTime,
|
||||
end: seg.endTime,
|
||||
}));
|
||||
|
||||
return { intro, credits, recap, commercial, preview };
|
||||
}, [segmentData]);
|
||||
|
||||
// Check which segment we're currently in
|
||||
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
|
||||
// before comparing with segment times (which are in seconds from the autoskip API)
|
||||
const currentSegment = useMemo(() => {
|
||||
if (isWithinSegment(currentProgressMs, segments.intro)) {
|
||||
return { type: "intro" as const, segment: segments.intro };
|
||||
}
|
||||
if (isWithinSegment(currentProgressMs, segments.credits)) {
|
||||
return { type: "credits" as const, segment: segments.credits };
|
||||
}
|
||||
if (isWithinSegment(currentProgressMs, segments.recap)) {
|
||||
return { type: "recap" as const, segment: segments.recap };
|
||||
}
|
||||
for (const commercial of segments.commercial) {
|
||||
if (isWithinSegment(currentProgressMs, commercial)) {
|
||||
return { type: "commercial" as const, segment: commercial };
|
||||
}
|
||||
}
|
||||
for (const preview of segments.preview) {
|
||||
if (isWithinSegment(currentProgressMs, preview)) {
|
||||
return { type: "preview" as const, segment: preview };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [currentProgressMs, segments]);
|
||||
|
||||
// Skip functions
|
||||
const skipIntro = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.intro) {
|
||||
await seekFn(segments.intro.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.intro],
|
||||
);
|
||||
|
||||
const skipCredits = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (segments.credits) {
|
||||
await seekFn(segments.credits.end * 1000);
|
||||
}
|
||||
},
|
||||
[segments.credits],
|
||||
);
|
||||
|
||||
const skipSegment = useCallback(
|
||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
||||
if (currentSegment?.segment) {
|
||||
await seekFn(currentSegment.segment.end * 1000);
|
||||
}
|
||||
},
|
||||
[currentSegment],
|
||||
);
|
||||
|
||||
// Auto-skip logic based on settings
|
||||
const shouldAutoSkip = useMemo(() => {
|
||||
if (!currentSegment) return false;
|
||||
|
||||
switch (currentSegment.type) {
|
||||
case "intro":
|
||||
return settings?.skipIntro === "auto";
|
||||
case "credits":
|
||||
return settings?.skipOutro === "auto";
|
||||
case "recap":
|
||||
return settings?.skipRecap === "auto";
|
||||
case "commercial":
|
||||
return settings?.skipCommercial === "auto";
|
||||
case "preview":
|
||||
return settings?.skipPreview === "auto";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
currentSegment,
|
||||
settings?.skipIntro,
|
||||
settings?.skipOutro,
|
||||
settings?.skipRecap,
|
||||
settings?.skipCommercial,
|
||||
settings?.skipPreview,
|
||||
]);
|
||||
|
||||
return {
|
||||
segments,
|
||||
currentSegment,
|
||||
skipIntro,
|
||||
skipCredits,
|
||||
skipSegment,
|
||||
shouldAutoSkip,
|
||||
hasIntro: !!segments.intro,
|
||||
hasCredits: !!segments.credits,
|
||||
};
|
||||
};
|
||||
@@ -133,7 +133,6 @@ const HomeMobile = () => {
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className='ml-1.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
|
||||
return (
|
||||
<MoreMoviesWithActor
|
||||
key={person.Id}
|
||||
key={`${person.Id}-${idx}`}
|
||||
currentItem={item}
|
||||
actorId={person.Id}
|
||||
actorName={person.Name}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "i18next";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Pair with Phone */}
|
||||
{onStartPairing && (
|
||||
{Platform.OS !== "ios" && onStartPairing && (
|
||||
<View>
|
||||
<Button
|
||||
onPress={onStartPairing}
|
||||
|
||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVSubmitButton
|
||||
onPress={handleSubmit}
|
||||
label={t("login.login_button")}
|
||||
label={t("login.login")}
|
||||
loading={isLoading}
|
||||
disabled={!password}
|
||||
/>
|
||||
|
||||
103
components/player/AutoplayCountdown.tsx
Normal file
103
components/player/AutoplayCountdown.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Player-agnostic "next episode" countdown card. The parent owns the timer and
|
||||
* positioning — this component only renders the next episode's poster, title,
|
||||
* the remaining seconds, and the Play-now / Cancel actions.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface AutoplayCountdownProps {
|
||||
/** The episode that will play next. */
|
||||
nextEpisode: BaseItemDto;
|
||||
/** Poster image URL for the next episode, or null. */
|
||||
posterUrl: string | null;
|
||||
/** Seconds left before the next episode plays. */
|
||||
secondsRemaining: number;
|
||||
/** Play the next episode immediately. */
|
||||
onPlayNow: () => void;
|
||||
/** Cancel autoplay — the next episode will not play. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AutoplayCountdown({
|
||||
nextEpisode,
|
||||
posterUrl,
|
||||
secondsRemaining,
|
||||
onPlayNow,
|
||||
onCancel,
|
||||
}: AutoplayCountdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
width: 320,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "rgba(20, 20, 20, 0.94)",
|
||||
}}
|
||||
>
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: 62, height: 93, borderRadius: 6 }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1, justifyContent: "space-between" }}>
|
||||
<View style={{ gap: 2 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
||||
{t("player.up_next")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "#fff", fontSize: 15, fontWeight: "600" }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{nextEpisode.Name}
|
||||
</Text>
|
||||
<Text style={{ color: "#a855f7", fontSize: 13 }}>
|
||||
{t("player.next_episode_in", { seconds: secondsRemaining })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 8 }}>
|
||||
<Pressable
|
||||
onPress={onPlayNow}
|
||||
accessibilityRole='button'
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#a855f7",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||
{t("player.play_now")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onCancel}
|
||||
accessibilityRole='button'
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#333",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
||||
{t("player.cancel")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -401,10 +401,6 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasMovies = movieResults && movieResults.length > 0;
|
||||
const hasTv = tvResults && tvResults.length > 0;
|
||||
const hasPersons = personResults && personResults.length > 0;
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* No section requests `hasTVPreferredFocus`: the native search field
|
||||
keeps focus while typing, otherwise the first result would re-grab
|
||||
focus on every keystroke as results re-render. The user navigates
|
||||
down to the grid manually. */}
|
||||
<TVJellyseerrMovieSection
|
||||
title={t("search.request_movies")}
|
||||
items={movieResults}
|
||||
isFirstSection={hasMovies}
|
||||
isFirstSection={false}
|
||||
onItemPress={onMoviePress}
|
||||
/>
|
||||
<TVJellyseerrTvSection
|
||||
title={t("search.request_series")}
|
||||
items={tvResults}
|
||||
isFirstSection={!hasMovies && hasTv}
|
||||
isFirstSection={false}
|
||||
onItemPress={onTvPress}
|
||||
/>
|
||||
<TVJellyseerrPersonSection
|
||||
title={t("search.actors")}
|
||||
items={personResults}
|
||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||
isFirstSection={false}
|
||||
onItemPress={onPersonPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -235,10 +235,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
module). It renders the native search bar + grid keyboard and
|
||||
forwards typed text into the existing query pipeline via setSearch;
|
||||
our own results grid renders below. */}
|
||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||
margins squeeze the bar's width and clip that trailing hint, so let
|
||||
the native view span the full width and own its own insets. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
@@ -280,13 +283,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
{sections.map((section) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
isFirstSection={index === 0}
|
||||
// Never auto-focus a result. The native search field owns focus
|
||||
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
||||
// every keystroke as results re-render. User navigates down to the
|
||||
// grid manually.
|
||||
isFirstSection={false}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
|
||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
removeClippedSubviews={false}
|
||||
getItemLayout={getItemLayout}
|
||||
style={{ overflow: "visible" }}
|
||||
contentInset={{
|
||||
left: edgePadding,
|
||||
right: edgePadding,
|
||||
}}
|
||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
||||
// contentOffset only applies on initial mount; since this FlatList is
|
||||
// reused across searches (stable key), a second search left the inset
|
||||
// without the offset and the grid snapped flush to the left edge.
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: edgePadding,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,62 @@
|
||||
import { Switch, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
|
||||
auto: "Automatic (recommended)",
|
||||
"force-hevc": "Force HEVC / H265",
|
||||
"force-h264": "Force H264",
|
||||
};
|
||||
|
||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const profileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: (
|
||||
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
|
||||
).map((mode) => ({
|
||||
type: "radio" as const,
|
||||
label: PROFILE_LABELS[mode],
|
||||
value: mode,
|
||||
selected: (settings.chromecastProfile ?? "auto") === mode,
|
||||
onPress: () => updateSettings({ chromecastProfile: mode }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings.chromecastProfile, updateSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Chromecast"}>
|
||||
<ListItem title={"Enable H265 for Chromecast"}>
|
||||
<Switch
|
||||
value={settings.enableH265ForChromecast}
|
||||
onValueChange={(enableH265ForChromecast) =>
|
||||
updateSettings({ enableH265ForChromecast })
|
||||
<ListItem
|
||||
title={"Profile"}
|
||||
subtitle={
|
||||
"Automatic picks codecs per device. Override only if needed."
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={profileOptions}
|
||||
title={"Chromecast profile"}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -96,6 +98,48 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
|
||||
// valid selection even if an out-of-range value was stored previously.
|
||||
const autoplayCountdown = Math.min(
|
||||
60,
|
||||
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
|
||||
);
|
||||
const castAutoplayCountdown = Math.min(
|
||||
60,
|
||||
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
|
||||
);
|
||||
|
||||
const autoplayCountdownOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||
type: "radio" as const,
|
||||
label: String(seconds),
|
||||
value: String(seconds),
|
||||
selected: seconds === autoplayCountdown,
|
||||
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[autoplayCountdown, updateSettings],
|
||||
);
|
||||
|
||||
const castAutoplayCountdownOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
||||
type: "radio" as const,
|
||||
label: String(seconds),
|
||||
value: String(seconds),
|
||||
selected: seconds === castAutoplayCountdown,
|
||||
onPress: () =>
|
||||
updateSettings({ castAutoplayCountdownSeconds: seconds }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[castAutoplayCountdown, updateSettings],
|
||||
);
|
||||
|
||||
const playbackSpeedOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -229,7 +273,10 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
disabled={
|
||||
!settings.autoPlayNextEpisode ||
|
||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
@@ -248,6 +295,57 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* Media Segment Skip Settings */}
|
||||
<ListItem
|
||||
title={t("home.settings.other.segment_skip_settings")}
|
||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
||||
onPress={() => router.push("/settings/segment-skip/page")}
|
||||
>
|
||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoplayCountdownOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={castAutoplayCountdownOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{castAutoplayCountdown}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
@@ -268,3 +366,6 @@ const AUTOPLAY_EPISODES_COUNT = (
|
||||
{ key: "6", value: 6 },
|
||||
{ key: "7", value: 7 },
|
||||
];
|
||||
|
||||
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
|
||||
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
ChapterInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
@@ -12,9 +13,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
@@ -35,11 +37,14 @@ interface BottomControlsProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
skipButtonText: string;
|
||||
showSkipCreditButton: boolean;
|
||||
skipCreditButtonText: string;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
api?: Api | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
handleControlsInteraction: () => void;
|
||||
@@ -90,11 +95,14 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
skipButtonText,
|
||||
showSkipCreditButton,
|
||||
skipCreditButtonText,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
nextItem,
|
||||
api,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
handleControlsInteraction,
|
||||
@@ -125,6 +133,83 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
);
|
||||
const hasChapters = chapterMarkerList.length > 1;
|
||||
|
||||
// Autoplay overlay: shown under the same condition the old countdown button used.
|
||||
const autoplayAllowed =
|
||||
settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||
|
||||
const showNextEpisodeCountdown =
|
||||
autoplayAllowed &&
|
||||
(!nextItem
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
remainingTime < 10000);
|
||||
|
||||
const [secondsRemaining, setSecondsRemaining] = useState(
|
||||
settings.autoplayCountdownSeconds,
|
||||
);
|
||||
const [autoplayCancelled, setAutoplayCancelled] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Keep a stable ref to the autoplay handler so the timer effect does not
|
||||
// restart when the handler identity changes.
|
||||
const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay);
|
||||
autoPlayHandlerRef.current = handleNextEpisodeAutoPlay;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNextEpisodeCountdown || autoplayCancelled) {
|
||||
// Either the show-condition flipped off OR the user cancelled.
|
||||
// In both cases, stop the running timer immediately so autoplay
|
||||
// can't fire after Cancel was pressed.
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
// Only reset cancellation + seconds when the show-condition itself
|
||||
// flipped off — a fresh credits/end-of-video window then starts a
|
||||
// brand-new countdown. If we got here because autoplayCancelled
|
||||
// just flipped true, keep it true so the countdown stays stopped.
|
||||
if (!showNextEpisodeCountdown) {
|
||||
setAutoplayCancelled(false);
|
||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSecondsRemaining(settings.autoplayCountdownSeconds);
|
||||
intervalRef.current = setInterval(() => {
|
||||
setSecondsRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
autoPlayHandlerRef.current();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
showNextEpisodeCountdown,
|
||||
autoplayCancelled,
|
||||
settings.autoplayCountdownSeconds,
|
||||
]);
|
||||
|
||||
const nextEpisodePosterUrl = useMemo(
|
||||
() =>
|
||||
nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null,
|
||||
[api, nextItem],
|
||||
);
|
||||
|
||||
// Current chapter name for the always-visible header label (live playback).
|
||||
const currentChapterName = useMemo(
|
||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||
@@ -202,7 +287,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
buttonText={skipButtonText}
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
@@ -212,24 +297,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
buttonText={skipCreditButtonText}
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: // Show during credits if no content after, OR near end of video
|
||||
(showSkipCreditButton && !hasContentAfterCredits) ||
|
||||
remainingTime < 10000
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
{showNextEpisodeCountdown && !autoplayCancelled && nextItem && (
|
||||
<AutoplayCountdown
|
||||
nextEpisode={nextItem}
|
||||
posterUrl={nextEpisodePosterUrl}
|
||||
secondsRemaining={secondsRemaining}
|
||||
onPlayNow={handleNextEpisodeManual}
|
||||
onCancel={() => setAutoplayCancelled(true)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -16,17 +24,17 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
@@ -43,6 +51,9 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
// No-op function to avoid creating new references on every render
|
||||
const noop = () => {};
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
isPlaying: boolean;
|
||||
@@ -111,6 +122,24 @@ export const Controls: FC<Props> = ({
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
// Ref to track pending play timeout for cleanup and cancellation
|
||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
|
||||
const playingRef = useRef(isPlaying);
|
||||
useEffect(() => {
|
||||
playingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
@@ -317,27 +346,125 @@ export const Controls: FC<Props> = ({
|
||||
subtitleIndex: string;
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
// Fetch all segments for the current item
|
||||
const { data: segments } = useSegments(
|
||||
item.Id ?? "",
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
api,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||
useCreditSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
maxMs,
|
||||
);
|
||||
// Convert milliseconds to seconds for segment comparison
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
||||
|
||||
// Wrapper to convert segment skip from seconds to milliseconds
|
||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
||||
const seekMs = useCallback(
|
||||
(timeInSeconds: number) => {
|
||||
// Cancel any pending play call to avoid race conditions
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current);
|
||||
}
|
||||
seek(timeInSeconds * 1000);
|
||||
// Brief delay ensures the seek operation completes before resuming playback
|
||||
// Without this, playback may resume from the old position
|
||||
// Read latest isPlaying from ref to avoid stale closure
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
if (playingRef.current) {
|
||||
play();
|
||||
}
|
||||
playTimeoutRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
[seek, play],
|
||||
);
|
||||
|
||||
// Use unified segment skipper for all segment types
|
||||
const introSkipper = useSegmentSkipper({
|
||||
segments: segments?.introSegments || [],
|
||||
segmentType: "Intro",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const outroSkipper = useSegmentSkipper({
|
||||
segments: segments?.creditSegments || [],
|
||||
segmentType: "Outro",
|
||||
currentTime: currentTimeSeconds,
|
||||
totalDuration: maxSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const recapSkipper = useSegmentSkipper({
|
||||
segments: segments?.recapSegments || [],
|
||||
segmentType: "Recap",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const commercialSkipper = useSegmentSkipper({
|
||||
segments: segments?.commercialSegments || [],
|
||||
segmentType: "Commercial",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
const previewSkipper = useSegmentSkipper({
|
||||
segments: segments?.previewSegments || [],
|
||||
segmentType: "Preview",
|
||||
currentTime: currentTimeSeconds,
|
||||
seek: seekMs,
|
||||
isPaused: !isPlaying,
|
||||
});
|
||||
|
||||
// Determine which segment button to show (priority order)
|
||||
// Commercial > Recap > Intro > Preview > Outro
|
||||
const activeSegment = useMemo(() => {
|
||||
if (commercialSkipper.currentSegment)
|
||||
return { type: "Commercial", ...commercialSkipper };
|
||||
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
||||
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
||||
if (previewSkipper.currentSegment)
|
||||
return { type: "Preview", ...previewSkipper };
|
||||
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
||||
return null;
|
||||
}, [
|
||||
commercialSkipper.currentSegment,
|
||||
recapSkipper.currentSegment,
|
||||
introSkipper.currentSegment,
|
||||
previewSkipper.currentSegment,
|
||||
outroSkipper.currentSegment,
|
||||
commercialSkipper,
|
||||
recapSkipper,
|
||||
introSkipper,
|
||||
previewSkipper,
|
||||
outroSkipper,
|
||||
]);
|
||||
|
||||
// Legacy compatibility: map to old variable names
|
||||
const showSkipButton = !!(
|
||||
activeSegment &&
|
||||
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
||||
);
|
||||
const skipIntro = activeSegment?.skipSegment || noop;
|
||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
||||
const skipCredit = outroSkipper.skipSegment || noop;
|
||||
const hasContentAfterCredits =
|
||||
outroSkipper.currentSegment && maxSeconds
|
||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
||||
: false;
|
||||
|
||||
// Get button text based on segment type using i18n
|
||||
const { t } = useTranslation();
|
||||
const skipButtonText = activeSegment
|
||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
||||
: t("player.skip_intro");
|
||||
const skipCreditButtonText = t("player.skip_outro");
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
@@ -564,11 +691,14 @@ export const Controls: FC<Props> = ({
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
skipButtonText={skipButtonText}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
skipCreditButtonText={skipCreditButtonText}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
api={api}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
handleControlsInteraction={handleControlsInteraction}
|
||||
|
||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
|
||||
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
||||
onFinish?: () => void;
|
||||
onPress?: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||
onFinish,
|
||||
onPress,
|
||||
show,
|
||||
...props
|
||||
}) => {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
progress.value = 0;
|
||||
progress.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 10000, // 10 seconds
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished && onFinish) {
|
||||
runOnJS(onFinish)();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Cancel animation on unmount to prevent onFinish from firing after exit
|
||||
return () => {
|
||||
cancelAnimation(progress);
|
||||
};
|
||||
}
|
||||
}, [show, onFinish]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${progress.value * 100}%`,
|
||||
backgroundColor: Colors.primary,
|
||||
};
|
||||
});
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className='w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900'
|
||||
{...props}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Animated.View style={animatedStyle} />
|
||||
<View className='px-3 py-3'>
|
||||
<Text numberOfLines={1} className='text-center font-bold'>
|
||||
{t("player.next_episode")}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextEpisodeCountDownButton;
|
||||
39
docs/chromecast-test-matrix.md
Normal file
39
docs/chromecast-test-matrix.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Chromecast Cast Test Matrix
|
||||
|
||||
Manual verification for the device-profile work. Run each row by casting the
|
||||
matching media from the app to a physical Chromecast and recording the result.
|
||||
|
||||
**Test device:** ___________________ (model name as reported by the app)
|
||||
**App build / commit:** ___________________
|
||||
**Date:** ___________________
|
||||
|
||||
## How to run
|
||||
|
||||
1. Pick a library item matching the row's codec / audio / container.
|
||||
2. Cast it. Note whether it direct-plays or transcodes (server logs show
|
||||
`Video is being transcoded` vs `Video is being direct played`).
|
||||
3. Record the load result: OK / 2100 / infinite-loading / other.
|
||||
|
||||
## Matrix
|
||||
|
||||
| # | Video codec | Audio | Container | Approx bitrate | Direct/Transcode | Result | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | H.264 1080p | AAC stereo | MP4 | ~4 Mb/s | | | |
|
||||
| 2 | H.264 1080p | AAC stereo | MKV | ~8 Mb/s | | | |
|
||||
| 3 | H.264 1080p | AAC 5.1 | MKV | ~8 Mb/s | | | |
|
||||
| 4 | H.264 1080p | AC3 5.1 | MKV | ~10 Mb/s | | | |
|
||||
| 5 | H.264 1080p | DTS 5.1 | MKV | ~12 Mb/s | | | |
|
||||
| 6 | H.264 1080p | TrueHD 7.1 | MKV | ~20 Mb/s | | | |
|
||||
| 7 | H.264 1080p | AAC stereo | MP4 | ~16 Mb/s | | | |
|
||||
| 8 | H.264 1080p | AAC stereo | MP4 | source-max | | | |
|
||||
| 9 | HEVC 8-bit | AAC stereo | MKV | ~10 Mb/s | | | |
|
||||
| 10 | HEVC 10-bit | AAC stereo | MKV | ~15 Mb/s | | | |
|
||||
|
||||
## Outcome
|
||||
|
||||
- Highest video bitrate that loads reliably on the test device: ___________
|
||||
-> update `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` in
|
||||
`utils/casting/capabilities.ts` accordingly.
|
||||
- Confirmed cause of issue #1423 (<= 2 Mb/s): ___________
|
||||
- Confirmed cause of the 5.1 crash (#1085): ___________
|
||||
- Cases where downgrade-on-failure retry rescued playback: ___________
|
||||
431
hooks/useCastAutoplay.ts
Normal file
431
hooks/useCastAutoplay.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Cast autoplay watcher.
|
||||
*
|
||||
* Always-mounted hook: subscribes to the Chromecast `mediaStatus`, captures the
|
||||
* currently-playing episode while playback is active, and on either
|
||||
* (a) playback entering the Outro segment (when `skipOutro !== "auto"`), or
|
||||
* (b) `IDLE + FINISHED` (hard end of media),
|
||||
* starts a cancellable countdown via `castAutoplayAtom` and ultimately loads
|
||||
* the next episode on the cast.
|
||||
*
|
||||
* The countdown atom is driven here; the casting-player overlay reads it.
|
||||
* Cancellation (overlay's Cancel button) sets the atom to `null` externally;
|
||||
* the watcher reacts by clearing its interval and refusing to retrigger for
|
||||
* the same item.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
MediaPlayerIdleReason,
|
||||
MediaPlayerState,
|
||||
useCastDevice,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
|
||||
/**
|
||||
* Cached next-episode resolution, keyed by the captured (seriesId, currentEpisodeId)
|
||||
* pair so the network calls are not repeated on every `mediaStatus` tick.
|
||||
*/
|
||||
interface NextEpisodeCache {
|
||||
seriesId: string;
|
||||
currentEpisodeId: string;
|
||||
nextEpisode: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export interface ShouldStartCountdownParams {
|
||||
playerState: MediaPlayerState | undefined;
|
||||
idleReason: MediaPlayerIdleReason | undefined;
|
||||
currentPositionMs: number;
|
||||
outroStartMs: number | null;
|
||||
outroEndMs: number | null;
|
||||
skipOutro: string;
|
||||
alreadyTriggered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision helper: should the countdown start *right now*?
|
||||
* Exported for testability.
|
||||
*/
|
||||
export const shouldStartCountdown = ({
|
||||
playerState,
|
||||
idleReason,
|
||||
currentPositionMs,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
skipOutro,
|
||||
alreadyTriggered,
|
||||
}: ShouldStartCountdownParams): boolean => {
|
||||
if (alreadyTriggered) return false;
|
||||
|
||||
// (b) hard end of media — fires regardless of segment availability.
|
||||
if (
|
||||
playerState === MediaPlayerState.IDLE &&
|
||||
idleReason === MediaPlayerIdleReason.FINISHED
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// (a) playback inside Outro segment, and Outro is not already auto-skipped.
|
||||
if (
|
||||
skipOutro !== "auto" &&
|
||||
outroStartMs != null &&
|
||||
outroEndMs != null &&
|
||||
currentPositionMs >= outroStartMs &&
|
||||
currentPositionMs < outroEndMs
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useCastAutoplay = (): void => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const mediaStatus = useMediaStatus();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [autoplayState, setAutoplayState] = useAtom(castAutoplayAtom);
|
||||
|
||||
// Continuously captured currently-playing item (full BaseItemDto, fetched
|
||||
// from Jellyfin). `mediaInfo` clears at IDLE so we must capture as it plays.
|
||||
const capturedItemRef = useRef<BaseItemDto | null>(null);
|
||||
const capturedItemIdRef = useRef<string | null>(null);
|
||||
// State mirror of the captured item id so downstream effects/hooks re-run
|
||||
// *after* the async getItem resolves — depending on `contentId` directly
|
||||
// would fire them before the ref is populated and they'd read stale data.
|
||||
const [capturedItemId, setCapturedItemId] = useState<string | null>(null);
|
||||
|
||||
// Cached next-episode resolution per (seriesId, currentEpisodeId).
|
||||
const nextEpisodeCacheRef = useRef<NextEpisodeCache | null>(null);
|
||||
|
||||
// Last item id we triggered a countdown for. Reset when captured item changes
|
||||
// so the same finished episode does not retrigger.
|
||||
const triggeredForItemIdRef = useRef<string | null>(null);
|
||||
|
||||
// Countdown interval handle.
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Track whether the atom transitioned to null while a countdown is running —
|
||||
// that means the overlay cancelled, so we must not retrigger for this item.
|
||||
const autoplayStateRef = useRef(autoplayState);
|
||||
autoplayStateRef.current = autoplayState;
|
||||
|
||||
// Latest settings snapshot reachable from the interval / load callback
|
||||
// without re-creating the interval on every settings change.
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
const updateSettingsRef = useRef(updateSettings);
|
||||
updateSettingsRef.current = updateSettings;
|
||||
|
||||
const apiRef = useRef(api);
|
||||
apiRef.current = api;
|
||||
const userRef = useRef(user);
|
||||
userRef.current = user;
|
||||
const remoteMediaClientRef = useRef(remoteMediaClient);
|
||||
remoteMediaClientRef.current = remoteMediaClient;
|
||||
const castDeviceRef = useRef(castDevice);
|
||||
castDeviceRef.current = castDevice;
|
||||
|
||||
const contentId = mediaStatus?.mediaInfo?.contentId ?? null;
|
||||
|
||||
// --- 1. Capture the currently-playing item, full BaseItemDto. ---
|
||||
useEffect(() => {
|
||||
if (!contentId || !api || !user?.Id) {
|
||||
// No active content: clear all captured state so downstream effects /
|
||||
// useSegments stop using a stale previous-item id.
|
||||
capturedItemRef.current = null;
|
||||
capturedItemIdRef.current = null;
|
||||
setCapturedItemId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the captured id changed, reset the trigger guard immediately — the
|
||||
// user moved to another episode, and that new episode should be eligible.
|
||||
if (capturedItemIdRef.current !== contentId) {
|
||||
triggeredForItemIdRef.current = null;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getUserLibraryApi(api).getItem(
|
||||
{ itemId: contentId, userId: user.Id! },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (cancelled) return;
|
||||
capturedItemRef.current = res.data;
|
||||
capturedItemIdRef.current = contentId;
|
||||
// Publish the captured id as state *after* the ref is set, so the
|
||||
// next-episode-resolve effect (keyed on this state) sees a populated
|
||||
// ref by the time it runs.
|
||||
setCapturedItemId(contentId);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
return;
|
||||
// Non-fatal: keep whatever we last captured.
|
||||
console.error("[useCastAutoplay] Failed to fetch item:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [contentId, api, user?.Id]);
|
||||
|
||||
// --- 2. Resolve next episode (cached per series+episode). ---
|
||||
// This effect runs whenever the captured item id changes; the cache key
|
||||
// prevents refetching on every mediaStatus tick.
|
||||
useEffect(() => {
|
||||
const item = capturedItemRef.current;
|
||||
if (!item || !api || !user) return;
|
||||
if (item.Type !== "Episode") {
|
||||
nextEpisodeCacheRef.current = null;
|
||||
return;
|
||||
}
|
||||
const seriesId = item.SeriesId;
|
||||
const currentEpisodeId = item.Id;
|
||||
if (!seriesId || !currentEpisodeId) {
|
||||
nextEpisodeCacheRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = nextEpisodeCacheRef.current;
|
||||
if (
|
||||
cached &&
|
||||
cached.seriesId === seriesId &&
|
||||
cached.currentEpisodeId === currentEpisodeId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const episodes = await fetchSeriesEpisodes(api, user, seriesId);
|
||||
if (cancelled) return;
|
||||
nextEpisodeCacheRef.current = {
|
||||
seriesId,
|
||||
currentEpisodeId,
|
||||
nextEpisode: findNextEpisode(episodes, currentEpisodeId),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to resolve next episode:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Depend on the *state* mirror of the captured id rather than `contentId`
|
||||
// directly: `contentId` flips synchronously on the new episode, but
|
||||
// `capturedItemRef.current` is only populated after the async getItem
|
||||
// resolves. Keying on `capturedItemId` (set right after the ref write)
|
||||
// guarantees the ref points at the new item by the time we read it here.
|
||||
}, [capturedItemId, api, user]);
|
||||
|
||||
// --- 3. Media segments for the captured item (Outro). ---
|
||||
// Matches `useChromecastSegments`: cast playback is online, no downloaded
|
||||
// files context to thread through.
|
||||
const { data: segmentData } = useSegments(
|
||||
capturedItemId ?? "",
|
||||
false,
|
||||
undefined,
|
||||
api,
|
||||
);
|
||||
|
||||
const outroSegment = segmentData?.creditSegments?.[0] ?? null;
|
||||
const outroStartMs = outroSegment ? outroSegment.startTime * 1000 : null;
|
||||
const outroEndMs = outroSegment ? outroSegment.endTime * 1000 : null;
|
||||
|
||||
// --- 4. Trigger detection. ---
|
||||
useEffect(() => {
|
||||
// Master gate: setting must allow autoplay, and a countdown must not be
|
||||
// already running. The atom drives the countdown; an active atom means
|
||||
// we already triggered (possibly via overlay's Play now).
|
||||
if (!settings.autoPlayNextEpisode) return;
|
||||
if (autoplayState !== null) return;
|
||||
|
||||
const maxValue = settings.maxAutoPlayEpisodeCount.value;
|
||||
if (maxValue !== -1 && settings.autoPlayEpisodeCount >= maxValue) return;
|
||||
|
||||
const capturedItem = capturedItemRef.current;
|
||||
const capturedItemId = capturedItemIdRef.current;
|
||||
if (!capturedItem || !capturedItemId) return;
|
||||
if (capturedItem.Type !== "Episode") return;
|
||||
|
||||
const cached = nextEpisodeCacheRef.current;
|
||||
if (
|
||||
!cached ||
|
||||
cached.currentEpisodeId !== capturedItemId ||
|
||||
!cached.nextEpisode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextEpisode = cached.nextEpisode;
|
||||
|
||||
const currentPositionMs = (mediaStatus?.streamPosition ?? 0) * 1000;
|
||||
|
||||
const should = shouldStartCountdown({
|
||||
playerState: mediaStatus?.playerState as MediaPlayerState | undefined,
|
||||
idleReason: mediaStatus?.idleReason as MediaPlayerIdleReason | undefined,
|
||||
currentPositionMs,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
skipOutro: settings.skipOutro,
|
||||
alreadyTriggered: triggeredForItemIdRef.current === capturedItemId,
|
||||
});
|
||||
|
||||
if (!should) return;
|
||||
|
||||
triggeredForItemIdRef.current = capturedItemId;
|
||||
setAutoplayState({
|
||||
nextEpisode,
|
||||
secondsRemaining: settings.castAutoplayCountdownSeconds,
|
||||
});
|
||||
// The countdown interval is started by the effect below (reacts to the
|
||||
// atom transitioning to non-null), so this effect stays pure-decide.
|
||||
}, [
|
||||
mediaStatus?.playerState,
|
||||
mediaStatus?.idleReason,
|
||||
mediaStatus?.streamPosition,
|
||||
outroStartMs,
|
||||
outroEndMs,
|
||||
settings.autoPlayNextEpisode,
|
||||
settings.autoPlayEpisodeCount,
|
||||
settings.maxAutoPlayEpisodeCount,
|
||||
settings.castAutoplayCountdownSeconds,
|
||||
settings.skipOutro,
|
||||
autoplayState,
|
||||
setAutoplayState,
|
||||
]);
|
||||
|
||||
// --- 5. Run countdown interval whenever atom is non-null. ---
|
||||
// Starting/stopping is driven by the atom value, so an external Cancel
|
||||
// (overlay) that sets the atom to null naturally tears the interval down.
|
||||
useEffect(() => {
|
||||
if (autoplayState === null) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only start an interval if one is not already running.
|
||||
if (intervalRef.current) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
// Read latest atom value from ref to decide what to do next.
|
||||
const current = autoplayStateRef.current;
|
||||
if (current === null) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = current.secondsRemaining - 1;
|
||||
if (next > 0) {
|
||||
setAutoplayState({ ...current, secondsRemaining: next });
|
||||
return;
|
||||
}
|
||||
|
||||
// Time's up — load the next episode and clear.
|
||||
// Snapshot what we need; clear the interval and atom synchronously to
|
||||
// avoid double-fire.
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
const episodeToLoad = current.nextEpisode;
|
||||
setAutoplayState(null);
|
||||
|
||||
const apiLocal = apiRef.current;
|
||||
const userLocal = userRef.current;
|
||||
const clientLocal = remoteMediaClientRef.current;
|
||||
const deviceLocal = castDeviceRef.current;
|
||||
const settingsLocal = settingsRef.current;
|
||||
|
||||
if (!apiLocal || !userLocal?.Id || !clientLocal || !episodeToLoad?.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror `useCastEpisodes.loadEpisode` exactly — same arguments,
|
||||
// same start-position derivation.
|
||||
(async () => {
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episodeToLoad.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client: clientLocal,
|
||||
device: deviceLocal,
|
||||
api: apiLocal,
|
||||
item: episodeToLoad,
|
||||
userId: userLocal.Id!,
|
||||
profileMode: settingsLocal.chromecastProfile,
|
||||
maxBitrateSetting: settingsLocal.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to load next episode:",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the freshest count at the moment of the write — the
|
||||
// overlay's "Play now" can reset this to 0 in parallel, and using
|
||||
// a snapshot taken before the await would clobber that reset.
|
||||
updateSettingsRef.current({
|
||||
autoPlayEpisodeCount: settingsRef.current.autoPlayEpisodeCount + 1,
|
||||
});
|
||||
toast("Playing next episode");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useCastAutoplay] Failed to load next episode:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoplayState, setAutoplayState]);
|
||||
|
||||
// --- 6. Final unmount cleanup is covered by the interval effect's
|
||||
// return; nothing else to do here.
|
||||
};
|
||||
|
||||
export default useCastAutoplay;
|
||||
69
hooks/useCastDismissGesture.ts
Normal file
69
hooks/useCastDismissGesture.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ImperativeRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface UseCastDismissGestureParams {
|
||||
router: ImperativeRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swipe-down-to-dismiss gesture cluster for the casting player modal.
|
||||
* Owns the `translateY`/`context` shared values, the pan gesture, the animated
|
||||
* style, and the `dismissModal` callback (also invoked by the header button).
|
||||
*/
|
||||
export function useCastDismissGesture({ router }: UseCastDismissGestureParams) {
|
||||
// Swipe down to dismiss gesture
|
||||
const translateY = useSharedValue(0);
|
||||
const context = useSharedValue({ y: 0 });
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
// Navigate immediately without animation to prevent crashes
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
context.value = { y: translateY.value };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Only allow downward swipes from top of screen
|
||||
if (event.translationY > 0) {
|
||||
translateY.value = context.value.y + event.translationY;
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
// Dismiss if swiped down more than 150px or fast swipe
|
||||
if (event.translationY > 150 || event.velocityY > 600) {
|
||||
// Animate down and dismiss
|
||||
translateY.value = withSpring(
|
||||
1000,
|
||||
{
|
||||
damping: 20,
|
||||
stiffness: 90,
|
||||
},
|
||||
() => {
|
||||
runOnJS(dismissModal)();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Spring back to position
|
||||
translateY.value = withSpring(0);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return { panGesture, animatedStyle, dismissModal };
|
||||
}
|
||||
156
hooks/useCastEpisodes.ts
Normal file
156
hooks/useCastEpisodes.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Device, RemoteMediaClient } from "react-native-google-cast";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
||||
import { fetchSeriesEpisodes, findNextEpisode } from "@/utils/casting/episodes";
|
||||
|
||||
interface UseCastEpisodesParams {
|
||||
api: Api | null;
|
||||
user: UserDto | null;
|
||||
currentItem: BaseItemDto | null;
|
||||
remoteMediaClient: RemoteMediaClient | null;
|
||||
castDevice: Device | null;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
interface UseCastEpisodesResult {
|
||||
episodes: BaseItemDto[];
|
||||
nextEpisode: BaseItemDto | null;
|
||||
seasonData: BaseItemDto | null;
|
||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
||||
/**
|
||||
* Id of the episode currently being loaded onto the cast device, or null
|
||||
* when no load is pending. The cast `customData` (and thus `currentItem`)
|
||||
* lags behind the load, so consumers use this to detect the stale window
|
||||
* between a `loadEpisode` call and the cast reporting the new episode.
|
||||
*/
|
||||
loadingEpisodeId: string | null;
|
||||
}
|
||||
|
||||
export function useCastEpisodes({
|
||||
api,
|
||||
user,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings,
|
||||
}: UseCastEpisodesParams): UseCastEpisodesResult {
|
||||
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
|
||||
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
|
||||
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
|
||||
// Target episode id while a load is in flight; cleared once it resolves.
|
||||
const [loadingEpisodeId, setLoadingEpisodeId] = useState<string | null>(null);
|
||||
|
||||
// Load a different episode on the Chromecast
|
||||
const loadEpisode = useCallback(
|
||||
async (episode: BaseItemDto) => {
|
||||
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
|
||||
|
||||
setLoadingEpisodeId(episode.Id);
|
||||
try {
|
||||
const startPositionMs =
|
||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
||||
|
||||
const result = await loadCastMedia({
|
||||
client: remoteMediaClient,
|
||||
device: castDevice,
|
||||
api,
|
||||
item: episode,
|
||||
userId: user.Id,
|
||||
profileMode: settings.chromecastProfile,
|
||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
||||
options: { startPositionMs },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Casting Player] Failed to load episode:",
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to load episode:", error);
|
||||
} finally {
|
||||
// Clear regardless of outcome: on success `currentItem` catches up via
|
||||
// customData; on failure the stale guard must not stay stuck.
|
||||
setLoadingEpisodeId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
remoteMediaClient,
|
||||
castDevice,
|
||||
settings.chromecastProfile,
|
||||
settings.chromecastMaxBitrate,
|
||||
],
|
||||
);
|
||||
|
||||
// Fetch season data for season poster
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentItem?.Type !== "Episode" ||
|
||||
!currentItem.SeasonId ||
|
||||
!api ||
|
||||
!user?.Id
|
||||
)
|
||||
return;
|
||||
|
||||
const fetchSeasonData = async () => {
|
||||
try {
|
||||
const userLibraryApi = getUserLibraryApi(api);
|
||||
const response = await userLibraryApi.getItem({
|
||||
itemId: currentItem.SeasonId!,
|
||||
userId: user.Id!,
|
||||
});
|
||||
setSeasonData(response.data);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to fetch season data:", error);
|
||||
setSeasonData(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSeasonData();
|
||||
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
|
||||
|
||||
// Fetch episodes for TV shows
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentItem?.Type !== "Episode" ||
|
||||
!currentItem.SeriesId ||
|
||||
!api ||
|
||||
!user
|
||||
)
|
||||
return;
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
// Fetch ALL episodes from ALL seasons (no season filter).
|
||||
const episodeList = await fetchSeriesEpisodes(
|
||||
api,
|
||||
user,
|
||||
currentItem.SeriesId!,
|
||||
);
|
||||
setEpisodes(episodeList);
|
||||
setNextEpisode(findNextEpisode(episodeList, currentItem.Id));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch episodes:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEpisodes();
|
||||
}, [
|
||||
currentItem?.Type,
|
||||
currentItem?.SeriesId,
|
||||
currentItem?.SeasonId,
|
||||
currentItem?.Id,
|
||||
api,
|
||||
user,
|
||||
]);
|
||||
|
||||
return { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId };
|
||||
}
|
||||
94
hooks/useCastPlayerItem.ts
Normal file
94
hooks/useCastPlayerItem.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MediaStatus } from "react-native-google-cast";
|
||||
|
||||
interface UseCastPlayerItemParams {
|
||||
api: Api | null;
|
||||
user: UserDto | null;
|
||||
mediaStatus: MediaStatus | null;
|
||||
}
|
||||
|
||||
interface UseCastPlayerItemResult {
|
||||
fetchedItem: BaseItemDto | null;
|
||||
currentItem: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export function useCastPlayerItem({
|
||||
api,
|
||||
user,
|
||||
mediaStatus,
|
||||
}: UseCastPlayerItemParams): UseCastPlayerItemResult {
|
||||
// Fetch full item data from Jellyfin by ID
|
||||
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchItemData = async () => {
|
||||
const itemId = mediaStatus?.mediaInfo?.contentId;
|
||||
if (!itemId || !api || !user?.Id) return;
|
||||
|
||||
try {
|
||||
const res = await getUserLibraryApi(api).getItem(
|
||||
{ itemId, userId: user.Id },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!controller.signal.aborted) {
|
||||
setFetchedItem(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError")
|
||||
return;
|
||||
console.error("[Casting Player] Failed to fetch item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItemData();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
|
||||
|
||||
// Extract item from customData, or use fetched item, or create a minimal fallback
|
||||
const currentItem = useMemo(() => {
|
||||
// Priority 1: Use fetched item from API (most reliable)
|
||||
if (fetchedItem) {
|
||||
return fetchedItem;
|
||||
}
|
||||
|
||||
// Priority 2: Try customData from mediaStatus
|
||||
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
|
||||
if (
|
||||
customData?.Type &&
|
||||
(customData.ImageTags || customData.MediaSources || customData.Id)
|
||||
) {
|
||||
// Use customData if it has a real Type AND meaningful metadata
|
||||
// (rules out placeholder objects that lack image tags, media sources, or an ID)
|
||||
return customData;
|
||||
}
|
||||
|
||||
// Priority 3: Create minimal fallback while loading
|
||||
if (mediaStatus?.mediaInfo) {
|
||||
const { contentId, metadata } = mediaStatus.mediaInfo;
|
||||
// Derive type from metadata if available, otherwise omit to avoid
|
||||
// misrepresenting episodes as movies
|
||||
let metadataType: string | undefined;
|
||||
if (metadata?.type === "movie") {
|
||||
metadataType = "Movie";
|
||||
} else if (metadata?.type === "tvShow") {
|
||||
metadataType = "Episode";
|
||||
}
|
||||
return {
|
||||
Id: contentId,
|
||||
Name: metadata?.title || "Unknown",
|
||||
...(metadataType ? { Type: metadataType } : {}),
|
||||
ServerId: "",
|
||||
} as BaseItemDto;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [fetchedItem, mediaStatus?.mediaInfo]);
|
||||
|
||||
return { fetchedItem, currentItem };
|
||||
}
|
||||
148
hooks/useCastPlayerProgress.ts
Normal file
148
hooks/useCastPlayerProgress.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import { MediaPlayerState, type MediaStatus } from "react-native-google-cast";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
|
||||
interface TrickplayTime {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface UseCastPlayerProgressParams {
|
||||
/** Raw Chromecast media status, or null when no session. */
|
||||
mediaStatus: MediaStatus | null;
|
||||
/** Full item fetched from Jellyfin, used to derive trickplay data. */
|
||||
fetchedItem: BaseItemDto | null;
|
||||
/** Total media duration, in seconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface UseCastPlayerProgressResult {
|
||||
/** Shared value tracking the slider progress, in milliseconds. */
|
||||
sliderProgress: SharedValue<number>;
|
||||
/** Shared value for the slider minimum, in milliseconds. */
|
||||
sliderMin: SharedValue<number>;
|
||||
/** Shared value for the slider maximum, in milliseconds. */
|
||||
sliderMax: SharedValue<number>;
|
||||
/** Mutable ref flag set true while the user is scrubbing. */
|
||||
isScrubbing: RefObject<boolean>;
|
||||
/** Trickplay time display state for the bubble. */
|
||||
trickplayTime: TrickplayTime;
|
||||
/** Updates the trickplay time display state. */
|
||||
setTrickplayTime: (time: TrickplayTime) => void;
|
||||
/** Current playback progress, in seconds (live-updating). */
|
||||
progress: number;
|
||||
/** Last stable playback position (seconds), for resuming across reloads. */
|
||||
resumePositionRef: RefObject<number>;
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Computes the trickplay URL for a given progress in ticks. */
|
||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress/slider/trickplay cluster for the casting player.
|
||||
* Owns the slider shared values, scrub state, live-progress interpolation,
|
||||
* resume-position tracking, and trickplay preview.
|
||||
*/
|
||||
export function useCastPlayerProgress({
|
||||
mediaStatus,
|
||||
fetchedItem,
|
||||
duration,
|
||||
}: UseCastPlayerProgressParams): UseCastPlayerProgressResult {
|
||||
// Shared values for progress slider (must be initialized before any early returns)
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Trickplay time display
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
// Live progress tracking - update every second
|
||||
const [liveProgress, setLiveProgress] = useState(0);
|
||||
const lastSyncPositionRef = useRef(0);
|
||||
const lastSyncTimestampRef = useRef(Date.now());
|
||||
|
||||
// Last stable playback position (seconds), for resuming across reloads.
|
||||
const resumePositionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync refs whenever mediaStatus provides a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
||||
lastSyncTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update every second when playing, deriving from last sync point
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||
mediaStatus?.streamPosition !== undefined
|
||||
) {
|
||||
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
||||
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
||||
|
||||
// Track the last stable position so a reload mid-switch resumes correctly.
|
||||
useEffect(() => {
|
||||
const pos = mediaStatus?.streamPosition ?? 0;
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
|
||||
resumePositionRef.current = pos;
|
||||
}
|
||||
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const progress = liveProgress; // Use live-updating progress
|
||||
|
||||
// Trickplay for seeking preview - use fetched item with full data
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
fetchedItem ?? null,
|
||||
);
|
||||
|
||||
// Update slider max when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Update slider progress when not scrubbing
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress > 0) {
|
||||
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
return {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
progress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
};
|
||||
}
|
||||
75
hooks/useCastSelection.ts
Normal file
75
hooks/useCastSelection.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Source of truth for the active cast track / quality / version selection.
|
||||
*
|
||||
* Truth = the CastSelection echoed back in the cast media customData. A local
|
||||
* `pending` selection is shown optimistically while a reload re-transcodes, then
|
||||
* cleared once the cast reports it (reconciled) or the reload fails.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { MediaStatus } from "react-native-google-cast";
|
||||
import { resolveSelection, selectionsEqual } from "@/utils/casting/selection";
|
||||
import type { CastSelection } from "@/utils/casting/types";
|
||||
|
||||
interface UseCastSelectionParams {
|
||||
currentItem: BaseItemDto | null;
|
||||
mediaStatus: MediaStatus | null | undefined;
|
||||
/** Reload the cast stream with the given selection. Resolves true on success. */
|
||||
reload: (selection: CastSelection) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface UseCastSelectionResult {
|
||||
/** Effective selection: optimistic pending, else cast truth, else default. */
|
||||
currentSelection: CastSelection | null;
|
||||
/** Merge a partial selection, show it optimistically, and reload the stream. */
|
||||
applySelection: (partial: Partial<CastSelection>) => void;
|
||||
}
|
||||
|
||||
export const useCastSelection = ({
|
||||
currentItem,
|
||||
mediaStatus,
|
||||
reload,
|
||||
}: UseCastSelectionParams): UseCastSelectionResult => {
|
||||
const [pending, setPending] = useState<CastSelection | null>(null);
|
||||
|
||||
// Truth: the selection the cast reports as loaded, via customData.
|
||||
const truth =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { selection?: CastSelection }
|
||||
| undefined
|
||||
)?.selection ?? null;
|
||||
|
||||
const currentSelection: CastSelection | null =
|
||||
pending ??
|
||||
truth ??
|
||||
(currentItem ? resolveSelection(currentItem, {}) : null);
|
||||
|
||||
// A new media item invalidates any pending selection from the previous one.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on item id only
|
||||
useEffect(() => {
|
||||
setPending(null);
|
||||
}, [currentItem?.Id]);
|
||||
|
||||
// Reconcile: once the cast reports the pending selection as loaded, clear it.
|
||||
useEffect(() => {
|
||||
if (pending && truth && selectionsEqual(pending, truth)) {
|
||||
setPending(null);
|
||||
}
|
||||
}, [pending, truth]);
|
||||
|
||||
const applySelection = useCallback(
|
||||
(partial: Partial<CastSelection>) => {
|
||||
if (!currentSelection) return;
|
||||
const next: CastSelection = { ...currentSelection, ...partial };
|
||||
setPending(next);
|
||||
reload(next).then((ok) => {
|
||||
if (!ok) setPending(null);
|
||||
});
|
||||
},
|
||||
[currentSelection, reload],
|
||||
);
|
||||
|
||||
return { currentSelection, applySelection };
|
||||
};
|
||||
407
hooks/useCasting.ts
Normal file
407
hooks/useCasting.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Unified Casting Hook
|
||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
||||
* Architecture allows for future protocol integrations
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
|
||||
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
|
||||
|
||||
/**
|
||||
* Unified hook for managing casting
|
||||
* Extensible architecture supporting multiple protocols
|
||||
*/
|
||||
export const useCasting = (item: BaseItemDto | null) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Chromecast hooks
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
// Local state
|
||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
||||
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
|
||||
|
||||
// Helper to update both state and ref
|
||||
const updateState = useCallback(
|
||||
(updater: (prev: CastPlayerState) => CastPlayerState) => {
|
||||
setState((prev) => {
|
||||
const next = updater(prev);
|
||||
stateRef.current = next;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo.
|
||||
const playSessionId =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { playSessionId?: string }
|
||||
| undefined
|
||||
)?.playSessionId ?? mediaStatus?.mediaInfo?.contentId;
|
||||
|
||||
const playMethod =
|
||||
(
|
||||
mediaStatus?.mediaInfo?.customData as
|
||||
| { playMethod?: "Transcode" | "DirectPlay" }
|
||||
| undefined
|
||||
)?.playMethod ?? "Transcode";
|
||||
|
||||
// Detect which protocol is active - use CastState for reliable detection
|
||||
const chromecastConnected = castState === CastState.CONNECTED;
|
||||
// Future: Add detection for other protocols here
|
||||
|
||||
const activeProtocol: CastProtocol | null = chromecastConnected
|
||||
? "chromecast"
|
||||
: null;
|
||||
|
||||
const isConnected = chromecastConnected;
|
||||
|
||||
// Update current device
|
||||
useEffect(() => {
|
||||
if (chromecastConnected && castDevice) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
protocol: "chromecast",
|
||||
currentDevice: {
|
||||
id: castDevice.deviceId,
|
||||
name: castDevice.friendlyName || castDevice.deviceId,
|
||||
protocol: "chromecast",
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
protocol: null,
|
||||
currentDevice: null,
|
||||
}));
|
||||
}
|
||||
// Future: Add device detection for other protocols
|
||||
}, [chromecastConnected, castDevice]);
|
||||
|
||||
// Chromecast: Update playback state
|
||||
useEffect(() => {
|
||||
if (activeProtocol === "chromecast" && mediaStatus) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: mediaStatus.playerState === "playing",
|
||||
progress: (mediaStatus.streamPosition || 0) * 1000,
|
||||
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
||||
isBuffering: mediaStatus.playerState === "buffering",
|
||||
}));
|
||||
}
|
||||
}, [mediaStatus, activeProtocol, updateState]);
|
||||
|
||||
// Chromecast: Sync volume from mediaStatus
|
||||
useEffect(() => {
|
||||
if (activeProtocol !== "chromecast") return;
|
||||
|
||||
// Sync from mediaStatus when available
|
||||
if (mediaStatus?.volume !== undefined) {
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
volume: mediaStatus.volume,
|
||||
}));
|
||||
}
|
||||
}, [mediaStatus?.volume, activeProtocol, updateState]);
|
||||
|
||||
// Progress reporting to Jellyfin (matches native player behavior)
|
||||
// Uses stateRef to read current progress/volume without adding them as deps
|
||||
useEffect(() => {
|
||||
if (!isConnected || !item?.Id || !user?.Id || !api) return;
|
||||
|
||||
const playStateApi = getPlaystateApi(api);
|
||||
|
||||
// Report playback start when media begins (only once per item)
|
||||
// Don't require progress > 0 — playback can legitimately start at position 0
|
||||
const currentState = stateRef.current;
|
||||
const isPlaybackActive =
|
||||
currentState.isPlaying ||
|
||||
mediaStatus?.playerState === "playing" ||
|
||||
currentState.progress > 0;
|
||||
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
|
||||
// Set synchronously before async call to prevent race condition duplicates
|
||||
hasReportedStartRef.current = item.Id || null;
|
||||
|
||||
playStateApi
|
||||
.reportPlaybackStart({
|
||||
playbackStartInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: Math.floor(currentState.progress * 10000),
|
||||
PlayMethod: playMethod,
|
||||
VolumeLevel: Math.floor(currentState.volume * 100),
|
||||
IsMuted: currentState.volume === 0,
|
||||
PlaySessionId: playSessionId,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
// Revert on failure so it can be retried
|
||||
hasReportedStartRef.current = null;
|
||||
console.error("[useCasting] Failed to report playback start:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const reportProgress = () => {
|
||||
const s = stateRef.current;
|
||||
// Don't report if no meaningful progress or if buffering
|
||||
if (s.progress <= 0 || s.isBuffering) return;
|
||||
|
||||
const progressMs = Math.floor(s.progress);
|
||||
const progressTicks = progressMs * 10000; // Convert ms to ticks
|
||||
const progressSeconds = Math.floor(progressMs / 1000);
|
||||
|
||||
// When paused, always report to keep server in sync
|
||||
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
|
||||
if (
|
||||
s.isPlaying &&
|
||||
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedProgressRef.current = progressSeconds;
|
||||
|
||||
playStateApi
|
||||
.reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: progressTicks,
|
||||
IsPaused: !s.isPlaying,
|
||||
PlayMethod: playMethod,
|
||||
VolumeLevel: Math.floor(s.volume * 100),
|
||||
IsMuted: s.volume === 0,
|
||||
PlaySessionId: playSessionId,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[useCasting] Failed to report progress:", error);
|
||||
});
|
||||
};
|
||||
|
||||
// Report progress on a fixed interval, reading latest state from ref
|
||||
const interval = setInterval(reportProgress, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
api,
|
||||
item?.Id,
|
||||
user?.Id,
|
||||
isConnected,
|
||||
activeProtocol,
|
||||
playSessionId,
|
||||
playMethod,
|
||||
]);
|
||||
|
||||
// Play/Pause controls
|
||||
const play = useCallback(async () => {
|
||||
if (activeProtocol === "chromecast") {
|
||||
// Check if there's an active media session
|
||||
if (!client || !mediaStatus?.mediaInfo) {
|
||||
console.warn(
|
||||
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.play();
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error playing:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Future: Add play control for other protocols
|
||||
}, [client, mediaStatus, activeProtocol]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
if (activeProtocol === "chromecast") {
|
||||
try {
|
||||
await client?.pause();
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error pausing:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Future: Add pause control for other protocols
|
||||
}, [client, activeProtocol]);
|
||||
|
||||
const togglePlayPause = useCallback(async () => {
|
||||
if (state.isPlaying) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}, [state.isPlaying, play, pause]);
|
||||
|
||||
// Seek controls
|
||||
const seek = useCallback(
|
||||
async (positionMs: number) => {
|
||||
// Validate position
|
||||
if (positionMs < 0 || !Number.isFinite(positionMs)) {
|
||||
console.error("[useCasting] Invalid seek position (ms):", positionMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const positionSeconds = positionMs / 1000;
|
||||
|
||||
// Additional validation for Chromecast
|
||||
if (activeProtocol === "chromecast") {
|
||||
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
|
||||
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
|
||||
const durationSeconds = state.duration / 1000;
|
||||
if (durationSeconds > 0 && positionSeconds > durationSeconds) {
|
||||
console.warn(
|
||||
"[useCasting] Seek position exceeds duration, clamping:",
|
||||
positionSeconds,
|
||||
"->",
|
||||
durationSeconds,
|
||||
);
|
||||
await client?.seek({ position: durationSeconds });
|
||||
return;
|
||||
}
|
||||
await client?.seek({ position: positionSeconds });
|
||||
}
|
||||
// Future: Add seek control for other protocols
|
||||
},
|
||||
[client, activeProtocol, state.duration],
|
||||
);
|
||||
|
||||
const skipForward = useCallback(
|
||||
async (seconds = 10) => {
|
||||
const newPosition = state.progress + seconds * 1000;
|
||||
await seek(Math.min(newPosition, state.duration));
|
||||
},
|
||||
[state.progress, state.duration, seek],
|
||||
);
|
||||
|
||||
const skipBackward = useCallback(
|
||||
async (seconds = 10) => {
|
||||
const newPosition = state.progress - seconds * 1000;
|
||||
await seek(Math.max(newPosition, 0));
|
||||
},
|
||||
[state.progress, seek],
|
||||
);
|
||||
|
||||
// Stop and disconnect
|
||||
const stop = useCallback(
|
||||
async (onStopComplete?: () => void) => {
|
||||
try {
|
||||
if (activeProtocol === "chromecast") {
|
||||
await client?.stop();
|
||||
}
|
||||
// Future: Add stop control for other protocols
|
||||
|
||||
// Report stop to Jellyfin
|
||||
if (api && item?.Id && user?.Id) {
|
||||
const playStateApi = getPlaystateApi(api);
|
||||
await playStateApi.reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: stateRef.current.progress * 10000,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useCasting] Error during stop:", error);
|
||||
} finally {
|
||||
hasReportedStartRef.current = null;
|
||||
setState(DEFAULT_CAST_STATE);
|
||||
stateRef.current = DEFAULT_CAST_STATE;
|
||||
|
||||
// Call callback after stop completes (e.g., to navigate away)
|
||||
if (onStopComplete) {
|
||||
onStopComplete();
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, api, item?.Id, user?.Id, activeProtocol],
|
||||
);
|
||||
|
||||
// Volume control (debounced to reduce API calls)
|
||||
const setVolume = useCallback(
|
||||
(volume: number) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
|
||||
// Update UI immediately
|
||||
updateState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||
|
||||
// Debounce API call
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
|
||||
volumeDebounceRef.current = setTimeout(async () => {
|
||||
if (activeProtocol === "chromecast" && client && isConnected) {
|
||||
// Use setStreamVolume for media stream volume (0.0 - 1.0)
|
||||
// Physical volume buttons are handled automatically by the framework
|
||||
await client.setStreamVolume(clampedVolume).catch(() => {
|
||||
// Ignore errors - session might have ended
|
||||
});
|
||||
}
|
||||
// Future: Add volume control for other protocols
|
||||
}, 300);
|
||||
},
|
||||
[client, activeProtocol, isConnected],
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isConnected,
|
||||
protocol: activeProtocol,
|
||||
isPlaying: state.isPlaying,
|
||||
isBuffering: state.isBuffering,
|
||||
currentItem: item,
|
||||
currentDevice: state.currentDevice,
|
||||
progress: state.progress,
|
||||
duration: state.duration,
|
||||
volume: state.volume,
|
||||
|
||||
// Availability - derived from actual cast state
|
||||
isChromecastAvailable:
|
||||
castState === CastState.CONNECTED ||
|
||||
castState === CastState.CONNECTING ||
|
||||
castState === CastState.NOT_CONNECTED,
|
||||
|
||||
// Raw clients (for advanced operations)
|
||||
remoteMediaClient: client,
|
||||
|
||||
// Controls
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
stop,
|
||||
setVolume,
|
||||
};
|
||||
};
|
||||
@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Derive prev/next from the current item's real position in the adjacent
|
||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
||||
* episode it can still return the current item as the first/last entry — so
|
||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
||||
*/
|
||||
const currentIndex = useMemo(
|
||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
||||
[adjacentItems, item],
|
||||
);
|
||||
|
||||
/** A neighbour is only navigable if it has an actual media file (not a
|
||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
||||
|
||||
const previousItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||
}
|
||||
|
||||
return adjacentItems[0];
|
||||
}, [adjacentItems, item]);
|
||||
if (!adjacentItems || currentIndex <= 0) return null;
|
||||
const candidate = adjacentItems[currentIndex - 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
|
||||
/** The next item in the series */
|
||||
const nextItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||
}
|
||||
|
||||
return adjacentItems[2];
|
||||
}, [adjacentItems, item]);
|
||||
if (!adjacentItems || currentIndex < 0) return null;
|
||||
const candidate = adjacentItems[currentIndex + 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
|
||||
/**
|
||||
* Reports playback progress.
|
||||
|
||||
64
hooks/useRemoteControl.ts
Normal file
64
hooks/useRemoteControl.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Dispatches Jellyfin remote-control WebSocket messages to the active
|
||||
* PlaybackController. DisplayMessage is shown as an in-app toast and needs no
|
||||
* controller.
|
||||
*/
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { activePlaybackControllerAtom } from "@/utils/playback/playbackController";
|
||||
import {
|
||||
mapRemoteCommand,
|
||||
type RemoteWsMessage,
|
||||
} from "@/utils/playback/remoteCommands";
|
||||
|
||||
/** Handle one remote-control message (call it whenever a new WS message arrives). */
|
||||
export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => {
|
||||
const controller = useAtomValue(activePlaybackControllerAtom);
|
||||
const handledRef = useRef<RemoteWsMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage || lastMessage === handledRef.current) return;
|
||||
handledRef.current = lastMessage;
|
||||
const action = mapRemoteCommand(lastMessage);
|
||||
if (!action) return;
|
||||
|
||||
if (action.kind === "displayMessage") {
|
||||
toast(action.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!controller) return;
|
||||
|
||||
switch (action.kind) {
|
||||
case "playPause":
|
||||
controller.playPause();
|
||||
break;
|
||||
case "pause":
|
||||
controller.pause();
|
||||
break;
|
||||
case "unpause":
|
||||
controller.unpause();
|
||||
break;
|
||||
case "stop":
|
||||
controller.stop();
|
||||
break;
|
||||
case "seek":
|
||||
controller.seek(action.positionMs);
|
||||
break;
|
||||
case "next":
|
||||
controller.next();
|
||||
break;
|
||||
case "previous":
|
||||
controller.previous();
|
||||
break;
|
||||
case "setVolume":
|
||||
controller.setVolume(action.level);
|
||||
break;
|
||||
case "toggleMute":
|
||||
controller.toggleMute();
|
||||
break;
|
||||
}
|
||||
}, [lastMessage, controller]);
|
||||
};
|
||||
113
hooks/useSegmentSkipper.ts
Normal file
113
hooks/useSegmentSkipper.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
||||
|
||||
interface UseSegmentSkipperProps {
|
||||
segments: MediaTimeSegment[];
|
||||
segmentType: SegmentType;
|
||||
currentTime: number;
|
||||
totalDuration?: number;
|
||||
seek: (time: number) => void;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
interface UseSegmentSkipperReturn {
|
||||
currentSegment: MediaTimeSegment | null;
|
||||
skipSegment: (notifyOrUseHaptics?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
||||
*/
|
||||
export const useSegmentSkipper = ({
|
||||
segments,
|
||||
segmentType,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
seek,
|
||||
isPaused,
|
||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
||||
const { settings } = useSettings();
|
||||
const haptic = useHaptic();
|
||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
||||
|
||||
// Get skip mode based on segment type
|
||||
const skipMode = (() => {
|
||||
switch (segmentType) {
|
||||
case "Intro":
|
||||
return settings.skipIntro;
|
||||
case "Outro":
|
||||
return settings.skipOutro;
|
||||
case "Recap":
|
||||
return settings.skipRecap;
|
||||
case "Commercial":
|
||||
return settings.skipCommercial;
|
||||
case "Preview":
|
||||
return settings.skipPreview;
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
})();
|
||||
|
||||
// Find current segment
|
||||
const currentSegment =
|
||||
segments.find(
|
||||
(segment) =>
|
||||
currentTime >= segment.startTime && currentTime < segment.endTime,
|
||||
) || null;
|
||||
|
||||
// Skip function with optional haptic feedback
|
||||
const skipSegment = useCallback(
|
||||
(notifyOrUseHaptics = true) => {
|
||||
if (!currentSegment || skipMode === "none") return;
|
||||
|
||||
// For Outro segments, prevent seeking past the end
|
||||
if (
|
||||
segmentType === "Outro" &&
|
||||
totalDuration != null &&
|
||||
Number.isFinite(totalDuration)
|
||||
) {
|
||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
||||
seek(seekTime);
|
||||
} else {
|
||||
seek(currentSegment.endTime);
|
||||
}
|
||||
|
||||
// Only trigger haptic feedback if explicitly requested (manual skip)
|
||||
if (notifyOrUseHaptics) {
|
||||
haptic();
|
||||
}
|
||||
},
|
||||
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
|
||||
);
|
||||
// Auto-skip logic when mode is 'auto'
|
||||
useEffect(() => {
|
||||
if (skipMode !== "auto" || isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track segment identity to avoid re-triggering on pause/unpause
|
||||
const segmentId = currentSegment
|
||||
? `${currentSegment.startTime}-${currentSegment.endTime}`
|
||||
: null;
|
||||
|
||||
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
|
||||
autoSkipTriggeredRef.current = segmentId;
|
||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
||||
}
|
||||
|
||||
if (!currentSegment) {
|
||||
autoSkipTriggeredRef.current = null;
|
||||
}
|
||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
||||
|
||||
// Return null segment if skip mode is 'none'
|
||||
return {
|
||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
||||
skipSegment,
|
||||
};
|
||||
};
|
||||
@@ -17,20 +17,24 @@ interface TrickplayUrl {
|
||||
}
|
||||
|
||||
/** Hook to handle trickplay logic for a given item. */
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
export const useTrickplay = (item: BaseItemDto | null) => {
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200;
|
||||
const isOffline = useGlobalSearchParams().offline === "true";
|
||||
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
||||
const trickplayInfo = useMemo(
|
||||
() => (item ? getTrickplayInfo(item) : null),
|
||||
[item],
|
||||
);
|
||||
|
||||
/** Generates the trickplay URL for the given item and sheet index.
|
||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||
const getTrickplayUrl = useCallback(
|
||||
(item: BaseItemDto, sheetIndex: number) => {
|
||||
if (!item.Id) return null;
|
||||
// If we are offline, we can use the downloaded item's trickplay data path
|
||||
const downloadedItem = getDownloadedItemById(item.Id!);
|
||||
const downloadedItem = getDownloadedItemById(item.Id);
|
||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export const useTrickplay = (item: BaseItemDto) => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!trickplayInfo ||
|
||||
!item.Id ||
|
||||
!item?.Id ||
|
||||
now - lastCalculationTime.current < throttleDelay
|
||||
)
|
||||
return;
|
||||
@@ -62,7 +66,7 @@ export const useTrickplay = (item: BaseItemDto) => {
|
||||
|
||||
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
||||
const prefetchAllTrickplayImages = useCallback(async () => {
|
||||
if (!trickplayInfo || !item.Id) return;
|
||||
if (!trickplayInfo || !item?.Id) return;
|
||||
const maxConcurrent = 4;
|
||||
const total = trickplayInfo.totalImageSheets;
|
||||
const urls: string[] = [];
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -32,12 +32,6 @@ export interface MediaTimeSegment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The Jellyfin item DTO. */
|
||||
@@ -56,6 +50,12 @@ export interface DownloadedItem {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** The credit segments for the item. */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** The recap segments for the item. */
|
||||
recapSegments?: MediaTimeSegment[];
|
||||
/** The commercial segments for the item. */
|
||||
commercialSegments?: MediaTimeSegment[];
|
||||
/** The preview segments for the item. */
|
||||
previewSegments?: MediaTimeSegment[];
|
||||
/** The user data for the item. */
|
||||
userData: UserData;
|
||||
}
|
||||
@@ -144,6 +144,12 @@ export type JobStatus = {
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
|
||||
recapSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
|
||||
commercialSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
|
||||
previewSegments?: MediaTimeSegment[];
|
||||
/** The audio stream index selected for this download */
|
||||
audioStreamIndex?: number;
|
||||
/** The subtitle stream index selected for this download */
|
||||
|
||||
@@ -28,6 +28,10 @@ import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
type PlaybackController,
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
|
||||
// Conditionally import TrackPlayer only on non-TV platforms
|
||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||
@@ -1621,6 +1625,43 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
settings?.audioLookaheadCount,
|
||||
]);
|
||||
|
||||
// App-wide remote-control surface: wraps the existing music controls so
|
||||
// remote commands can target whatever player is currently active.
|
||||
const isMusicActive = state.currentTrack !== null;
|
||||
|
||||
const playbackController = useMemo<PlaybackController>(
|
||||
() => ({
|
||||
playPause: () => {
|
||||
togglePlayPause();
|
||||
},
|
||||
pause: () => {
|
||||
pause();
|
||||
},
|
||||
unpause: () => {
|
||||
resume();
|
||||
},
|
||||
stop: () => {
|
||||
stop();
|
||||
},
|
||||
// TrackPlayer works in seconds; the controller contract is milliseconds.
|
||||
seek: (positionMs: number) => {
|
||||
seek(positionMs / 1000);
|
||||
},
|
||||
next: () => {
|
||||
next();
|
||||
},
|
||||
previous: () => {
|
||||
previous();
|
||||
},
|
||||
// The music player exposes no volume API — keep these as no-ops.
|
||||
setVolume: () => {},
|
||||
toggleMute: () => {},
|
||||
}),
|
||||
[togglePlayPause, pause, resume, stop, seek, next, previous],
|
||||
);
|
||||
|
||||
useRegisterPlaybackController(playbackController, isMusicActive);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useRemoteControl } from "@/hooks/useRemoteControl";
|
||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
|
||||
@@ -54,6 +55,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
// Route Jellyfin remote-control messages to the active player.
|
||||
useRemoteControl(lastMessage);
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const deviceId = useMemo(() => {
|
||||
@@ -219,7 +222,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
IconUrl:
|
||||
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
|
||||
PlayableMediaTypes: ["Audio", "Video"],
|
||||
SupportedCommands: ["Play"],
|
||||
SupportedCommands: [
|
||||
"Play",
|
||||
"DisplayMessage",
|
||||
"SetVolume",
|
||||
"ToggleMute",
|
||||
"Mute",
|
||||
"Unmute",
|
||||
],
|
||||
SupportsMediaControl: true,
|
||||
SupportsPersistentIdentifier: true,
|
||||
},
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* i18n key checker for Streamyfin.
|
||||
*
|
||||
* Detects:
|
||||
* - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code
|
||||
* that does not exist in the source locale (translations/en.json). These are bugs —
|
||||
* the app renders the raw key. Always fails CI.
|
||||
* - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the
|
||||
* code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`).
|
||||
* These are dead weight that also clutter every locale on Crowdin.
|
||||
*
|
||||
* Dynamic usage is handled conservatively:
|
||||
* - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used.
|
||||
* - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to
|
||||
* whitelist keys (in Streamyfin these are user-defined section
|
||||
* titles, not translation keys).
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, def) => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
return v === undefined ? true : v;
|
||||
};
|
||||
const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off
|
||||
const JSON_OUT = !!flag("json", false);
|
||||
const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
// (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead.
|
||||
srcDirs: ["."],
|
||||
srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||
excludeDirs: [
|
||||
"node_modules",
|
||||
"ios",
|
||||
"android",
|
||||
".expo",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"translations",
|
||||
"scripts",
|
||||
],
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- helpers ----
|
||||
const flatten = (obj, prefix = "", out = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key, pattern) => {
|
||||
if (pattern.endsWith(".*"))
|
||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||
return key === pattern;
|
||||
};
|
||||
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return files;
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
if (config.excludeDirs.includes(name)) continue;
|
||||
walk(full, files);
|
||||
} else if (config.srcExtensions.includes(extname(name))) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
// ---- scan code ----
|
||||
const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b')
|
||||
const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation
|
||||
const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||
|
||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set();
|
||||
const fullyDynamic = []; // { file, line }
|
||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||
|
||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||
// `://` inside string URLs intact.
|
||||
const stripComments = (src) =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
|
||||
const files = config.srcDirs.flatMap((d) =>
|
||||
walk(join(ROOT, d === "." ? "" : d) || ROOT),
|
||||
);
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const clean = stripComments(text);
|
||||
codeBlob += `\n${clean}`;
|
||||
for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
|
||||
for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
|
||||
for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
|
||||
for (const m of clean.matchAll(TPL_DYN_RE)) {
|
||||
const prefix = m[1];
|
||||
if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
|
||||
else {
|
||||
const idx = clean.slice(0, m.index).split("\n").length;
|
||||
fullyDynamic.push({ file: relative(ROOT, file), line: idx });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prefixList = [...dynamicPrefixes];
|
||||
// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in
|
||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||
const literalUsed = (key) =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key) =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
|
||||
// ---- compute ----
|
||||
const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
|
||||
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
|
||||
// affects the UNUSED calculation, never MISSING.
|
||||
const missing = [...usedStatic]
|
||||
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
|
||||
.sort();
|
||||
|
||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||
const removeKey = (obj, parts) => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
removeKey(obj[head], rest);
|
||||
if (
|
||||
obj[head] &&
|
||||
typeof obj[head] === "object" &&
|
||||
Object.keys(obj[head]).length === 0
|
||||
)
|
||||
delete obj[head];
|
||||
};
|
||||
if (FIX_UNUSED && unused.length) {
|
||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||
// the keys from them automatically on the next sync once they disappear from the source.
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||
for (const key of unused) removeKey(data, key.split("."));
|
||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(
|
||||
`🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- report ----
|
||||
if (JSON_OUT) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sourceKeys: sourceKeys.length,
|
||||
missing,
|
||||
unused,
|
||||
dynamicPrefixes: prefixList,
|
||||
fullyDynamic,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`🔑 i18n key check — source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`,
|
||||
);
|
||||
if (prefixList.length)
|
||||
console.log(
|
||||
` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`,
|
||||
);
|
||||
if (fullyDynamic.length)
|
||||
console.log(
|
||||
` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`,
|
||||
);
|
||||
|
||||
if (missing.length) {
|
||||
console.log(
|
||||
`\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) — ${missing.length}:`,
|
||||
);
|
||||
for (const k of missing) console.log(` - ${k}`);
|
||||
} else console.log("\n✅ No missing keys.");
|
||||
|
||||
if (UNUSED_MODE !== "off") {
|
||||
if (unused.length) {
|
||||
console.log(
|
||||
`\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) — ${unused.length}:`,
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||
);
|
||||
console.log(
|
||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||
);
|
||||
} else console.log("\n✅ No unused keys.");
|
||||
}
|
||||
}
|
||||
|
||||
const fail =
|
||||
missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"localesDir": "translations",
|
||||
"sourceLocale": "en",
|
||||
"srcDirs": [
|
||||
"app",
|
||||
"components",
|
||||
"hooks",
|
||||
"providers",
|
||||
"utils",
|
||||
"modules",
|
||||
"packages",
|
||||
"constants"
|
||||
],
|
||||
"srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||
"excludeDirs": [
|
||||
"node_modules",
|
||||
"ios",
|
||||
"android",
|
||||
".expo",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"translations"
|
||||
],
|
||||
"_ignoreUnusedNote": "Keys for planned features that are intentionally kept in en.json but not yet wired in code. They are exempt from the unused-key check until implemented. Remove an entry once its feature ships and uses the key.",
|
||||
"ignoreUnused": [
|
||||
"watchlists.add_to_watchlist",
|
||||
"watchlists.remove_from_watchlist",
|
||||
"watchlists.create_one_first",
|
||||
"watchlists.no_compatible_watchlists",
|
||||
"pin.confirm_pin",
|
||||
"pin.pins_dont_match",
|
||||
"player.search_subtitles",
|
||||
"player.subtitle_search",
|
||||
"player.subtitle_download_hint",
|
||||
"player.subtitle_tracks",
|
||||
"player.using_jellyfin_server",
|
||||
"player.swipe_down_settings",
|
||||
"player.stopPlayback",
|
||||
"player.stopPlayingTitle",
|
||||
"player.stopPlayingConfirm"
|
||||
]
|
||||
}
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "تابع و التالي",
|
||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
||||
"suggested_movies": "أفلام مقترحة",
|
||||
"suggested_episodes": "حلقات مقترحة",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "مرحبًا بك في Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "عميل مجاني ومفتوح المصدر لـJellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "لا شيء",
|
||||
"OnlyForced": "فقط الإجبارية"
|
||||
},
|
||||
"text_color": "لون النص",
|
||||
"background_color": "لون الخلفية",
|
||||
"outline_color": "لون إطار الخط",
|
||||
"outline_thickness": "سمك إطار الخط",
|
||||
"background_opacity": "شفافية الخلفية",
|
||||
"outline_opacity": "شفافية إطار الخط",
|
||||
"bold_text": "خط عريض",
|
||||
"colors": {
|
||||
"Black": "أسود",
|
||||
"Gray": "رمادي",
|
||||
"Silver": "فضي",
|
||||
"White": "أبيض",
|
||||
"Maroon": "أحمر داكن",
|
||||
"Red": "أحمر",
|
||||
"Fuchsia": "وردي",
|
||||
"Yellow": "أصفر",
|
||||
"Olive": "أخضر زيتوني",
|
||||
"Green": "أخضر",
|
||||
"Teal": "أزرق مخضر",
|
||||
"Lime": "ليموني",
|
||||
"Purple": "بنفسجي",
|
||||
"Navy": "كحلي",
|
||||
"Blue": "أزرق",
|
||||
"Aqua": "أزرق بحري"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "لا شيء",
|
||||
"Thin": "نحيف",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "إعدادات ترجمة VLC",
|
||||
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
|
||||
"text_color": "لون النص",
|
||||
"background_color": "لون الخلفية",
|
||||
"background_opacity": "شفافية الخلفية",
|
||||
"outline_color": "لون إطار الخط",
|
||||
"outline_opacity": "شفافية إطار الخط",
|
||||
"outline_thickness": "سمك إطار الخط",
|
||||
"bold": "خط عريض",
|
||||
"margin": "الهامش السفلي"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "مشغل الفيديو",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "أخرى",
|
||||
"video_orientation": "اتجاه الفيديو",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "غير معروف"
|
||||
},
|
||||
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
||||
},
|
||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
||||
"hide_libraries": "إخفاء المكتبات",
|
||||
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||
"disabled": "معطل"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات"
|
||||
},
|
||||
"music": {
|
||||
"title": "الموسيقى",
|
||||
"playback_title": "التشغيل",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
||||
"save_button": "حفظ",
|
||||
"toasts": {
|
||||
"saved": "تم الحفظ"
|
||||
}
|
||||
"saved": "تم الحفظ",
|
||||
"refreshed": "تم تحديث الإعدادات من الخادم"
|
||||
},
|
||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "تفعيل Streamystats",
|
||||
"disable_streamystats": "تعطيل Streamystats",
|
||||
"enable_search": "استخدم للبحث",
|
||||
"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": "قوائم مشاهدة مختارة",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا"
|
||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
||||
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||
"music_cache_title": "التخزين المؤقت للموسيقى",
|
||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
||||
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
||||
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
||||
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "تصدير السجلات",
|
||||
"click_for_more_info": "اضغط للمزيد من المعلومات",
|
||||
"level": "المستوى",
|
||||
"no_logs_available": "لا توجد سجلات متاحة"
|
||||
"no_logs_available": "لا توجد سجلات متاحة",
|
||||
"delete_all_logs": "حذف جميع السجلات"
|
||||
},
|
||||
"languages": {
|
||||
"title": "اللغات",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "النظام"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "خطأ في حذف الملفات"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "التنزيلات",
|
||||
"tvseries": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"queue": "قائمة الانتظار",
|
||||
"other_media": "وسائط أخرى",
|
||||
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
||||
"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": "تعذر استئناف التنزيل",
|
||||
"download_completed": "اكتمل التنزيل",
|
||||
"download_failed": "فشل التنزيل",
|
||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} قيد التنزيل بالفعل",
|
||||
"all_files_deleted": "تم حذف جميع التنزيلات بنجاح",
|
||||
"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}}",
|
||||
"go_to_downloads": "الذهاب إلى التنزيلات",
|
||||
"file_deleted": "تم حذف {{item}}"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "لا شيء",
|
||||
"track": "أغنية",
|
||||
"cancel": "إلغاء",
|
||||
"stop": "Stop",
|
||||
"delete": "حذف",
|
||||
"ok": "حسناً",
|
||||
"remove": "إزالة",
|
||||
"next": "التالي",
|
||||
"back": "رجوع",
|
||||
"continue": "متابعة",
|
||||
"verifying": "جارٍ التحقق...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "بحث...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||
"next_episode": "الحلقة التالية",
|
||||
"refresh_tracks": "تحديث المسارات",
|
||||
"audio_tracks": "مسارات الصوت:",
|
||||
"playback_state": "حالة التشغيل:",
|
||||
"index": "الفِهْرِس:",
|
||||
"continue_watching": "متابعة المشاهدة",
|
||||
"go_back": "رجوع",
|
||||
"downloaded_file_title": "تم تنزيل هذا الملف",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "عرض المزيد",
|
||||
"show_less": "عرض أقل",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "قوائم التشغيل",
|
||||
"tracks": "الأغاني"
|
||||
},
|
||||
"filters": {
|
||||
"all": "الكل"
|
||||
},
|
||||
"recently_added": "أضيف مؤخرًا",
|
||||
"recently_played": "تم تشغيله مؤخرًا",
|
||||
"frequently_played": "الأكثر تشغيلاً",
|
||||
"explore": "اكتشف",
|
||||
"top_tracks": "أفضل الأغاني",
|
||||
"play": "تشغيل",
|
||||
"shuffle": "ترتيب عشوائي",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Afegit recentment a {{libraryName}}",
|
||||
"suggested_movies": "Pel·lícules suggerides",
|
||||
"suggested_episodes": "Episodis suggerits",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Benvingut a Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Un client gratuït i de codi obert per a Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Cap",
|
||||
"OnlyForced": "Només els forçats"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"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": "Cap",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Altres",
|
||||
"video_orientation": "Orientació del vídeo",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Desconeguda"
|
||||
},
|
||||
"safe_area_in_controls": "Àrea segura als controls",
|
||||
"video_player": "Reproductor de vídeo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Oculta biblioteques",
|
||||
"select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
||||
"disable_haptic_feedback": "Desactiva la resposta hàptica",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
|
||||
"disabled": "Desactivat"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descàrregues"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Mostra més sobre Marlin.",
|
||||
"save_button": "Desa",
|
||||
"toasts": {
|
||||
"saved": "Desat"
|
||||
}
|
||||
"saved": "Desat",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Exporta registres",
|
||||
"click_for_more_info": "Feu clic per obtenir més informació",
|
||||
"level": "Nivell",
|
||||
"no_logs_available": "No hi ha registres disponibles"
|
||||
"no_logs_available": "No hi ha registres disponibles",
|
||||
"delete_all_logs": "Suprimeix tots els registres"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Idiomes",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error en suprimir fitxers"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Descàrregues",
|
||||
"tvseries": "Sèries",
|
||||
"movies": "Pel·lícules",
|
||||
"queue": "Cua",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "La cua i les descàrregues es perdran en reiniciar l'aplicació",
|
||||
"no_items_in_queue": "No hi ha elements a la cua",
|
||||
"no_downloaded_items": "No hi ha elements descarregats",
|
||||
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
||||
"delete_all_tvseries_button": "Suprimeix totes les sèries",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Descàrrega cancel·lada",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Descàrrega completada",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tots els fitxers, carpetes i treballs s'han suprimit correctament",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Ves a les descàrregues",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
||||
"message_from_server": "Missatge del servidor: {{message}}",
|
||||
"next_episode": "Episodi següent",
|
||||
"refresh_tracks": "Actualitzar pistes",
|
||||
"audio_tracks": "Pistes d'àudio:",
|
||||
"playback_state": "Estat de reproducció:",
|
||||
"index": "Índex:",
|
||||
"continue_watching": "Continuar veient",
|
||||
"go_back": "Enrere",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Mostra més",
|
||||
"show_less": "Mostra menys",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Nedávno přidané v {{libraryName}}",
|
||||
"suggested_movies": "Navrhované filmy",
|
||||
"suggested_episodes": "Navrhované epizody",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Vítejte v Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Volný a Open-Source klient pro Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Nic",
|
||||
"OnlyForced": "Pouze vynucené"
|
||||
},
|
||||
"text_color": "Barva textu",
|
||||
"background_color": "Barva pozadí",
|
||||
"outline_color": "Barva obrysu",
|
||||
"outline_thickness": "Obrys tloušťky",
|
||||
"background_opacity": "Průhlednost pozadí",
|
||||
"outline_opacity": "Průhlednost obrysu",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Černý",
|
||||
"Gray": "Šedá",
|
||||
"Silver": "Stříbro",
|
||||
"White": "Bílý",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Červená",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Žlutá",
|
||||
"Olive": "Olivy",
|
||||
"Green": "Zelená",
|
||||
"Teal": "Modrozelený",
|
||||
"Lime": "Světle zelená",
|
||||
"Purple": "Fialová",
|
||||
"Navy": "Námořní loď",
|
||||
"Blue": "Modrá",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Nic",
|
||||
"Thin": "Tenké",
|
||||
"Normal": "Normální",
|
||||
"Thick": "Tlustá"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Ostatní",
|
||||
"video_orientation": "Orientace videa",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Neznámý"
|
||||
},
|
||||
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
||||
"video_player": "Video přehrávač",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (experimentální + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
||||
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
||||
"hide_libraries": "Skrýt knihovny",
|
||||
"select_liraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
||||
"disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Maximální počet automatických přehrávání epizod",
|
||||
"disabled": "Zakázáno"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Stahování"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Přečtěte si více o Marlinu.",
|
||||
"save_button": "Uložit",
|
||||
"toasts": {
|
||||
"saved": "Uloženo"
|
||||
}
|
||||
"saved": "Uloženo",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Exportovat protokoly",
|
||||
"click_for_more_info": "Klikněte pro více informací",
|
||||
"level": "Úrovně",
|
||||
"no_logs_available": "Žádné protokoly nejsou k dispozici"
|
||||
"no_logs_available": "Žádné protokoly nejsou k dispozici",
|
||||
"delete_all_logs": "Odstranit všechny logy"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Jazyky",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Systém"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Chyba při mazání souborů"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Stahování",
|
||||
"tvseries": "Televizní série",
|
||||
"movies": "Filmy",
|
||||
"queue": "Fronta",
|
||||
"other_media": "Ostatní média",
|
||||
"queue_hint": "Fronta a stahování budou ztraceny při restartu aplikace",
|
||||
"no_items_in_queue": "Žádné položky ve frontě",
|
||||
"no_downloaded_items": "Žádné stažené položky",
|
||||
"delete_all_movies_button": "Odstranit všechny filmy",
|
||||
"delete_all_tvseries_button": "Odstranit všechny TV-série",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Nepodařilo se odstranit všechny TV-série",
|
||||
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
||||
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
||||
"download_deleted": "Stahování smazáno",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Stahování nelze odstranit",
|
||||
"download_paused": "Stahování pozastaveno",
|
||||
"could_not_pause_download": "Nelze pozastavit stahování",
|
||||
"download_resumed": "Stahování obnoveno",
|
||||
"could_not_resume_download": "Nelze pokračovat v stahování",
|
||||
"download_completed": "Stahování dokončeno",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Stahování se nezdařilo pro {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Všechny soubory, složky a úlohy byly úspěšně odstraněny",
|
||||
"failed_to_clean_cache_directory": "Nepodařilo se vyčistit adresář mezipaměti",
|
||||
"could_not_get_download_url_for_item": "Nelze získat URL pro stažení {{itemName}}",
|
||||
"go_to_downloads": "Přejít na stahování",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Hledat...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
||||
"message_from_server": "Zpráva od serveru: {{message}}",
|
||||
"next_episode": "Další epizoda",
|
||||
"refresh_tracks": "Obnovit skladby",
|
||||
"audio_tracks": "Zvukové stopy:",
|
||||
"playback_state": "Stav přehrávání:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Pokračovat ve sledování",
|
||||
"go_back": "Zpět",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Zobrazit více",
|
||||
"show_less": "Zobrazit méně",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Senest tilføjet i {{libraryName}}",
|
||||
"suggested_movies": "Foreslåede film",
|
||||
"suggested_episodes": "Foreslåede episoder",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Velkommen til Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "En gratis og open-source klient til Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Ingen",
|
||||
"OnlyForced": "Kun tvungne undertekster"
|
||||
},
|
||||
"text_color": "Tekst Farve",
|
||||
"background_color": "Baggrunds Farve",
|
||||
"outline_color": "Omrids Farve",
|
||||
"outline_thickness": "Omrids Tykkelse",
|
||||
"background_opacity": "Baggrunds Gennemsigtighed",
|
||||
"outline_opacity": "Omrids Gennemsigtighed",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Sort",
|
||||
"Gray": "Grå",
|
||||
"Silver": "Sølv",
|
||||
"White": "Hvid",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Rød",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Gul",
|
||||
"Olive": "Oliven",
|
||||
"Green": "Grøn",
|
||||
"Teal": "Grønblåt",
|
||||
"Lime": "Limegrøn",
|
||||
"Purple": "Lilla",
|
||||
"Navy": "Flåden",
|
||||
"Blue": "Blå",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Ingen",
|
||||
"Thin": "Tynd",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Tyk"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Andet",
|
||||
"video_orientation": "Videoorientering",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Ukendt"
|
||||
},
|
||||
"safe_area_in_controls": "Sikkert område i kontroller",
|
||||
"video_player": "Videospiller",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimentel + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Vis tilpassede menulinks",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Skjul biblioteker",
|
||||
"select_liraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
||||
"disable_haptic_feedback": "Deaktiver haptisk feedback",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Maks. Auto Afspil Episode Antal",
|
||||
"disabled": "Deaktiveret"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Læs mere om Marlin.",
|
||||
"save_button": "Gem",
|
||||
"toasts": {
|
||||
"saved": "Gemt"
|
||||
}
|
||||
"saved": "Gemt",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Eksporter logfiler",
|
||||
"click_for_more_info": "Klik for mere info",
|
||||
"level": "Niveau",
|
||||
"no_logs_available": "Ingen logfiler tilgængelige"
|
||||
"no_logs_available": "Ingen logfiler tilgængelige",
|
||||
"delete_all_logs": "Slet alle logfiler"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Sprog",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fejl ved sletning af filer"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-serier",
|
||||
"movies": "Film",
|
||||
"queue": "Kø",
|
||||
"other_media": "Andre medier",
|
||||
"queue_hint": "Kø og downloads vil gå tabt ved genstart af appen",
|
||||
"no_items_in_queue": "Ingen elementer i køen",
|
||||
"no_downloaded_items": "Ingen downloadede elementer",
|
||||
"delete_all_movies_button": "Slet alle film",
|
||||
"delete_all_tvseries_button": "Slet alle TV-serier",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
||||
"deleted_media_successfully": "Slettede andre medier med succes!",
|
||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||
"download_deleted": "Download Slettet",
|
||||
"download_cancelled": "Download afbrudt",
|
||||
"could_not_delete_download": "Kunne Ikke Slette Download",
|
||||
"download_paused": "Download Pauset",
|
||||
"could_not_pause_download": "Kunne Ikke Pause Download",
|
||||
"download_resumed": "Download Genoprettet",
|
||||
"could_not_resume_download": "Kunne Ikke Genoptage Download",
|
||||
"download_completed": "Download fuldført",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download mislykkedes for {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobs blev slettet med succes",
|
||||
"failed_to_clean_cache_directory": "Kunne ikke rense cache-mappe",
|
||||
"could_not_get_download_url_for_item": "Kunne ikke hente download URL til {{itemName}}",
|
||||
"go_to_downloads": "Gå til downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Søg...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
||||
"message_from_server": "Besked fra server: {{message}}",
|
||||
"next_episode": "Næste episode",
|
||||
"refresh_tracks": "Opdater spor",
|
||||
"audio_tracks": "Lydspor:",
|
||||
"playback_state": "Afspilningstilstand:",
|
||||
"index": "Indeks:",
|
||||
"continue_watching": "Fortsæt med at se",
|
||||
"go_back": "Gå Tilbage",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Vis mere",
|
||||
"show_less": "Vis mindre",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||
"suggested_movies": "Empfohlene Filme",
|
||||
"suggested_episodes": "Empfohlene Episoden",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Willkommen bei Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Ein kostenloser und Open-Source-Client für Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Keine",
|
||||
"OnlyForced": "Nur erzwungene"
|
||||
},
|
||||
"text_color": "Textfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"bold_text": "Fettgedruckter Text",
|
||||
"colors": {
|
||||
"Black": "Schwarz",
|
||||
"Gray": "Grau",
|
||||
"Silver": "Silber",
|
||||
"White": "Weiß",
|
||||
"Maroon": "Rotbraun",
|
||||
"Red": "Rot",
|
||||
"Fuchsia": "Magenta",
|
||||
"Yellow": "Gelb",
|
||||
"Olive": "Olivgrün",
|
||||
"Green": "Grün",
|
||||
"Teal": "Türkis",
|
||||
"Lime": "Hellgrün",
|
||||
"Purple": "Lila",
|
||||
"Navy": "Marineblau",
|
||||
"Blue": "Blau",
|
||||
"Aqua": "Himmelblau"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Keine",
|
||||
"Thin": "Dünn",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Dick"
|
||||
},
|
||||
"subtitle_color": "Untertitelfarbe",
|
||||
"subtitle_background_color": "Hintergrundfarbe",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Untertitel-Einstellungen",
|
||||
"hint": "Anpassen des Untertitel-Erscheinungsbildes für VLC. Änderungen werden bei der nächsten Wiedergabe übernommen.",
|
||||
"text_color": "Schriftfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"bold": "Fettgedruckter Text",
|
||||
"margin": "Unterer Abstand"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Videoplayer",
|
||||
"video_player": "Videoplayer",
|
||||
"video_player_description": "Videoplayer auf iOS auswählen.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Sonstiges",
|
||||
"video_orientation": "Videoausrichtung",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||
"video_player": "Videoplayer",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
"select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
"playback_title": "Wiedergabe",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||
"save_button": "Speichern",
|
||||
"toasts": {
|
||||
"saved": "Gespeichert"
|
||||
}
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Einstellungen vom Server aktualisiert"
|
||||
},
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats aktivieren",
|
||||
"disable_streamystats": "Streamystats deaktivieren",
|
||||
"enable_search": "Zum Suchen verwenden",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
||||
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
||||
"save_button": "Speichern",
|
||||
"save": "Gespeichert",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Startseitenbereiche",
|
||||
"enable_movie_recommendations": "Filmempfehlungen",
|
||||
"enable_series_recommendations": "Serienempfehlungen",
|
||||
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren"
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren",
|
||||
"watchlist_button": "Merklisten-Integration umschalten"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
||||
"music_cache_title": "Musik-Cache",
|
||||
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
|
||||
"enable_music_cache": "Musik-Cache aktivieren",
|
||||
"clear_music_cache": "Musik-Cache leeren",
|
||||
"music_cache_size": "{{size}} gechached",
|
||||
"music_cache_cleared": "Musik-Cache geleert",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Logs exportieren",
|
||||
"click_for_more_info": "Für mehr Informationen klicken",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Keine Logs verfügbar"
|
||||
"no_logs_available": "Keine Logs verfügbar",
|
||||
"delete_all_logs": "Alle Logs löschen"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Sprachen",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fehler beim Löschen von Dateien"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "Serien",
|
||||
"movies": "Filme",
|
||||
"queue": "Warteschlange",
|
||||
"other_media": "Andere Medien",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird",
|
||||
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||
"delete_all_movies_button": "Alle Filme löschen",
|
||||
"delete_all_tvseries_button": "Alle Serien löschen",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
|
||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||
"download_deleted": "Download gelöscht",
|
||||
"download_cancelled": "Download abgebrochen",
|
||||
"could_not_delete_download": "Download konnte nicht gelöscht werden",
|
||||
"download_paused": "Download pausiert",
|
||||
"could_not_pause_download": "Download konnte nicht angehalten werden",
|
||||
"download_resumed": "Download fortgesetzt",
|
||||
"could_not_resume_download": "Download konnte nicht fortgesetzt werden",
|
||||
"download_completed": "Download abgeschlossen",
|
||||
"download_failed": "Download fehlgeschlagen",
|
||||
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} Lädt",
|
||||
"all_files_deleted": "Alle Downloads gelöscht",
|
||||
"files_deleted_by_type": "{{count}} {{type}} gelöscht",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
|
||||
"failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses",
|
||||
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
|
||||
"go_to_downloads": "Zu Downloads gehen",
|
||||
"file_deleted": "{{item}} gelöscht"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"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"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Suchen...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||
"next_episode": "Nächste Episode",
|
||||
"refresh_tracks": "Spuren aktualisieren",
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Fortsetzen",
|
||||
"go_back": "Zurück",
|
||||
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Mehr anzeigen",
|
||||
"show_less": "Weniger anzeigen",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "Titel"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle"
|
||||
},
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"recently_played": "Vor kurzem gehört",
|
||||
"frequently_played": "Oft gehört",
|
||||
"explore": "Entdecken",
|
||||
"top_tracks": "Top-Titel",
|
||||
"play": "Abspielen",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Προστέθηκε πρόσφατα στο {{libraryName}}",
|
||||
"suggested_movies": "Προτεινόμενες Ταινίες",
|
||||
"suggested_episodes": "Προτεινόμενα Επεισόδια",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Καλώς ήρθατε στο Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Ένας ελεύθερος και ανοιχτού κώδικα πελάτης για τη ζελυφίνη.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Κανένα",
|
||||
"OnlyForced": "Μόνο"
|
||||
},
|
||||
"text_color": "Χρώμα Κειμένου",
|
||||
"background_color": "Χρώμα Φόντου",
|
||||
"outline_color": "Χρώμα Περιγράμματος",
|
||||
"outline_thickness": "Πάχος Περιγράμματος",
|
||||
"background_opacity": "Αδιαφάνεια Φόντου",
|
||||
"outline_opacity": "Αδιαφάνεια Περιγράμματος",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Μαύρο",
|
||||
"Gray": "Γκρι",
|
||||
"Silver": "Ασημένιο",
|
||||
"White": "Λευκό",
|
||||
"Maroon": "Μαρώ",
|
||||
"Red": "Κόκκινο",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Κίτρινο",
|
||||
"Olive": "Ελιές",
|
||||
"Green": "Πράσινο",
|
||||
"Teal": "Τιρκουάζ",
|
||||
"Lime": "Άσβεστος",
|
||||
"Purple": "Μωβ",
|
||||
"Navy": "Ναυτικό",
|
||||
"Blue": "Μπλε",
|
||||
"Aqua": "Νερό"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Κανένα",
|
||||
"Thin": "Λεπτό",
|
||||
"Normal": "Κανονικό",
|
||||
"Thick": "Παχύ"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Άλλο",
|
||||
"video_orientation": "Προσανατολισμός Βίντεο",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Άγνωστο"
|
||||
},
|
||||
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
||||
"video_player": "Αναπαραγωγέας Βίντεο",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Πειραματική + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
||||
"select_liraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
|
||||
"disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Μέγιστο Πλήθος Επεισόδιο Αυτόματου Παιχνιδιού",
|
||||
"disabled": "Απενεργοποιημένο"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Λήψεις"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Διαβάστε Περισσότερα Σχετικά Με Marlin.",
|
||||
"save_button": "Αποθήκευση",
|
||||
"toasts": {
|
||||
"saved": "Αποθηκεύτηκε"
|
||||
}
|
||||
"saved": "Αποθηκεύτηκε",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Εξαγωγή Αρχείων Καταγραφής",
|
||||
"click_for_more_info": "Κάντε κλικ για περισσότερες πληροφορίες",
|
||||
"level": "Επίπεδο",
|
||||
"no_logs_available": "Δεν Υπάρχουν Διαθέσιμα Αρχεία Καταγραφής"
|
||||
"no_logs_available": "Δεν Υπάρχουν Διαθέσιμα Αρχεία Καταγραφής",
|
||||
"delete_all_logs": "Διαγραφή Όλων Των Καταγραφών"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Γλώσσες",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Σύστημα"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Σφάλμα Διαγραφής Αρχείων"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Λήψεις",
|
||||
"tvseries": "Τηλεόραση-Σειρά",
|
||||
"movies": "Ταινίες",
|
||||
"queue": "Ουρά",
|
||||
"other_media": "Άλλα μέσα",
|
||||
"queue_hint": "Ουρά και λήψεις θα χαθούν κατά την επανεκκίνηση της εφαρμογής",
|
||||
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
||||
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
||||
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
||||
"delete_all_tvseries_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
||||
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
||||
"download_deleted": "Η Λήψη Διαγράφηκε",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Αδυναμία Διαγραφής Λήψης",
|
||||
"download_paused": "Λήψη Σε Παύση",
|
||||
"could_not_pause_download": "Αδυναμία Παύσης Λήψης",
|
||||
"download_resumed": "Συνέχιση Λήψης",
|
||||
"could_not_resume_download": "Αδυναμία Συνέχισης Λήψης",
|
||||
"download_completed": "Η Λήψη Ολοκληρώθηκε",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Η λήψη απέτυχε για το {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Όλα τα αρχεία, οι φάκελοι και οι εργασίες διαγράφηκαν με επιτυχία",
|
||||
"failed_to_clean_cache_directory": "Αποτυχία καθαρισμού φακέλου προσωρινής μνήμης",
|
||||
"could_not_get_download_url_for_item": "Αδυναμία λήψης του URL λήψης για το {{itemName}}",
|
||||
"go_to_downloads": "Μετάβαση στις λήψεις",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Αναζήτηση...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
||||
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
||||
"next_episode": "Επόμενο Επεισόδιο",
|
||||
"refresh_tracks": "Ανανέωση Κομματιών",
|
||||
"audio_tracks": "Κομμάτια Ήχου:",
|
||||
"playback_state": "Κατάσταση Αναπαραγωγής:",
|
||||
"index": "Δείκτης:",
|
||||
"continue_watching": "Συνέχεια Παρακολούθησης",
|
||||
"go_back": "Μετάβαση Πίσω",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Εμφάνιση Περισσότερων",
|
||||
"show_less": "Εμφάνιση Λιγότερων",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -27,6 +27,112 @@
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"player": {
|
||||
"skip_intro": "Skip Intro",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"skip_outro": "Skip Outro",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_preview": "Skip Preview",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"up_next": "Up next",
|
||||
"next_episode_in": "Next episode in {{seconds}}s",
|
||||
"play_now": "Play now",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"casting_player": {
|
||||
"buffering": "Buffering...",
|
||||
"changing_audio": "Changing audio...",
|
||||
"changing_subtitles": "Changing subtitles...",
|
||||
"season_episode_format": "Season {{season}} • Episode {{episode}}",
|
||||
"connecting": "Connecting to Chromecast...",
|
||||
"unknown_device": "Unknown Device",
|
||||
"ending_at": "Ending at {{time}}",
|
||||
"unknown": "Unknown",
|
||||
"connected": "Connected",
|
||||
"volume": "Volume",
|
||||
"muted": "Muted",
|
||||
"disconnect": "Disconnect",
|
||||
"stop_casting": "Stop Casting",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"chromecast": "Chromecast",
|
||||
"device_name": "Device Name",
|
||||
"playback_settings": "Playback Settings",
|
||||
"version": "Version",
|
||||
"stop": "Stop",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
"none": "None",
|
||||
"playback_speed": "Playback Speed",
|
||||
"normal": "Normal",
|
||||
"episodes": "Episodes",
|
||||
"season": "Season {{number}}",
|
||||
"minutes_short": "min",
|
||||
"episode_label": "Episode {{number}}",
|
||||
"forced": "Forced",
|
||||
"device": "Device",
|
||||
"cancel": "Cancel",
|
||||
"connection_quality": {
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"fair": "Fair",
|
||||
"poor": "Poor",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"error_title": "Chromecast Error",
|
||||
"error_description": "Something went wrong with the cast session",
|
||||
"retry": "Try Again",
|
||||
"critical_error_title": "Multiple Errors Detected",
|
||||
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
|
||||
"track_changed": "Track changed successfully",
|
||||
"audio_track_changed": "Audio track changed",
|
||||
"subtitle_track_changed": "Subtitle track changed",
|
||||
"seeking": "Seeking...",
|
||||
"seeking_error": "Failed to seek",
|
||||
"load_failed": "Failed to load media",
|
||||
"load_retry": "Retrying media load..."
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
@@ -100,6 +206,7 @@
|
||||
"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": "Welcome to Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||
@@ -260,6 +367,43 @@
|
||||
"None": "None",
|
||||
"OnlyForced": "OnlyForced"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"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": "None",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +421,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Other",
|
||||
"video_orientation": "Video Orientation",
|
||||
@@ -294,7 +457,13 @@
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"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 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",
|
||||
@@ -302,8 +471,28 @@
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"segment_skip_settings": "Segment Skip Settings",
|
||||
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_intro_description": "Action when intro segment is detected",
|
||||
"skip_outro": "Skip Outro/Credits",
|
||||
"skip_outro_description": "Action when outro/credits segment is detected",
|
||||
"skip_recap": "Skip Recap",
|
||||
"skip_recap_description": "Action when recap segment is detected",
|
||||
"skip_commercial": "Skip Commercial",
|
||||
"skip_commercial_description": "Action when commercial segment is detected",
|
||||
"skip_preview": "Skip Preview",
|
||||
"skip_preview_description": "Action when preview segment is detected",
|
||||
"segment_skip_none": "None",
|
||||
"segment_skip_ask": "Show Skip Button",
|
||||
"segment_skip_auto": "Auto Skip",
|
||||
"autoplay_countdown_seconds": "Player countdown (seconds)",
|
||||
"cast_autoplay_countdown_seconds": "Chromecast countdown (seconds)",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +536,23 @@
|
||||
"read_more_about_marlin": "Read More About Marlin.",
|
||||
"save_button": "Save",
|
||||
"toasts": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +568,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +580,7 @@
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
||||
"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",
|
||||
@@ -394,6 +590,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +604,8 @@
|
||||
"export_logs": "Export Logs",
|
||||
"click_for_more_info": "Click for More Info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "No Logs Available"
|
||||
"no_logs_available": "No Logs Available",
|
||||
"delete_all_logs": "Delete All Logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Languages",
|
||||
@@ -414,12 +613,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error Deleting Files"
|
||||
"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",
|
||||
@@ -429,10 +631,6 @@
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"sessions_title": "Sessions"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
@@ -443,7 +641,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"movies": "Movies",
|
||||
"queue": "Queue",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||
"no_items_in_queue": "No Items in Queue",
|
||||
"no_downloaded_items": "No Downloaded Items",
|
||||
"delete_all_movies_button": "Delete All Movies",
|
||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||
@@ -468,8 +669,13 @@
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Download Completed",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||
@@ -479,7 +685,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Go to Downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -497,17 +706,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -596,47 +804,6 @@
|
||||
"custom_links": {
|
||||
"no_links": "No Links"
|
||||
},
|
||||
"player": {
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
@@ -673,6 +840,7 @@
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -695,8 +863,7 @@
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
"continue_from": "Continue from {{time}}"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Next",
|
||||
@@ -800,9 +967,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -936,6 +1107,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"next_up": "Sekva",
|
||||
"recently_added_in": "Ĵus Aldonita en {{libraryName}}",
|
||||
"suggested_movies": "Sugestitaj Filmoj",
|
||||
"suggested_episodes": "Sugestitaj Epizodoj",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Bonvenon al Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Senpaga kaj malfermfonta kliento por Jellyfin.",
|
||||
@@ -127,6 +128,11 @@
|
||||
"UNKNOWN": "Nekonata"
|
||||
},
|
||||
"safe_area_in_controls": "Sekura areo en kontroloj",
|
||||
"video_player": "Video-ludilo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimenta + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Montri Proprajn Menuajn Ligilojn",
|
||||
"hide_libraries": "Kaŝi Bibliotekojn",
|
||||
"select_liraries_you_want_to_hide": "Elektu la bibliotekojn, kiujn vi volas kaŝi de la Biblioteka langeto kaj hejmpaĝaj sekcioj.",
|
||||
@@ -134,6 +140,7 @@
|
||||
"default_quality": "Defaŭlta kvalito"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Elŝutoj",
|
||||
"optimized_versions_server": "Optimumigitaj versioj servilo",
|
||||
"save_button": "Konservi",
|
||||
"optimized_server": "Optimumigita Servilo",
|
||||
@@ -198,7 +205,8 @@
|
||||
"export_logs": "Eksporti protokolojn",
|
||||
"click_for_more_info": "Klaku por pli da informoj",
|
||||
"level": "Nivelo",
|
||||
"no_logs_available": "Neniuj protokoloj disponeblaj"
|
||||
"no_logs_available": "Neniuj protokoloj disponeblaj",
|
||||
"delete_all_logs": "Forigi ĉiujn protokolojn"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Lingvoj",
|
||||
@@ -208,6 +216,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Eraro forigante dosierojn",
|
||||
"background_downloads_enabled": "Fonaj elŝutoj ebligitaj",
|
||||
"background_downloads_disabled": "Fonaj elŝutoj malŝaltitaj",
|
||||
"connected": "Konektita",
|
||||
"could_not_connect": "Ne povis konekti",
|
||||
"invalid_url": "Nevalida URL"
|
||||
@@ -221,6 +231,9 @@
|
||||
"downloads_title": "Elŝutoj",
|
||||
"tvseries": "Televidaj serioj",
|
||||
"movies": "Filmoj",
|
||||
"queue": "Vico",
|
||||
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
||||
"no_items_in_queue": "Neniuj eroj en vico",
|
||||
"no_downloaded_items": "Neniuj elŝutitaj eroj",
|
||||
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
|
||||
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
|
||||
@@ -256,7 +269,9 @@
|
||||
"no_response_received_from_server": "Neniu respondo ricevita de la servilo",
|
||||
"error_setting_up_the_request": "Eraro starigante la peton",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Malsukcesis komenci elŝutadon por {{item}}: Neatendita eraro",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Eraro okazis dum forigo de dosieroj kaj taskoj"
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Ĉiuj dosieroj, dosierujoj kaj taskoj sukcese forigitaj",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Eraro okazis dum forigo de dosieroj kaj taskoj",
|
||||
"go_to_downloads": "Iri al elŝutoj"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -350,8 +365,12 @@
|
||||
"video_has_finished_playing": "Video finis ludi!",
|
||||
"no_video_source": "Neniu video-fonto...",
|
||||
"next_episode": "Sekva Epizodo",
|
||||
"refresh_tracks": "Refreŝigi Trakojn",
|
||||
"subtitle_tracks": "Subtekstaj Trakoj:",
|
||||
"no_data_available": "Neniuj datumoj disponeblaj"
|
||||
"audio_tracks": "Aŭdiaj Trakoj:",
|
||||
"playback_state": "Ludada Stato:",
|
||||
"no_data_available": "Neniuj datumoj disponeblaj",
|
||||
"index": "Indekso:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Sekva",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continuar y siguiente",
|
||||
"recently_added_in": "Recientemente añadido en {{libraryName}}",
|
||||
"suggested_movies": "Películas sugeridas",
|
||||
"suggested_episodes": "Episodios sugeridos",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Bienvenido a Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Un cliente gratuito y de código abierto para Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Nada",
|
||||
"OnlyForced": "Solo forzados"
|
||||
},
|
||||
"text_color": "Color del texto",
|
||||
"background_color": "Color de fondo",
|
||||
"outline_color": "Color de salida",
|
||||
"outline_thickness": "Grosor exterior",
|
||||
"background_opacity": "Opacidad de fondo",
|
||||
"outline_opacity": "Opacidad exterior",
|
||||
"bold_text": "Texto en negrita",
|
||||
"colors": {
|
||||
"Black": "Negro",
|
||||
"Gray": "Gris",
|
||||
"Silver": "Plata",
|
||||
"White": "Blanco",
|
||||
"Maroon": "Granate",
|
||||
"Red": "Rojo",
|
||||
"Fuchsia": "Fucsia",
|
||||
"Yellow": "Amarillo",
|
||||
"Olive": "Oliva",
|
||||
"Green": "Verde",
|
||||
"Teal": "Cereal",
|
||||
"Lime": "Lima",
|
||||
"Purple": "Morado",
|
||||
"Navy": "Naval",
|
||||
"Blue": "Azul",
|
||||
"Aqua": "Agua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Ninguno",
|
||||
"Thin": "Ligero",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Grosor"
|
||||
},
|
||||
"subtitle_color": "Color de los Subtítulos",
|
||||
"subtitle_background_color": "Color del fondo",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "Configuración de subtítulos VLC",
|
||||
"hint": "Personalizar la apariencia de los subtítulos para el reproductor VLC. Los cambios tendrán efecto en la próxima reproducción.",
|
||||
"text_color": "Color del texto",
|
||||
"background_color": "Color del fondo",
|
||||
"background_opacity": "Opacidad del fondo",
|
||||
"outline_color": "Color del contorno",
|
||||
"outline_opacity": "Opacidad del contorno",
|
||||
"outline_thickness": "Grosor del contorno",
|
||||
"bold": "Texto en negrita",
|
||||
"margin": "Margen inferior"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Reproductor de vídeo",
|
||||
"video_player": "Reproductor de vídeo",
|
||||
"video_player_description": "Elige qué reproductor de vídeo en iOS",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Otros",
|
||||
"video_orientation": "Orientación de vídeo",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Desconocida"
|
||||
},
|
||||
"safe_area_in_controls": "Área segura en controles",
|
||||
"video_player": "Reproductor de vídeo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
||||
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
||||
"disable_haptic_feedback": "Desactivar feedback háptico",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
||||
"disabled": "Deshabilitado"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas"
|
||||
},
|
||||
"music": {
|
||||
"title": "Música",
|
||||
"playback_title": "Reproducir",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Leer más sobre Marlin.",
|
||||
"save_button": "Guardar",
|
||||
"toasts": {
|
||||
"saved": "Guardado"
|
||||
}
|
||||
"saved": "Guardado",
|
||||
"refreshed": "Ajustes del servidor actualizados"
|
||||
},
|
||||
"refresh_from_server": "Actualizar ajustes del servidor"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Habilitar Streamystats",
|
||||
"disable_streamystats": "Deshabilitar Streamystats",
|
||||
"enable_search": "Usar para la búsqueda",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.ejemplo.com",
|
||||
"streamystats_search_hint": "Introduzca la URL para su servidor Streamystats. La URL debe incluir http o https y opcionalmente el puerto.",
|
||||
"read_more_about_streamystats": "Leer más sobre Streamystats.",
|
||||
"save_button": "Guardar",
|
||||
"save": "Guardar",
|
||||
"features_title": "Características",
|
||||
"home_sections_title": "Secciones de inicio",
|
||||
"enable_movie_recommendations": "Recomendaciones de películas",
|
||||
"enable_series_recommendations": "Recomendaciones de series",
|
||||
"enable_promoted_watchlists": "Listas promocionadas",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Actualizar ajustes desde el servidor"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento"
|
||||
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento",
|
||||
"watchlist_button": "Activar o desactivar la integración de la lista de seguimiento"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
||||
"music_cache_title": "Caché de música",
|
||||
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
|
||||
"enable_music_cache": "Activar Caché de Música",
|
||||
"clear_music_cache": "Borrar Caché de Música",
|
||||
"music_cache_size": "Caché {{Tamaño}}",
|
||||
"music_cache_cleared": "Caché de música eliminado",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Nivel",
|
||||
"no_logs_available": "No hay registros disponibles"
|
||||
"no_logs_available": "No hay registros disponibles",
|
||||
"delete_all_logs": "Eliminar todos los registros"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Idiomas",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error al eliminar archivos"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Descargas",
|
||||
"tvseries": "Series",
|
||||
"movies": "Películas",
|
||||
"queue": "Cola",
|
||||
"other_media": "Otros medios",
|
||||
"queue_hint": "La cola de series y películas se perderá al reiniciar la app",
|
||||
"no_items_in_queue": "No hay ítems en la cola",
|
||||
"no_downloaded_items": "No hay ítems descargados",
|
||||
"delete_all_movies_button": "Eliminar todas las películas",
|
||||
"delete_all_tvseries_button": "Eliminar todas las series",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
|
||||
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
||||
"failed_to_delete_media": "Error al eliminar otros medios",
|
||||
"download_deleted": "Descarga eliminada",
|
||||
"download_cancelled": "Descarga cancelada",
|
||||
"could_not_delete_download": "No se pudo eliminar la descarga",
|
||||
"download_paused": "Descarga pausada",
|
||||
"could_not_pause_download": "No se pudo pausar la descarga",
|
||||
"download_resumed": "Descarga rebatida",
|
||||
"could_not_resume_download": "No se pudo reiniciar la descarga",
|
||||
"download_completed": "Descarga completada",
|
||||
"download_failed": "Descarga fallida",
|
||||
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} ya está descargando",
|
||||
"all_files_deleted": "Todas las descargas eliminadas correctamente",
|
||||
"files_deleted_by_type": "{{count}} {{type}} eliminado",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Todos los archivos, carpetas y trabajos eliminados con éxito",
|
||||
"failed_to_clean_cache_directory": "Error al limpiar el directorio de caché",
|
||||
"could_not_get_download_url_for_item": "No se pudo obtener la URL de descarga para {{itemName}}",
|
||||
"go_to_downloads": "Ir a descargas",
|
||||
"file_deleted": "{{item}} eliminado"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"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"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Buscar...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
||||
"message_from_server": "Mensaje del servidor: {{message}}",
|
||||
"next_episode": "Siguiente episodio",
|
||||
"refresh_tracks": "Refrescar pistas",
|
||||
"audio_tracks": "Pistas de audio:",
|
||||
"playback_state": "Estado de la reproducción:",
|
||||
"index": "Índice:",
|
||||
"continue_watching": "Continuar viendo",
|
||||
"go_back": "Volver",
|
||||
"downloaded_file_title": "Ya tienes este archivo descargado",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Mostrar más",
|
||||
"show_less": "Mostrar menos",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Listas de reproducción",
|
||||
"tracks": "Canciones"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Todas"
|
||||
},
|
||||
"recently_added": "Recientemente añadido",
|
||||
"recently_played": "Reproducidos Recientemente",
|
||||
"frequently_played": "Reproducido con frecuencia",
|
||||
"explore": "Explorar",
|
||||
"top_tracks": "Canciones Populares",
|
||||
"play": "Reproducir",
|
||||
"shuffle": "Aleatorio",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Äskettäin lisätty {{libraryName}}-kirjastoon",
|
||||
"suggested_movies": "Ehdotetut elokuvat",
|
||||
"suggested_episodes": "Ehdotetut jaksot",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Tervetuloa Streamyfiniin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Ilmainen ja avoimen lähdekoodin asiakas Jellyfinille.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Ei mitään",
|
||||
"OnlyForced": "Vain pakotettu"
|
||||
},
|
||||
"text_color": "Tekstin väri",
|
||||
"background_color": "Taustaväri",
|
||||
"outline_color": "Ääriviivan väri",
|
||||
"outline_thickness": "Ääriviivan paksuus",
|
||||
"background_opacity": "Taustan läpinäkyvyys",
|
||||
"outline_opacity": "Ääriviivan Läpinäkyvyys",
|
||||
"bold_text": "Lihavoi teksti",
|
||||
"colors": {
|
||||
"Black": "Musta",
|
||||
"Gray": "Harmaa",
|
||||
"Silver": "Hopea",
|
||||
"White": "Valkoinen",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Punainen",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Keltainen",
|
||||
"Olive": "Oliivit",
|
||||
"Green": "Vihreä",
|
||||
"Teal": "Sinappi",
|
||||
"Lime": "Limea",
|
||||
"Purple": "Violetti",
|
||||
"Navy": "Laiva",
|
||||
"Blue": "Sininen",
|
||||
"Aqua": "Vesi"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Ei mitään",
|
||||
"Thin": "Ohut",
|
||||
"Normal": "Normaali",
|
||||
"Thick": "Paksu"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Muut",
|
||||
"video_orientation": "Videon suunta",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Tuntematon"
|
||||
},
|
||||
"safe_area_in_controls": "Turvallinen alue ohjaimissa",
|
||||
"video_player": "Videosoitin",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Kokeellinen + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
|
||||
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
|
||||
"hide_libraries": "Piilota kirjastot",
|
||||
"select_liraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
|
||||
"disable_haptic_feedback": "Poista haptinen palautteet käytöstä",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Automaattisten Toistojaksojen Maksimimäärä",
|
||||
"disabled": "Pois Käytöstä"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Lataukset"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Lue lisää Marlinista.",
|
||||
"save_button": "Tallenna",
|
||||
"toasts": {
|
||||
"saved": "Tallennettu"
|
||||
}
|
||||
"saved": "Tallennettu",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Vie lokit",
|
||||
"click_for_more_info": "Napsauta lisätietoja varten",
|
||||
"level": "Taso",
|
||||
"no_logs_available": "Ei lokitietoja saatavilla"
|
||||
"no_logs_available": "Ei lokitietoja saatavilla",
|
||||
"delete_all_logs": "Poista kaikki lokit"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Kielet",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Järjestelmä"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Virhe tiedostojen poistamisessa"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Lataukset",
|
||||
"tvseries": "TV-sarjat",
|
||||
"movies": "Elokuvat",
|
||||
"queue": "Jonot",
|
||||
"other_media": "Muu media",
|
||||
"queue_hint": "Jonot ja lataukset menetetään sovelluksen uudelleenkäynnistyksen yhteydessä",
|
||||
"no_items_in_queue": "Ei kohteita jonossa",
|
||||
"no_downloaded_items": "Ei ladattuja kohteita",
|
||||
"delete_all_movies_button": "Poista kaikki elokuvat",
|
||||
"delete_all_tvseries_button": "Poista kaikki TV-sarjat",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
||||
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
||||
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
||||
"download_deleted": "Lataus Poistettu",
|
||||
"download_cancelled": "Lataus peruutettu",
|
||||
"could_not_delete_download": "Latausta Ei Voitu Poistaa",
|
||||
"download_paused": "Lataus Keskeytetty",
|
||||
"could_not_pause_download": "Latausta Ei Voitu Keskeyttää",
|
||||
"download_resumed": "Lataus Jatketaan",
|
||||
"could_not_resume_download": "Latausta Ei Voitu Jatkaa.",
|
||||
"download_completed": "Lataus valmis",
|
||||
"download_failed": "Lataus epäonnistui",
|
||||
"download_failed_for_item": "Lataus epäonnistui kohteelle {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "Kaikki lataukset poistettu onnistuneesti",
|
||||
"files_deleted_by_type": "{{count}} {{type}} poistettu",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Kaikki tiedostot, kansiot ja tehtävät poistettu onnistuneesti",
|
||||
"failed_to_clean_cache_directory": "Välimuistin hakemiston puhdistus epäonnistui",
|
||||
"could_not_get_download_url_for_item": "Latauksen URL-osoitetta ei voitu ladata {{itemName}}",
|
||||
"go_to_downloads": "Siirry latauksiin",
|
||||
"file_deleted": "{{item}} poistettu"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"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"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Haku...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
||||
"message_from_server": "Viesti palvelimelta: {{message}}",
|
||||
"next_episode": "Seuraava Jakso",
|
||||
"refresh_tracks": "Päivitä Kappaleet",
|
||||
"audio_tracks": "Ääni Kappaleet:",
|
||||
"playback_state": "Toiston Tila:",
|
||||
"index": "Indeksi:",
|
||||
"continue_watching": "Jatka katsomista",
|
||||
"go_back": "Siirry Takaisin",
|
||||
"downloaded_file_title": "Tämä tiedosto on ladattuna",
|
||||
@@ -664,6 +761,7 @@
|
||||
"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",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "התווסף לאחרונה ב-{{libraryName}}",
|
||||
"suggested_movies": "סרטים מוצעים",
|
||||
"suggested_episodes": "פרקים מוצעים",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "ברוך הבא ל-Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "קליינט חינמי ובקוד פתוח לשרתי Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "ללא",
|
||||
"OnlyForced": "רק כפוי"
|
||||
},
|
||||
"text_color": "צבע הטקסט",
|
||||
"background_color": "צבע רקע",
|
||||
"outline_color": "צבע קו מתאר",
|
||||
"outline_thickness": "עובי קו מתאר",
|
||||
"background_opacity": "שקיפות רקע",
|
||||
"outline_opacity": "אטימות קו מתאר",
|
||||
"bold_text": "טקסט בולט",
|
||||
"colors": {
|
||||
"Black": "שחור",
|
||||
"Gray": "אפור",
|
||||
"Silver": "כסף",
|
||||
"White": "לבן",
|
||||
"Maroon": "חום ערמוני",
|
||||
"Red": "אדום",
|
||||
"Fuchsia": "פוקסיה",
|
||||
"Yellow": "צהוב",
|
||||
"Olive": "זית",
|
||||
"Green": "ירוק",
|
||||
"Teal": "תכלת",
|
||||
"Lime": "ירוק ליים",
|
||||
"Purple": "סגול",
|
||||
"Navy": "כחול כהה",
|
||||
"Blue": "כחול",
|
||||
"Aqua": "כחול בהיר"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "ללא",
|
||||
"Thin": "דק",
|
||||
"Normal": "רגיל",
|
||||
"Thick": "עבה"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "אחר",
|
||||
"video_orientation": "כיוון וידיאו",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "לא ידוע"
|
||||
},
|
||||
"safe_area_in_controls": "איזור בטוח בפקדים",
|
||||
"video_player": "נגן וידאו",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (ניסיוני + נגן בתוך נגן)"
|
||||
},
|
||||
"show_custom_menu_links": "הצג קישורים לתפריטים מותאמים אישית",
|
||||
"show_large_home_carousel": "הצג קרוסלה גדולה במסך הבית (בטא)",
|
||||
"hide_libraries": "הסתר ספריות",
|
||||
"select_liraries_you_want_to_hide": "בחר את הספריות שתרצה להסתיר ממסך הספריות וגם ממסך הבית.",
|
||||
"disable_haptic_feedback": "בטל משוב רטט",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "כמות פרקים מקסימלית לניגון אוטומטי",
|
||||
"disabled": "כבוי"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "הורדות"
|
||||
},
|
||||
"music": {
|
||||
"title": "מוזיקה",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "קרא עוד על Marlin.",
|
||||
"save_button": "שמור",
|
||||
"toasts": {
|
||||
"saved": "נשמר"
|
||||
}
|
||||
"saved": "נשמר",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "מחק את כל הקבצים שהורדו",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "ייצוא לוגים",
|
||||
"click_for_more_info": "לחץ למידע נוסף",
|
||||
"level": "רמה",
|
||||
"no_logs_available": "אין לוגים זמינים"
|
||||
"no_logs_available": "אין לוגים זמינים",
|
||||
"delete_all_logs": "מחק את כל הלוגים"
|
||||
},
|
||||
"languages": {
|
||||
"title": "שפות",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "מערכת"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "שגיאה במחיקת קבצים"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "הורדות",
|
||||
"tvseries": "סדרות",
|
||||
"movies": "סרטים",
|
||||
"queue": "תוֹר",
|
||||
"other_media": "תוכן אחר",
|
||||
"queue_hint": "התור וההורדות יאבדו בפתיחה מחדש של האפליקציה",
|
||||
"no_items_in_queue": "אין פרטים בתור",
|
||||
"no_downloaded_items": "אין פריטים שהורדו",
|
||||
"delete_all_movies_button": "מחק את כל הסרטים",
|
||||
"delete_all_tvseries_button": "מחק את כל הסדרות",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "נכשל במחיקת כל הסדרות",
|
||||
"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": "לא היה ניתן לחדש את ההורדה",
|
||||
"download_completed": "ההורדה הושלמה",
|
||||
"download_failed": "ההורדה נכשלה",
|
||||
"download_failed_for_item": "ההורדה נכשלה עבור {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} כבר נמצא בהורדה",
|
||||
"all_files_deleted": "כל ההורדות נמחקו בהצלחה",
|
||||
"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": "לא היה ניתן להשיג את קישור ההורדה של {{itemName}}",
|
||||
"go_to_downloads": "עבור להורדות",
|
||||
"file_deleted": "{{item}} נמחק"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "ללא",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "חפש...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
|
||||
"message_from_server": "הודעה מהשרת: {{message}}",
|
||||
"next_episode": "הפרק הבא",
|
||||
"refresh_tracks": "רענן רצועות",
|
||||
"audio_tracks": "רצועות שמע:",
|
||||
"playback_state": "מצב ניגון:",
|
||||
"index": "מיקום:",
|
||||
"continue_watching": "המשך לצפות",
|
||||
"go_back": "חזור",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "הצג עוד",
|
||||
"show_less": "הצג פחות",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
|
||||
"suggested_movies": "Javasolt Filmek",
|
||||
"suggested_episodes": "Javasolt Epizódok",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Üdvözöljük a Streamyfinben",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Egy Ingyenes és Nyílt Forráskódú Jellyfin Kliens.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Nincs",
|
||||
"OnlyForced": "Csak Kényszerített"
|
||||
},
|
||||
"text_color": "Szövegszín",
|
||||
"background_color": "Háttérszín",
|
||||
"outline_color": "Körvonal színe",
|
||||
"outline_thickness": "Körvonal Vastagsága",
|
||||
"background_opacity": "Háttér Áttetszőség",
|
||||
"outline_opacity": "Körvonal Áttetszőség",
|
||||
"bold_text": "Félkövér Szöveg",
|
||||
"colors": {
|
||||
"Black": "Fekete",
|
||||
"Gray": "Szürke",
|
||||
"Silver": "Ezüst",
|
||||
"White": "Fehér",
|
||||
"Maroon": "Sötétvörös",
|
||||
"Red": "Piros",
|
||||
"Fuchsia": "Fukszia",
|
||||
"Yellow": "Sárga",
|
||||
"Olive": "Oliva",
|
||||
"Green": "Zöld",
|
||||
"Teal": "Türkiz",
|
||||
"Lime": "Lime",
|
||||
"Purple": "Lila",
|
||||
"Navy": "Sötétkék",
|
||||
"Blue": "Kék",
|
||||
"Aqua": "Türkizkék"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Nincs",
|
||||
"Thin": "Vékony",
|
||||
"Normal": "Normál",
|
||||
"Thick": "Vastag"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Egyéb",
|
||||
"video_orientation": "Videó Tájolás",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Ismeretlen"
|
||||
},
|
||||
"safe_area_in_controls": "Biztonsági Sáv a Vezérlőkben",
|
||||
"video_player": "Videólejátszó",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Kísérleti + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Könyvtárak Elrejtése",
|
||||
"select_liraries_you_want_to_hide": "Válaszd ki azokat a könyvtárakat, amelyeket el szeretnél rejteni a Könyvtár fülön és a kezdőlapon.",
|
||||
"disable_haptic_feedback": "Haptikus Visszajelzés Letiltása",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Max. Auto. Epizódlejátszás",
|
||||
"disabled": "Letiltva"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Letöltések"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Tudj Meg Többet a Marlinról",
|
||||
"save_button": "Mentés",
|
||||
"toasts": {
|
||||
"saved": "Mentve"
|
||||
}
|
||||
"saved": "Mentve",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Minden Letöltött Fájl Törlése",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Naplók Exportálása",
|
||||
"click_for_more_info": "Kattints a Részletekért",
|
||||
"level": "Szint",
|
||||
"no_logs_available": "Nincsenek Naplók"
|
||||
"no_logs_available": "Nincsenek Naplók",
|
||||
"delete_all_logs": "Összes Napló Törlése"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Nyelvek",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Rendszer"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Hiba a Fájlok Törlésekor"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Letöltések",
|
||||
"tvseries": "Sorozatok",
|
||||
"movies": "Filmek",
|
||||
"queue": "Sor",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "A sor és a letöltések az alkalmazás újraindításakor elvesznek",
|
||||
"no_items_in_queue": "Nincs Elem a Sorban",
|
||||
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
||||
"delete_all_movies_button": "Összes Film Törlése",
|
||||
"delete_all_tvseries_button": "Összes Sorozat Törlése",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Letöltés Törölve",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Nem Sikerült Törölni a Letöltést",
|
||||
"download_paused": "Letöltés Szüneteltetve",
|
||||
"could_not_pause_download": "Nem Sikerült Szüneteltetni a Letöltést",
|
||||
"download_resumed": "Letöltés Folytatva",
|
||||
"could_not_resume_download": "Nem Sikerült Folytatni a Letöltést",
|
||||
"download_completed": "Letöltés Befejezve",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "A(z) {{item}} letöltése sikertelen - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Minden fájl, mappa és feladat sikeresen törölve",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Ugrás a Letöltésekhez",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Keresés...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
|
||||
"message_from_server": "Üzenet a szervertől: {{message}}",
|
||||
"next_episode": "Következő Epizód",
|
||||
"refresh_tracks": "Sávok Frissítése",
|
||||
"audio_tracks": "Hangsávok:",
|
||||
"playback_state": "Lejátszás Állapota:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Folytatás",
|
||||
"go_back": "Vissza",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"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",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
|
||||
"suggested_movies": "Film consigliati",
|
||||
"suggested_episodes": "Episodi consigliati",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Benvenuto a Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Un client gratuito e open-source per Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Nessuno",
|
||||
"OnlyForced": "Solo forzati"
|
||||
},
|
||||
"text_color": "Colore Del Testo",
|
||||
"background_color": "Colore Di Sfondo",
|
||||
"outline_color": "Colore Contorno",
|
||||
"outline_thickness": "Spessore Contorno",
|
||||
"background_opacity": "Opacità Dello Sfondo",
|
||||
"outline_opacity": "Opacità Contorno",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Nero",
|
||||
"Gray": "Grigio",
|
||||
"Silver": "Argento",
|
||||
"White": "Bianco",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Rosso",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Giallo",
|
||||
"Olive": "Olive",
|
||||
"Green": "Verde",
|
||||
"Teal": "Teal",
|
||||
"Lime": "Lime",
|
||||
"Purple": "Viola",
|
||||
"Navy": "Marina",
|
||||
"Blue": "Blu",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Nessuno",
|
||||
"Thin": "Sottile",
|
||||
"Normal": "Normale",
|
||||
"Thick": "Spessa"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Altro",
|
||||
"video_orientation": "Orientamento del video",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Sconosciuto"
|
||||
},
|
||||
"safe_area_in_controls": "Area sicura per i controlli",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Sperimentale + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostra i link del menu personalizzato",
|
||||
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
|
||||
"hide_libraries": "Nascondi Librerie",
|
||||
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
|
||||
"disable_haptic_feedback": "Disabilita il feedback aptico",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Numero Massimo Di Episodi Riproduzione Automatica",
|
||||
"disabled": "Disabilitato"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Scaricamento"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Leggi di più su Marlin.",
|
||||
"save_button": "Salva",
|
||||
"toasts": {
|
||||
"saved": "Salvato"
|
||||
}
|
||||
"saved": "Salvato",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Livello",
|
||||
"no_logs_available": "Nessun log disponibile"
|
||||
"no_logs_available": "Nessun log disponibile",
|
||||
"delete_all_logs": "Cancella tutti i log"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Lingue",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Errore nella cancellazione dei file"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Scaricati",
|
||||
"tvseries": "Serie TV",
|
||||
"movies": "Film",
|
||||
"queue": "Coda",
|
||||
"other_media": "Altri supporti",
|
||||
"queue_hint": "La coda e gli elementi scaricati saranno persi con il riavvio dell'app",
|
||||
"no_items_in_queue": "Nessun elemento in coda",
|
||||
"no_downloaded_items": "Nessun elemento scaricato",
|
||||
"delete_all_movies_button": "Cancella tutti i film",
|
||||
"delete_all_tvseries_button": "Cancella tutte le serie TV",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
|
||||
"deleted_media_successfully": "Eliminato altri supporti con successo!",
|
||||
"failed_to_delete_media": "Impossibile eliminare altri media",
|
||||
"download_deleted": "Download Eliminato",
|
||||
"download_cancelled": "Scaricamento annullato",
|
||||
"could_not_delete_download": "Impossibile Eliminare Il Download",
|
||||
"download_paused": "Download In Pausa",
|
||||
"could_not_pause_download": "Impossibile Sbloccare Il Download",
|
||||
"download_resumed": "Download Ripreso",
|
||||
"could_not_resume_download": "Impossibile Riprendere Il Download",
|
||||
"download_completed": "Scaricamento completato",
|
||||
"download_failed": "Scaricamento non riuscito",
|
||||
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} è già in download",
|
||||
"all_files_deleted": "Tutti i Download Eliminati con Successo",
|
||||
"files_deleted_by_type": "{{count}} {{type}} cancellati",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tutti i file, le cartelle e i processi sono stati eliminati con successo.",
|
||||
"failed_to_clean_cache_directory": "Pulizia della directory della cache non riuscita",
|
||||
"could_not_get_download_url_for_item": "Impossibile ottenere l'URL di download per {{itemName}}",
|
||||
"go_to_downloads": "Vai agli elementi scaricati",
|
||||
"file_deleted": "{{item}} cancellato"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "Nulla",
|
||||
"track": "Traccia",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
|
||||
"message_from_server": "Messaggio dal server",
|
||||
"next_episode": "Prossimo Episodio",
|
||||
"refresh_tracks": "Aggiorna tracce",
|
||||
"audio_tracks": "Tracce audio:",
|
||||
"playback_state": "Stato della riproduzione:",
|
||||
"index": "Indice:",
|
||||
"continue_watching": "Continua a guardare",
|
||||
"go_back": "Indietro",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Mostra di più",
|
||||
"show_less": "Mostra di meno",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "{{libraryName}}に最近追加された",
|
||||
"suggested_movies": "おすすめ映画",
|
||||
"suggested_episodes": "おすすめエピソード",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Streamyfinへようこそ",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Jellyfinのためのフリーでオープンソースのクライアント。",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "なし",
|
||||
"OnlyForced": "強制のみ"
|
||||
},
|
||||
"text_color": "テキストの色",
|
||||
"background_color": "背景色",
|
||||
"outline_color": "アウトラインの色",
|
||||
"outline_thickness": "概要 厚さ",
|
||||
"background_opacity": "背景の透明度",
|
||||
"outline_opacity": "アウトラインの透明度",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "ブラック",
|
||||
"Gray": "グレー",
|
||||
"Silver": "シルバー",
|
||||
"White": "白",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "赤",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "黄色",
|
||||
"Olive": "オリーブ",
|
||||
"Green": "緑",
|
||||
"Teal": "ティール",
|
||||
"Lime": "黄緑",
|
||||
"Purple": "パープル",
|
||||
"Navy": "海軍format@@0",
|
||||
"Blue": "青",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "なし",
|
||||
"Thin": "細いです",
|
||||
"Normal": "標準",
|
||||
"Thick": "濃厚な"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "その他",
|
||||
"video_orientation": "動画の向き",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "不明"
|
||||
},
|
||||
"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": "大きなヒーロー(Beta)",
|
||||
"hide_libraries": "ライブラリを非表示",
|
||||
"select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
|
||||
"disable_haptic_feedback": "触覚フィードバックを無効にする",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "自動再生エピソードの最大数",
|
||||
"disabled": "無効"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "ダウンロード"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Marlinについて詳しく読む。",
|
||||
"save_button": "保存",
|
||||
"toasts": {
|
||||
"saved": "保存しました"
|
||||
}
|
||||
"saved": "保存しました",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "レベル",
|
||||
"no_logs_available": "ログがありません"
|
||||
"no_logs_available": "ログがありません",
|
||||
"delete_all_logs": "すべてのログを削除"
|
||||
},
|
||||
"languages": {
|
||||
"title": "言語",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "システム"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "ファイルの削除エラー"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "ダウンロード",
|
||||
"tvseries": "TVシリーズ",
|
||||
"movies": "映画",
|
||||
"queue": "キュー",
|
||||
"other_media": "その他のメディア",
|
||||
"queue_hint": "アプリを再起動するとキューとダウンロードは失われます",
|
||||
"no_items_in_queue": "キューにアイテムがありません",
|
||||
"no_downloaded_items": "ダウンロードしたアイテムはありません",
|
||||
"delete_all_movies_button": "すべての映画を削除",
|
||||
"delete_all_tvseries_button": "すべてのシリーズを削除",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
|
||||
"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": "ダウンロードを再開できませんでした",
|
||||
"download_completed": "ダウンロードが完了しました",
|
||||
"download_failed": "ダウンロードに失敗しました",
|
||||
"download_failed_for_item": "{{item}}のダウンロードに失敗しました - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "すべてのファイル、フォルダ、ジョブが正常に削除されました",
|
||||
"failed_to_clean_cache_directory": "キャッシュディレクトリのクリーンアップに失敗しました",
|
||||
"could_not_get_download_url_for_item": "{{itemName}} のダウンロードURLを取得できませんでした",
|
||||
"go_to_downloads": "ダウンロードに移動",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "検索...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
|
||||
"message_from_server": "サーバーからのメッセージ",
|
||||
"next_episode": "次のエピソード",
|
||||
"refresh_tracks": "トラックを更新",
|
||||
"audio_tracks": "音声トラック:",
|
||||
"playback_state": "再生状態:",
|
||||
"index": "インデックス:",
|
||||
"continue_watching": "視聴を続ける",
|
||||
"go_back": "戻る",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "もっと見る",
|
||||
"show_less": "少なく表示",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "이어서 보기 & 다음 시청",
|
||||
"recently_added_in": "최근에 추가된 {{libraryName}}",
|
||||
"suggested_movies": "추천 영화",
|
||||
"suggested_episodes": "추천 에피소드",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "스트리미핀에 오신 것을 환영합니다",
|
||||
"a_free_and_open_source_client_for_jellyfin": "젤리핀을 위한 무료 오픈소스 클라이언트입니다.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "None",
|
||||
"OnlyForced": "OnlyForced"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"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": "아쿠아색"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "없음",
|
||||
"Thin": "얇게",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC 자막 설정",
|
||||
"hint": "VLC 플레이어의 자막 표시 방식을 설정하세요. 변경 사항은 다음 재생 시 적용됩니다.",
|
||||
"text_color": "글자색",
|
||||
"background_color": "배경 색상",
|
||||
"background_opacity": "배경 투명도",
|
||||
"outline_color": "외곽선 색상",
|
||||
"outline_opacity": "외곽선 투명도",
|
||||
"outline_thickness": "외곽선 굵기",
|
||||
"bold": "굵은 글씨",
|
||||
"margin": "아래쪽 여백"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "비디오 플레이어",
|
||||
"video_player": "비디오 플레이어",
|
||||
"video_player_description": "iOS 사용자는 비디오 플레이어를 선택하세요.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Other",
|
||||
"video_orientation": "Video Orientation",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"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": "라이브러리 숨기기",
|
||||
"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",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Read More About Marlin.",
|
||||
"save_button": "Save",
|
||||
"toasts": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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": "시리즈 추천",
|
||||
"enable_promoted_watchlists": "추천 관심 목록",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "서버에서 설정 새로고침"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "관심 목록 통합 기능 활성화"
|
||||
"watchlist_enabler": "관심 목록 통합 기능 활성화",
|
||||
"watchlist_button": "관심 목록 연동 켜기/끄기"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
||||
"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": "음악 캐시가 삭제되었습니다",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export Logs",
|
||||
"click_for_more_info": "Click for More Info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "No Logs Available"
|
||||
"no_logs_available": "No Logs Available",
|
||||
"delete_all_logs": "Delete All Logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Languages",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error Deleting Files"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"movies": "Movies",
|
||||
"queue": "Queue",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||
"no_items_in_queue": "No Items in Queue",
|
||||
"no_downloaded_items": "No Downloaded Items",
|
||||
"delete_all_movies_button": "Delete All Movies",
|
||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Download Completed",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Go to Downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"next_up": "Neste",
|
||||
"recently_added_in": "Nylig lagt til i {{libraryName}}",
|
||||
"suggested_movies": "Foreslåtte filmer",
|
||||
"suggested_episodes": "Foreslåtte episoder",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Velkommen til Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "En gratis og åpen kildekode-klient for Jellyfin.",
|
||||
@@ -127,6 +128,11 @@
|
||||
"UNKNOWN": "Ukjent"
|
||||
},
|
||||
"safe_area_in_controls": "Trygt område i kontrollene",
|
||||
"video_player": "Videospiller",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Vis Tilpassede Meny Linker",
|
||||
"hide_libraries": "Skjul biblioteker",
|
||||
"select_liraries_you_want_to_hide": "Velg bibliotekene du vil skjule fra Bibliotek-fanen og hjemmesidedelene.",
|
||||
@@ -134,6 +140,7 @@
|
||||
"default_quality": "Standardkvalitet"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedlastinger",
|
||||
"optimized_versions_server": "Optimaliserte versjoner server",
|
||||
"save_button": "Lagre",
|
||||
"optimized_server": "Optimalisert Server",
|
||||
@@ -198,7 +205,8 @@
|
||||
"export_logs": "Eksporter logger",
|
||||
"click_for_more_info": "Klikk for mer informasjon",
|
||||
"level": "Nivå",
|
||||
"no_logs_available": "Ingen logger tilgjengelig"
|
||||
"no_logs_available": "Ingen logger tilgjengelig",
|
||||
"delete_all_logs": "Slett alle logger"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Språk",
|
||||
@@ -208,6 +216,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Feil ved sletting av filer",
|
||||
"background_downloads_enabled": "Bakgrunnsnedlastinger aktivert",
|
||||
"background_downloads_disabled": "Bakgrunnsnedlastinger deaktivert",
|
||||
"connected": "Tilkoblet",
|
||||
"could_not_connect": "Kunne ikke koble til",
|
||||
"invalid_url": "Ugyldig URL"
|
||||
@@ -221,6 +231,9 @@
|
||||
"downloads_title": "Nedlastinger",
|
||||
"tvseries": "TV-serier",
|
||||
"movies": "Filmer",
|
||||
"queue": "Kø",
|
||||
"queue_hint": "Kø og nedlastinger vil gå tapt ved omstart av appen",
|
||||
"no_items_in_queue": "Ingen elementer i køen",
|
||||
"no_downloaded_items": "Ingen nedlastede elementer",
|
||||
"delete_all_movies_button": "Slett alle filmer",
|
||||
"delete_all_tvseries_button": "Slett alle TV-serier",
|
||||
@@ -256,7 +269,9 @@
|
||||
"no_response_received_from_server": "Ingen respons mottatt fra serveren",
|
||||
"error_setting_up_the_request": "Feil under oppsett av forespørselen",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Kunne ikke starte nedlasting for {{item}}: Uventet feil",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "En feil oppstod under sletting av filer og jobber"
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobber ble slettet",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "En feil oppstod under sletting av filer og jobber",
|
||||
"go_to_downloads": "Gå til nedlastinger"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -350,8 +365,12 @@
|
||||
"video_has_finished_playing": "Videoen har avsluttet avspilling!",
|
||||
"no_video_source": "Ingen videosource...",
|
||||
"next_episode": "Neste episode",
|
||||
"refresh_tracks": "Oppdater spor",
|
||||
"subtitle_tracks": "Undertekstspor:",
|
||||
"no_data_available": "Ingen data tilgjengelig"
|
||||
"audio_tracks": "Lydspor:",
|
||||
"playback_state": "Avspillingsstatus:",
|
||||
"no_data_available": "Ingen data tilgjengelig",
|
||||
"index": "Indeks:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Neste opp",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Doorgaan & Volgende",
|
||||
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
|
||||
"suggested_movies": "Voorgestelde films",
|
||||
"suggested_episodes": "Voorgestelde Afleveringen",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Welkom bij Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Een gratis en open-source client voor Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Geen",
|
||||
"OnlyForced": "Alleen Geforceerd"
|
||||
},
|
||||
"text_color": "Tekst kleur",
|
||||
"background_color": "Achtergrond Kleur",
|
||||
"outline_color": "Kleur omlijning",
|
||||
"outline_thickness": "Dikte omlijning",
|
||||
"background_opacity": "Transparantie achtergrond",
|
||||
"outline_opacity": "Doorzichtigheid omlijning",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Zwart",
|
||||
"Gray": "Grijs",
|
||||
"Silver": "Zilver",
|
||||
"White": "Wit",
|
||||
"Maroon": "Kastanjebruin",
|
||||
"Red": "Rood",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Geel",
|
||||
"Olive": "Olijf",
|
||||
"Green": "Groen",
|
||||
"Teal": "Groenblauw",
|
||||
"Lime": "Lichtgroen",
|
||||
"Purple": "Paars",
|
||||
"Navy": "Marine",
|
||||
"Blue": "Blauw",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Geen",
|
||||
"Thin": "Dun",
|
||||
"Normal": "normaal",
|
||||
"Thick": "Dikke"
|
||||
},
|
||||
"subtitle_color": "Kleur ondertiteling",
|
||||
"subtitle_background_color": "Achtergrondkleur",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC ondertitel instellingen",
|
||||
"hint": "Aanpassen van ondertiteling voor VLC-speler. Wijzigingen worden toegepast bij het afspelen.",
|
||||
"text_color": "Tekstkleur",
|
||||
"background_color": "Achtergrondkleur",
|
||||
"background_opacity": "Doorzichtigheid achtergrond",
|
||||
"outline_color": "Kleur omlijning",
|
||||
"outline_opacity": "Omtrek opaciteit",
|
||||
"outline_thickness": "Omtrek dikte",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Videospeler",
|
||||
"video_player": "Videospeler",
|
||||
"video_player_description": "Kies welke videospeler gebruikt moet worden op iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Andere",
|
||||
"video_orientation": "Video oriëntatie",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Onbekend"
|
||||
},
|
||||
"safe_area_in_controls": "Veilig gebied in bedieningen",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentele + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Aangepaste menulinks tonen",
|
||||
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
|
||||
"hide_libraries": "Verberg Bibliotheken",
|
||||
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
|
||||
"disable_haptic_feedback": "Haptische feedback uitschakelen",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Max Automatisch Aflevering Aantal",
|
||||
"disabled": "Uitgeschakeld"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Muziek",
|
||||
"playback_title": "Afspelen",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Lees meer over Marlin.",
|
||||
"save_button": "Opslaan",
|
||||
"toasts": {
|
||||
"saved": "Opgeslagen"
|
||||
}
|
||||
"saved": "Opgeslagen",
|
||||
"refreshed": "Instellingen zijn vernieuwd vanaf server"
|
||||
},
|
||||
"refresh_from_server": "Ververs Instellingen van Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats inschakelen",
|
||||
"disable_streamystats": "Streamystats Uitschakelen",
|
||||
"enable_search": "Gebruik voor Zoeken",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Vul de URL van de Streamystats server in. De URL moet http of https bevatten en optioneel de poort.",
|
||||
"read_more_about_streamystats": "Lees Meer over Streamystats.",
|
||||
"save_button": "Opslaan",
|
||||
"save": "Opslaan",
|
||||
"features_title": "Functies",
|
||||
"home_sections_title": "Thuis Secties",
|
||||
"enable_movie_recommendations": "Film Aanbevelingen",
|
||||
"enable_series_recommendations": "Series Aanbevelingen",
|
||||
"enable_promoted_watchlists": "Gepromote Kijklijst",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden",
|
||||
"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}} gecached",
|
||||
"music_cache_cleared": "Muziek cache gewist",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Klik voor meer info",
|
||||
"level": "Niveau",
|
||||
"no_logs_available": "Geen logs beschikbaar"
|
||||
"no_logs_available": "Geen logs beschikbaar",
|
||||
"delete_all_logs": "Alle logs verwijderen"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Talen",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Systeem"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fout bij het verwijderen van bestanden"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "Series",
|
||||
"movies": "Films",
|
||||
"queue": "Wachtrij",
|
||||
"other_media": "Andere media",
|
||||
"queue_hint": "Wachtrij en downloads verdwijnen bij een herstart van de app",
|
||||
"no_items_in_queue": "Geen items in wachtrij",
|
||||
"no_downloaded_items": "Geen gedownloade items",
|
||||
"delete_all_movies_button": "Verwijder alle films",
|
||||
"delete_all_tvseries_button": "Verwijder alle Series",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
|
||||
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
||||
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
||||
"download_deleted": "Download verwijderd",
|
||||
"download_cancelled": "Download geannuleerd",
|
||||
"could_not_delete_download": "Kon download niet verwijderen",
|
||||
"download_paused": "Download gepauzeerd",
|
||||
"could_not_pause_download": "Kan niet pauzeren download",
|
||||
"download_resumed": "Download hervat",
|
||||
"could_not_resume_download": "Kon de download niet hervatten",
|
||||
"download_completed": "Download afgerond",
|
||||
"download_failed": "Download Mislukt",
|
||||
"download_failed_for_item": "Download gefaald voor {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} wordt al gedownload",
|
||||
"all_files_deleted": "Alle Bestanden Succesvol Gedownload",
|
||||
"files_deleted_by_type": "{{count}} {{type}} verwijderd",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle bestanden, mappen en taken succesvol verwijderd",
|
||||
"failed_to_clean_cache_directory": "Opschonen cachemap mislukt",
|
||||
"could_not_get_download_url_for_item": "Kan download-URL voor {{itemName}} niet ophalen",
|
||||
"go_to_downloads": "Ga naar downloads",
|
||||
"file_deleted": "{{item}} verwijderd"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"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"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Zoek...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
|
||||
"message_from_server": "Bericht van de server",
|
||||
"next_episode": "Volgende Aflevering",
|
||||
"refresh_tracks": "Tracks verversen",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Afspeelstatus:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Verder kijken",
|
||||
"go_back": "Terug",
|
||||
"downloaded_file_title": "Je hebt dit bestand gedownload",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Toon meer",
|
||||
"show_less": "Toon minder",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Afspeellijsten",
|
||||
"tracks": "Nummers"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle"
|
||||
},
|
||||
"recently_added": "Recent toegevoegd",
|
||||
"recently_played": "Onlangs afgespeeld",
|
||||
"frequently_played": "Vaak afgespeeld",
|
||||
"explore": "Ontdek",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Afspelen",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"next_up": "Neste",
|
||||
"recently_added_in": "Nyleg lagt til i {{libraryName}}",
|
||||
"suggested_movies": "Foreslåtte filmar",
|
||||
"suggested_episodes": "Foreslåtte episodar",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Velkommen til Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Ein gratis og open kjeldekode-klient for Jellyfin.",
|
||||
@@ -127,6 +128,11 @@
|
||||
"UNKNOWN": "Ukjent"
|
||||
},
|
||||
"safe_area_in_controls": "Trygt område i kontrollane",
|
||||
"video_player": "Videospelar",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Vis Tilpassede Meny Linker",
|
||||
"hide_libraries": "Skjul bibliotek",
|
||||
"select_liraries_you_want_to_hide": "Vel biblioteka du vil skjula frå Bibliotek-fanen og nettsidedelane.",
|
||||
@@ -134,6 +140,7 @@
|
||||
"default_quality": "Standardkvalitet"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedlastingar",
|
||||
"optimized_versions_server": "Optimaliserte versjonar servar",
|
||||
"save_button": "Lagre",
|
||||
"optimized_server": "Optimalisert Servar",
|
||||
@@ -198,7 +205,8 @@
|
||||
"export_logs": "Eksporter loggar",
|
||||
"click_for_more_info": "Klikk for meir informasjon",
|
||||
"level": "Nivå",
|
||||
"no_logs_available": "Ingen loggar tilgjengelege"
|
||||
"no_logs_available": "Ingen loggar tilgjengelege",
|
||||
"delete_all_logs": "Slett alle loggar"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Språk",
|
||||
@@ -208,6 +216,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Feil ved sletting av filer",
|
||||
"background_downloads_enabled": "Bakgrunnsnedlastingar aktiverte",
|
||||
"background_downloads_disabled": "Bakgrunnsnedlastingar deaktiverte",
|
||||
"connected": "Tilkopla",
|
||||
"could_not_connect": "Kunne ikkje kopla til",
|
||||
"invalid_url": "Ugyldig URL"
|
||||
@@ -221,6 +231,9 @@
|
||||
"downloads_title": "Nedlastingar",
|
||||
"tvseries": "TV-seriar",
|
||||
"movies": "Filmar",
|
||||
"queue": "Kø",
|
||||
"queue_hint": "Kø og nedlastingar vil gå tapt ved omstart av appen",
|
||||
"no_items_in_queue": "Ingen element i køen",
|
||||
"no_downloaded_items": "Ingen nedlasta element",
|
||||
"delete_all_movies_button": "Slett alle filmar",
|
||||
"delete_all_tvseries_button": "Slett alle TV-seriar",
|
||||
@@ -256,7 +269,9 @@
|
||||
"no_response_received_from_server": "Ingen respons motteken frå serveren",
|
||||
"error_setting_up_the_request": "Feil under oppsett av førespurnaden",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Kunne ikkje starta nedlasting for {{item}}: Uventa feil",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ein feil oppstod under sletting av filer og jobbar"
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobbar vart sletta",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ein feil oppstod under sletting av filer og jobbar",
|
||||
"go_to_downloads": "Gå til nedlastingar"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -350,8 +365,12 @@
|
||||
"video_has_finished_playing": "Videoen er ferdig avspelt!",
|
||||
"no_video_source": "Ingen videokjelde...",
|
||||
"next_episode": "Neste episode",
|
||||
"refresh_tracks": "Oppdater spor",
|
||||
"subtitle_tracks": "Undertekstspor:",
|
||||
"no_data_available": "Ingen data tilgjengelege"
|
||||
"audio_tracks": "Lydspor:",
|
||||
"playback_state": "Avspelingstatus:",
|
||||
"no_data_available": "Ingen data tilgjengelege",
|
||||
"index": "Indeks:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Neste opp",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Nylig lagt til i {{libraryName}}",
|
||||
"suggested_movies": "Foreslåtte filmer",
|
||||
"suggested_episodes": "Foreslåtte episoder",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Velkommen til Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "En gratis og Open-Source klient for Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Ingen",
|
||||
"OnlyForced": "Enkelt"
|
||||
},
|
||||
"text_color": "Tekst farge",
|
||||
"background_color": "Bakgrunnsfarge",
|
||||
"outline_color": "Omrissets farge",
|
||||
"outline_thickness": "Omriss Tykkelse",
|
||||
"background_opacity": "Bakgrunns gjennomsiktighet",
|
||||
"outline_opacity": "Omrissets gjennomsiktighet",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Svart",
|
||||
"Gray": "Grå",
|
||||
"Silver": "Sølv",
|
||||
"White": "Hvit",
|
||||
"Maroon": "Rødbrun",
|
||||
"Red": "Rød",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Gul",
|
||||
"Olive": "Olivengrønn",
|
||||
"Green": "Grønn",
|
||||
"Teal": "Blågrønn",
|
||||
"Lime": "Limegrønn",
|
||||
"Purple": "Lilla",
|
||||
"Navy": "Marineblå",
|
||||
"Blue": "Blå",
|
||||
"Aqua": "Vann"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Ingen",
|
||||
"Thin": "Tynn",
|
||||
"Normal": "Vanlig",
|
||||
"Thick": "Tykk"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Annet",
|
||||
"video_orientation": "Video Retning",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Ukjent"
|
||||
},
|
||||
"safe_area_in_controls": "Sikker sone i kontroller",
|
||||
"video_player": "Video Spiller",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (eksperimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Vis tilpassede menylenker",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Skjul biblioteker",
|
||||
"select_liraries_you_want_to_hide": "Velg bibliotekene du vil skjule deg for Biblioteket og avsnittene for hjemmesider.",
|
||||
"disable_haptic_feedback": "Deaktiver Haptisk tilbakemelding",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Maks automatisk avspilling Episode Telling",
|
||||
"disabled": "Deaktivert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedlastinger"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Les mer om Marlin.",
|
||||
"save_button": "Lagre",
|
||||
"toasts": {
|
||||
"saved": "Lagret"
|
||||
}
|
||||
"saved": "Lagret",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Slett alle nedlastede filer",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Eksportlogger",
|
||||
"click_for_more_info": "Klikk for mer info",
|
||||
"level": "Nivå",
|
||||
"no_logs_available": "Ingen logger tilgjengelig"
|
||||
"no_logs_available": "Ingen logger tilgjengelig",
|
||||
"delete_all_logs": "Slett alle loggene"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Språk",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Systemadministrasjon"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Feil ved sletting av filer"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Nedlastinger",
|
||||
"tvseries": "TV-Serier",
|
||||
"movies": "Filmer",
|
||||
"queue": "Kø",
|
||||
"other_media": "Andre medier",
|
||||
"queue_hint": "Kø og nedlastinger vil gå tapt når appen startes på nytt",
|
||||
"no_items_in_queue": "Ingen elementer i køen",
|
||||
"no_downloaded_items": "Ingen nedlastede elementer",
|
||||
"delete_all_movies_button": "Slett alle filmer",
|
||||
"delete_all_tvseries_button": "Slett alle TV-Serier",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-Serier",
|
||||
"deleted_media_successfully": "Slettet andre media vellykket!",
|
||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||
"download_deleted": "Nedlasting slettet",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Kunne ikke slette nedlasting",
|
||||
"download_paused": "Last ned Pauset",
|
||||
"could_not_pause_download": "Kunne ikke pause nedlasting",
|
||||
"download_resumed": "Nedlastingen er gjenopptatt",
|
||||
"could_not_resume_download": "Kunne ikke fortsette nedlasting",
|
||||
"download_completed": "Nedlasting fullført",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Nedlasting feilet for {{item}} – {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle filer, mapper og jobber slettet",
|
||||
"failed_to_clean_cache_directory": "Klarte ikke å tømme mellomlagermappen",
|
||||
"could_not_get_download_url_for_item": "Kunne ikke hente nedlastings-URL for {{itemName}}",
|
||||
"go_to_downloads": "Gå til nedlastinger",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Søk...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast",
|
||||
"message_from_server": "Melding fra tjener: {{message}}",
|
||||
"next_episode": "Neste Episode",
|
||||
"refresh_tracks": "Oppdater sporing",
|
||||
"audio_tracks": "Lyd Tracks:",
|
||||
"playback_state": "Avspillingsstatus:",
|
||||
"index": "Indeks:",
|
||||
"continue_watching": "Fortsett å se",
|
||||
"go_back": "Gå tilbake",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Vis mer",
|
||||
"show_less": "Vis mindre",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Oglądaj dalej i Następne",
|
||||
"recently_added_in": "Ostatnio dodano w {{libraryName}}",
|
||||
"suggested_movies": "Sugerowane filmy",
|
||||
"suggested_episodes": "Sugerowane odcinki",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Witamy w Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Darmowy i otwartoźródłowy klient dla Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Brak",
|
||||
"OnlyForced": "Tylko wymuszone"
|
||||
},
|
||||
"text_color": "Kolor tekstu",
|
||||
"background_color": "Kolor tła",
|
||||
"outline_color": "Kolor konturu",
|
||||
"outline_thickness": "Grubość konturu",
|
||||
"background_opacity": "Przezroczystość tła",
|
||||
"outline_opacity": "Przezroczystość konturu",
|
||||
"bold_text": "Tekst pogrubiony",
|
||||
"colors": {
|
||||
"Black": "Czarny",
|
||||
"Gray": "Szary",
|
||||
"Silver": "Srebro",
|
||||
"White": "Biały",
|
||||
"Maroon": "Bordowy",
|
||||
"Red": "Czerwony",
|
||||
"Fuchsia": "Fuksja",
|
||||
"Yellow": "Żółty",
|
||||
"Olive": "Oliwki",
|
||||
"Green": "Zielony",
|
||||
"Teal": "Turkusowy",
|
||||
"Lime": "Limonkowy",
|
||||
"Purple": "Fioletowy",
|
||||
"Navy": "Granatowy",
|
||||
"Blue": "Niebieski",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Brak",
|
||||
"Thin": "Cienka",
|
||||
"Normal": "Normalny",
|
||||
"Thick": "Gruba"
|
||||
},
|
||||
"subtitle_color": "Kolor napisów",
|
||||
"subtitle_background_color": "Kolor tła",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "Ustawienia napisów VLC",
|
||||
"hint": "Personalizuj wygląd napisów dla odtwarzacza VLC. Zmiany zajdą przy następnym odtwarzaniu.",
|
||||
"text_color": "Kolor tekstu",
|
||||
"background_color": "Kolor tła",
|
||||
"background_opacity": "Przezroczystość tła",
|
||||
"outline_color": "Kolor obrysu",
|
||||
"outline_opacity": "Przezroczystość obrysu",
|
||||
"outline_thickness": "Grubość obrysu",
|
||||
"bold": "Pogrubiony tekst",
|
||||
"margin": "Dolny margines"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Odtwarzacz wideo",
|
||||
"video_player": "Odtwarzacz wideo",
|
||||
"video_player_description": "Wybierz którego odtwarzacza wideo używać w iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Inne",
|
||||
"video_orientation": "Orientacja wideo",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Nieznana"
|
||||
},
|
||||
"safe_area_in_controls": "Bezpieczny obszar w kontrolkach",
|
||||
"video_player": "Odtwarzacz wideo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperymentalny + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu",
|
||||
"show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)",
|
||||
"hide_libraries": "Ukryj biblioteki",
|
||||
"select_liraries_you_want_to_hide": "Wybierz biblioteki, które chcesz ukryć na karcie Biblioteka i w sekcjach strony głównej.",
|
||||
"disable_haptic_feedback": "Wyłącz wibracje",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Maksymalna liczba odcinków automatycznego odtwarzania",
|
||||
"disabled": "Wyłączone"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Pobieranie"
|
||||
},
|
||||
"music": {
|
||||
"title": "Muzyka",
|
||||
"playback_title": "Odtwarzanie",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Dowiedz się więcej o Marlin.",
|
||||
"save_button": "Zapisz",
|
||||
"toasts": {
|
||||
"saved": "Zapisano"
|
||||
}
|
||||
"saved": "Zapisano",
|
||||
"refreshed": "Ustawienia odświeżone z serwera"
|
||||
},
|
||||
"refresh_from_server": "Odśwież ustawienia z serwera"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Włącz Streamystats",
|
||||
"disable_streamystats": "Wyłącz Streamystats",
|
||||
"enable_search": "Używaj do wyszukiwania",
|
||||
"url": "Adres URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Wprowadź adres URL dla twojego serwera Streamystats. URL powinien zawierać http lub https i opcjonalnie port.",
|
||||
"read_more_about_streamystats": "Dowiedz się więcej o Streamystats.",
|
||||
"save_button": "Zapisz",
|
||||
"save": "Zapisz",
|
||||
"features_title": "Funkcje",
|
||||
"home_sections_title": "Sekcja główna",
|
||||
"enable_movie_recommendations": "Rekomendacje filmów",
|
||||
"enable_series_recommendations": "Rekomendację seriali",
|
||||
"enable_promoted_watchlists": "Promowane listy oglądania",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Odśwież ustawienia z serwera"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Aktywuj naszą integrację Listy Oglądania"
|
||||
"watchlist_enabler": "Aktywuj naszą integrację Listy Oglądania",
|
||||
"watchlist_button": "Przelącz integrację Listy Oglądania"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Usuń wszystkie pobrane pliki",
|
||||
"music_cache_title": "Bufor muzyki",
|
||||
"music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline",
|
||||
"enable_music_cache": "Włącz bufor muzyki",
|
||||
"clear_music_cache": "Wyczyść bufor muzyki",
|
||||
"music_cache_size": "Zbuforowano {{size}}",
|
||||
"music_cache_cleared": "Wyczyszczono bufor muzyki",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Eksportuj logi",
|
||||
"click_for_more_info": "Kliknij po więcej informacji",
|
||||
"level": "Poziom",
|
||||
"no_logs_available": "Brak dostępnych logów"
|
||||
"no_logs_available": "Brak dostępnych logów",
|
||||
"delete_all_logs": "Usuń wszystkie logi"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Języki",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Błąd podczas usuwania plików"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Pobrane",
|
||||
"tvseries": "Seriale",
|
||||
"movies": "Filmy",
|
||||
"queue": "Kolejka",
|
||||
"other_media": "Inne media",
|
||||
"queue_hint": "Kolejka i pobierania zostaną utracone po ponownym uruchomieniu aplikacji",
|
||||
"no_items_in_queue": "Brak elementów w kolejce",
|
||||
"no_downloaded_items": "Brak pobranych elementów",
|
||||
"delete_all_movies_button": "Usuń wszystkie filmy",
|
||||
"delete_all_tvseries_button": "Usuń wszystkie seriale",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Nie udało się usunąć wszystkich seriali",
|
||||
"deleted_media_successfully": "Pomyślnie usunięto inne media!",
|
||||
"failed_to_delete_media": "Nie udało się usunąć innych mediów",
|
||||
"download_deleted": "Pobieranie usunięte",
|
||||
"download_cancelled": "Pobieranie anulowane",
|
||||
"could_not_delete_download": "Nie można usunąć pobrania",
|
||||
"download_paused": "Pobieranie wstrzymane",
|
||||
"could_not_pause_download": "Nie można wstrzymać pobierania",
|
||||
"download_resumed": "Pobieranie wznowione",
|
||||
"could_not_resume_download": "Nie można wznowić pobierania",
|
||||
"download_completed": "Pobieranie zakończone",
|
||||
"download_failed": "Pobieranie nie powiodło się",
|
||||
"download_failed_for_item": "Pobieranie nie powiodło się dla {{item}} – {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} jest w trakcie pobierania",
|
||||
"all_files_deleted": "Pomyślnie usunięto wszystkie pobrane",
|
||||
"files_deleted_by_type": "{{count}} {{type}} usunięto",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Wszystkie pliki, foldery i zadania zostały pomyślnie usunięte",
|
||||
"failed_to_clean_cache_directory": "Nie udało się wyczyścić katalogu pamięci podręcznej",
|
||||
"could_not_get_download_url_for_item": "Nie można pobrać adresu URL dla {{itemName}}",
|
||||
"go_to_downloads": "Przejdź do pobranych",
|
||||
"file_deleted": "Usunięto {{item}}"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"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"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Szukaj...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta",
|
||||
"message_from_server": "Wiadomość z serwera: {{message}}",
|
||||
"next_episode": "Następny odcinek",
|
||||
"refresh_tracks": "Odśwież ścieżki",
|
||||
"audio_tracks": "Ścieżki audio:",
|
||||
"playback_state": "Stan odtwarzania:",
|
||||
"index": "Indeks:",
|
||||
"continue_watching": "Kontynuuj oglądanie",
|
||||
"go_back": "Wstecz",
|
||||
"downloaded_file_title": "Ten plik masz już pobrany",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Pokaż więcej",
|
||||
"show_less": "Pokaż mniej",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlisty",
|
||||
"tracks": "utwory"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Wszystkie"
|
||||
},
|
||||
"recently_added": "Ostatnio dodano",
|
||||
"recently_played": "Ostatnio odtwarzano",
|
||||
"frequently_played": "Często odtwarzane",
|
||||
"explore": "Odkrywaj",
|
||||
"top_tracks": "Popularne utwory",
|
||||
"play": "Odtwórz",
|
||||
"shuffle": "Losuj",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"next_up": "Próximo em",
|
||||
"recently_added_in": "Adicionados recentemente em {{libraryName}}",
|
||||
"suggested_movies": "Filmes sugeridos",
|
||||
"suggested_episodes": "Episódios sugeridos",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Bem-vindo ao Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Um cliente gratuito e de código livre para o Jellyfin.",
|
||||
@@ -127,6 +128,11 @@
|
||||
"UNKNOWN": "Desconhecido"
|
||||
},
|
||||
"safe_area_in_controls": "Área segura nos controles",
|
||||
"video_player": "Tocador de vídeo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostrar Custom Links no Menu",
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
"select_liraries_you_want_to_hide": "Selecione as bibliotecas que você deseja ocultar das abas Biblioteca e Início.",
|
||||
@@ -135,6 +141,7 @@
|
||||
"disabled": "Desativado"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"optimized_versions_server": "Servidor do optimized versions",
|
||||
"save_button": "Salvar",
|
||||
"optimized_server": "Optimized Server",
|
||||
@@ -196,7 +203,8 @@
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Logs",
|
||||
"no_logs_available": "Sem logs disponíveis"
|
||||
"no_logs_available": "Sem logs disponíveis",
|
||||
"delete_all_logs": "Remover todos os logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Idiomas",
|
||||
@@ -206,6 +214,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Erro ao remover arquivos",
|
||||
"background_downloads_enabled": "Downloads em segundo plano ativado",
|
||||
"background_downloads_disabled": "Downloads em segundo plano desativado",
|
||||
"connected": "Conectado",
|
||||
"could_not_connect": "Não foi possível conectar",
|
||||
"invalid_url": "URL inválida"
|
||||
@@ -219,6 +229,9 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV/Séries",
|
||||
"movies": "Filmes",
|
||||
"queue": "Fila",
|
||||
"queue_hint": "A fila e os downloads serão perdidos ao reiniciar o aplicativo",
|
||||
"no_items_in_queue": "Nenhum item na fila",
|
||||
"no_downloaded_items": "Nenhum item baixado",
|
||||
"delete_all_movies_button": "Remover todos os filmes",
|
||||
"delete_all_tvseries_button": "Remover todos as TV/Séries",
|
||||
@@ -254,7 +267,9 @@
|
||||
"no_response_received_from_server": "Sem resposta recebida do servidor",
|
||||
"error_setting_up_the_request": "Erro ao configurar a solicitação",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Falha ao iniciar o download de {{item}}: Erro inesperado",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ocorreu um erro ao remover os arquivos e jobs"
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Todos arquivos, pastas e jobs removidos com sucesso",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ocorreu um erro ao remover os arquivos e jobs",
|
||||
"go_to_downloads": "Vá para downloads"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -348,8 +363,12 @@
|
||||
"video_has_finished_playing": "O vídeo terminou!",
|
||||
"no_video_source": "Nenhuma fonte de vídeo...",
|
||||
"next_episode": "Próximo episódio",
|
||||
"refresh_tracks": "Atualizar faixas",
|
||||
"subtitle_tracks": "Faixas da legenda:",
|
||||
"audio_tracks": "Faixas do áudio:",
|
||||
"playback_state": "Playback State:",
|
||||
"no_data_available": "Nenhum dado disponível",
|
||||
"index": "Índice:",
|
||||
"continue_watching": "Continuar assistindo",
|
||||
"go_back": "Voltar"
|
||||
},
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continuar e Próximo",
|
||||
"recently_added_in": "Adicionado recentemente em {{libraryName}}",
|
||||
"suggested_movies": "Filmes Sugeridos",
|
||||
"suggested_episodes": "Episódios sugeridos",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Bem-vindo ao Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Um Cliente de código aberto e gratuito para o Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Nenhuma",
|
||||
"OnlyForced": "Somente Forçado"
|
||||
},
|
||||
"text_color": "Cor do texto",
|
||||
"background_color": "Cor de fundo",
|
||||
"outline_color": "Cor do contorno",
|
||||
"outline_thickness": "Espessura do Contorno",
|
||||
"background_opacity": "Opacidade de fundo",
|
||||
"outline_opacity": "Opacidade do Contorno",
|
||||
"bold_text": "Texto em negrito",
|
||||
"colors": {
|
||||
"Black": "Preto",
|
||||
"Gray": "Cinzento",
|
||||
"Silver": "Prata",
|
||||
"White": "Branco",
|
||||
"Maroon": "Castanho",
|
||||
"Red": "Vermelho",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Amarelo",
|
||||
"Olive": "Verde-oliva",
|
||||
"Green": "Verde",
|
||||
"Teal": "Verde-azulado",
|
||||
"Lime": "Verde-limão",
|
||||
"Purple": "Roxo",
|
||||
"Navy": "Azul-marinho",
|
||||
"Blue": "Azul",
|
||||
"Aqua": "Água"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Nenhuma",
|
||||
"Thin": "Magro",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Grosso"
|
||||
},
|
||||
"subtitle_color": "Cor da legenda",
|
||||
"subtitle_background_color": "Cor de fundo",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Reprodutor de Vídeo",
|
||||
"video_player": "Reprodutor de Vídeo",
|
||||
"video_player_description": "Escolha qual player de vídeo usar no iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Outros",
|
||||
"video_orientation": "Orientação do Vídeo",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Desconhecido"
|
||||
},
|
||||
"safe_area_in_controls": "Área segura nos controles",
|
||||
"video_player": "Reprodutor de Vídeo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostrar Links de Menu Personalizado",
|
||||
"show_large_home_carousel": "Mostrar Carrossel Grande (beta)",
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
"select_liraries_you_want_to_hide": "Selecione as bibliotecas que você deseja ocultar da aba Biblioteca e seções da página inicial.",
|
||||
"disable_haptic_feedback": "Desativar o retorno tátil",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Contagem máxima de episódios de reprodução automática",
|
||||
"disabled": "Desabilitado"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Música",
|
||||
"playback_title": "Reproduzir",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Leia mais sobre Marlin.",
|
||||
"save_button": "Salvar",
|
||||
"toasts": {
|
||||
"saved": "Salvo"
|
||||
}
|
||||
"saved": "Salvo",
|
||||
"refreshed": "Configurações atualizadas do servidor"
|
||||
},
|
||||
"refresh_from_server": "Atualizar as configurações do servidor"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Ativar Streamystats",
|
||||
"disable_streamystats": "Desativar streamystats",
|
||||
"enable_search": "Usar para Pesquisa",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Digite a URL para seu servidor de StreamyStats. A URL deve incluir http ou https e, opcionalmente, a porta.",
|
||||
"read_more_about_streamystats": "Leia mais sobre Streamystats.",
|
||||
"save_button": "Salvar",
|
||||
"save": "Salvar",
|
||||
"features_title": "Funcionalidades",
|
||||
"home_sections_title": "Seções da Página Inicial",
|
||||
"enable_movie_recommendations": "Recomendações de filmes",
|
||||
"enable_series_recommendations": "Recomendações de Séries",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Atualizar Configurações do Servidor"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Ative nossa integração de Lista de Interesses"
|
||||
"watchlist_enabler": "Ative nossa integração de Lista de Interesses",
|
||||
"watchlist_button": "Ativar/desativar Lista de Interesses"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Excluir todos os arquivos baixados",
|
||||
"music_cache_title": "Cache de Música",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Habilitar Cache de Música",
|
||||
"clear_music_cache": "Limpar Cache de Música",
|
||||
"music_cache_size": "{{size}} em cache",
|
||||
"music_cache_cleared": "Cache de música limpo",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Exportar logs",
|
||||
"click_for_more_info": "Clique para mais informações",
|
||||
"level": "Nível",
|
||||
"no_logs_available": "Não há registros disponíveis"
|
||||
"no_logs_available": "Não há registros disponíveis",
|
||||
"delete_all_logs": "Excluir todos os registros"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Idiomas",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Erro ao excluir arquivos"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Séries",
|
||||
"movies": "Filmes",
|
||||
"queue": "Fila",
|
||||
"other_media": "Outras mídias",
|
||||
"queue_hint": "A fila e os downloads serão perdidos ao reiniciar o aplicativo",
|
||||
"no_items_in_queue": "Nenhum item na fila",
|
||||
"no_downloaded_items": "Nenhum item baixado",
|
||||
"delete_all_movies_button": "Excluir todos os filmes",
|
||||
"delete_all_tvseries_button": "Excluir todas as séries",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Falha ao excluir todas as séries",
|
||||
"deleted_media_successfully": "Outras mídias excluídas com sucesso!",
|
||||
"failed_to_delete_media": "Falha ao excluir outras mídias",
|
||||
"download_deleted": "Download Excluído",
|
||||
"download_cancelled": "Download Cancelado",
|
||||
"could_not_delete_download": "Não foi possível excluir o download",
|
||||
"download_paused": "Download Pausado",
|
||||
"could_not_pause_download": "Não foi possível Pausar o Download",
|
||||
"download_resumed": "Download Retomado",
|
||||
"could_not_resume_download": "Não foi possível retomar o download",
|
||||
"download_completed": "Download concluído",
|
||||
"download_failed": "Download Falhou",
|
||||
"download_failed_for_item": "Download Falhou para {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} já está sendo baixado",
|
||||
"all_files_deleted": "Todos os Downloads Excluídos com Sucesso",
|
||||
"files_deleted_by_type": "{{count}} {{type}} excluído",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Todos os arquivos, pastas e trabalhos excluídos com sucesso",
|
||||
"failed_to_clean_cache_directory": "Falha ao limpar o diretório de cache",
|
||||
"could_not_get_download_url_for_item": "Não foi possível obter o URL de download para {{itemName}}",
|
||||
"go_to_downloads": "Ir para Downloads",
|
||||
"file_deleted": "{{item}} deletado"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "Nenhum",
|
||||
"track": "Faixa",
|
||||
"cancel": "Cancelar",
|
||||
"stop": "Stop",
|
||||
"delete": "Apagar",
|
||||
"ok": "OK",
|
||||
"remove": "Remover",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Buscar...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast",
|
||||
"message_from_server": "Mensagem do Servidor: {{message}}",
|
||||
"next_episode": "Próximo Episódio",
|
||||
"refresh_tracks": "Atualizar Faixas",
|
||||
"audio_tracks": "Faixas de Áudio:",
|
||||
"playback_state": "Estado de Reprodução:",
|
||||
"index": "Índice",
|
||||
"continue_watching": "Continuar assistindo",
|
||||
"go_back": "Voltar atrás",
|
||||
"downloaded_file_title": "Você já fez o download deste arquivo",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Mostrar mais",
|
||||
"show_less": "Mostrar menos",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "faixas"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Tudo"
|
||||
},
|
||||
"recently_added": "Adicionado recentemente",
|
||||
"recently_played": "Reproduzido Recentemente",
|
||||
"frequently_played": "Reproduzidos com frequência",
|
||||
"explore": "Explorar",
|
||||
"top_tracks": "Músicas populares",
|
||||
"play": "Reproduzir",
|
||||
"shuffle": "Alteatório",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Adăugat recent în {{libraryName}}",
|
||||
"suggested_movies": "Filme sugerate",
|
||||
"suggested_episodes": "Episoade sugerate",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Bun venit la Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Un client gratuit și open-source pentru Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Niciuna",
|
||||
"OnlyForced": "OnlyForced"
|
||||
},
|
||||
"text_color": "Culoare text",
|
||||
"background_color": "Culoare fundal",
|
||||
"outline_color": "Culoare contur",
|
||||
"outline_thickness": "Grosime contur",
|
||||
"background_opacity": "Opacitatea fundalului",
|
||||
"outline_opacity": "Opacitatea conturului",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Negru",
|
||||
"Gray": "Gri",
|
||||
"Silver": "Argint",
|
||||
"White": "Alb",
|
||||
"Maroon": "Maro",
|
||||
"Red": "Roșu",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Galben",
|
||||
"Olive": "Oliv",
|
||||
"Green": "Verde",
|
||||
"Teal": "Turcoaz",
|
||||
"Lime": "Verde-Deschis",
|
||||
"Purple": "Violet",
|
||||
"Navy": "Marină",
|
||||
"Blue": "Albastru",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Nimic",
|
||||
"Thin": "Subțire",
|
||||
"Normal": "Normală",
|
||||
"Thick": "Grozav"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Altele",
|
||||
"video_orientation": "Orientarea video",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Necunoscut"
|
||||
},
|
||||
"safe_area_in_controls": "Zona sigură pentru controale",
|
||||
"video_player": "Player video",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Afișează link-uri personalizate în meniu",
|
||||
"show_large_home_carousel": "Arată Caruselul Media Mare (beta)",
|
||||
"hide_libraries": "Ascunde bibliotecile",
|
||||
"select_liraries_you_want_to_hide": "Selectează bibliotecile pe care dorești să le ascunzi din fila Bibliotecă și din secțiunile paginii principale.",
|
||||
"disable_haptic_feedback": "Dezactivează vibrațiile tactile",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Maxim episoade redare automată",
|
||||
"disabled": "Dezactivat"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descărcări"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Citește mai multe despre Marlin.",
|
||||
"save_button": "Salvează",
|
||||
"toasts": {
|
||||
"saved": "Salvat"
|
||||
}
|
||||
"saved": "Salvat",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Ștergeți toate fișierele descărcate",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export loguri",
|
||||
"click_for_more_info": "Apasă pt mai multe informații",
|
||||
"level": "Nivel",
|
||||
"no_logs_available": "Niciun log disponibil"
|
||||
"no_logs_available": "Niciun log disponibil",
|
||||
"delete_all_logs": "Șterge toate logurile"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Limbi",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Sistem"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Eroare la ștergerea fișierelor"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Descărcări",
|
||||
"tvseries": "Seriale",
|
||||
"movies": "Filme",
|
||||
"queue": "Coadă",
|
||||
"other_media": "Alte suporturi",
|
||||
"queue_hint": "Descărcările se vor pierde la repornirea aplicației",
|
||||
"no_items_in_queue": "Niciun articol în coadă",
|
||||
"no_downloaded_items": "Niciun element descărcat",
|
||||
"delete_all_movies_button": "Șterge toate filmele",
|
||||
"delete_all_tvseries_button": "Șterge toate serialele",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Nu s-au putut șterge toate serialele",
|
||||
"deleted_media_successfully": "Alte fișiere șterse cu succes!",
|
||||
"failed_to_delete_media": "Ștergerea altor fișiere media a eșuat",
|
||||
"download_deleted": "Descărcare ştearsă",
|
||||
"download_cancelled": "Descărcare anulată",
|
||||
"could_not_delete_download": "Nu s-a putut șterge descărcarea",
|
||||
"download_paused": "Descărcare întreruptă",
|
||||
"could_not_pause_download": "Nu s-a putut întrerupe descărcarea",
|
||||
"download_resumed": "Descărcare din nou",
|
||||
"could_not_resume_download": "Nu s-a putut relua descărcarea",
|
||||
"download_completed": "Descărcare completă",
|
||||
"download_failed": "Descărcare eșuată",
|
||||
"download_failed_for_item": "Descărcarea a eșuat {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} se descarcă deja",
|
||||
"all_files_deleted": "Toate descărcările au fost șterse cu succes",
|
||||
"files_deleted_by_type": "{{count}} {{type}} au fost șterse",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Toate fișierele, folderele și lucrările au fost șterse cu succes",
|
||||
"failed_to_clean_cache_directory": "Curățarea directorului cache a eșuat",
|
||||
"could_not_get_download_url_for_item": "Nu s-a putut obține URL-ul de descărcare pentru {{itemName}}",
|
||||
"go_to_downloads": "Accesați descărcările",
|
||||
"file_deleted": "{{item}} șters"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"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"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Caută...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Nu s-a putut crea un flux pentru Chromecast",
|
||||
"message_from_server": "Mesaj de la server: {{message}}",
|
||||
"next_episode": "Episodul următor",
|
||||
"refresh_tracks": "Reîmprospătare piese",
|
||||
"audio_tracks": "Audio:",
|
||||
"playback_state": "Stare de redare:",
|
||||
"index": "Indice:",
|
||||
"continue_watching": "Continuă să vizionezi",
|
||||
"go_back": "Înapoi",
|
||||
"downloaded_file_title": "Aveţi acest fişier descărcat",
|
||||
@@ -664,6 +761,7 @@
|
||||
"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",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Продолжить и Далее",
|
||||
"recently_added_in": "Недавно добавлено в {{libraryName}}",
|
||||
"suggested_movies": "Предложенные фильмы",
|
||||
"suggested_episodes": "Предложенные серии",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Добро пожаловать в Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Отсутствует",
|
||||
"OnlyForced": "Только принудительные"
|
||||
},
|
||||
"text_color": "Цвет текста",
|
||||
"background_color": "Цвет фона",
|
||||
"outline_color": "Цвет контура",
|
||||
"outline_thickness": "Толщина контура",
|
||||
"background_opacity": "Прозрачность фона",
|
||||
"outline_opacity": "Прозрачность контура",
|
||||
"bold_text": "Жирный",
|
||||
"colors": {
|
||||
"Black": "Черный",
|
||||
"Gray": "Серый",
|
||||
"Silver": "Серебристый",
|
||||
"White": "Белый",
|
||||
"Maroon": "Бордовый",
|
||||
"Red": "Красный",
|
||||
"Fuchsia": "Пурпурный",
|
||||
"Yellow": "Жёлтый",
|
||||
"Olive": "Оливковый",
|
||||
"Green": "Зелёный",
|
||||
"Teal": "Бирюзовый",
|
||||
"Lime": "Лаймовый",
|
||||
"Purple": "Фиолетовый",
|
||||
"Navy": "Тёмно-синий",
|
||||
"Blue": "Синий",
|
||||
"Aqua": "Голубой"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Отсутствует",
|
||||
"Thin": "Тонкий",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "Настройки субтитров в VLC",
|
||||
"hint": "Настройте внешний вид субтитров в VLC плеере. Изменения применятся при следующем воспроизведении.",
|
||||
"text_color": "Цвет текста",
|
||||
"background_color": "Цвет фона",
|
||||
"background_opacity": "Прозрачность фона",
|
||||
"outline_color": "Цвет контура",
|
||||
"outline_opacity": "Прозрачность контура",
|
||||
"outline_thickness": "Толщина контура",
|
||||
"bold": "Жирный",
|
||||
"margin": "Отступ снизу"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Видео плеер",
|
||||
"video_player": "Видео плеер",
|
||||
"video_player_description": "Выберите видео плеер в iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Другое",
|
||||
"video_orientation": "Ориентация видео",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Неизвестное"
|
||||
},
|
||||
"safe_area_in_controls": "Безопасная зона в элементах управления",
|
||||
"video_player": "Видео плеер",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Показать ссылки пользовательского меню",
|
||||
"show_large_home_carousel": "Показывать большую карусель (beta)",
|
||||
"hide_libraries": "Скрыть библиотеки",
|
||||
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
|
||||
"disable_haptic_feedback": "Отключить тактильную обратную связь",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Максимальное количество авто воспроизводимых эпизодов",
|
||||
"disabled": "Отключено"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Загрузки"
|
||||
},
|
||||
"music": {
|
||||
"title": "Музыка",
|
||||
"playback_title": "Воспроизведение",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Узнать больше о Marlin.",
|
||||
"save_button": "Сохранить",
|
||||
"toasts": {
|
||||
"saved": "Сохранено"
|
||||
}
|
||||
"saved": "Сохранено",
|
||||
"refreshed": "Настройки обновлены с сервера"
|
||||
},
|
||||
"refresh_from_server": "Обновить настройки с сервера"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Включить Streamystats",
|
||||
"disable_streamystats": "Выключить Streamystats",
|
||||
"enable_search": "Использовать в поиске",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Введите URL вашего сервера Streamystats. URL должен включать http/https и порт при необходимости.",
|
||||
"read_more_about_streamystats": "Узнать больше про Streamystats.",
|
||||
"save_button": "Сохранить",
|
||||
"save": "Сохранить",
|
||||
"features_title": "Функции",
|
||||
"home_sections_title": "Показывать на главной",
|
||||
"enable_movie_recommendations": "Рекомендации фильмов",
|
||||
"enable_series_recommendations": "Рекомендации сериалов",
|
||||
"enable_promoted_watchlists": "Продвигаемые списки просмотра",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Обновить настройки с сервера"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Включить интеграцию со списками просмотра"
|
||||
"watchlist_enabler": "Включить интеграцию со списками просмотра",
|
||||
"watchlist_button": "Изменить интеграцию со списками просмотра"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Удалить все загруженные файлы",
|
||||
"music_cache_title": "Кеш музыки",
|
||||
"music_cache_description": "Автоматически кешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
|
||||
"enable_music_cache": "Кешировать музыку",
|
||||
"clear_music_cache": "Очистить кеш музыки",
|
||||
"music_cache_size": "Кешировано: {{size}}",
|
||||
"music_cache_cleared": "Кеш музыки очищен",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Сохранить логи",
|
||||
"click_for_more_info": "Нажмите для получения дополнительной информации",
|
||||
"level": "Уровень",
|
||||
"no_logs_available": "Логи не доступны"
|
||||
"no_logs_available": "Логи не доступны",
|
||||
"delete_all_logs": "Удалить все логи"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Языки",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Системный"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Ошибка при удалении файлов"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Загрузки",
|
||||
"tvseries": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"queue": "Очередь",
|
||||
"other_media": "Прочие файлы",
|
||||
"queue_hint": "Очередь очистится после перезапуска",
|
||||
"no_items_in_queue": "Нет элементов в очереди",
|
||||
"no_downloaded_items": "Нет загруженных файлов",
|
||||
"delete_all_movies_button": "Удалить все фильмы",
|
||||
"delete_all_tvseries_button": "Удалить все сериалы",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
|
||||
"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": "Не удалось возобновить загрузку",
|
||||
"download_completed": "Завершено",
|
||||
"download_failed": "Не удалось загрузить",
|
||||
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} уже загружается",
|
||||
"all_files_deleted": "Все загрузки удалены",
|
||||
"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}}",
|
||||
"go_to_downloads": "В загрузки",
|
||||
"file_deleted": "Удалено: {{item}}"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "Отсутствует",
|
||||
"track": "Трек",
|
||||
"cancel": "Отмена",
|
||||
"stop": "Stop",
|
||||
"delete": "Удалить",
|
||||
"ok": "ОК",
|
||||
"remove": "Удалить",
|
||||
"next": "Вперед",
|
||||
"back": "Назад",
|
||||
"continue": "Продолжить",
|
||||
"verifying": "Проверка...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Поиск...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
|
||||
"message_from_server": "Сообщение от сервера: {{message}}",
|
||||
"next_episode": "Следующая серия",
|
||||
"refresh_tracks": "Обновить дорожки",
|
||||
"audio_tracks": "Аудио дорожки:",
|
||||
"playback_state": "Состояние воспроизведения:",
|
||||
"index": "Индекс:",
|
||||
"continue_watching": "Продолжить просмотр",
|
||||
"go_back": "Назад",
|
||||
"downloaded_file_title": "Этот файл уже скачан",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Показать больше",
|
||||
"show_less": "Показать меньше",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Плейлисты",
|
||||
"tracks": "треки"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все"
|
||||
},
|
||||
"recently_added": "Недавно добавлено",
|
||||
"recently_played": "Недавно воспроизведено",
|
||||
"frequently_played": "Часто играет",
|
||||
"explore": "Найти новое",
|
||||
"top_tracks": "Топ",
|
||||
"play": "Воспроизвести",
|
||||
"shuffle": "Перемешать",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"next_up": "I ardhshëm",
|
||||
"recently_added_in": "Shtuar kohët e fundit në {{libraryName}}",
|
||||
"suggested_movies": "Filma të sugjeruar",
|
||||
"suggested_episodes": "Episodat të sugjeruara",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Mirë se vini në Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Një klient falas dhe me burim të hapur për Jellyfin.",
|
||||
@@ -127,6 +128,11 @@
|
||||
"UNKNOWN": "E panjohur"
|
||||
},
|
||||
"safe_area_in_controls": "Zonë e sigurt në kontrolla",
|
||||
"video_player": "Video lexues",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimentale + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Shfaq lidhje menuje të personalizuara",
|
||||
"hide_libraries": "Fsheh bibliotekat",
|
||||
"select_liraries_you_want_to_hide": "Zgjidhni bibliotekat që dëshironi të fshehni nga skeda e Bibliotekut dhe seksionet e faqes kryesore.",
|
||||
@@ -134,6 +140,7 @@
|
||||
"default_quality": "Kvaliteti standard"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Shkarkime",
|
||||
"optimized_versions_server": "Serveri i versioneve të optimizuara",
|
||||
"save_button": "Ruaj",
|
||||
"optimized_server": "Server i optimizuar",
|
||||
@@ -198,7 +205,8 @@
|
||||
"export_logs": "Eksporto regjistrin",
|
||||
"click_for_more_info": "Kliko për më shumë informacion",
|
||||
"level": "Nivele",
|
||||
"no_logs_available": "Nuk ka regjistrime të disponueshme"
|
||||
"no_logs_available": "Nuk ka regjistrime të disponueshme",
|
||||
"delete_all_logs": "Fshijë të gjitha regjistrimet"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Gjuhët",
|
||||
@@ -208,6 +216,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Gabim gjatë fshirjes së skedarëve",
|
||||
"background_downloads_enabled": "Shkarkimet në sfond aktivizuar",
|
||||
"background_downloads_disabled": "Shkarkimet në sfond deaktivizuar",
|
||||
"connected": "Lidhur",
|
||||
"could_not_connect": "Nuk u mundet te vendoset kyqja",
|
||||
"invalid_url": "URL i pavlefshme"
|
||||
@@ -221,6 +231,9 @@
|
||||
"downloads_title": "Shkarkimet",
|
||||
"tvseries": "Seriale TV",
|
||||
"movies": "Filma",
|
||||
"queue": "Rradhë",
|
||||
"queue_hint": "Rradhat dhe shkarkimet do të humbasin pas genstartit të aplikacionit",
|
||||
"no_items_in_queue": "Nuk ka elemente në rradhë",
|
||||
"no_downloaded_items": "Nuk ka shkarkime",
|
||||
"delete_all_movies_button": "Fshijë të gjithë filmat",
|
||||
"delete_all_tvseries_button": "Fshijë të gjitha serialet TV",
|
||||
@@ -256,7 +269,9 @@
|
||||
"no_response_received_from_server": "Nuk u mor asnjë përgjigje nga serveri",
|
||||
"error_setting_up_the_request": "Gabim gjatë konfigurimit të kërkesës",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Dështoj fillimi i shkarkimit për {{item}}: Gabim i papritur",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ndodhi një gabim gjatë fshirjes së skedarëve dhe detyrave"
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Të gjitha skedarët, dosjet dhe detyrat u fshinë me sukses",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ndodhi një gabim gjatë fshirjes së skedarëve dhe detyrave",
|
||||
"go_to_downloads": "Shko te shkarkimet"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -350,8 +365,12 @@
|
||||
"video_has_finished_playing": "Videoja ka përfunduar shfaqjen!",
|
||||
"no_video_source": "Asnjë burim video...",
|
||||
"next_episode": "Epizoda e ardhshme",
|
||||
"refresh_tracks": "Rifresko shtigjet",
|
||||
"subtitle_tracks": "Shtigjet e nënteksteve:",
|
||||
"no_data_available": "Nuk ka të dhëna të disponueshme"
|
||||
"audio_tracks": "Shtigjet audio:",
|
||||
"playback_state": "Gjendja e rishikimit:",
|
||||
"no_data_available": "Nuk ka të dhëna të disponueshme",
|
||||
"index": "Indeksi:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "E ardhshme",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Fortsätt titta & nästa avsnitt",
|
||||
"recently_added_in": "Nyligen tillagt i {{libraryName}}",
|
||||
"suggested_movies": "Filmförslag",
|
||||
"suggested_episodes": "Föreslagna avsnitt",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Välkommen till Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "En gratis klient för Jellyfin med öppen källkod.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Inga",
|
||||
"OnlyForced": "Bara Tvingande"
|
||||
},
|
||||
"text_color": "Textfärg",
|
||||
"background_color": "Bakgrundsfärg",
|
||||
"outline_color": "Konturfärg",
|
||||
"outline_thickness": "Konturtjocklek",
|
||||
"background_opacity": "Bakgrundsgenomskinlighet",
|
||||
"outline_opacity": "Kontursgenomskinlighet",
|
||||
"bold_text": "FetStil",
|
||||
"colors": {
|
||||
"Black": "Svart",
|
||||
"Gray": "Grå",
|
||||
"Silver": "Silver",
|
||||
"White": "Vit",
|
||||
"Maroon": "Rödbrun",
|
||||
"Red": "Röd",
|
||||
"Fuchsia": "Purpur",
|
||||
"Yellow": "Gul",
|
||||
"Olive": "Olivgrön",
|
||||
"Green": "Grön",
|
||||
"Teal": "Turkos",
|
||||
"Lime": "Limegrön",
|
||||
"Purple": "Lila",
|
||||
"Navy": "Marinblå",
|
||||
"Blue": "Blå",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Inget",
|
||||
"Thin": "Tunn",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Tjock"
|
||||
},
|
||||
"subtitle_color": "Undertextfärg",
|
||||
"subtitle_background_color": "Bakgrundsfärg",
|
||||
"subtitle_font": "Typsnitt för undertexter",
|
||||
"ksplayer_title": "KSPlayer-inställningar",
|
||||
"hardware_decode": "Hårdvaruavkodning",
|
||||
"hardware_decode_description": "Använd hårdvaruacceleration för videoavkodning. Inaktivera om du upplever uppspelningsproblem.",
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Ange din OpenSubtitles API-nyckel för att aktivera klientbaserad undertextsökning som reserv när din Jellyfin-server inte har en undertextleverantör konfigurerad.",
|
||||
"opensubtitles_api_key": "API-nyckel",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Botten"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC undertextsinställningar",
|
||||
"hint": "Anpassa undertextens utseende för VLC-spelare. Förändringar träder i kraft vid nästa uppspelning.",
|
||||
"text_color": "Textfärg",
|
||||
"background_color": "Bakgrundsfärg",
|
||||
"background_opacity": "Bakgrundsgenomskinlighet",
|
||||
"outline_color": "Konturfärg",
|
||||
"outline_opacity": "Kontursgenomskinlighet",
|
||||
"outline_thickness": "Konturtjocklek",
|
||||
"bold": "FetStil",
|
||||
"margin": "Nedre marginal"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Videospelare",
|
||||
"video_player": "Videospelare",
|
||||
"video_player_description": "Välj vilken videospelare som ska användas på iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Övrigt",
|
||||
"video_orientation": "Videoriktning",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Okänt"
|
||||
},
|
||||
"safe_area_in_controls": "Säkert område i kontrollerna",
|
||||
"video_player": "Videospelare",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Visa anpassade menylänkar",
|
||||
"show_large_home_carousel": "Visa toppbanner (beta)",
|
||||
"hide_libraries": "Dölj bibliotek",
|
||||
"select_liraries_you_want_to_hide": "Välj de bibliotek du vill dölja på fliken Bibliotek och på startsidan.",
|
||||
"disable_haptic_feedback": "Stäng av vibrationer",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Antal Avsnitt för Automatisk Uppspelning",
|
||||
"disabled": "Inaktiverad"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Nedladdningar"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musik",
|
||||
"playback_title": "Uppspelning",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Läs mer om Marlin.",
|
||||
"save_button": "Spara",
|
||||
"toasts": {
|
||||
"saved": "Sparade"
|
||||
}
|
||||
"saved": "Sparade",
|
||||
"refreshed": "Inställningarna uppdateras från servern"
|
||||
},
|
||||
"refresh_from_server": "Uppdatera inställningar från server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Aktivera Streamystats",
|
||||
"disable_streamystats": "Inaktivera Streamystats",
|
||||
"enable_search": "Använd för sökning",
|
||||
"url": "Webbadress",
|
||||
"server_url_placeholder": "http(s)://streamystats.exempel.se",
|
||||
"streamystats_search_hint": "Ange URL för Marlin-servern. URL bör innehålla http eller https och vid behov port.",
|
||||
"read_more_about_streamystats": "Läs mer om Streamystats.",
|
||||
"save_button": "Spara",
|
||||
"save": "Spara",
|
||||
"features_title": "Funktioner",
|
||||
"home_sections_title": "Hemsektioner",
|
||||
"enable_movie_recommendations": "Filmrekommendationer",
|
||||
"enable_series_recommendations": "serierekommendationer",
|
||||
"enable_promoted_watchlists": "rekommenderade listor att titta på",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Uppdatera inställningar från server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Aktivera vår bevakningslista integration"
|
||||
"watchlist_enabler": "Aktivera vår bevakningslista integration",
|
||||
"watchlist_button": "sätt på/av bevakningslisteintegrationen"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Ta bort alla nerladdade filer",
|
||||
"music_cache_title": "Musikcache",
|
||||
"music_cache_description": "Cacha automatiskt låtar när du lyssnar för smidigare uppspelning och offline-stöd",
|
||||
"enable_music_cache": "Aktivera musikcache",
|
||||
"clear_music_cache": "Rensa musikcache",
|
||||
"music_cache_size": "{{size}} cachad",
|
||||
"music_cache_cleared": "Musikcache rensad",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Exportera Loggar",
|
||||
"click_for_more_info": "Klicka för mer Information",
|
||||
"level": "Nivå",
|
||||
"no_logs_available": "Inga Loggar Tillgängliga"
|
||||
"no_logs_available": "Inga Loggar Tillgängliga",
|
||||
"delete_all_logs": "Ta Bort Alla Loggar"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Språk",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fel Vid Borttagning Av Filer"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Nedladdningar",
|
||||
"tvseries": "TV-Serier",
|
||||
"movies": "Filmer",
|
||||
"queue": "Kö",
|
||||
"other_media": "Annan media",
|
||||
"queue_hint": "Kö och nedladdningar kommer försvinna vid omstart av appen",
|
||||
"no_items_in_queue": "Inga objekt i Kön",
|
||||
"no_downloaded_items": "Inga Nedladdade Objekt",
|
||||
"delete_all_movies_button": "Ta Bort Alla Filmer",
|
||||
"delete_all_tvseries_button": "Ta Bort Alla TV-Serier",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Det Gick Inte Att Ta Bort Alla TV-Serier",
|
||||
"deleted_media_successfully": "Andra Medier Har Tagits Bort!",
|
||||
"failed_to_delete_media": "Kunde Inte Ta Bort Andra Medier",
|
||||
"download_deleted": "Nedladdning Borttagen",
|
||||
"download_cancelled": "Nerladdningen Avbruten",
|
||||
"could_not_delete_download": "Kunde Inte Ta Bort Nedladdning",
|
||||
"download_paused": "Nedladdning Pausad",
|
||||
"could_not_pause_download": "Kunde Inte Pausa Nedladdning",
|
||||
"download_resumed": "Nedladdning Återupptagen",
|
||||
"could_not_resume_download": "Kunde Inte Återuppta Nedladdning",
|
||||
"download_completed": "Nedladdning Slutförd",
|
||||
"download_failed": "Nerladdningen misslyckades",
|
||||
"download_failed_for_item": "Nedladdning misslyckades för {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} Laddas redan ner",
|
||||
"all_files_deleted": "Alla nedladdningar raderades",
|
||||
"files_deleted_by_type": "{{count}} {{type}} Raderad",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alla filer, mappar och jobb har tagits bort",
|
||||
"failed_to_clean_cache_directory": "Det gick inte att rensa cachemappen",
|
||||
"could_not_get_download_url_for_item": "Kunde inte hämta nedladdnings-URL för {{itemName}}",
|
||||
"go_to_downloads": "Gå till nedladdningar",
|
||||
"file_deleted": "{{item}} Raderad"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "Ingen",
|
||||
"track": "Spår",
|
||||
"cancel": "Avbryt",
|
||||
"stop": "Stoppa",
|
||||
"delete": "Ta bort",
|
||||
"ok": "OK",
|
||||
"remove": "Radera",
|
||||
"next": "Nästa",
|
||||
"back": "Tillbaka",
|
||||
"continue": "Fortsätt",
|
||||
"verifying": "Verifierar...",
|
||||
"login": "Logga in"
|
||||
"login": "Logga in",
|
||||
"refresh": "Uppdatera"
|
||||
},
|
||||
"search": {
|
||||
"search": "Sök...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Kunde inte skapa stream för Chromecast",
|
||||
"message_from_server": "Meddelande från servern: {{message}}",
|
||||
"next_episode": "Nästa avsnitt",
|
||||
"refresh_tracks": "Uppdatera spår",
|
||||
"audio_tracks": "Ljudspår:",
|
||||
"playback_state": "Uppspelningsstatus:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Fortsätt titta",
|
||||
"go_back": "Tillbaka",
|
||||
"downloaded_file_title": "Du har denna fil nedladdad",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Visa Mer",
|
||||
"show_less": "Visa Mindre",
|
||||
"left": "kvar",
|
||||
"more_info": "Mer info",
|
||||
"director": "Regissör",
|
||||
"cast": "Skådespelare",
|
||||
"technical_details": "Tekniska detaljer",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Spellistor",
|
||||
"tracks": "spår"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alla"
|
||||
},
|
||||
"recently_added": "Nyligen tillagt",
|
||||
"recently_played": "Nyligen spelat",
|
||||
"frequently_played": "Spelas ofta",
|
||||
"explore": "Utforska",
|
||||
"top_tracks": "Toppspår",
|
||||
"play": "Spela",
|
||||
"shuffle": "Blanda spår",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"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": "Welcome to Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "None",
|
||||
"OnlyForced": "OnlyForced"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"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": "สีน้ำเงิน",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "None",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Other",
|
||||
"video_orientation": "Video Orientation",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"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 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",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Read More About Marlin.",
|
||||
"save_button": "Save",
|
||||
"toasts": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export Logs",
|
||||
"click_for_more_info": "Click for More Info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "No Logs Available"
|
||||
"no_logs_available": "No Logs Available",
|
||||
"delete_all_logs": "Delete All Logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Languages",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error Deleting Files"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"movies": "Movies",
|
||||
"queue": "Queue",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||
"no_items_in_queue": "No Items in Queue",
|
||||
"no_downloaded_items": "No Downloaded Items",
|
||||
"delete_all_movies_button": "Delete All Movies",
|
||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Download Completed",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Go to Downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "num tu'lu' {{libraryName}}",
|
||||
"suggested_movies": "rutlh DIS",
|
||||
"suggested_episodes": "rutlh Hem",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Streamyfin yI'el!",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Jellyfin lut 'el je'be' 'ej wang.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "pagh",
|
||||
"OnlyForced": "Dun je'"
|
||||
},
|
||||
"text_color": "GhItlh rIt",
|
||||
"background_color": "Tlhagh rIt",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"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": "pagh",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "patlh",
|
||||
"video_orientation": "mu'tlhegh pegh",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Sovbe'"
|
||||
},
|
||||
"safe_area_in_controls": "SeHlawDaq yot QIH",
|
||||
"video_player": "mu'tlhegh tlholwI'",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (PiP mIwHa')"
|
||||
},
|
||||
"show_custom_menu_links": "menuDaq ret teqlu' yInej",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "De'wI' bom yIQIj",
|
||||
"select_liraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
|
||||
"disable_haptic_feedback": "Qub quvHa' yIQIj",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Qaw' Doch"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Marlin latlh yIlaD",
|
||||
"save_button": "yIqIp",
|
||||
"toasts": {
|
||||
"saved": "qIp"
|
||||
}
|
||||
"saved": "qIp",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Hoch Qaw' Doch yIQaw'",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "De' qon yISamqa'",
|
||||
"click_for_more_info": "latlh De' yIchIch",
|
||||
"level": "quv",
|
||||
"no_logs_available": "De' qon pagh"
|
||||
"no_logs_available": "De' qon pagh",
|
||||
"delete_all_logs": "Hoch De' qon yIQaw'"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Holmey",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "mIw'a'"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Qaw' ghIq"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Qaw' Doch",
|
||||
"tvseries": "TV Hem",
|
||||
"movies": "DIS",
|
||||
"queue": "ghom",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "ghun ghImDI' ghom Qaw'laH.",
|
||||
"no_items_in_queue": "ghom Doch pagh",
|
||||
"no_downloaded_items": "Qaw' Doch pagh",
|
||||
"delete_all_movies_button": "Hoch DIS yIQaw'",
|
||||
"delete_all_tvseries_button": "Hoch TV Hem yIQaw'",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Hoch TV Hem Qaw'laHbe'",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Qaw' ghIm",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Qaw' Qapla'",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "{{item}} Qaw'laHbe' - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Hoch De', ram 'ej vum Qaw' Qapla'",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Qaw' Doch yIghoS",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "yISam...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Chromecast tlhol ret qonlaHbe'",
|
||||
"message_from_server": "Ho'Do' veS jach: {{message}}",
|
||||
"next_episode": "wej HemHom",
|
||||
"refresh_tracks": "ret yIchu'qa'",
|
||||
"audio_tracks": "QoQ ret:",
|
||||
"playback_state": "tlhol mIw:",
|
||||
"index": "nem:",
|
||||
"continue_watching": "tlhol yIHaDqa'",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "latlh yIHoch",
|
||||
"show_less": "Hom yIHoch",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "İzlemeye Devam Et & Sıradakiler",
|
||||
"recently_added_in": "{{libraryName}} Kütüphanesine Son Eklenenler",
|
||||
"suggested_movies": "Önerilen Filmler",
|
||||
"suggested_episodes": "Önerilen Bölümler",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Streamyfin'e Hoş Geldiniz",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Jellyfin için ücretsiz ve açık kaynak bir istemci.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Yok",
|
||||
"OnlyForced": "Sadece Zorunlu"
|
||||
},
|
||||
"text_color": "Metin Rengi",
|
||||
"background_color": "Arkaplan Rengi",
|
||||
"outline_color": "Kenarlık Rengi",
|
||||
"outline_thickness": "Kenarlık kalınlığı",
|
||||
"background_opacity": "Arkaplan Opaklığı",
|
||||
"outline_opacity": "Kenarlık Opaklığı",
|
||||
"bold_text": "Kalın Metin",
|
||||
"colors": {
|
||||
"Black": "Siyah",
|
||||
"Gray": "Gri",
|
||||
"Silver": "Gümüş",
|
||||
"White": "Beyaz",
|
||||
"Maroon": "Kestane",
|
||||
"Red": "Kırmızı",
|
||||
"Fuchsia": "Fuşya",
|
||||
"Yellow": "Sarı",
|
||||
"Olive": "Zeytin yeşili",
|
||||
"Green": "Yeşil",
|
||||
"Teal": "Deniz mavisi",
|
||||
"Lime": "Limon",
|
||||
"Purple": "Mor",
|
||||
"Navy": "Lacivert",
|
||||
"Blue": "Mavi",
|
||||
"Aqua": "Açık Mavi"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Hiçbiri",
|
||||
"Thin": "İnce",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Kalın"
|
||||
},
|
||||
"subtitle_color": "Altyazı Rengi",
|
||||
"subtitle_background_color": "Arkaplan Rengi",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Altyazı Ayarları",
|
||||
"hint": "VLC oynatıcı için altyazı görünümünü değiştirin. Değişiklikler bir sonraki oynatmada etkili olacak.",
|
||||
"text_color": "Metin Rengi",
|
||||
"background_color": "Arkaplan Rengi",
|
||||
"background_opacity": "Arkaplan Opaklığı",
|
||||
"outline_color": "Kenarlık Rengi",
|
||||
"outline_opacity": "Kenarlık Opaklığı",
|
||||
"outline_thickness": "Kenarlık Kalınlığı",
|
||||
"bold": "Kalın Metin",
|
||||
"margin": "Alt Kenar Boşluğu"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video oynatıcısı",
|
||||
"video_player": "Video oynatıcısı",
|
||||
"video_player_description": "iOS'da hangi video oynatıcının kullanılacağını seçin.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Diğer",
|
||||
"video_orientation": "Video Yönü",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Bilinmeyen"
|
||||
},
|
||||
"safe_area_in_controls": "Kontrollerde Güvenli Alan",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Deneysel + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Kütüphaneleri Gizle",
|
||||
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
|
||||
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "En Fazla Otomatik Oynatılacak Bölüm Sayısı",
|
||||
"disabled": "Devre dışı"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirmeler"
|
||||
},
|
||||
"music": {
|
||||
"title": "Müzik",
|
||||
"playback_title": "Oynatma",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Marlin hakkında daha fazla oku.",
|
||||
"save_button": "Kaydet",
|
||||
"toasts": {
|
||||
"saved": "Kaydedildi"
|
||||
}
|
||||
"saved": "Kaydedildi",
|
||||
"refreshed": "Ayarlar sunucudan yeniden alındı"
|
||||
},
|
||||
"refresh_from_server": "Ayarları Sunucudan Yeniden Al"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats'ı Etkinleştir",
|
||||
"disable_streamystats": "Streamystats'ı Devre Dışı Bırak",
|
||||
"enable_search": "Arama için kullan",
|
||||
"url": "URL Adresi",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Streamystats sunucu URL'sini girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
|
||||
"read_more_about_streamystats": "Streamystats hakkında daha fazla bilgi.",
|
||||
"save_button": "Kaydet",
|
||||
"save": "Kaydet",
|
||||
"features_title": "Özellikler",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Film Önerileri",
|
||||
"enable_series_recommendations": "Dizi Önerileri",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Ayarları Sunucudan Yeniden Al"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil",
|
||||
"music_cache_title": "Müzik Ön Belleği",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Müzik Ön Belleğini Etkinleştir",
|
||||
"clear_music_cache": "Müzik Ön Belleğini Temizle",
|
||||
"music_cache_size": "{{size}} ön belleklendi",
|
||||
"music_cache_cleared": "Müzik ön belleği temizlendi",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Düzey",
|
||||
"no_logs_available": "Günlükler mevcut değil"
|
||||
"no_logs_available": "Günlükler mevcut değil",
|
||||
"delete_all_logs": "Tüm günlükleri sil"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Diller",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Sistem"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Dosyalar silinirken hata oluştu"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "İndirilenler",
|
||||
"tvseries": "Diziler",
|
||||
"movies": "Filmler",
|
||||
"queue": "Sıra",
|
||||
"other_media": "Diğer medya",
|
||||
"queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır",
|
||||
"no_items_in_queue": "Sırada öğe yok",
|
||||
"no_downloaded_items": "İndirilen öğe yok",
|
||||
"delete_all_movies_button": "Tüm Filmleri Sil",
|
||||
"delete_all_tvseries_button": "Tüm Dizileri Sil",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Diziler silinemedi",
|
||||
"deleted_media_successfully": "Diğer medya başarıyla silindi!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "İndirme silindi",
|
||||
"download_cancelled": "İndirme iptal edildi",
|
||||
"could_not_delete_download": "İndirme Silinemedi",
|
||||
"download_paused": "İndirme Duraklatıldı",
|
||||
"could_not_pause_download": "İndirme Duraklatılamadı",
|
||||
"download_resumed": "İndirme Devam Ediyor",
|
||||
"could_not_resume_download": "İndirme Devam Ettirilemedi",
|
||||
"download_completed": "İndirme tamamlandı",
|
||||
"download_failed": "İndirme başarısız oldu",
|
||||
"download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} zaten indiriliyor",
|
||||
"all_files_deleted": "Bütün indirilenler başarıyla silindi",
|
||||
"files_deleted_by_type": "{{count}} {{type}} silindi",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tüm dosyalar, klasörler ve işler başarıyla silindi",
|
||||
"failed_to_clean_cache_directory": "Önbellek dizini temizlenemedi",
|
||||
"could_not_get_download_url_for_item": "{{itemName}} için indirme URL'si alınamadı",
|
||||
"go_to_downloads": "İndirmelere git",
|
||||
"file_deleted": "{{item}} silindi"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"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"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Ara...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
|
||||
"message_from_server": "Sunucudan mesaj: {{message}}",
|
||||
"next_episode": "Sonraki bölüm",
|
||||
"refresh_tracks": "Parçaları yenile",
|
||||
"audio_tracks": "Ses Parçaları:",
|
||||
"playback_state": "Oynatma Durumu:",
|
||||
"index": "İndeks:",
|
||||
"continue_watching": "İzlemeye devam et",
|
||||
"go_back": "Geri",
|
||||
"downloaded_file_title": "Bu dosya indirilmiş",
|
||||
@@ -664,6 +761,7 @@
|
||||
"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",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Çalma listeleri",
|
||||
"tracks": "parçalar"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Tümü"
|
||||
},
|
||||
"recently_added": "Son Eklenenler",
|
||||
"recently_played": "Son Oynatılanlar",
|
||||
"frequently_played": "Sık Oynatılanlar",
|
||||
"explore": "Keşfet",
|
||||
"top_tracks": "En Popülar Parçalar",
|
||||
"play": "Oynat",
|
||||
"shuffle": "Karıştır",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Нещодавно додане до \"{{libraryName}}\"",
|
||||
"suggested_movies": "Рекомендовані Фільми",
|
||||
"suggested_episodes": "Рекомендовані Епізоди",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Вітаємо у Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Вільний і open-source клієнт для Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Някий",
|
||||
"OnlyForced": "Виключно Форсовані"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"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": "None",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Інші",
|
||||
"video_orientation": "Орієнтація відео",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Невідомо"
|
||||
},
|
||||
"safe_area_in_controls": "Безпечна зона в елементах керування",
|
||||
"video_player": "Відео плеєр",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Показати користувацькі посилання меню",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Сховати медіатеки",
|
||||
"select_liraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
|
||||
"disable_haptic_feedback": "Вимкнути тактильний зворотний зв'язок",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Вимкнено"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Завантаження"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Дізнайтеся більше про Marlin.",
|
||||
"save_button": "Зберегти",
|
||||
"toasts": {
|
||||
"saved": "Збережено"
|
||||
}
|
||||
"saved": "Збережено",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Видалити усі завантаженні файли",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Нема доступних журналів"
|
||||
"no_logs_available": "Нема доступних журналів",
|
||||
"delete_all_logs": "Видалити усі журнали"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Мова",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Системна"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Помилка при видалені файлів"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Завантаження",
|
||||
"tvseries": "ТБ-Серіали",
|
||||
"movies": "Фільми",
|
||||
"queue": "Черга",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Черга і завантаження буде втрачене при перезапуску застосунку",
|
||||
"no_items_in_queue": "Нема елементів в черзі",
|
||||
"no_downloaded_items": "Нема завантажених елементів",
|
||||
"delete_all_movies_button": "Видалити всі Фільми",
|
||||
"delete_all_tvseries_button": "Видалити всі ТБ-Серіали",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Не вдалося видалити всі телесеріали",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Завантаження скасоване",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Завантаження завершено",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Не вдалося завантажити {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Усі файли, папки та завдання успішно видалено",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Перейти до завантаження",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Шукати...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
|
||||
"message_from_server": "Повідомлення від серверу: {{message}}",
|
||||
"next_episode": "Наступний Епізод",
|
||||
"refresh_tracks": "Оновити доріжки",
|
||||
"audio_tracks": "Аудіо-доріжки:",
|
||||
"playback_state": "Стан відтворення:",
|
||||
"index": "Індекс:",
|
||||
"continue_watching": "Продовжити перегляд",
|
||||
"go_back": "Назад",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Показати більше",
|
||||
"show_less": "Показати менше",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Mới thêm trong {{libraryName}}",
|
||||
"suggested_movies": "Phim gợi ý",
|
||||
"suggested_episodes": "Tập gợi ý",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Chào mừng đến với Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Một ứng dụng miễn phí và mã nguồn mở cho Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "Không hiển thị",
|
||||
"OnlyForced": "Bắt buộc"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"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": "Không có",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Khác",
|
||||
"video_orientation": "Hướng video",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Không rõ"
|
||||
},
|
||||
"safe_area_in_controls": "Vùng an toàn trong điều khiển",
|
||||
"video_player": "Trình phát video",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Thử nghiệm + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Hiện liên kết tùy chỉnh",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Ẩn thư viện",
|
||||
"select_liraries_you_want_to_hide": "Chọn các thư viện muốn ẩn khỏi mục Thư viện và Trang chủ.",
|
||||
"disable_haptic_feedback": "Tắt phản hồi rung",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Số tập tự chạy tối đa",
|
||||
"disabled": "Đã tắt"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Tải xuống"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Tìm hiểu thêm về Marlin.",
|
||||
"save_button": "Lưu",
|
||||
"toasts": {
|
||||
"saved": "Đã lưu"
|
||||
}
|
||||
"saved": "Đã lưu",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Xóa toàn bộ tập tin đã tải",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Xuất nhật ký",
|
||||
"click_for_more_info": "Nhấn để xem thêm thông tin",
|
||||
"level": "Mức độ",
|
||||
"no_logs_available": "Không có nhật ký"
|
||||
"no_logs_available": "Không có nhật ký",
|
||||
"delete_all_logs": "Xóa tất cả nhật ký"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Ngôn ngữ",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "Hệ thống"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Lỗi khi xóa tập tin"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Tải xuống",
|
||||
"tvseries": "Chương trình TV",
|
||||
"movies": "Phim",
|
||||
"queue": "Hàng đợi",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Hàng đợi và tải xuống sẽ bị mất khi khởi động lại ứng dụng",
|
||||
"no_items_in_queue": "Không có mục trong hàng đợi",
|
||||
"no_downloaded_items": "Không có mục đã tải",
|
||||
"delete_all_movies_button": "Xóa tất cả phim",
|
||||
"delete_all_tvseries_button": "Xóa tất cả chương trình TV",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Xóa chương trình TV thất bại",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Tải xuống bị hủy",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Tải xuống hoàn tất",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Tải {{item}} thất bại – {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Đã xóa thành công tất cả tập tin, thư mục và công việc",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Tới phần tải về",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Tìm...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Không thể tạo luồng cho Chromecast",
|
||||
"message_from_server": "Thông báo từ máy chủ: {{message}}",
|
||||
"next_episode": "Tập tiếp theo",
|
||||
"refresh_tracks": "Làm mới các track",
|
||||
"audio_tracks": "Track âm thanh:",
|
||||
"playback_state": "Trạng thái phát:",
|
||||
"index": "Chỉ mục:",
|
||||
"continue_watching": "Tiếp tục xem",
|
||||
"go_back": "Quay lại",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Xem thêm",
|
||||
"show_less": "Thu gọn",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"next_up": "下一个",
|
||||
"recently_added_in": "最近添加于 {{libraryName}}",
|
||||
"suggested_movies": "推荐电影",
|
||||
"suggested_episodes": "推荐剧集",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "欢迎来到 Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "一个免费且开源的 Jellyfin 客户端。",
|
||||
@@ -127,12 +128,18 @@
|
||||
"UNKNOWN": "未知"
|
||||
},
|
||||
"safe_area_in_controls": "控制中的安全区域",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "显示自定义菜单链接",
|
||||
"hide_libraries": "隐藏媒体库",
|
||||
"select_liraries_you_want_to_hide": "选择您想从媒体库页面和主页隐藏的媒体库。",
|
||||
"disable_haptic_feedback": "禁用触觉反馈"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下载",
|
||||
"optimized_versions_server": "Optimized Version 服务器",
|
||||
"save_button": "保存",
|
||||
"optimized_server": "Optimized Server",
|
||||
@@ -197,7 +204,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "无可用日志"
|
||||
"no_logs_available": "无可用日志",
|
||||
"delete_all_logs": "删除所有日志"
|
||||
},
|
||||
"languages": {
|
||||
"title": "语言",
|
||||
@@ -207,6 +215,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "删除文件时出错",
|
||||
"background_downloads_enabled": "后台下载已启用",
|
||||
"background_downloads_disabled": "后台下载已禁用",
|
||||
"connected": "已连接",
|
||||
"could_not_connect": "无法连接",
|
||||
"invalid_url": "无效 URL"
|
||||
@@ -216,6 +226,9 @@
|
||||
"downloads_title": "下载",
|
||||
"tvseries": "剧集",
|
||||
"movies": "电影",
|
||||
"queue": "队列",
|
||||
"queue_hint": "应用重启后队列和下载将会丢失",
|
||||
"no_items_in_queue": "队列中无项目",
|
||||
"no_downloaded_items": "无已下载项目",
|
||||
"delete_all_movies_button": "删除所有电影",
|
||||
"delete_all_tvseries_button": "删除所有剧集",
|
||||
@@ -251,7 +264,9 @@
|
||||
"no_response_received_from_server": "未收到服务器响应",
|
||||
"error_setting_up_the_request": "设置请求时出错",
|
||||
"failed_to_start_download_for_item_unexpected_error": "无法开始下载 {{item}}: 发生意外错误",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "删除文件和任务时发生错误"
|
||||
"all_files_folders_and_jobs_deleted_successfully": "所有文件、文件夹和任务成功删除",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "删除文件和任务时发生错误",
|
||||
"go_to_downloads": "前往下载"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -345,8 +360,12 @@
|
||||
"video_has_finished_playing": "视频播放完成!",
|
||||
"no_video_source": "无视频来源...",
|
||||
"next_episode": "下一集",
|
||||
"refresh_tracks": "刷新轨道",
|
||||
"subtitle_tracks": "字幕轨道:",
|
||||
"audio_tracks": "音频轨道:",
|
||||
"playback_state": "播放状态:",
|
||||
"no_data_available": "无可用数据",
|
||||
"index": "索引:",
|
||||
"continue_watching": "继续观看",
|
||||
"go_back": "返回"
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"next_up": "下一個",
|
||||
"recently_added_in": "最近添加於 {{libraryName}}",
|
||||
"suggested_movies": "推薦電影",
|
||||
"suggested_episodes": "推薦劇集",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "歡迎來到 Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "一個免費且開源的 Jellyfin 客戶端。",
|
||||
@@ -127,6 +128,11 @@
|
||||
"UNKNOWN": "未知"
|
||||
},
|
||||
"safe_area_in_controls": "控制中的安全區域",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "顯示自定義菜單鏈接",
|
||||
"hide_libraries": "隱藏媒體庫",
|
||||
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
|
||||
@@ -136,6 +142,7 @@
|
||||
"disabled": "已停用"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下載",
|
||||
"optimized_versions_server": "Optimized Version 伺服器",
|
||||
"save_button": "保存",
|
||||
"optimized_server": "Optimized Server",
|
||||
@@ -200,7 +207,8 @@
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "無可用日誌"
|
||||
"no_logs_available": "無可用日誌",
|
||||
"delete_all_logs": "刪除所有日誌"
|
||||
},
|
||||
"languages": {
|
||||
"title": "語言",
|
||||
@@ -210,6 +218,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "刪除文件時出錯",
|
||||
"background_downloads_enabled": "背景下載已啟用",
|
||||
"background_downloads_disabled": "背景下載已禁用",
|
||||
"connected": "已連接",
|
||||
"could_not_connect": "無法連接",
|
||||
"invalid_url": "無效的 URL"
|
||||
@@ -223,6 +233,9 @@
|
||||
"downloads_title": "下載",
|
||||
"tvseries": "電視劇",
|
||||
"movies": "電影",
|
||||
"queue": "隊列",
|
||||
"queue_hint": "應用重啟後隊列和下載將會丟失",
|
||||
"no_items_in_queue": "隊列中無項目",
|
||||
"no_downloaded_items": "無已下載項目",
|
||||
"delete_all_movies_button": "刪除所有電影",
|
||||
"delete_all_tvseries_button": "刪除所有電視劇",
|
||||
@@ -258,7 +271,9 @@
|
||||
"no_response_received_from_server": "未收到伺服器的響應",
|
||||
"error_setting_up_the_request": "設置請求時出錯",
|
||||
"failed_to_start_download_for_item_unexpected_error": "無法開始下載 {{item}}: 發生意外錯誤",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "刪除文件和任務時發生錯誤"
|
||||
"all_files_folders_and_jobs_deleted_successfully": "所有文件、文件夾和任務成功刪除",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "刪除文件和任務時發生錯誤",
|
||||
"go_to_downloads": "前往下載"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -352,8 +367,12 @@
|
||||
"video_has_finished_playing": "影片播放完畢!",
|
||||
"no_video_source": "無影片來源...",
|
||||
"next_episode": "下一集",
|
||||
"refresh_tracks": "刷新軌道",
|
||||
"subtitle_tracks": "字幕軌道:",
|
||||
"audio_tracks": "音頻軌道:",
|
||||
"playback_state": "播放狀態:",
|
||||
"no_data_available": "無可用數據",
|
||||
"index": "索引:",
|
||||
"continue_watching": "繼續觀看",
|
||||
"go_back": "返回"
|
||||
},
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"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": "Welcome to Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||
@@ -260,6 +261,43 @@
|
||||
"None": "None",
|
||||
"OnlyForced": "OnlyForced"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"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": "None",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
@@ -277,6 +315,25 @@
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"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": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Other",
|
||||
"video_orientation": "Video Orientation",
|
||||
@@ -294,7 +351,13 @@
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"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 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",
|
||||
@@ -304,6 +367,9 @@
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
@@ -347,18 +413,23 @@
|
||||
"read_more_about_marlin": "Read More About Marlin.",
|
||||
"save_button": "Save",
|
||||
"toasts": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"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": "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",
|
||||
@@ -374,7 +445,8 @@
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration"
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -385,6 +457,7 @@
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
||||
"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",
|
||||
@@ -394,6 +467,8 @@
|
||||
"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."
|
||||
},
|
||||
"intro": {
|
||||
@@ -406,7 +481,8 @@
|
||||
"export_logs": "Export Logs",
|
||||
"click_for_more_info": "Click for More Info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "No Logs Available"
|
||||
"no_logs_available": "No Logs Available",
|
||||
"delete_all_logs": "Delete All Logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Languages",
|
||||
@@ -414,12 +490,15 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error Deleting Files"
|
||||
"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",
|
||||
@@ -439,7 +518,10 @@
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"movies": "Movies",
|
||||
"queue": "Queue",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||
"no_items_in_queue": "No Items in Queue",
|
||||
"no_downloaded_items": "No Downloaded Items",
|
||||
"delete_all_movies_button": "Delete All Movies",
|
||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||
@@ -464,8 +546,13 @@
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Download Completed",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||
@@ -475,7 +562,10 @@
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Go to Downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
@@ -493,13 +583,16 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login"
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -598,6 +691,10 @@
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
@@ -664,6 +761,7 @@
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
"left": "left",
|
||||
"more_info": "More Info",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
@@ -790,9 +888,13 @@
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
@@ -926,6 +1028,7 @@
|
||||
"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...",
|
||||
|
||||
17
utils/atoms/castAutoplay.ts
Normal file
17
utils/atoms/castAutoplay.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Countdown state for Chromecast next-episode autoplay. The watcher
|
||||
* (`useCastAutoplay`) writes it; the casting-player overlay reads it.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export interface CastAutoplayState {
|
||||
/** The episode queued to play next. */
|
||||
nextEpisode: BaseItemDto;
|
||||
/** Seconds left before it loads. */
|
||||
secondsRemaining: number;
|
||||
}
|
||||
|
||||
/** Active cast autoplay countdown, or null when none is running. */
|
||||
export const castAutoplayAtom = atom<CastAutoplayState | null>(null);
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
type SortOrder,
|
||||
SubtitlePlaybackMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { t } from "i18next";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
||||
import { writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "../mmkv";
|
||||
|
||||
@@ -121,6 +123,46 @@ export interface MaxAutoPlayEpisodeCount {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin may send object-typed settings as plain primitives.
|
||||
* Resolve to the proper option object from the available choices.
|
||||
*/
|
||||
const normalizePluginValue = (
|
||||
settingsKey: keyof Settings,
|
||||
value: unknown,
|
||||
): unknown => {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
const defaultVal = defaultValues[settingsKey];
|
||||
if (
|
||||
typeof defaultVal === "object" &&
|
||||
defaultVal !== null &&
|
||||
"key" in defaultVal &&
|
||||
"value" in defaultVal
|
||||
) {
|
||||
// defaultBitrate needs a lookup because its keys are human-readable
|
||||
// (e.g. "8 Mb/s") that can't be derived from the raw value (e.g. 8000000).
|
||||
// Other { key, value } settings like maxAutoPlayEpisodeCount work with
|
||||
// the fallback because their keys are just String(value) (e.g. "5").
|
||||
if (settingsKey === "defaultBitrate") {
|
||||
const match = BITRATES.find(
|
||||
(b) => b.key === value || b.value === value,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
// maxAutoPlayEpisodeCount: 0 is invalid (breaks autoplay), clamp to -1
|
||||
// -1 key must match the translated dropdown label so the UI shows "Disabled"
|
||||
if (
|
||||
settingsKey === "maxAutoPlayEpisodeCount" &&
|
||||
(value === 0 || value === -1)
|
||||
) {
|
||||
return { key: t("home.settings.other.disabled"), value: -1 };
|
||||
}
|
||||
return { key: String(value), value };
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export type HomeSectionLatestResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
@@ -134,6 +176,9 @@ export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
}
|
||||
|
||||
// Segment skip behavior options
|
||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
||||
|
||||
// TV Typography scale presets
|
||||
export enum TVTypographyScale {
|
||||
Small = "small",
|
||||
@@ -201,10 +246,23 @@ export type Settings = {
|
||||
jellyseerrServerUrl?: string;
|
||||
useKefinTweaks: boolean;
|
||||
hiddenLibraries?: string[];
|
||||
enableH265ForChromecast: boolean;
|
||||
/** Chromecast profile selection mode. "auto" detects per device. */
|
||||
chromecastProfile: ChromecastProfileMode;
|
||||
/** Optional manual Chromecast video bitrate cap, in bits per second. */
|
||||
chromecastMaxBitrate?: number;
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
autoPlayNextEpisode: boolean;
|
||||
// Media segment skip preferences
|
||||
skipIntro: SegmentSkipMode;
|
||||
skipOutro: SegmentSkipMode;
|
||||
skipRecap: SegmentSkipMode;
|
||||
skipCommercial: SegmentSkipMode;
|
||||
skipPreview: SegmentSkipMode;
|
||||
/** Native player next-episode countdown, in seconds. */
|
||||
autoplayCountdownSeconds: number;
|
||||
/** Chromecast next-episode countdown, in seconds. */
|
||||
castAutoplayCountdownSeconds: number;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
@@ -304,10 +362,19 @@ export const defaultValues: Settings = {
|
||||
jellyseerrServerUrl: undefined,
|
||||
useKefinTweaks: false,
|
||||
hiddenLibraries: [],
|
||||
enableH265ForChromecast: false,
|
||||
chromecastProfile: "auto",
|
||||
chromecastMaxBitrate: undefined,
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
autoPlayNextEpisode: true,
|
||||
// Media segment skip defaults
|
||||
skipIntro: "ask",
|
||||
skipOutro: "ask",
|
||||
skipRecap: "ask",
|
||||
skipCommercial: "ask",
|
||||
skipPreview: "ask",
|
||||
autoplayCountdownSeconds: 15,
|
||||
castAutoplayCountdownSeconds: 30,
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
@@ -427,61 +494,37 @@ export const useSettings = () => {
|
||||
[_setPluginSettings],
|
||||
);
|
||||
|
||||
const refreshStreamyfinPluginSettings = useCallback(
|
||||
async (forceOverride = false) => {
|
||||
if (!api) {
|
||||
return;
|
||||
const refreshStreamyfinPluginSettings = useCallback(async () => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog("Got plugin settings", data?.settings);
|
||||
return data?.settings;
|
||||
},
|
||||
(_err) => undefined,
|
||||
);
|
||||
setPluginSettings(newPluginSettings);
|
||||
|
||||
// Locked/unlocked values are handled by the settings memo, which
|
||||
// applies locked values at runtime without overwriting user storage.
|
||||
// We only handle auto-enabling Streamystats here.
|
||||
if (newPluginSettings && _settings) {
|
||||
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
||||
if (streamyStatsUrl?.value && _settings.searchEngine !== "Streamystats") {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
searchEngine: "Streamystats",
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog("Got plugin settings", data?.settings);
|
||||
return data?.settings;
|
||||
},
|
||||
(_err) => undefined,
|
||||
);
|
||||
setPluginSettings(newPluginSettings);
|
||||
}
|
||||
|
||||
// Apply plugin values to settings
|
||||
if (newPluginSettings && _settings) {
|
||||
const updates: Partial<Settings> = {};
|
||||
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
||||
if (setting && !setting.locked && setting.value !== undefined) {
|
||||
const settingsKey = key as keyof Settings;
|
||||
const effectiveValue = getEffectiveSettingValue(
|
||||
_settings,
|
||||
settingsKey,
|
||||
);
|
||||
// Apply if forceOverride is true, or if neither persisted settings
|
||||
// nor app defaults provide a meaningful value.
|
||||
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
|
||||
(updates as any)[settingsKey] = setting.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-enable Streamystats if server URL is provided
|
||||
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
||||
if (
|
||||
streamyStatsUrl?.value &&
|
||||
_settings.searchEngine !== "Streamystats"
|
||||
) {
|
||||
updates.searchEngine = "Streamystats";
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
...updates,
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
return newPluginSettings;
|
||||
},
|
||||
[api, _settings],
|
||||
);
|
||||
return newPluginSettings;
|
||||
}, [api, _settings]);
|
||||
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
if (!_settings) {
|
||||
@@ -512,8 +555,13 @@ export const useSettings = () => {
|
||||
Partial<Settings>
|
||||
>((acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
let { value } = setting;
|
||||
const { locked } = setting;
|
||||
const settingsKey = key as keyof Settings;
|
||||
|
||||
// Normalize object-typed settings from plugin (plain primitive → { key, value })
|
||||
value = normalizePluginValue(settingsKey, value);
|
||||
|
||||
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||
|
||||
(acc as any)[settingsKey] = locked
|
||||
|
||||
55
utils/casting/buildProfile.test.ts
Normal file
55
utils/casting/buildProfile.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildChromecastProfile } from "./buildProfile";
|
||||
import { CONSERVATIVE_CAPABILITIES } from "./capabilities";
|
||||
|
||||
describe("buildChromecastProfile", () => {
|
||||
test("conservative caps produce an H.264-only video codec list", () => {
|
||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
expect(videoCodecProfile?.Codec).toBe("h264");
|
||||
});
|
||||
|
||||
test("HEVC-capable caps include hevc in the video codec list", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
hevc: true,
|
||||
});
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
expect(videoCodecProfile?.Codec).toContain("hevc");
|
||||
});
|
||||
|
||||
test("maxVideoBitrate drives MaxStreamingBitrate", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
maxVideoBitrate: 5_000_000,
|
||||
});
|
||||
expect(profile.MaxStreamingBitrate).toBe(5_000_000);
|
||||
});
|
||||
|
||||
test("maxAudioChannels constrains transcoding profiles", () => {
|
||||
const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES);
|
||||
const videoTranscode = profile.TranscodingProfiles?.find(
|
||||
(p) => p.Type === "Video",
|
||||
);
|
||||
expect(videoTranscode?.MaxAudioChannels).toBe("2");
|
||||
});
|
||||
|
||||
test("non-10bit HEVC caps add a video bit-depth condition", () => {
|
||||
const profile = buildChromecastProfile({
|
||||
...CONSERVATIVE_CAPABILITIES,
|
||||
hevc: true,
|
||||
hevc10bit: false,
|
||||
});
|
||||
const videoCodecProfile = profile.CodecProfiles?.find(
|
||||
(c) => c.Type === "Video",
|
||||
);
|
||||
const bitDepthCondition = videoCodecProfile?.Conditions?.find(
|
||||
(cond) => cond.Property === "VideoBitDepth",
|
||||
);
|
||||
expect(bitDepthCondition).toBeDefined();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user