forked from Ninjalama/streamyfin_mirror
Compare commits
10 Commits
fix/search
...
feature/mp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de95b2dd18 | ||
|
|
3a8fa09881 | ||
|
|
b0c8aefda6 | ||
|
|
f477e86718 | ||
|
|
5ce4eb1be1 | ||
|
|
dd25feea25 | ||
|
|
d8f8224d0c | ||
|
|
6631cc5d65 | ||
|
|
f1f2777119 | ||
|
|
b6198b21bd |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
"editor.defaultFormatter": "swiftlang.swift-vscode"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
|
||||
13
README.md
13
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||
@@ -15,11 +15,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming clien
|
||||
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
@@ -31,7 +31,7 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
|
||||
|
||||
### Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||
|
||||
### Streamyfin Plugin
|
||||
|
||||
@@ -118,13 +118,6 @@ If you have questions or need support, feel free to reach out:
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
|
||||
## FAQ
|
||||
|
||||
1. Q: Why can't I see my libraries in Streamyfin?
|
||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
||||
2. Q: Why can't I see my music library?
|
||||
A: We don't currently support music and are unlikely to support music in the near future.
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||
|
||||
2
app.json
2
app.json
@@ -48,6 +48,7 @@
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
@@ -112,7 +113,6 @@
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withRNBackgroundDownloader.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -46,7 +46,6 @@ const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||
params as unknown as {
|
||||
@@ -237,65 +236,30 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<View className='mb-4'>
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{isLoading || isFetching ? (
|
||||
<Button
|
||||
loading={true}
|
||||
disabled={true}
|
||||
color='purple'
|
||||
className='mt-4'
|
||||
/>
|
||||
<Button loading={true} disabled={true} color='purple' />
|
||||
) : canRequest ? (
|
||||
<Button color='purple' onPress={request} className='mt-4'>
|
||||
<Button color='purple' onPress={request}>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
<View className='flex flex-row space-x-2 mt-4'>
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='warning-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||
onPress={() => {
|
||||
const url =
|
||||
mediaType === MediaType.MOVIE
|
||||
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
||||
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
iconLeft={
|
||||
<Ionicons name='play-outline' size={20} color='white' />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>Play</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
<Button
|
||||
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons name='warning-outline' size={24} color='white' />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Button>
|
||||
)}
|
||||
<OverviewText text={result.overview} className='mt-4' />
|
||||
</View>
|
||||
|
||||
@@ -433,6 +433,15 @@ const Page = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
if (flatData.length === 0)
|
||||
return (
|
||||
<View className='h-full w-full flex justify-center items-center'>
|
||||
<Text className='text-lg text-neutral-500'>
|
||||
{t("library.no_items_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
|
||||
@@ -331,7 +331,7 @@ export default function search() {
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
items={movies}
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
@@ -349,7 +349,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={series}
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -368,7 +368,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={episodes}
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -382,7 +382,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={collections}
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -398,7 +398,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={actors}
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -434,10 +434,7 @@ export default function search() {
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearch(e);
|
||||
searchBarRef.current?.setText(e);
|
||||
}}
|
||||
onPress={() => setSearch(e)}
|
||||
key={e}
|
||||
className='mb-2'
|
||||
>
|
||||
|
||||
@@ -6,18 +6,24 @@ import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
import { MpvPlayerView, ProgressUpdatePayload, VlcPlayerView } from "@/modules";
|
||||
// import type {
|
||||
// PipStartedPayload,
|
||||
// PlaybackStatePayload,
|
||||
// ProgressUpdatePayload,
|
||||
// VlcPlayerViewRef,
|
||||
// } from "@/modules/VlcPlayer.types";
|
||||
|
||||
import type {
|
||||
MpvPlayerViewRef,
|
||||
PipStartedPayload,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
} from "@/modules/MpvPlayer.types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import native from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import {
|
||||
type BaseItemDto,
|
||||
@@ -50,7 +56,7 @@ const downloadProvider = !Platform.isTV
|
||||
: null;
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
@@ -159,7 +165,6 @@ export default function page() {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async () => {
|
||||
const native = await generateDeviceProfile();
|
||||
try {
|
||||
let result: Stream | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
@@ -392,20 +397,23 @@ export default function page() {
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: textSubs.indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
}
|
||||
const initOptions = [
|
||||
`--sub-text-scale=${settings.subtitleSize}`,
|
||||
`--start=${startPosition}`,
|
||||
];
|
||||
// if (
|
||||
// chosenSubtitleTrack &&
|
||||
// (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
// ) {
|
||||
// const finalIndex = notTranscoding
|
||||
// ? allSubs.indexOf(chosenSubtitleTrack)
|
||||
// : textSubs.indexOf(chosenSubtitleTrack);
|
||||
// initOptions.push(`--sub-track=${finalIndex}`);
|
||||
// }
|
||||
|
||||
if (notTranscoding && chosenAudioTrack) {
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
}
|
||||
// if (notTranscoding && chosenAudioTrack) {
|
||||
// initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
// }
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
@@ -444,7 +452,7 @@ export default function page() {
|
||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
<MpvPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream?.url || "",
|
||||
@@ -488,7 +496,6 @@ export default function page() {
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||
play={videoRef.current?.play}
|
||||
pause={videoRef.current?.pause}
|
||||
seek={videoRef.current?.seekTo}
|
||||
|
||||
11
bun.lock
11
bun.lock
@@ -5,6 +5,7 @@
|
||||
"name": "streamyfin",
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "0.8.6",
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
|
||||
"@expo/config-plugins": "~9.0.15",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
@@ -43,7 +44,7 @@
|
||||
"expo-router": "~4.0.17",
|
||||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-sensors": "~14.0.2",
|
||||
"expo-sharing": "~13.0.1",
|
||||
"expo-sharing": "~13.1.0",
|
||||
"expo-splash-screen": "~0.29.22",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.8",
|
||||
@@ -398,6 +399,8 @@
|
||||
|
||||
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.8.6", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-hLlyBAUz4ahaVK2Op2VcJeAkCSpm3KKho4IojkPyXsos4WEHtO44EYWC71TDbVGeOP5HQ9k7FSwAW3IiZs0wHw=="],
|
||||
|
||||
"@config-plugins/ffmpeg-kit-react-native": ["@config-plugins/ffmpeg-kit-react-native@9.0.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "expo": "^52" } }, "sha512-04bXwdq7pmUPoGqYV0YGsrW/8Db+TNicn2Hznb5t+Dl740z9QkNGP4A38y1Mdz7mCU2EW0riASwl/JTH+6rBvw=="],
|
||||
|
||||
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
|
||||
|
||||
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
|
||||
@@ -1170,7 +1173,7 @@
|
||||
|
||||
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
|
||||
|
||||
"expo-image": ["expo-image@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-NHpIZmGnrPbyDadil6eK+sUgyFMQfapEVb7YaGgxSFWBUQ1rSpjqdIQrCD24IZTO9uSH8V+hMh2ROxrAjAixzQ=="],
|
||||
"expo-image": ["expo-image@2.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-kv40OIJOkItwznhdqFmKxTMC5O8GkpyTf8ng7Py4Hy6IBiH59dkeP6vUZQhzPhJOm5v1kZK4XldbskBosqzOug=="],
|
||||
|
||||
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
|
||||
|
||||
@@ -1198,7 +1201,7 @@
|
||||
|
||||
"expo-sensors": ["expo-sensors@14.0.2", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nCb1Q3ctb0oVTZ9p6eFmQ2fINa6KoxXXIhagPpdN0qR82p00YosP27IuyxjVB3fnCJFeC4TffNxNjBxwAUk+nA=="],
|
||||
|
||||
"expo-sharing": ["expo-sharing@13.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw=="],
|
||||
"expo-sharing": ["expo-sharing@13.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-7O29Bdm95v6aBXBhrbKx9FBqL5loQcK0nvCMFSbZHMy1r7Z6vb6sTMsaGbvknfOH+tEzn+LIleTw5TreoxNT9g=="],
|
||||
|
||||
"expo-splash-screen": ["expo-splash-screen@0.29.22", "", { "dependencies": { "@expo/prebuild-config": "^8.0.27" }, "peerDependencies": { "expo": "*" } }, "sha512-f+bPpF06bqiuW1Fbrd3nxeaSsmTVTBEKEYe3epYt4IE6y4Ulli3qEUamMLlRQiDGuIXPU6zQlscpy2mdBUI5cA=="],
|
||||
|
||||
@@ -2312,6 +2315,8 @@
|
||||
|
||||
"@babel/runtime/regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
|
||||
|
||||
"@config-plugins/ffmpeg-kit-react-native/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
|
||||
|
||||
"@expo/bunyan/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
@@ -151,7 +152,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}
|
||||
closeModal();
|
||||
|
||||
initiateDownload(...itemsNotDownloaded);
|
||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||
else {
|
||||
queueActions.enqueue(
|
||||
queue,
|
||||
setQueue,
|
||||
...itemsNotDownloaded.map((item) => ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
@@ -191,6 +203,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
mediaSource = defaults.mediaSource;
|
||||
audioIndex = defaults.audioIndex;
|
||||
subtitleIndex = defaults.subtitleIndex;
|
||||
// Keep using the selected bitrate for consistency across all downloads
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
@@ -203,8 +216,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
download: true,
|
||||
// deviceId: mediaSource?.Id,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
@@ -219,8 +230,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
||||
if (usingOptimizedServer) {
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
await startBackgroundDownload(url, item, source);
|
||||
} else {
|
||||
//await startRemuxing(item, url, source);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -234,6 +249,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
//startRemuxing,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
@@ -66,14 +67,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [settings] = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string) => {
|
||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||
}
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router],
|
||||
|
||||
@@ -23,6 +23,9 @@ import { Button } from "../Button";
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
//const FFmpegKitProvider = !Platform.isTV
|
||||
// ? require("ffmpeg-kit-react-native")
|
||||
// : null;
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -69,18 +72,23 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
mutationFn: async (id: string) => {
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
try {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) {
|
||||
task.stop();
|
||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||
try {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) {
|
||||
task.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await removeProcess(id);
|
||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||
} finally {
|
||||
await removeProcess(id);
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
} else {
|
||||
//FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||
setProcesses((prev: any[]) =>
|
||||
prev.filter((p: { id: string }) => p.id !== id),
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
type SearchItemWrapperProps<T> = {
|
||||
ids?: string[] | null;
|
||||
items?: T[];
|
||||
renderItem: (item: any) => React.ReactNode;
|
||||
header?: string;
|
||||
@@ -16,6 +17,7 @@ type SearchItemWrapperProps<T> = {
|
||||
};
|
||||
|
||||
export const SearchItemWrapper = <T,>({
|
||||
ids,
|
||||
items,
|
||||
renderItem,
|
||||
header,
|
||||
@@ -24,7 +26,33 @@ export const SearchItemWrapper = <T,>({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
const { data, isLoading: l1 } = useQuery({
|
||||
queryKey: ["items", ids],
|
||||
queryFn: async () => {
|
||||
if (!user?.Id || !api || !ids || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemPromises = ids.map((id) =>
|
||||
getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(itemPromises);
|
||||
|
||||
// Filter out null items
|
||||
return results.filter(
|
||||
(item) => item !== null,
|
||||
) as unknown as BaseItemDto[];
|
||||
},
|
||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
if (!data && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -39,7 +67,7 @@ export const SearchItemWrapper = <T,>({
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
estimatedItemSize={250}
|
||||
/*@ts-ignore */
|
||||
data={items}
|
||||
data={data || items}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
//@ts-ignore
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "@/utils/background-tasks";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import i18n, { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -252,46 +251,7 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<Dropdown
|
||||
data={AUTOPLAY_EPISODES_COUNT(t)}
|
||||
keyExtractor={(item) => item.key}
|
||||
titleExtractor={(item) => item.key}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.max_auto_play_episode_count")}
|
||||
onSelected={(maxAutoPlayEpisodeCount) =>
|
||||
updateSettings({ maxAutoPlayEpisodeCount })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
|
||||
const AUTOPLAY_EPISODES_COUNT = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): {
|
||||
key: string;
|
||||
value: number;
|
||||
}[] => [
|
||||
{ key: t("home.settings.other.disabled"), value: -1 },
|
||||
{ key: "1", value: 1 },
|
||||
{ key: "2", value: 2 },
|
||||
{ key: "3", value: 3 },
|
||||
{ key: "4", value: 4 },
|
||||
{ key: "5", value: 5 },
|
||||
{ key: "6", value: 6 },
|
||||
{ key: "7", value: 7 },
|
||||
];
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
export interface ContinueWatchingOverlayProps {
|
||||
goToNextItem: (options: {
|
||||
isAutoPlay: boolean;
|
||||
resetWatchCount: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||
goToNextItem,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
|
||||
return settings.autoPlayEpisodeCount >=
|
||||
settings.maxAutoPlayEpisodeCount.value ? (
|
||||
<View
|
||||
className={
|
||||
"absolute top-0 bottom-0 left-0 right-0 flex flex-col px-4 items-center justify-center bg-[#000000B3]"
|
||||
}
|
||||
>
|
||||
<Text className='text-2xl font-bold text-white py-4 '>
|
||||
Are you still watching ?
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => {
|
||||
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
|
||||
}}
|
||||
color={"purple"}
|
||||
className='my-4 w-2/3'
|
||||
>
|
||||
{t("player.continue_watching")}
|
||||
</Button>
|
||||
|
||||
<Button onPress={router.back} color={"transparent"} className='w-2/3'>
|
||||
{t("player.go_back")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default ContinueWatchingOverlay;
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import type { MpvPlayerViewRef, TrackInfo } from "@/modules/MpvPlayer.types";
|
||||
import { VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -29,7 +29,7 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import React, {
|
||||
import {
|
||||
type Dispatch,
|
||||
type FC,
|
||||
type MutableRefObject,
|
||||
@@ -66,7 +66,7 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
|
||||
videoRef: MutableRefObject<VlcPlayerViewRef | MpvPlayerViewRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
@@ -82,7 +82,7 @@ interface Props {
|
||||
isVideoLoaded?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
play: (() => Promise<void>) | (() => void);
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
@@ -122,7 +122,7 @@ export const Controls: FC<Props> = ({
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -237,76 +237,15 @@ export const Controls: FC<Props> = ({
|
||||
goToItemCommon(previousItem);
|
||||
}, [previousItem, goToItemCommon]);
|
||||
|
||||
const goToNextItem = useCallback(
|
||||
({
|
||||
isAutoPlay,
|
||||
resetWatchCount,
|
||||
}: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAutoPlay) {
|
||||
// if we are not autoplaying, we won't update anything, we just go to the next item
|
||||
goToItemCommon(nextItem);
|
||||
if (resetWatchCount) {
|
||||
updateSettings({
|
||||
autoPlayEpisodeCount: 0,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
|
||||
if (settings.maxAutoPlayEpisodeCount.value === -1) {
|
||||
goToItemCommon(nextItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.autoPlayEpisodeCount + 1 <
|
||||
settings.maxAutoPlayEpisodeCount.value
|
||||
) {
|
||||
goToItemCommon(nextItem);
|
||||
}
|
||||
|
||||
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
|
||||
if (
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
|
||||
) {
|
||||
// update the autoPlayEpisodeCount in settings
|
||||
updateSettings({
|
||||
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
[nextItem, goToItemCommon],
|
||||
);
|
||||
|
||||
// Add a memoized handler for autoplay next episode
|
||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
|
||||
// Add a memoized handler for manual next episode
|
||||
const handleNextEpisodeManual = useCallback(() => {
|
||||
goToNextItem({ isAutoPlay: false });
|
||||
}, [goToNextItem]);
|
||||
|
||||
// Add a memoized handler for ContinueWatchingOverlay
|
||||
const handleContinueWatching = useCallback(
|
||||
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
||||
goToNextItem(options);
|
||||
},
|
||||
[goToNextItem],
|
||||
);
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem) return;
|
||||
goToItemCommon(nextItem);
|
||||
}, [nextItem, goToItemCommon]);
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) {
|
||||
return;
|
||||
}
|
||||
if (!gotoItem) return;
|
||||
goToItemCommon(gotoItem);
|
||||
},
|
||||
[goToItemCommon, api],
|
||||
@@ -362,9 +301,7 @@ export const Controls: FC<Props> = ({
|
||||
};
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (!showControls) {
|
||||
return;
|
||||
}
|
||||
if (!showControls) return;
|
||||
|
||||
setIsSliding(true);
|
||||
wasPlayingRef.current = isPlaying;
|
||||
@@ -403,9 +340,7 @@ export const Controls: FC<Props> = ({
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) {
|
||||
return;
|
||||
}
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
lightHapticFeedback();
|
||||
try {
|
||||
@@ -437,9 +372,7 @@ export const Controls: FC<Props> = ({
|
||||
? curr + secondsToMs(settings.forwardSkipTime)
|
||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||
seek(Math.max(0, newTime));
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
}
|
||||
if (wasPlayingRef.current) play();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
@@ -522,9 +455,9 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const onClose = async () => {
|
||||
lightHapticFeedback();
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
// await ScreenOrientation.lockAsync(
|
||||
// ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
// );
|
||||
router.back();
|
||||
};
|
||||
|
||||
@@ -614,7 +547,7 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
{nextItem && !offline && (
|
||||
<TouchableOpacity
|
||||
onPress={() => goToNextItem({ isAutoPlay: false })}
|
||||
onPress={goToNextItem}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={24} color='white' />
|
||||
@@ -809,21 +742,17 @@ export const Controls: FC<Props> = ({
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={goToNextItem}
|
||||
onPress={goToNextItem}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
@@ -871,9 +800,6 @@ export const Controls: FC<Props> = ({
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||
)}
|
||||
</ControlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||
>
|
||||
<Animated.View style={animatedStyle} />
|
||||
<View className='px-3 py-3'>
|
||||
<Text numberOfLines={1} className='text-center font-bold'>
|
||||
<Text className='text-center font-bold'>
|
||||
{t("player.next_episode")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
@@ -48,7 +47,6 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
@@ -128,13 +126,15 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
if (getSubtitleTracks) {
|
||||
const subtitleData = await getSubtitleTracks();
|
||||
|
||||
console.log("subtitleData", subtitleData);
|
||||
|
||||
// Step 1: Move external subs to the end, because VLC puts external subs at the end
|
||||
const sortedSubs = allSubs.sort(
|
||||
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
|
||||
);
|
||||
|
||||
// Step 2: Apply VLC indexing logic
|
||||
let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1;
|
||||
let textSubIndex = 0;
|
||||
const processedSubs: Track[] = sortedSubs?.map((sub) => {
|
||||
// Always increment for non-transcoding subtitles
|
||||
// Only increment for text-based subtitles when transcoding
|
||||
@@ -172,6 +172,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
});
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
|
||||
if (getAudioTracks) {
|
||||
const audioData = await getAudioTracks();
|
||||
|
||||
|
||||
231
hooks/useRemuxHlsToMp4.ts
Normal file
231
hooks/useRemuxHlsToMp4.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||
const FFMPEGKitReactNative = !Platform.isTV
|
||||
? require("ffmpeg-kit-react-native")
|
||||
: null;
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useImageStorage from "./useImageStorage";
|
||||
|
||||
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
|
||||
type Statistics = typeof FFMPEGKitReactNative.Statistics;
|
||||
const FFmpegKit = Platform.isTV
|
||||
? null
|
||||
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
|
||||
const createFFmpegCommand = (url: string, output: string) => [
|
||||
"-y", // overwrite output files without asking
|
||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||
|
||||
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
||||
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
||||
"-multiple_requests 1", // http
|
||||
"-tcp_nodelay 1", // http
|
||||
// endregion ffmpeg protocol commands
|
||||
|
||||
"-fflags +genpts", // format flags
|
||||
`-i ${url}`, // infile
|
||||
"-map 0:v -map 0:a", // select all streams for video & audio
|
||||
"-c copy", // streamcopy, preventing transcoding
|
||||
"-bufsize 25M", // amount of data processed before calculating current bitrate
|
||||
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
|
||||
output,
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
*
|
||||
* @param url - The URL of the HLS stream
|
||||
* @param item - The BaseItemDto object representing the media item
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { saveImage } = useImageStorage();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const {
|
||||
saveDownloadedItemInfo,
|
||||
setProcesses,
|
||||
processes,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
} = useDownload();
|
||||
|
||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
};
|
||||
|
||||
const completeCallback = useCallback(
|
||||
async (session: FFmpegSession, item: BaseItemDto) => {
|
||||
try {
|
||||
console.log("completeCallback");
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await FileSystem.moveAsync({
|
||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
saveDownloadedItemInfo(item, stat.getSize());
|
||||
toast.success(t("home.downloads.toasts.download_completed"));
|
||||
}
|
||||
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.filter(
|
||||
(process: { itemId: string | undefined }) =>
|
||||
process.itemId !== item.Id,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
console.log("completeCallback ~ end");
|
||||
},
|
||||
[processes, setProcesses],
|
||||
);
|
||||
|
||||
const statisticsCallback = useCallback(
|
||||
(statistics: Statistics, item: BaseItemDto) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
||||
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
setProcesses((prev: JobStatus[]) => {
|
||||
return prev.map((process: JobStatus) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
id: statistics.getSessionId().toString(),
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
});
|
||||
},
|
||||
[setProcesses, completeCallback],
|
||||
);
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||
const cacheDir = await FileSystem.getInfoAsync(
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
);
|
||||
if (!cacheDir.exists) {
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
intermediates: true,
|
||||
});
|
||||
}
|
||||
|
||||
const output = `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`;
|
||||
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
// First lets save any important assets we want to present to the user offline
|
||||
await onSaveAssets(api, item);
|
||||
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_started_for", { item: item.Name }),
|
||||
{
|
||||
action: {
|
||||
label: "Go to download",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const job: JobStatus = {
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: url,
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
outputPath: output,
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||
setProcesses((prev: any) => [...prev, job]);
|
||||
|
||||
await FFmpegKit.executeAsync(
|
||||
createFFmpegCommand(url, output).join(" "),
|
||||
(session: any) => completeCallback(session, item),
|
||||
undefined,
|
||||
(s: any) => statisticsCallback(s, item),
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error("Failed to remux:", error);
|
||||
writeErrorLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||
Error: ${error.message}, Stack: ${error.stack}`,
|
||||
);
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.filter(
|
||||
(process: { itemId: string | undefined }) =>
|
||||
process.itemId !== item.Id,
|
||||
);
|
||||
});
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[settings, processes, setProcesses, completeCallback, statisticsCallback],
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProcesses([]);
|
||||
}, []);
|
||||
|
||||
return { startRemuxing, cancelRemuxing };
|
||||
};
|
||||
15
i18n.ts
15
i18n.ts
@@ -5,18 +5,16 @@ import { getLocales } from "expo-localization";
|
||||
import de from "./translations/de.json";
|
||||
import en from "./translations/en.json";
|
||||
import es from "./translations/es.json";
|
||||
import eo from "./translations/eo.json";
|
||||
import fr from "./translations/fr.json";
|
||||
import it from "./translations/it.json";
|
||||
import ja from "./translations/ja.json";
|
||||
import nl from "./translations/nl.json";
|
||||
import pl from "./translations/pl.json";
|
||||
import ptBR from "./translations/pt-BR.json";
|
||||
import ru from "./translations/ru.json";
|
||||
import sv from "./translations/sv.json";
|
||||
import ru from "./translations/ru.json";
|
||||
import tr from "./translations/tr.json";
|
||||
import tlh from "./translations/tlh.json";
|
||||
import uk from "./translations/uk.json";
|
||||
import ua from "./translations/ua.json";
|
||||
import zhCN from "./translations/zh-CN.json";
|
||||
import zhTW from "./translations/zh-TW.json";
|
||||
|
||||
@@ -24,19 +22,16 @@ export const APP_LANGUAGES = [
|
||||
{ label: "Deutsch", value: "de" },
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "Esperanto", value: "eo" },
|
||||
{ label: "Français", value: "fr" },
|
||||
{ label: "Italiano", value: "it" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "Klingon", value: "tlh" },
|
||||
{ label: "Türkçe", value: "tr" },
|
||||
{ label: "Nederlands", value: "nl" },
|
||||
{ label: "Polski", value: "pl" },
|
||||
{ label: "Português (Brasil)", value: "pt-BR" },
|
||||
{ label: "Svenska", value: "sv" },
|
||||
{ label: "Русский", value: "ru" },
|
||||
{ label: "Українська", value: "uk" },
|
||||
{ label: "Українська", value: "uk" },
|
||||
{ label: "Українська", value: "ua" },
|
||||
{ label: "简体中文", value: "zh-CN" },
|
||||
{ label: "繁體中文", value: "zh-TW" },
|
||||
];
|
||||
@@ -47,7 +42,6 @@ i18n.use(initReactI18next).init({
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
eo: { translation: eo },
|
||||
fr: { translation: fr },
|
||||
it: { translation: it },
|
||||
ja: { translation: ja },
|
||||
@@ -57,8 +51,7 @@ i18n.use(initReactI18next).init({
|
||||
sv: { translation: sv },
|
||||
ru: { translation: ru },
|
||||
tr: { translation: tr },
|
||||
tlh: { translation: tlh },
|
||||
uk: { translation: uk },
|
||||
ua: { translation: ua },
|
||||
"zh-CN": { translation: zhCN },
|
||||
"zh-TW": { translation: zhTW },
|
||||
},
|
||||
|
||||
98
modules/MpvPlayer.types.ts
Normal file
98
modules/MpvPlayer.types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { ViewStyle } from "react-native";
|
||||
|
||||
export type PlaybackStatePayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isBuffering: boolean;
|
||||
isPlaying: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ProgressUpdatePayload = {
|
||||
nativeEvent: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoLoadStartPayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PipStartedPayload = {
|
||||
nativeEvent: {
|
||||
pipStarted: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
||||
|
||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
||||
|
||||
export type MpvPlayerSource = {
|
||||
uri: string;
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
startPosition?: number;
|
||||
};
|
||||
|
||||
export type TrackInfo = {
|
||||
name: string;
|
||||
index: number;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type ChapterInfo = {
|
||||
name: string;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type MpvPlayerViewProps = {
|
||||
source: MpvPlayerSource;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
progressUpdateInterval?: number;
|
||||
paused?: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
videoAspectRatio?: string;
|
||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
||||
onPipStarted?: (event: PipStartedPayload) => void;
|
||||
};
|
||||
|
||||
export interface MpvPlayerViewRef {
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seekTo: (time: number) => Promise<void>;
|
||||
setAudioTrack: (trackIndex: number) => Promise<void>;
|
||||
getAudioTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleTrack: (trackIndex: number) => Promise<void>;
|
||||
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleDelay: (delay: number) => Promise<void>;
|
||||
setAudioDelay: (delay: number) => Promise<void>;
|
||||
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
|
||||
setRate: (rate: number) => Promise<void>;
|
||||
nextChapter: () => Promise<void>;
|
||||
previousChapter: () => Promise<void>;
|
||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
||||
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
|
||||
getVideoCropGeometry: () => Promise<string | null>;
|
||||
setSubtitleURL: (url: string, name: string) => Promise<void>;
|
||||
}
|
||||
139
modules/MpvPlayerView.tsx
Normal file
139
modules/MpvPlayerView.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import * as React from "react";
|
||||
import { ViewStyle } from "react-native";
|
||||
import type {
|
||||
MpvPlayerSource,
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
} from "./MpvPlayer.types";
|
||||
|
||||
interface NativeViewRef extends MpvPlayerViewRef {
|
||||
setNativeProps?: (props: Partial<MpvPlayerViewProps>) => void;
|
||||
}
|
||||
|
||||
const MpvViewManager = requireNativeViewManager("MpvPlayer");
|
||||
|
||||
// Create a forwarded ref version of the native view
|
||||
const NativeView = React.forwardRef<NativeViewRef, MpvPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
return <MpvViewManager {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
const MpvPlayerView = React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
stop: async () => {
|
||||
await nativeRef.current?.stop();
|
||||
},
|
||||
seekTo: async (time: number) => {
|
||||
await nativeRef.current?.seekTo(time);
|
||||
},
|
||||
setAudioTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackIndex);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getAudioTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackIndex);
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getSubtitleTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setSubtitleDelay(delay);
|
||||
},
|
||||
setAudioDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setAudioDelay(delay);
|
||||
},
|
||||
takeSnapshot: async (path: string, width: number, height: number) => {
|
||||
await nativeRef.current?.takeSnapshot(path, width, height);
|
||||
},
|
||||
setRate: async (rate: number) => {
|
||||
await nativeRef.current?.setRate(rate);
|
||||
},
|
||||
nextChapter: async () => {
|
||||
await nativeRef.current?.nextChapter();
|
||||
},
|
||||
previousChapter: async () => {
|
||||
await nativeRef.current?.previousChapter();
|
||||
},
|
||||
getChapters: async () => {
|
||||
const chapters = await nativeRef.current?.getChapters();
|
||||
return chapters ?? null;
|
||||
},
|
||||
setVideoCropGeometry: async (geometry: string | null) => {
|
||||
await nativeRef.current?.setVideoCropGeometry(geometry);
|
||||
},
|
||||
getVideoCropGeometry: async () => {
|
||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
||||
return geometry ?? null;
|
||||
},
|
||||
setSubtitleURL: async (url: string, name: string) => {
|
||||
await nativeRef.current?.setSubtitleURL(url, name);
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
source,
|
||||
style,
|
||||
progressUpdateInterval = 500,
|
||||
paused,
|
||||
muted,
|
||||
volume,
|
||||
videoAspectRatio,
|
||||
onVideoLoadStart,
|
||||
onVideoStateChange,
|
||||
onVideoProgress,
|
||||
onVideoLoadEnd,
|
||||
onVideoError,
|
||||
onPipStarted,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const processedSource: MpvPlayerSource =
|
||||
typeof source === "string"
|
||||
? ({ uri: source } as unknown as MpvPlayerSource)
|
||||
: source;
|
||||
|
||||
if (processedSource.startPosition !== undefined) {
|
||||
processedSource.startPosition = Math.floor(processedSource.startPosition);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...otherProps}
|
||||
ref={nativeRef}
|
||||
source={processedSource}
|
||||
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
|
||||
progressUpdateInterval={progressUpdateInterval}
|
||||
paused={paused}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
videoAspectRatio={videoAspectRatio}
|
||||
onVideoLoadStart={onVideoLoadStart}
|
||||
onVideoLoadEnd={onVideoLoadEnd}
|
||||
onVideoStateChange={onVideoStateChange}
|
||||
onVideoProgress={onVideoProgress}
|
||||
onVideoError={onVideoError}
|
||||
onPipStarted={onPipStarted}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default MpvPlayerView;
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
import {
|
||||
MpvPlayerSource,
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
} from "./MpvPlayer.types";
|
||||
import MpvPlayerView from "./MpvPlayerView";
|
||||
|
||||
export {
|
||||
VlcPlayerView,
|
||||
VlcPlayerViewProps,
|
||||
@@ -24,4 +31,9 @@ export {
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
// MPV Player exports
|
||||
MpvPlayerView,
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
MpvPlayerSource,
|
||||
};
|
||||
|
||||
6
modules/mpv-player/expo-module.config.json
Normal file
6
modules/mpv-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos"],
|
||||
"ios": {
|
||||
"modules": ["MpvPlayerModule"]
|
||||
}
|
||||
}
|
||||
23
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
23
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '0.40.0'
|
||||
s.summary = 'MPVKit player for iOS/tvOS'
|
||||
s.description = 'A module that integrates MPVKit for video playback in iOS and tvOS applications'
|
||||
s.author = ''
|
||||
s.source = { git: '' }
|
||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit', '~> 0.40.6'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
|
||||
end
|
||||
71
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
71
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class MpvPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("MpvPlayer")
|
||||
View(MpvPlayerView.self) {
|
||||
Prop("source") { (view: MpvPlayerView, source: [String: Any]) in
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { (view: MpvPlayerView, paused: Bool) in
|
||||
if paused {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { (view: MpvPlayerView) in
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: MpvPlayerView, time: Int32) in
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackIndex: Int) in
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]]? in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") {
|
||||
(view: MpvPlayerView, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
892
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
892
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
@@ -0,0 +1,892 @@
|
||||
import ExpoModulesCore
|
||||
import Libmpv
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Metal Layer
|
||||
class MetalLayer: CAMetalLayer {
|
||||
// Workaround for MoltenVK issue that sets drawableSize to 1x1
|
||||
override var drawableSize: CGSize {
|
||||
get { return super.drawableSize }
|
||||
set {
|
||||
if Int(newValue.width) > 1 && Int(newValue.height) > 1 {
|
||||
super.drawableSize = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle extended dynamic range content on iOS 16+
|
||||
@available(iOS 16.0, *)
|
||||
override var wantsExtendedDynamicRangeContent: Bool {
|
||||
get { return super.wantsExtendedDynamicRangeContent }
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
super.wantsExtendedDynamicRangeContent = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
super.wantsExtendedDynamicRangeContent = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to set HDR content safely
|
||||
func setHDRContent(_ enabled: Bool) {
|
||||
if #available(iOS 16.0, *) {
|
||||
if Thread.isMainThread {
|
||||
self.wantsExtendedDynamicRangeContent = enabled
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
self.wantsExtendedDynamicRangeContent = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPV Properties
|
||||
enum MpvProperty {
|
||||
static let timePosition = "time-pos"
|
||||
static let duration = "duration"
|
||||
static let pause = "pause"
|
||||
static let pausedForCache = "paused-for-cache"
|
||||
static let videoParamsSigPeak = "video-params/sig-peak"
|
||||
}
|
||||
|
||||
// MARK: - Protocol
|
||||
protocol MpvPlayerDelegate: AnyObject {
|
||||
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?)
|
||||
}
|
||||
|
||||
// MARK: - MPV Player View
|
||||
class MpvPlayerView: ExpoView {
|
||||
// MARK: - Properties
|
||||
|
||||
private var playerController: MpvMetalViewController?
|
||||
private var source: [String: Any]?
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
|
||||
// MARK: - Event Emitters
|
||||
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupView() {
|
||||
backgroundColor = .black
|
||||
|
||||
print("Setting up direct MPV view")
|
||||
|
||||
// Create player controller
|
||||
let controller = MpvMetalViewController()
|
||||
|
||||
// Configure player delegate
|
||||
controller.delegate = self
|
||||
playerController = controller
|
||||
|
||||
// Add the controller's view to our view hierarchy
|
||||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
controller.view.backgroundColor = .clear
|
||||
|
||||
addSubview(controller.view)
|
||||
NSLayoutConstraint.activate([
|
||||
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
controller.view.topAnchor.constraint(equalTo: topAnchor),
|
||||
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func setSource(_ source: [String: Any]) {
|
||||
self.source = source
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.onVideoLoadStart?(["target": self.reactTag as Any])
|
||||
|
||||
// Store external subtitle data
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
|
||||
if let uri = source["uri"] as? String, let url = URL(string: uri) {
|
||||
print("Loading file: \(url.absoluteString)")
|
||||
self.playerController?.playUrl = url
|
||||
|
||||
// Set start position if available
|
||||
if let startPosition = source["startPosition"] as? Double {
|
||||
self.playerController?.setStartPosition(startPosition)
|
||||
}
|
||||
|
||||
self.playerController?.loadFile(url)
|
||||
|
||||
// Set video to fill the screen
|
||||
self.setVideoScalingMode("cover")
|
||||
|
||||
// Add external subtitles after the video is loaded
|
||||
self.setInitialExternalSubtitles()
|
||||
|
||||
self.onVideoLoadEnd?(["target": self.reactTag as Any])
|
||||
} else {
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startPictureInPicture() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.onPipStarted?(["pipStarted": false, "target": self.reactTag as Any])
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
playerController?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
playerController?.pause()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
playerController?.command("stop", args: [])
|
||||
}
|
||||
|
||||
func seekTo(_ time: Int32) {
|
||||
let seconds = Double(time) / 1000.0
|
||||
print("Seeking to absolute position: \(seconds) seconds")
|
||||
playerController?.command("seek", args: ["\(seconds)", "absolute"])
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackIndex: Int) {
|
||||
playerController?.command("set", args: ["aid", "\(trackIndex)"])
|
||||
}
|
||||
|
||||
func getAudioTracks() -> [[String: Any]] {
|
||||
guard let playerController = playerController else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get track list as a node
|
||||
guard let trackListStr = playerController.getNode("track-list") else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse the JSON string into an array
|
||||
guard let data = trackListStr.data(using: .utf8),
|
||||
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter to audio tracks only
|
||||
var audioTracks: [[String: Any]] = []
|
||||
for case let track as [String: Any] in trackList {
|
||||
if let type = track["type"] as? String, type == "audio" {
|
||||
let id = track["id"] as? Int ?? 0
|
||||
let title = track["title"] as? String ?? "Audio \(id)"
|
||||
let lang = track["lang"] as? String ?? "unknown"
|
||||
let selected = track["selected"] as? Bool ?? false
|
||||
|
||||
audioTracks.append([
|
||||
"id": id,
|
||||
"title": title,
|
||||
"language": lang,
|
||||
"selected": selected,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return audioTracks
|
||||
}
|
||||
|
||||
func setSubtitleTrack(_ trackIndex: Int) {
|
||||
playerController?.command("set", args: ["sid", "\(trackIndex)"])
|
||||
}
|
||||
|
||||
func getSubtitleTracks() -> [[String: Any]] {
|
||||
guard let playerController = playerController else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get track list as a node
|
||||
guard let trackListStr = playerController.getNode("track-list") else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse the JSON string into an array
|
||||
guard let data = trackListStr.data(using: .utf8),
|
||||
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter to subtitle tracks only
|
||||
var subtitleTracks: [[String: Any]] = []
|
||||
for case let track as [String: Any] in trackList {
|
||||
if let type = track["type"] as? String, type == "sub" {
|
||||
let id = track["id"] as? Int ?? 0
|
||||
let title = track["title"] as? String ?? "Subtitle \(id)"
|
||||
let lang = track["lang"] as? String ?? "unknown"
|
||||
let selected = track["selected"] as? Bool ?? false
|
||||
|
||||
subtitleTracks.append([
|
||||
"id": id,
|
||||
"title": title,
|
||||
"language": lang,
|
||||
"selected": selected,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else { return }
|
||||
|
||||
print("Adding subtitle: \(name) from \(subtitleURL)")
|
||||
|
||||
// Add the subtitle file
|
||||
playerController?.command("sub-add", args: [url.absoluteString])
|
||||
}
|
||||
|
||||
@objc
|
||||
func setVideoScalingMode(_ mode: String) {
|
||||
// Mode can be: "contain" (letterbox), "cover" (crop/fill), or "stretch"
|
||||
|
||||
guard let playerController = playerController else { return }
|
||||
|
||||
switch mode.lowercased() {
|
||||
case "cover", "fill", "crop":
|
||||
// Fill the screen, cropping if necessary
|
||||
playerController.command("set", args: ["panscan", "1.0"])
|
||||
playerController.command("set", args: ["video-unscaled", "no"])
|
||||
playerController.command("set", args: ["video-aspect-override", "no"])
|
||||
// Center the crop
|
||||
playerController.command("set", args: ["video-align-x", "0.5"])
|
||||
playerController.command("set", args: ["video-align-y", "0.5"])
|
||||
case "stretch":
|
||||
// Stretch to fill without maintaining aspect ratio
|
||||
playerController.command("set", args: ["panscan", "0.0"])
|
||||
playerController.command("set", args: ["video-unscaled", "no"])
|
||||
playerController.command("set", args: ["video-aspect-override", "-1"])
|
||||
// No need for alignment as it stretches to fill entire area
|
||||
case "contain", "letterbox", "fit":
|
||||
// Keep aspect ratio, fit within screen (letterbox)
|
||||
playerController.command("set", args: ["panscan", "0.0"])
|
||||
playerController.command("set", args: ["video-unscaled", "no"])
|
||||
playerController.command("set", args: ["video-aspect-override", "no"])
|
||||
// Set alignment to center
|
||||
playerController.command("set", args: ["video-align-x", "0.5"])
|
||||
playerController.command("set", args: ["video-align-y", "0.5"])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Adding external subtitle: \(subtitleName) from \(subtitleURL)")
|
||||
setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func isPaused() -> Bool {
|
||||
return playerController?.getFlag(MpvProperty.pause) ?? true
|
||||
}
|
||||
|
||||
private func isBuffering() -> Bool {
|
||||
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
|
||||
}
|
||||
|
||||
private func getCurrentTime() -> Double {
|
||||
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
|
||||
}
|
||||
|
||||
private func getVideoDuration() -> Double {
|
||||
return playerController?.getDouble(MpvProperty.duration) ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
override func removeFromSuperview() {
|
||||
cleanup()
|
||||
super.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
// Check if we already cleaned up
|
||||
|
||||
print("Cleaning up player")
|
||||
guard playerController != nil else { return }
|
||||
|
||||
// First stop playback
|
||||
stop()
|
||||
|
||||
// Break reference cycles
|
||||
playerController?.delegate = nil
|
||||
|
||||
// Remove from view hierarchy
|
||||
playerController?.view.removeFromSuperview()
|
||||
|
||||
// Release references
|
||||
playerController = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// Check if player needs reset when the view appears
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
|
||||
// If we're returning to the window and player is missing, reset
|
||||
if window != nil && playerController == nil {
|
||||
setupView()
|
||||
|
||||
// Reload previous source if available
|
||||
if let source = source {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.setSource(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPV Player Delegate
|
||||
extension MpvPlayerView: MpvPlayerDelegate {
|
||||
// Move the static properties to class level
|
||||
private static var lastTimePositionUpdate = Date(timeIntervalSince1970: 0)
|
||||
|
||||
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
|
||||
// Add throttling for frequently updated properties
|
||||
switch propertyName {
|
||||
case MpvProperty.timePosition:
|
||||
// Throttle timePosition updates to once per second
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(MpvPlayerView.lastTimePositionUpdate) < 1.0 {
|
||||
return
|
||||
}
|
||||
MpvPlayerView.lastTimePositionUpdate = now
|
||||
|
||||
if let position = value as? Double {
|
||||
let timeMs = position * 1000
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
print("IsPlaying: \(!self.isPaused())")
|
||||
self.onVideoProgress?([
|
||||
"currentTime": timeMs,
|
||||
"duration": self.getVideoDuration() * 1000,
|
||||
"isPlaying": !self.isPaused(),
|
||||
"isBuffering": self.isBuffering(),
|
||||
"target": self.reactTag as Any,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
case MpvProperty.pausedForCache:
|
||||
// We want to respond immediately to buffering state changes
|
||||
let isBuffering = value as? Bool ?? false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.onVideoStateChange?([
|
||||
"isBuffering": isBuffering, "target": self.reactTag as Any,
|
||||
"isPlaying": !self.isPaused(),
|
||||
"state": self.isPaused() ? "Paused" : "Playing",
|
||||
])
|
||||
}
|
||||
|
||||
case MpvProperty.pause:
|
||||
// We want to respond immediately to play/pause state changes
|
||||
if let isPaused = value as? Bool {
|
||||
let state = isPaused ? "Paused" : "Playing"
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
print("onPlaybackStateChanged: \(state)")
|
||||
self.onPlaybackStateChanged?([
|
||||
"state": state,
|
||||
"isPlaying": !isPaused,
|
||||
"isBuffering": self.isBuffering(),
|
||||
"currentTime": self.getCurrentTime() * 1000,
|
||||
"duration": self.getVideoDuration() * 1000,
|
||||
"target": self.reactTag as Any,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Controller
|
||||
final class MpvMetalViewController: UIViewController {
|
||||
// MARK: - Properties
|
||||
|
||||
var metalLayer = MetalLayer()
|
||||
var mpv: OpaquePointer?
|
||||
weak var delegate: MpvPlayerDelegate?
|
||||
let mpvQueue = DispatchQueue(label: "mpv.queue", qos: .userInitiated)
|
||||
|
||||
private var isBeingDeallocated = false
|
||||
|
||||
// Use a static dictionary to store controller references instead of WeakContainer
|
||||
private static var controllers = [UInt: MpvMetalViewController]()
|
||||
private var controllerId: UInt = 0
|
||||
|
||||
var playUrl: URL?
|
||||
|
||||
var hdrAvailable: Bool {
|
||||
if #available(iOS 16.0, *) {
|
||||
let maxEDRRange = view.window?.screen.potentialEDRHeadroom ?? 1.0
|
||||
let sigPeak = getDouble(MpvProperty.videoParamsSigPeak)
|
||||
return maxEDRRange > 1.0 && sigPeak > 1.0
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var hdrEnabled = false {
|
||||
didSet {
|
||||
guard let mpv = mpv else { return }
|
||||
|
||||
if hdrEnabled {
|
||||
mpv_set_option_string(mpv, "target-colorspace-hint", "yes")
|
||||
metalLayer.setHDRContent(true)
|
||||
} else {
|
||||
mpv_set_option_string(mpv, "target-colorspace-hint", "no")
|
||||
metalLayer.setHDRContent(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new property to track shutdown state
|
||||
private var isShuttingDown = false
|
||||
private let syncQueue = DispatchQueue(label: "com.mpv.sync", qos: .userInitiated)
|
||||
|
||||
private var startPosition: Double?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupMetalLayer()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.setupMPV()
|
||||
|
||||
if let url = self?.playUrl {
|
||||
self?.loadFile(url)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
metalLayer.frame = view.bounds
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Flag that we're being deinitialized
|
||||
isBeingDeallocated = true
|
||||
|
||||
// Clean up on main thread to avoid threading issues
|
||||
if Thread.isMainThread {
|
||||
safeCleanup()
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
self.safeCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func safeCleanup() {
|
||||
// Remove from controllers dictionary first
|
||||
if controllerId != 0 {
|
||||
MpvMetalViewController.controllers.removeValue(forKey: controllerId)
|
||||
}
|
||||
|
||||
// Remove the wakeup callback
|
||||
if let mpv = self.mpv {
|
||||
mpv_set_wakeup_callback(mpv, nil, nil)
|
||||
}
|
||||
|
||||
// Terminate and destroy MPV instance
|
||||
if let mpv = self.mpv {
|
||||
// Unobserve all properties
|
||||
mpv_unobserve_property(mpv, 0)
|
||||
|
||||
// Store locally to avoid accessing after freeing
|
||||
let mpvToDestroy = mpv
|
||||
self.mpv = nil
|
||||
|
||||
// Terminate and destroy
|
||||
mpv_terminate_destroy(mpvToDestroy)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupMetalLayer() {
|
||||
metalLayer.frame = view.bounds
|
||||
metalLayer.contentsScale = UIScreen.main.nativeScale
|
||||
metalLayer.framebufferOnly = true
|
||||
metalLayer.backgroundColor = UIColor.black.cgColor
|
||||
|
||||
view.layer.addSublayer(metalLayer)
|
||||
}
|
||||
|
||||
private func setupMPV() {
|
||||
guard let mpvHandle = mpv_create() else {
|
||||
print("Failed to create MPV instance")
|
||||
return
|
||||
}
|
||||
|
||||
mpv = mpvHandle
|
||||
|
||||
// Configure mpv options
|
||||
#if DEBUG
|
||||
// mpv_request_log_messages(mpvHandle, "debug")
|
||||
#else
|
||||
mpv_request_log_messages(mpvHandle, "no")
|
||||
#endif
|
||||
|
||||
// Force a proper window setup to prevent black screens
|
||||
mpv_set_option_string(mpvHandle, "force-window", "yes")
|
||||
mpv_set_option_string(mpvHandle, "reset-on-next-file", "all")
|
||||
|
||||
// Set rendering options
|
||||
|
||||
var layerPtr = Unmanaged.passUnretained(metalLayer).toOpaque()
|
||||
mpv_set_option(mpvHandle, "wid", MPV_FORMAT_INT64, &layerPtr)
|
||||
mpv_set_option_string(mpvHandle, "vo", "gpu-next")
|
||||
mpv_set_option_string(mpvHandle, "gpu-api", "metal")
|
||||
mpv_set_option_string(mpvHandle, "gpu-context", "auto")
|
||||
mpv_set_option_string(mpvHandle, "hwdec", "videotoolbox")
|
||||
|
||||
// Set subtitle options
|
||||
mpv_set_option_string(mpvHandle, "subs-match-os-language", "yes")
|
||||
mpv_set_option_string(mpvHandle, "subs-fallback", "yes")
|
||||
mpv_set_option_string(mpvHandle, "sub-auto", "no")
|
||||
|
||||
// Disable subtitle selection at start
|
||||
mpv_set_option_string(mpvHandle, "sid", "no")
|
||||
|
||||
// Set starting point if available
|
||||
if let startPos = startPosition {
|
||||
let startPosString = String(format: "%.1f", startPos)
|
||||
print("Setting initial start position to \(startPosString)")
|
||||
mpv_set_option_string(mpvHandle, "start", startPosString)
|
||||
}
|
||||
|
||||
// Set video options
|
||||
mpv_set_option_string(mpvHandle, "video-rotate", "no")
|
||||
mpv_set_option_string(mpvHandle, "ytdl", "no")
|
||||
|
||||
// Initialize mpv
|
||||
let status = mpv_initialize(mpvHandle)
|
||||
if status < 0 {
|
||||
print("Failed to initialize MPV: \(String(cString: mpv_error_string(status)))")
|
||||
mpv_terminate_destroy(mpvHandle)
|
||||
mpv = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Observe properties
|
||||
observeProperty(mpvHandle, MpvProperty.videoParamsSigPeak, MPV_FORMAT_DOUBLE)
|
||||
observeProperty(mpvHandle, MpvProperty.pausedForCache, MPV_FORMAT_FLAG)
|
||||
observeProperty(mpvHandle, MpvProperty.timePosition, MPV_FORMAT_DOUBLE)
|
||||
observeProperty(mpvHandle, MpvProperty.duration, MPV_FORMAT_DOUBLE)
|
||||
observeProperty(mpvHandle, MpvProperty.pause, MPV_FORMAT_FLAG)
|
||||
|
||||
// Store controller in static dictionary and set its unique ID
|
||||
controllerId = UInt(bitPattern: ObjectIdentifier(self))
|
||||
MpvMetalViewController.controllers[controllerId] = self
|
||||
|
||||
// Set wakeup callback using the static method
|
||||
mpv_set_wakeup_callback(
|
||||
mpvHandle, MpvMetalViewController.mpvWakeupCallback,
|
||||
UnsafeMutableRawPointer(bitPattern: controllerId))
|
||||
|
||||
print("MPV initialized")
|
||||
}
|
||||
|
||||
// Static callback function - no WeakContainer needed
|
||||
private static let mpvWakeupCallback: (@convention(c) (UnsafeMutableRawPointer?) -> Void) = {
|
||||
(ctx) in
|
||||
guard let ctx = ctx else { return }
|
||||
|
||||
// Get the controllerId from the context pointer
|
||||
let controllerId = UInt(bitPattern: ctx)
|
||||
|
||||
// Dispatch to main queue to handle UI updates safely
|
||||
DispatchQueue.main.async {
|
||||
// Get the controller safely from the dictionary
|
||||
if let controller = MpvMetalViewController.controllers[controllerId] {
|
||||
// Only process events if not being deallocated
|
||||
if !controller.isBeingDeallocated {
|
||||
controller.processEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method for safer property observation
|
||||
private func observeProperty(_ handle: OpaquePointer, _ name: String, _ format: mpv_format) {
|
||||
let status = mpv_observe_property(handle, 0, name, format)
|
||||
if status < 0 {
|
||||
print(
|
||||
"Failed to observe property \(name): \(String(cString: mpv_error_string(status)))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPV Methods
|
||||
|
||||
func loadFile(_ url: URL) {
|
||||
guard let mpv = mpv else { return }
|
||||
|
||||
print("Loading file: \(url.absoluteString)")
|
||||
|
||||
// Use string array extension for safer command execution
|
||||
command("loadfile", args: [url.absoluteString, "replace"])
|
||||
}
|
||||
|
||||
func play() {
|
||||
setFlag(MpvProperty.pause, false)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
print("Pausing")
|
||||
setFlag(MpvProperty.pause, true)
|
||||
}
|
||||
|
||||
func getDouble(_ name: String) -> Double {
|
||||
guard let mpv = mpv else { return 0.0 }
|
||||
|
||||
var data = 0.0
|
||||
let status = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
|
||||
if status < 0 {
|
||||
print(
|
||||
"Failed to get double property \(name): \(String(cString: mpv_error_string(status)))"
|
||||
)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func getNode(_ name: String) -> String? {
|
||||
guard let mpv = mpv else { return nil }
|
||||
|
||||
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
|
||||
// Use defer to ensure memory is freed even if an exception occurs
|
||||
defer {
|
||||
mpv_free(UnsafeMutableRawPointer(mutating: cString))
|
||||
}
|
||||
return String(cString: cString)
|
||||
}
|
||||
|
||||
func getString(_ name: String) -> String? {
|
||||
guard let mpv = mpv else { return nil }
|
||||
|
||||
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
|
||||
// Use defer to ensure memory is freed even if an exception occurs
|
||||
defer {
|
||||
mpv_free(UnsafeMutableRawPointer(mutating: cString))
|
||||
}
|
||||
return String(cString: cString)
|
||||
}
|
||||
|
||||
func getFlag(_ name: String) -> Bool {
|
||||
guard let mpv = mpv else { return false }
|
||||
|
||||
var data: Int32 = 0
|
||||
let status = mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
|
||||
if status < 0 {
|
||||
print(
|
||||
"Failed to get flag property \(name): \(String(cString: mpv_error_string(status)))")
|
||||
}
|
||||
return data > 0
|
||||
}
|
||||
|
||||
func setFlag(_ name: String, _ value: Bool) {
|
||||
guard let mpv = mpv else { return }
|
||||
|
||||
var data: Int32 = value ? 1 : 0
|
||||
print("Setting flag \(name) to \(value)")
|
||||
let status = mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
|
||||
if status < 0 {
|
||||
print(
|
||||
"Failed to set flag property \(name): \(String(cString: mpv_error_string(status)))")
|
||||
}
|
||||
}
|
||||
|
||||
func command(
|
||||
_ command: String,
|
||||
args: [String] = [],
|
||||
checkErrors: Bool = true,
|
||||
completion: ((Int32) -> Void)? = nil
|
||||
) {
|
||||
guard let mpv = mpv else {
|
||||
completion?(-1)
|
||||
return
|
||||
}
|
||||
|
||||
// Approach 1: Create array of C strings directly from Swift strings
|
||||
let allArgs = [command] + args
|
||||
|
||||
// Allocate array of C string pointers of the correct type
|
||||
let cArray = UnsafeMutablePointer<UnsafePointer<CChar>?>.allocate(
|
||||
capacity: allArgs.count + 1)
|
||||
|
||||
// Convert Swift strings to C strings and store in the array
|
||||
for i in 0..<allArgs.count {
|
||||
cArray[i] = (allArgs[i] as NSString).utf8String
|
||||
}
|
||||
|
||||
// Set final element to nil
|
||||
cArray[allArgs.count] = nil
|
||||
|
||||
// Execute the command
|
||||
let status = mpv_command(mpv, cArray)
|
||||
|
||||
// Clean up
|
||||
cArray.deallocate()
|
||||
|
||||
if checkErrors && status < 0 {
|
||||
print("MPV command error: \(String(cString: mpv_error_string(status)))")
|
||||
}
|
||||
|
||||
completion?(status)
|
||||
}
|
||||
|
||||
// MARK: - Event Processing
|
||||
|
||||
private func processEvents() {
|
||||
// Exit if we're being deallocated
|
||||
if isBeingDeallocated {
|
||||
return
|
||||
}
|
||||
|
||||
guard let mpv = mpv else { return }
|
||||
|
||||
// Process a limited number of events to avoid infinite loops
|
||||
let maxEvents = 10
|
||||
var eventCount = 0
|
||||
|
||||
while !isBeingDeallocated && eventCount < maxEvents {
|
||||
guard let event = mpv_wait_event(mpv, 0) else { break }
|
||||
if event.pointee.event_id == MPV_EVENT_NONE { break }
|
||||
|
||||
handleEvent(event)
|
||||
eventCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(_ event: UnsafePointer<mpv_event>) {
|
||||
// Exit early if we're being deallocated
|
||||
if isBeingDeallocated {
|
||||
return
|
||||
}
|
||||
|
||||
guard let mpv = mpv else { return }
|
||||
|
||||
switch event.pointee.event_id {
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
guard let propertyData = event.pointee.data else { break }
|
||||
|
||||
// Safely create a typed pointer to the property data
|
||||
let propertyPtr = propertyData.bindMemory(
|
||||
to: mpv_event_property.self, capacity: 1)
|
||||
|
||||
// Safely get the property name
|
||||
guard let namePtr = propertyPtr.pointee.name else { break }
|
||||
let propertyName = String(cString: namePtr)
|
||||
|
||||
var value: Any?
|
||||
|
||||
// Handle different property types safely
|
||||
switch propertyName {
|
||||
case MpvProperty.pausedForCache, MpvProperty.pause:
|
||||
if propertyPtr.pointee.format == MPV_FORMAT_FLAG,
|
||||
let data = propertyPtr.pointee.data
|
||||
{
|
||||
// Cast to Int32 which is MPV's flag format
|
||||
let flagPtr = data.bindMemory(to: Int32.self, capacity: 1)
|
||||
value = flagPtr.pointee != 0
|
||||
}
|
||||
|
||||
case MpvProperty.timePosition, MpvProperty.duration:
|
||||
if propertyPtr.pointee.format == MPV_FORMAT_DOUBLE,
|
||||
let data = propertyPtr.pointee.data
|
||||
{
|
||||
// Cast to Double which is MPV's double format
|
||||
let doublePtr = data.bindMemory(to: Double.self, capacity: 1)
|
||||
value = doublePtr.pointee
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Notify delegate on main thread
|
||||
if let value = value {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, !self.isBeingDeallocated else { return }
|
||||
self.delegate?.propertyChanged(
|
||||
mpv: mpv, propertyName: propertyName, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
case MPV_EVENT_SHUTDOWN:
|
||||
print("MPV shutdown event received")
|
||||
isBeingDeallocated = true
|
||||
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
return
|
||||
|
||||
default:
|
||||
if let eventName = mpv_event_name(event.pointee.event_id) {
|
||||
print("MPV event: \(String(cString: eventName))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func setStartPosition(_ position: Double) {
|
||||
startPosition = position
|
||||
|
||||
// If MPV is already initialized, we need to update the option
|
||||
if let mpv = mpv {
|
||||
let positionString = String(format: "%.1f", position)
|
||||
print("Setting start position to \(positionString)")
|
||||
mpv_set_option_string(mpv, "start", positionString)
|
||||
}
|
||||
}
|
||||
}
|
||||
831
modules/mpv-player/ios/MpvPlayerViewGL.swift
Normal file
831
modules/mpv-player/ios/MpvPlayerViewGL.swift
Normal file
@@ -0,0 +1,831 @@
|
||||
import ExpoModulesCore
|
||||
import Foundation
|
||||
import GLKit
|
||||
import Libmpv
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - MPV Properties
|
||||
enum MpvProperty {
|
||||
static let timePosition = "time-pos"
|
||||
static let duration = "duration"
|
||||
static let pause = "pause"
|
||||
static let pausedForCache = "paused-for-cache"
|
||||
static let videoParamsSigPeak = "video-params/sig-peak"
|
||||
}
|
||||
|
||||
// MARK: - Protocol
|
||||
protocol MpvPlayerDelegate: AnyObject {
|
||||
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?)
|
||||
}
|
||||
|
||||
// MARK: - MPV Player View
|
||||
class MpvPlayerView: ExpoView {
|
||||
// MARK: - Properties
|
||||
|
||||
private var playerController: MpvGLViewController?
|
||||
private var source: [String: Any]?
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
|
||||
// MARK: - Event Emitters
|
||||
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupView() {
|
||||
backgroundColor = .black
|
||||
|
||||
print("Setting up MPV GL view")
|
||||
|
||||
// Create player controller - IMPORTANT: Use init(nibName:bundle:) to ensure proper GLKView setup
|
||||
let controller = MpvGLViewController(nibName: nil, bundle: nil)
|
||||
|
||||
// Force view loading immediately
|
||||
_ = controller.view
|
||||
|
||||
// Configure player delegate
|
||||
controller.mpvDelegate = self
|
||||
playerController = controller
|
||||
|
||||
// Make sure controller view is properly set up as GLKView
|
||||
controller.view.backgroundColor = .black
|
||||
|
||||
// Set explicit frame to ensure it's visible
|
||||
controller.view.frame = bounds
|
||||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
// Add to hierarchy
|
||||
addSubview(controller.view)
|
||||
|
||||
// Use constraints to ensure proper sizing
|
||||
NSLayoutConstraint.activate([
|
||||
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
controller.view.topAnchor.constraint(equalTo: topAnchor),
|
||||
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
// Override layoutSubviews to make sure the player view is properly sized
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
playerController?.view.frame = bounds
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func setSource(_ source: [String: Any]) {
|
||||
self.source = source
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.onVideoLoadStart?(["target": self.reactTag as Any])
|
||||
|
||||
// Store external subtitle data
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
|
||||
if let uri = source["uri"] as? String, let url = URL(string: uri) {
|
||||
print("Loading file: \(url.absoluteString)")
|
||||
self.playerController?.playUrl = url
|
||||
|
||||
// Set start position if available
|
||||
if let startPosition = source["startPosition"] as? Double {
|
||||
self.playerController?.startPosition = startPosition
|
||||
}
|
||||
|
||||
self.playerController?.loadFile(url)
|
||||
|
||||
// Set video to fill the screen
|
||||
self.setVideoScalingMode("cover")
|
||||
|
||||
// Add external subtitles after the video is loaded
|
||||
self.setInitialExternalSubtitles()
|
||||
|
||||
self.onVideoLoadEnd?(["target": self.reactTag as Any])
|
||||
} else {
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startPictureInPicture() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.onPipStarted?(["pipStarted": false, "target": self.reactTag as Any])
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
playerController?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
playerController?.pause()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
playerController?.command("stop", args: [])
|
||||
}
|
||||
|
||||
func seekTo(_ time: Int32) {
|
||||
let seconds = Double(time) / 1000.0
|
||||
print("Seeking to absolute position: \(seconds) seconds")
|
||||
playerController?.command("seek", args: ["\(seconds)", "absolute"])
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackIndex: Int) {
|
||||
playerController?.command("set", args: ["aid", "\(trackIndex)"])
|
||||
}
|
||||
|
||||
func getAudioTracks() -> [[String: Any]] {
|
||||
guard let playerController = playerController else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get track list as a node
|
||||
guard let trackListStr = playerController.getNode("track-list") else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse the JSON string into an array
|
||||
guard let data = trackListStr.data(using: .utf8),
|
||||
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter to audio tracks only
|
||||
var audioTracks: [[String: Any]] = []
|
||||
for case let track as [String: Any] in trackList {
|
||||
if let type = track["type"] as? String, type == "audio" {
|
||||
let id = track["id"] as? Int ?? 0
|
||||
let title = track["title"] as? String ?? "Audio \(id)"
|
||||
let lang = track["lang"] as? String ?? "unknown"
|
||||
let selected = track["selected"] as? Bool ?? false
|
||||
|
||||
audioTracks.append([
|
||||
"id": id,
|
||||
"title": title,
|
||||
"language": lang,
|
||||
"selected": selected,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return audioTracks
|
||||
}
|
||||
|
||||
func setSubtitleTrack(_ trackIndex: Int) {
|
||||
playerController?.command("set", args: ["sid", "\(trackIndex)"])
|
||||
}
|
||||
|
||||
func getSubtitleTracks() -> [[String: Any]] {
|
||||
guard let playerController = playerController else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get track list as a node
|
||||
guard let trackListStr = playerController.getNode("track-list") else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse the JSON string into an array
|
||||
guard let data = trackListStr.data(using: .utf8),
|
||||
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter to subtitle tracks only
|
||||
var subtitleTracks: [[String: Any]] = []
|
||||
for case let track as [String: Any] in trackList {
|
||||
if let type = track["type"] as? String, type == "sub" {
|
||||
let id = track["id"] as? Int ?? 0
|
||||
let title = track["title"] as? String ?? "Subtitle \(id)"
|
||||
let lang = track["lang"] as? String ?? "unknown"
|
||||
let selected = track["selected"] as? Bool ?? false
|
||||
|
||||
subtitleTracks.append([
|
||||
"id": id,
|
||||
"title": title,
|
||||
"language": lang,
|
||||
"selected": selected,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else { return }
|
||||
|
||||
print("Adding subtitle: \(name) from \(subtitleURL)")
|
||||
|
||||
// Add the subtitle file
|
||||
playerController?.command("sub-add", args: [url.absoluteString])
|
||||
}
|
||||
|
||||
@objc
|
||||
func setVideoScalingMode(_ mode: String) {
|
||||
// Mode can be: "contain" (letterbox), "cover" (crop/fill), or "stretch"
|
||||
|
||||
guard let playerController = playerController else { return }
|
||||
|
||||
switch mode.lowercased() {
|
||||
case "cover", "fill", "crop":
|
||||
// Fill the screen, cropping if necessary
|
||||
playerController.command("set", args: ["panscan", "1.0"])
|
||||
playerController.command("set", args: ["video-unscaled", "no"])
|
||||
playerController.command("set", args: ["video-aspect-override", "no"])
|
||||
// Center the crop
|
||||
playerController.command("set", args: ["video-align-x", "0.5"])
|
||||
playerController.command("set", args: ["video-align-y", "0.5"])
|
||||
case "stretch":
|
||||
// Stretch to fill without maintaining aspect ratio
|
||||
playerController.command("set", args: ["panscan", "0.0"])
|
||||
playerController.command("set", args: ["video-unscaled", "no"])
|
||||
playerController.command("set", args: ["video-aspect-override", "-1"])
|
||||
// No need for alignment as it stretches to fill entire area
|
||||
case "contain", "letterbox", "fit":
|
||||
// Keep aspect ratio, fit within screen (letterbox)
|
||||
playerController.command("set", args: ["panscan", "0.0"])
|
||||
playerController.command("set", args: ["video-unscaled", "no"])
|
||||
playerController.command("set", args: ["video-aspect-override", "no"])
|
||||
// Set alignment to center
|
||||
playerController.command("set", args: ["video-align-x", "0.5"])
|
||||
playerController.command("set", args: ["video-align-y", "0.5"])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Adding external subtitle: \(subtitleName) from \(subtitleURL)")
|
||||
setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func isPaused() -> Bool {
|
||||
return playerController?.getFlag(MpvProperty.pause) ?? true
|
||||
}
|
||||
|
||||
private func isBuffering() -> Bool {
|
||||
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
|
||||
}
|
||||
|
||||
private func getCurrentTime() -> Double {
|
||||
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
|
||||
}
|
||||
|
||||
private func getVideoDuration() -> Double {
|
||||
return playerController?.getDouble(MpvProperty.duration) ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
override func removeFromSuperview() {
|
||||
cleanup()
|
||||
super.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
// Check if we already cleaned up
|
||||
|
||||
print("Cleaning up player")
|
||||
guard playerController != nil else { return }
|
||||
|
||||
// First stop playback
|
||||
stop()
|
||||
|
||||
// Break reference cycles
|
||||
playerController?.mpvDelegate = nil
|
||||
|
||||
// Remove from view hierarchy
|
||||
playerController?.view.removeFromSuperview()
|
||||
|
||||
// Release references
|
||||
playerController = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// Check if player needs reset when the view appears
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
|
||||
// If we're returning to the window and player is missing, reset
|
||||
if window != nil && playerController == nil {
|
||||
setupView()
|
||||
|
||||
// Reload previous source if available
|
||||
if let source = source {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.setSource(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPV Player Delegate
|
||||
extension MpvPlayerView: MpvPlayerDelegate {
|
||||
// Move the static properties to class level
|
||||
private static var lastTimePositionUpdate = Date(timeIntervalSince1970: 0)
|
||||
|
||||
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
|
||||
// Add throttling for frequently updated properties
|
||||
switch propertyName {
|
||||
case MpvProperty.timePosition:
|
||||
// Throttle timePosition updates to once per second
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(MpvPlayerView.lastTimePositionUpdate) < 1.0 {
|
||||
return
|
||||
}
|
||||
MpvPlayerView.lastTimePositionUpdate = now
|
||||
|
||||
if let position = value as? Double {
|
||||
let timeMs = position * 1000
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
print("IsPlaying: \(!self.isPaused())")
|
||||
self.onVideoProgress?([
|
||||
"currentTime": timeMs,
|
||||
"duration": self.getVideoDuration() * 1000,
|
||||
"isPlaying": !self.isPaused(),
|
||||
"isBuffering": self.isBuffering(),
|
||||
"target": self.reactTag as Any,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
case MpvProperty.pausedForCache:
|
||||
// We want to respond immediately to buffering state changes
|
||||
let isBuffering = value as? Bool ?? false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.onVideoStateChange?([
|
||||
"isBuffering": isBuffering, "target": self.reactTag as Any,
|
||||
"isPlaying": !self.isPaused(),
|
||||
"state": self.isPaused() ? "Paused" : "Playing",
|
||||
])
|
||||
}
|
||||
|
||||
case MpvProperty.pause:
|
||||
// We want to respond immediately to play/pause state changes
|
||||
if let isPaused = value as? Bool {
|
||||
let state = isPaused ? "Paused" : "Playing"
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
print("onPlaybackStateChanged: \(state)")
|
||||
self.onPlaybackStateChanged?([
|
||||
"state": state,
|
||||
"isPlaying": !isPaused,
|
||||
"isBuffering": self.isBuffering(),
|
||||
"currentTime": self.getCurrentTime() * 1000,
|
||||
"duration": self.getVideoDuration() * 1000,
|
||||
"target": self.reactTag as Any,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Controller
|
||||
final class MpvGLViewController: GLKViewController {
|
||||
// MARK: - Properties
|
||||
var mpv: OpaquePointer!
|
||||
var mpvGL: OpaquePointer!
|
||||
weak var mpvDelegate: MpvPlayerDelegate?
|
||||
var queue: DispatchQueue = DispatchQueue(label: "mpv", qos: .userInteractive)
|
||||
private var defaultFBO: GLint = -1
|
||||
|
||||
var playUrl: URL?
|
||||
var startPosition: Double?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupContext()
|
||||
setupMpv()
|
||||
|
||||
if let url = playUrl {
|
||||
self.loadFile(url)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
print("GLKViewController viewWillAppear")
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
print("GLKViewController viewDidAppear")
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Clean up on deallocation
|
||||
if mpvGL != nil {
|
||||
mpv_render_context_free(mpvGL)
|
||||
mpvGL = nil
|
||||
}
|
||||
|
||||
if mpv != nil {
|
||||
mpv_terminate_destroy(mpv)
|
||||
mpv = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
func setupContext() {
|
||||
print("Setting up OpenGL ES context")
|
||||
|
||||
let context = EAGLContext(api: .openGLES3)!
|
||||
if context == nil {
|
||||
print("ERROR: Failed to create OpenGL ES context")
|
||||
return
|
||||
}
|
||||
|
||||
let isSuccess = EAGLContext.setCurrent(context)
|
||||
if !isSuccess {
|
||||
print("ERROR: Failed to set current GL context")
|
||||
return
|
||||
}
|
||||
|
||||
// Set the context on our GLKView
|
||||
let glkView = self.view as! GLKView
|
||||
glkView.context = context
|
||||
|
||||
print("Successfully set up OpenGL ES context")
|
||||
}
|
||||
|
||||
func setupMpv() {
|
||||
print("Setting up MPV")
|
||||
|
||||
mpv = mpv_create()
|
||||
if mpv == nil {
|
||||
print("ERROR: failed creating mpv context\n")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// https://mpv.io/manual/stable/#options
|
||||
#if DEBUG
|
||||
checkError(mpv_request_log_messages(mpv, "debug"))
|
||||
#else
|
||||
checkError(mpv_request_log_messages(mpv, "no"))
|
||||
#endif
|
||||
#if os(macOS)
|
||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||
#endif
|
||||
|
||||
// Set options
|
||||
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", "auto-copy"))
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
checkError(mpv_set_option_string(mpv, "profile", "gpu-hq"))
|
||||
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
|
||||
|
||||
// Add in setupMpv before initialization
|
||||
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "opengl-version", "3"))
|
||||
|
||||
// Initialize MPV
|
||||
checkError(mpv_initialize(mpv))
|
||||
|
||||
// Set starting point if available
|
||||
if let startPos = startPosition {
|
||||
let startPosString = String(format: "%.1f", startPos)
|
||||
print("Setting initial start position to \(startPosString)")
|
||||
checkError(mpv_set_option_string(mpv, "start", startPosString))
|
||||
}
|
||||
|
||||
// Set up rendering
|
||||
print("Setting up MPV GL rendering context")
|
||||
let api = UnsafeMutableRawPointer(
|
||||
mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||
var initParams = mpv_opengl_init_params(
|
||||
get_proc_address: {
|
||||
(ctx, name) in
|
||||
return MpvGLViewController.getProcAddress(ctx, name)
|
||||
},
|
||||
get_proc_address_ctx: nil
|
||||
)
|
||||
|
||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
|
||||
mpv_render_param(),
|
||||
]
|
||||
|
||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||
puts("ERROR: failed to initialize mpv GL context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
print("Successfully created MPV GL render context")
|
||||
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
mpvGLUpdate,
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(self.view).toOpaque())
|
||||
)
|
||||
}
|
||||
|
||||
// Observe properties
|
||||
mpv_observe_property(mpv, 0, MpvProperty.videoParamsSigPeak, MPV_FORMAT_DOUBLE)
|
||||
mpv_observe_property(mpv, 0, MpvProperty.pausedForCache, MPV_FORMAT_FLAG)
|
||||
mpv_observe_property(mpv, 0, MpvProperty.timePosition, MPV_FORMAT_DOUBLE)
|
||||
mpv_observe_property(mpv, 0, MpvProperty.duration, MPV_FORMAT_DOUBLE)
|
||||
mpv_observe_property(mpv, 0, MpvProperty.pause, MPV_FORMAT_FLAG)
|
||||
|
||||
// Set wakeup callback
|
||||
mpv_set_wakeup_callback(
|
||||
self.mpv,
|
||||
{ (ctx) in
|
||||
let client = unsafeBitCast(ctx, to: MpvGLViewController.self)
|
||||
client.readEvents()
|
||||
}, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||
|
||||
print("MPV setup complete")
|
||||
|
||||
// Configure GLKView properly for better performance
|
||||
let glkView = self.view as! GLKView
|
||||
glkView.enableSetNeedsDisplay = false // Allow continuous rendering
|
||||
glkView.drawableMultisample = .multisample4X // Might help or hurt - test both
|
||||
glkView.drawableColorFormat = .RGBA8888
|
||||
|
||||
// Set higher preferred frame rate
|
||||
self.preferredFramesPerSecond = 60 // Or even higher on newer devices
|
||||
}
|
||||
|
||||
// MARK: - MPV Methods
|
||||
|
||||
func loadFile(_ url: URL) {
|
||||
print("Loading file: \(url.absoluteString)")
|
||||
|
||||
var args = [url.absoluteString]
|
||||
args.append("replace")
|
||||
|
||||
print("MPV Command: loadfile with args \(args)")
|
||||
command("loadfile", args: args.map { $0 as String? })
|
||||
|
||||
// Set video settings for visibility
|
||||
command("set", args: ["video-unscaled", "no"])
|
||||
command("set", args: ["panscan", "1.0"]) // Ensure video fills screen
|
||||
}
|
||||
|
||||
func togglePause() {
|
||||
getFlag(MpvProperty.pause) ? play() : pause()
|
||||
}
|
||||
|
||||
func play() {
|
||||
setFlag(MpvProperty.pause, false)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
setFlag(MpvProperty.pause, true)
|
||||
}
|
||||
|
||||
func getDouble(_ name: String) -> Double {
|
||||
var data = 0.0
|
||||
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
|
||||
return data
|
||||
}
|
||||
|
||||
func getNode(_ name: String) -> String? {
|
||||
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
|
||||
defer {
|
||||
mpv_free(UnsafeMutableRawPointer(mutating: cString))
|
||||
}
|
||||
return String(cString: cString)
|
||||
}
|
||||
|
||||
func getFlag(_ name: String) -> Bool {
|
||||
var data = Int64()
|
||||
mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
|
||||
return data > 0
|
||||
}
|
||||
|
||||
func setFlag(_ name: String, _ flag: Bool) {
|
||||
guard mpv != nil else { return }
|
||||
var data: Int = flag ? 1 : 0
|
||||
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
|
||||
}
|
||||
|
||||
func command(
|
||||
_ command: String,
|
||||
args: [String?] = [],
|
||||
checkForErrors: Bool = true,
|
||||
returnValueCallback: ((Int32) -> Void)? = nil
|
||||
) {
|
||||
guard mpv != nil else {
|
||||
return
|
||||
}
|
||||
var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer<CChar>(strdup($0)) } }
|
||||
defer {
|
||||
for ptr in cargs where ptr != nil {
|
||||
free(UnsafeMutablePointer(mutating: ptr!))
|
||||
}
|
||||
}
|
||||
let returnValue = mpv_command(mpv, &cargs)
|
||||
if checkForErrors {
|
||||
checkError(returnValue)
|
||||
}
|
||||
if let cb = returnValueCallback {
|
||||
cb(returnValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
|
||||
if !args.isEmpty, args.last == nil {
|
||||
fatalError("Command do not need a nil suffix")
|
||||
}
|
||||
|
||||
var strArgs = args
|
||||
strArgs.insert(command, at: 0)
|
||||
strArgs.append(nil)
|
||||
|
||||
return strArgs
|
||||
}
|
||||
|
||||
// MARK: - Event Processing
|
||||
|
||||
func readEvents() {
|
||||
queue.async { [self] in
|
||||
while self.mpv != nil {
|
||||
let event = mpv_wait_event(self.mpv, 0)
|
||||
if event!.pointee.event_id == MPV_EVENT_NONE {
|
||||
break
|
||||
}
|
||||
switch event!.pointee.event_id {
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
let dataOpaquePtr = OpaquePointer(event!.pointee.data)
|
||||
if let property = UnsafePointer<mpv_event_property>(dataOpaquePtr)?.pointee {
|
||||
let propertyName = String(cString: property.name)
|
||||
|
||||
// Handle different property types
|
||||
var value: Any?
|
||||
|
||||
switch propertyName {
|
||||
case MpvProperty.pausedForCache, MpvProperty.pause:
|
||||
if property.format == MPV_FORMAT_FLAG,
|
||||
let data = property.data
|
||||
{
|
||||
let boolValue =
|
||||
UnsafePointer<Bool>(OpaquePointer(data))?.pointee ?? false
|
||||
value = boolValue
|
||||
}
|
||||
|
||||
case MpvProperty.timePosition, MpvProperty.duration:
|
||||
if property.format == MPV_FORMAT_DOUBLE,
|
||||
let data = property.data
|
||||
{
|
||||
let doubleValue =
|
||||
UnsafePointer<Double>(OpaquePointer(data))?.pointee ?? 0.0
|
||||
value = doubleValue
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Notify delegate if we have a value
|
||||
if let value = value {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.mpvDelegate?.propertyChanged(
|
||||
mpv: self.mpv, propertyName: propertyName, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
case MPV_EVENT_SHUTDOWN:
|
||||
mpv_render_context_free(mpvGL)
|
||||
mpv_terminate_destroy(mpv)
|
||||
mpv = nil
|
||||
print("event: shutdown\n")
|
||||
break
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
let msg = UnsafeMutablePointer<mpv_event_log_message>(
|
||||
OpaquePointer(event!.pointee.data))
|
||||
print(
|
||||
"[\(String(cString: (msg!.pointee.prefix)!))] \(String(cString: (msg!.pointee.level)!)): \(String(cString: (msg!.pointee.text)!))",
|
||||
terminator: "")
|
||||
default:
|
||||
let eventName = mpv_event_name(event!.pointee.event_id)
|
||||
print("event: \(String(cString: (eventName)!))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkError(_ status: CInt) {
|
||||
if status < 0 {
|
||||
print("MPV API error: \(String(cString: mpv_error_string(status)))\n")
|
||||
}
|
||||
}
|
||||
|
||||
private var machine: String {
|
||||
var systeminfo = utsname()
|
||||
uname(&systeminfo)
|
||||
return withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
|
||||
let data = Data(bufPtr)
|
||||
if let lastIndex = data.lastIndex(where: { $0 != 0 }) {
|
||||
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
|
||||
} else {
|
||||
return String(data: data, encoding: .isoLatin1)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GL Rendering
|
||||
|
||||
override func glkView(_ view: GLKView, drawIn rect: CGRect) {
|
||||
guard let mpvGL else {
|
||||
return
|
||||
}
|
||||
|
||||
// fill black background
|
||||
glClearColor(0, 0, 0, 0)
|
||||
glClear(UInt32(GL_COLOR_BUFFER_BIT))
|
||||
|
||||
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO)
|
||||
|
||||
var dims: [GLint] = [0, 0, 0, 0]
|
||||
glGetIntegerv(GLenum(GL_VIEWPORT), &dims)
|
||||
|
||||
var data = mpv_opengl_fbo(
|
||||
fbo: Int32(defaultFBO),
|
||||
w: Int32(dims[2]),
|
||||
h: Int32(dims[3]),
|
||||
internal_format: 0
|
||||
)
|
||||
|
||||
var flip: CInt = 1
|
||||
withUnsafeMutablePointer(to: &flip) { flip in
|
||||
withUnsafeMutablePointer(to: &data) { data in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip),
|
||||
mpv_render_param(),
|
||||
]
|
||||
mpv_render_context_render(mpvGL, ¶ms)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?)
|
||||
-> UnsafeMutableRawPointer?
|
||||
{
|
||||
let symbolName = CFStringCreateWithCString(
|
||||
kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
|
||||
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengles" as CFString)
|
||||
|
||||
return CFBundleGetFunctionPointerForName(identifier, symbolName)
|
||||
}
|
||||
}
|
||||
|
||||
private func mpvGLUpdate(_ ctx: UnsafeMutableRawPointer?) {
|
||||
let glView = unsafeBitCast(ctx, to: GLKView.self)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
glView.display()
|
||||
}
|
||||
}
|
||||
5
modules/mpv-player/src/MpvPlayerModule.ts
Normal file
5
modules/mpv-player/src/MpvPlayerModule.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule("MpvPlayer");
|
||||
@@ -1,10 +1,10 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
#if os(tvOS)
|
||||
import TVVLCKit
|
||||
import TVVLCKit
|
||||
#else
|
||||
import MobileVLCKit
|
||||
import MobileVLCKit
|
||||
#endif
|
||||
import UIKit
|
||||
|
||||
class VlcPlayer3View: ExpoView {
|
||||
private var mediaPlayer: VLCMediaPlayer?
|
||||
@@ -16,7 +16,7 @@ class VlcPlayer3View: ExpoView {
|
||||
private var lastReportedIsPlaying: Bool?
|
||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||
private var startPosition: Int32 = 0
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
private var isMediaReady: Bool = false
|
||||
private var externalTrack: [String: String]?
|
||||
private var progressTimer: DispatchSourceTimer?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
@@ -61,7 +61,7 @@ class VlcPlayer3View: ExpoView {
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func startPictureInPicture() {}
|
||||
func startPictureInPicture() { }
|
||||
|
||||
@objc func play() {
|
||||
self.mediaPlayer?.play()
|
||||
@@ -109,7 +109,6 @@ class VlcPlayer3View: ExpoView {
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
initOptions.append("--start-time=\(self.startPosition)")
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
@@ -144,8 +143,8 @@ class VlcPlayer3View: ExpoView {
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.mediaPlayer?.media = media
|
||||
self.setInitialExternalSubtitles()
|
||||
self.hasSource = true
|
||||
|
||||
if autoplay {
|
||||
print("Playing...")
|
||||
self.play()
|
||||
@@ -183,9 +182,9 @@ class VlcPlayer3View: ExpoView {
|
||||
return
|
||||
}
|
||||
|
||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||
if let result = result {
|
||||
let internalName = "Track \(self.customSubtitles.count)"
|
||||
let internalName = "Track \(self.customSubtitles.count + 1)"
|
||||
print("Subtitle added with result: \(result) \(internalName)")
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
} else {
|
||||
@@ -193,19 +192,6 @@ class VlcPlayer3View: ExpoView {
|
||||
}
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||
guard let mediaPlayer = self.mediaPlayer else {
|
||||
return nil
|
||||
@@ -290,6 +276,16 @@ class VlcPlayer3View: ExpoView {
|
||||
|
||||
print("Debug: Current time: \(currentTimeMs)")
|
||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||
if player.isPlaying && !self.isMediaReady {
|
||||
self.isMediaReady = true
|
||||
// Set external track subtitle when starting.
|
||||
if let externalTrack = self.externalTrack {
|
||||
if let name = externalTrack["name"], !name.isEmpty {
|
||||
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
|
||||
self.setSubtitleURL(deliveryUrl, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
|
||||
@@ -12,6 +12,7 @@ Pod::Spec.new do |s|
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'VLCKit', s.version
|
||||
s.tvos.dependency 'VLCKit', s.version
|
||||
s.dependency 'Alamofire', '~> 5.10'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "0.8.6",
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
|
||||
"@expo/config-plugins": "~9.0.15",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
@@ -56,7 +57,7 @@
|
||||
"expo-router": "~4.0.17",
|
||||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-sensors": "~14.0.2",
|
||||
"expo-sharing": "~13.0.1",
|
||||
"expo-sharing": "~13.1.0",
|
||||
"expo-splash-screen": "~0.29.22",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.8",
|
||||
@@ -70,7 +71,7 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-native": "npm:react-native-tvos@~0.77.2-0",
|
||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "0.8.6",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,5 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
@@ -19,7 +18,6 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
|
||||
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
@@ -40,7 +38,6 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppState, type AppStateStatus, Platform } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Bitrate } from "../components/BitrateSelector";
|
||||
import { apiAtom } from "./JellyfinProvider";
|
||||
|
||||
export type DownloadedItem = {
|
||||
@@ -69,7 +66,7 @@ function useDownloadProvider() {
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
let [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
@@ -77,17 +74,6 @@ function useDownloadProvider() {
|
||||
return api?.accessToken;
|
||||
}, [api]);
|
||||
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
[settings],
|
||||
);
|
||||
|
||||
const getDownloadUrl = (process: JobStatus) => {
|
||||
return usingOptimizedServer
|
||||
? `${settings.optimizedVersionsServerUrl}download/${process.id}`
|
||||
: process.inputUrl;
|
||||
};
|
||||
|
||||
const { data: downloadedFiles, refetch } = useQuery({
|
||||
queryKey: ["downloadedItems"],
|
||||
queryFn: getAllDownloadedItems,
|
||||
@@ -178,64 +164,6 @@ function useDownloadProvider() {
|
||||
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
});
|
||||
|
||||
/// Cant use the background downloader callback. As its not triggered if size is unknown.
|
||||
const updateProgress = async () => {
|
||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// const response = await getSessionApi(api).getSessions({
|
||||
// activeWithinSeconds: 300,
|
||||
// });
|
||||
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
// check if processes are missing
|
||||
const missingProcesses = tasks
|
||||
.filter((t) => !processes.some((p) => p.id === t.id))
|
||||
.map((t) => {
|
||||
return t.metadata;
|
||||
});
|
||||
|
||||
processes = [...processes, ...missingProcesses];
|
||||
|
||||
const updatedProcesses = processes.map((p) => {
|
||||
// const result = response.data.find((s) => s.Id == p.sessionId);
|
||||
// if (result) {
|
||||
// return {
|
||||
// ...p,
|
||||
// progress: result.TranscodingInfo?.CompletionPercentage,
|
||||
// };
|
||||
// }
|
||||
|
||||
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
|
||||
// We make an wild guess by comparing bitrates
|
||||
const task = tasks.find((s) => s.id === p.id);
|
||||
if (task) {
|
||||
let progress = p.progress;
|
||||
let size = p.mediaSource.Size;
|
||||
const maxBitrate = p.maxBitrate.value;
|
||||
if (maxBitrate && maxBitrate < p.mediaSource.Bitrate) {
|
||||
size = (size / p.mediaSource.Bitrate) * maxBitrate;
|
||||
}
|
||||
progress = (100 / size) * task.bytesDownloaded;
|
||||
if (progress >= 100) {
|
||||
progress = 99;
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
setProcesses(updatedProcesses);
|
||||
};
|
||||
|
||||
useInterval(updateProgress, 2000);
|
||||
|
||||
useEffect(() => {
|
||||
const checkIfShouldStartDownload = async () => {
|
||||
if (processes.length === 0) return;
|
||||
@@ -248,25 +176,18 @@ function useDownloadProvider() {
|
||||
const removeProcess = useCallback(
|
||||
async (id: string) => {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
if (!deviceId || !authHeader) return;
|
||||
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
|
||||
return;
|
||||
|
||||
if (usingOptimizedServer) {
|
||||
try {
|
||||
await cancelJobById({
|
||||
authHeader,
|
||||
id,
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
try {
|
||||
await cancelJobById({
|
||||
authHeader,
|
||||
id,
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.filter(
|
||||
(process: { itemId: string | undefined }) => process.id !== id,
|
||||
);
|
||||
});
|
||||
},
|
||||
[settings?.optimizedVersionsServerUrl, authHeader],
|
||||
);
|
||||
@@ -317,9 +238,8 @@ function useDownloadProvider() {
|
||||
|
||||
BackGroundDownloader?.download({
|
||||
id: process.id,
|
||||
url: getDownloadUrl(process),
|
||||
url: `${settings?.optimizedVersionsServerUrl}download/${process.id}`,
|
||||
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
||||
metadata: process,
|
||||
})
|
||||
.begin(() => {
|
||||
setProcesses((prev) =>
|
||||
@@ -336,9 +256,6 @@ function useDownloadProvider() {
|
||||
);
|
||||
})
|
||||
.progress((data) => {
|
||||
if (!usingOptimizedServer) {
|
||||
return;
|
||||
}
|
||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) =>
|
||||
@@ -411,12 +328,7 @@ function useDownloadProvider() {
|
||||
);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (
|
||||
url: string,
|
||||
item: BaseItemDto,
|
||||
mediaSource: MediaSourceInfo,
|
||||
maxBitrate?: Bitrate,
|
||||
) => {
|
||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
||||
if (!api || !item.Id || !authHeader)
|
||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||
|
||||
@@ -433,42 +345,26 @@ function useDownloadProvider() {
|
||||
width: 500,
|
||||
});
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
if (usingOptimizedServer) {
|
||||
const response = await axios.post(
|
||||
`${settings?.optimizedVersionsServerUrl}optimize-version`,
|
||||
{
|
||||
url,
|
||||
fileExtension,
|
||||
deviceId,
|
||||
itemId: item.Id,
|
||||
item,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error("Failed to start optimization job");
|
||||
}
|
||||
} else {
|
||||
const job: JobStatus = {
|
||||
id: item.Id!,
|
||||
deviceId: deviceId,
|
||||
inputUrl: url,
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
mediaSource,
|
||||
progress: 0,
|
||||
maxBitrate,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setProcesses([...processes, job]);
|
||||
startDownload(job);
|
||||
const response = await axios.post(
|
||||
`${settings?.optimizedVersionsServerUrl}optimize-version`,
|
||||
{
|
||||
url,
|
||||
fileExtension,
|
||||
deviceId,
|
||||
itemId: item.Id,
|
||||
item,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error("Failed to start optimization job");
|
||||
}
|
||||
|
||||
toast.success(
|
||||
|
||||
108
providers/DownloadProvider.tv.tsx
Normal file
108
providers/DownloadProvider.tv.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Application from "expo-application";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
const DownloadContext = createContext<ReturnType<
|
||||
typeof useDownloadProvider
|
||||
> | null>(null);
|
||||
|
||||
/**
|
||||
* Dummy download provider for tvOS
|
||||
*/
|
||||
function useDownloadProvider() {
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const downloadedFiles: DownloadedItem[] = [];
|
||||
|
||||
const removeProcess = useCallback(async (id: string) => {}, []);
|
||||
|
||||
const startDownload = useCallback(async (process: JobStatus) => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAllFiles = async (): Promise<void> => {};
|
||||
|
||||
const deleteFile = async (id: string): Promise<void> => {};
|
||||
|
||||
const deleteItems = async (items: BaseItemDto[]) => {};
|
||||
|
||||
const cleanCacheDirectory = async () => {};
|
||||
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
|
||||
|
||||
const appSizeUsage = useMemo(async () => {
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto, size = 0) {}
|
||||
|
||||
function getDownloadedItemSize(itemId: string): number {
|
||||
const size = storage.getString(`downloadedItemSize-${itemId}`);
|
||||
return size ? Number.parseInt(size) : 0;
|
||||
}
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
||||
|
||||
return {
|
||||
processes,
|
||||
startBackgroundDownload,
|
||||
downloadedFiles,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
saveDownloadedItemInfo,
|
||||
removeProcess,
|
||||
setProcesses,
|
||||
startDownload,
|
||||
getDownloadedItem,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
getDownloadedItemSize,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
const downloadProviderValue = useDownloadProvider();
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={downloadProviderValue}>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
const context = useContext(DownloadContext);
|
||||
if (context === null) {
|
||||
throw new Error("useDownload must be used within a DownloadProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Bitrate } from "@/components/BitrateSelector";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import native from "@/utils/profiles/native";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
@@ -84,7 +84,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const native = await generateDeviceProfile();
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
deviceProfile: native,
|
||||
|
||||
@@ -138,8 +138,7 @@
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
|
||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||
"default_quality": "Standardqualität",
|
||||
"disabled": "Deaktiviert"
|
||||
"default_quality": "Standardqualität"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
@@ -371,9 +370,7 @@
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"no_data_available": "Keine Daten verfügbar",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"go_back": "Zurück"
|
||||
"index": "Index:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Als Nächstes",
|
||||
|
||||
@@ -138,9 +138,7 @@
|
||||
"hide_libraries": "Hide Libraries",
|
||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||
"default_quality": "Default quality",
|
||||
"max_auto_play_episode_count": "Max auto play episode count",
|
||||
"disabled": "Disabled"
|
||||
"default_quality": "Default quality"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
@@ -376,9 +374,7 @@
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"no_data_available": "No data available",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go back"
|
||||
"index": "Index:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next up",
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Uzantnomo estas deviga",
|
||||
"error_title": "Eraro",
|
||||
"login_title": "Ensaluti",
|
||||
"login_to_title": "Ensaluti al",
|
||||
"username_placeholder": "Uzantnomo",
|
||||
"password_placeholder": "Pasvorto",
|
||||
"login_button": "Ensaluti",
|
||||
"quick_connect": "Rapida Konekto",
|
||||
"enter_code_to_login": "Enigu kodon {{code}} por ensaluti",
|
||||
"failed_to_initiate_quick_connect": "Malsukcesis iniciati Rapidan Konekton",
|
||||
"got_it": "Komprenita",
|
||||
"connection_failed": "Konekto malsukcesis",
|
||||
"could_not_connect_to_server": "Ne povis konekti al la servilo. Bonvolu kontroli la URL-on kaj vian retan konekton.",
|
||||
"an_unexpected_error_occured": "Neatendita eraro okazis",
|
||||
"change_server": "Ŝanĝi servilon",
|
||||
"invalid_username_or_password": "Nevalida uzantnomo aŭ pasvorto",
|
||||
"user_does_not_have_permission_to_log_in": "Uzanto ne havas permeson ensaluti",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Servilo respondas tro malrapide, provu denove poste",
|
||||
"server_received_too_many_requests_try_again_later": "Servilo ricevis tro multajn petojn, provu denove poste.",
|
||||
"there_is_a_server_error": "Estas servila eraro",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Neatendita eraro okazis. Ĉu vi enigis la ĝustan servilan URL-on?"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enigu la URL-on al via Jellyfin-servilo",
|
||||
"server_url_placeholder": "http(s)://via-servilo.com",
|
||||
"connect_button": "Konekti",
|
||||
"previous_servers": "antaŭaj serviloj",
|
||||
"clear_button": "Forviŝi",
|
||||
"search_for_local_servers": "Serĉi lokajn servilojn",
|
||||
"searching": "Serĉante...",
|
||||
"servers": "Serviloj"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "Neniu Interreto",
|
||||
"no_items": "Neniuj eroj",
|
||||
"no_internet_message": "Ne zorgu, vi ankoraŭ povas spekti\nelsŝutitan enhavon.",
|
||||
"go_to_downloads": "Iri al elŝutoj",
|
||||
"oops": "Ho ve!",
|
||||
"error_message": "Io misfunkciis.\nBonvolu elsaluti kaj reensaluti.",
|
||||
"continue_watching": "Daŭrigi Spektadon",
|
||||
"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.",
|
||||
"features_title": "Trajtoj",
|
||||
"features_description": "Streamyfin havas multajn trajtojn kaj integriĝas kun vasta gamo de programaroj, kiujn vi povas trovi en la agorda menuo, tiuj inkluzivas:",
|
||||
"jellyseerr_feature_description": "Konekti al via Jellyseerr-instanco kaj peti filmojn rekte en la aplikaĵo.",
|
||||
"downloads_feature_title": "Elŝutoj",
|
||||
"downloads_feature_description": "Elŝutu filmojn kaj televidajn seriojn por vidi senkonekte. Uzu aŭ la defaŭltan metodon aŭ instalu la optimumigan servilon por elŝuti dosierojn en la fono.",
|
||||
"chromecast_feature_description": "Ĵetu filmojn kaj televidajn seriojn al viaj Chromecast-aparatoj.",
|
||||
"centralised_settings_plugin_title": "Centralizita Agorda Kromprogramo",
|
||||
"centralised_settings_plugin_description": "Agordu agordojn de centralizita loko sur via Jellyfin-servilo. Ĉiuj klientaj agordoj por ĉiuj uzantoj estos sinkronigitaj aŭtomate.",
|
||||
"done_button": "Farite",
|
||||
"go_to_settings_button": "Iri al agordoj",
|
||||
"read_more": "Legu pli"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Agordoj",
|
||||
"log_out_button": "Elsaluti",
|
||||
"user_info": {
|
||||
"user_info_title": "Uzantaj Informoj",
|
||||
"user": "Uzanto",
|
||||
"server": "Servilo",
|
||||
"token": "Ĵetono",
|
||||
"app_version": "Aplikaĵa Versio"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Rapida Konekto",
|
||||
"authorize_button": "Aŭtorizi Rapidan Konekton",
|
||||
"enter_the_quick_connect_code": "Enigu la rapidan konektan kodon...",
|
||||
"success": "Sukceso",
|
||||
"quick_connect_autorized": "Rapida Konekto aŭtorizita",
|
||||
"error": "Eraro",
|
||||
"invalid_code": "Nevalida kodo",
|
||||
"authorize": "Aŭtorizi"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Mediaj Kontroloj",
|
||||
"forward_skip_length": "Antaŭensalta longeco",
|
||||
"rewind_length": "Rebobena longeco",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Agordi Aŭdian Trakon De Antaŭa Ero",
|
||||
"audio_language": "Aŭdia lingvo",
|
||||
"audio_hint": "Elektu defaŭltan aŭdian lingvon.",
|
||||
"none": "Neniu",
|
||||
"language": "Lingvo"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Subtekstoj",
|
||||
"subtitle_language": "Subteksta lingvo",
|
||||
"subtitle_mode": "Subteksta Reĝimo",
|
||||
"set_subtitle_track": "Agordi Subtekstan Trakon De Antaŭa Ero",
|
||||
"subtitle_size": "Subteksta Grandeco",
|
||||
"subtitle_hint": "Agordu subtekstan preferon.",
|
||||
"none": "Neniu",
|
||||
"language": "Lingvo",
|
||||
"loading": "Ŝarĝante",
|
||||
"modes": {
|
||||
"Default": "Defaŭlta",
|
||||
"Smart": "Inteligenta",
|
||||
"Always": "Ĉiam",
|
||||
"None": "Neniu",
|
||||
"OnlyForced": "NurDevigita"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Alia",
|
||||
"follow_device_orientation": "Aŭtomata rotacio",
|
||||
"video_orientation": "Video-orientiĝo",
|
||||
"orientation": "Orientiĝo",
|
||||
"orientations": {
|
||||
"DEFAULT": "Defaŭlta",
|
||||
"ALL": "Ĉiuj",
|
||||
"PORTRAIT": "Portreta",
|
||||
"PORTRAIT_UP": "Portreta Supren",
|
||||
"PORTRAIT_DOWN": "Portreta Malsupren",
|
||||
"LANDSCAPE": "Pejzaĝa",
|
||||
"LANDSCAPE_LEFT": "Pejzaĝa Maldekstren",
|
||||
"LANDSCAPE_RIGHT": "Pejzaĝa Dekstren",
|
||||
"OTHER": "Alia",
|
||||
"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.",
|
||||
"disable_haptic_feedback": "Malŝalti Haptan Rimarkon",
|
||||
"default_quality": "Defaŭlta kvalito"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Elŝutoj",
|
||||
"download_method": "Elŝuta metodo",
|
||||
"remux_max_download": "Remux maksimuma elŝuto",
|
||||
"auto_download": "Aŭtomata elŝuto",
|
||||
"optimized_versions_server": "Optimumigitaj versioj servilo",
|
||||
"save_button": "Konservi",
|
||||
"optimized_server": "Optimumigita Servilo",
|
||||
"optimized": "Optimumigita",
|
||||
"default": "Defaŭlta",
|
||||
"optimized_version_hint": "Enigu la URL-on por la optimumiga servilo. La URL devus inkluzivi http aŭ https kaj laŭvole la pordon.",
|
||||
"read_more_about_optimized_server": "Legu pli pri la optimumiga servilo.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domajno.org:pordo"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Kromprogramoj",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Ĉi tiu integriĝo estas en siaj fruaj stadioj. Atendu ŝanĝojn.",
|
||||
"server_url": "Servila URL",
|
||||
"server_url_hint": "Ekzemplo: http(s)://via-gastiganto.url\n(aldonu pordon se necese)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Pasvorto",
|
||||
"password_placeholder": "Enigu pasvorton por Jellyfin-uzanto {{username}}",
|
||||
"save_button": "Konservi",
|
||||
"clear_button": "Forviŝi",
|
||||
"login_button": "Ensaluti",
|
||||
"total_media_requests": "Totalaj mediaj petoj",
|
||||
"movie_quota_limit": "Filma kvota limo",
|
||||
"movie_quota_days": "Filmaj kvotaj tagoj",
|
||||
"tv_quota_limit": "Televida kvota limo",
|
||||
"tv_quota_days": "Televidaj kvotaj tagoj",
|
||||
"reset_jellyseerr_config_button": "Restarigi Jellyseerr-agordon",
|
||||
"unlimited": "Senlima",
|
||||
"plus_n_more": "+{{n}} pli",
|
||||
"order_by": {
|
||||
"DEFAULT": "Defaŭlta",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Voĉdonkalkulo kaj mezumo",
|
||||
"POPULARITY": "Populareco"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Ebligi Marlin Serĉon ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domajno.org:pordo",
|
||||
"marlin_search_hint": "Enigu la URL-on por la Marlin-servilo. La URL devus inkluzivi http aŭ https kaj laŭvole la pordon.",
|
||||
"read_more_about_marlin": "Legu pli pri Marlin.",
|
||||
"save_button": "Konservi",
|
||||
"toasts": {
|
||||
"saved": "Konservita"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Stokado",
|
||||
"app_usage": "Aplikaĵo {{usedSpace}}%",
|
||||
"device_usage": "Aparato {{availableSpace}}%",
|
||||
"size_used": "{{used}} el {{total}} uzata",
|
||||
"delete_all_downloaded_files": "Forigi Ĉiujn Elŝutitajn Dosierojn"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Montri enkondukon",
|
||||
"reset_intro": "Restarigi enkondukon"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Protokoloj",
|
||||
"export_logs": "Eksporti protokolojn",
|
||||
"click_for_more_info": "Klaku por pli da informoj",
|
||||
"level": "Nivelo",
|
||||
"no_logs_available": "Neniuj protokoloj disponeblaj",
|
||||
"delete_all_logs": "Forigi ĉiujn protokolojn"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Lingvoj",
|
||||
"app_language": "Aplikaĵa lingvo",
|
||||
"app_language_description": "Elektu la lingvon por la aplikaĵo.",
|
||||
"system": "Sistemo"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sesioj",
|
||||
"no_active_sessions": "Neniuj aktivaj sesioj"
|
||||
},
|
||||
"downloads": {
|
||||
"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",
|
||||
"delete_all_button": "Forigi ĉion",
|
||||
"active_download": "Aktiva elŝuto",
|
||||
"no_active_downloads": "Neniuj aktivaj elŝutoj",
|
||||
"active_downloads": "Aktivaj elŝutoj",
|
||||
"new_app_version_requires_re_download": "Nova aplikaĵa versio postulas re-elŝuton",
|
||||
"new_app_version_requires_re_download_description": "La nova ĝisdatigo postulas, ke enhavo estu elŝutita denove. Bonvolu forigi ĉian elŝutitan enhavon kaj provi denove.",
|
||||
"back": "Reen",
|
||||
"delete": "Forigi",
|
||||
"something_went_wrong": "Io misfunkciis",
|
||||
"could_not_get_stream_url_from_jellyfin": "Ne povis akiri la fluan URL-on de Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Metodoj",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
|
||||
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
|
||||
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
|
||||
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||
"download_cancelled": "Elŝuto nuligita",
|
||||
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
||||
"download_completed": "Elŝuto finita",
|
||||
"download_started_for": "Elŝuto komenciĝis por {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} estas preta por esti elŝutita",
|
||||
"download_stated_for_item": "Elŝuto komenciĝis por {{item}}",
|
||||
"download_failed_for_item": "Elŝuto malsukcesis por {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Elŝuto finita por {{item}}",
|
||||
"queued_item_for_optimization": "Envicigis {{item}} por optimumigo",
|
||||
"failed_to_start_download_for_item": "Malsukcesis komenci elŝutadon por {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Servilo respondis kun statuskodo {{statusCode}}",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Serĉu ĉi tie...",
|
||||
"search": "Serĉi...",
|
||||
"x_items": "{{count}} eroj",
|
||||
"library": "Biblioteko",
|
||||
"discover": "Malkovri",
|
||||
"no_results": "Neniuj rezultoj",
|
||||
"no_results_found_for": "Neniuj rezultoj trovitaj por",
|
||||
"movies": "Filmoj",
|
||||
"series": "Serioj",
|
||||
"episodes": "Epizodoj",
|
||||
"collections": "Kolektoj",
|
||||
"actors": "Aktoroj",
|
||||
"request_movies": "Peti Filmojn",
|
||||
"request_series": "Peti Seriojn",
|
||||
"recently_added": "Ĵus Aldonita",
|
||||
"recent_requests": "Lastatempaj Petoj",
|
||||
"plex_watchlist": "Plex Spektolisto",
|
||||
"trending": "Tendencaj",
|
||||
"popular_movies": "Popularaj Filmoj",
|
||||
"movie_genres": "Filmaj Ĝenroj",
|
||||
"upcoming_movies": "Venontaj Filmoj",
|
||||
"studios": "Studioj",
|
||||
"popular_tv": "Populara Televido",
|
||||
"tv_genres": "Televidaj Ĝenroj",
|
||||
"upcoming_tv": "Venonta Televido",
|
||||
"networks": "Retoj",
|
||||
"tmdb_movie_keyword": "TMDB Filma Ŝlosilvorto",
|
||||
"tmdb_movie_genre": "TMDB Filma Ĝenro",
|
||||
"tmdb_tv_keyword": "TMDB Televida Ŝlosilvorto",
|
||||
"tmdb_tv_genre": "TMDB Televida Ĝenro",
|
||||
"tmdb_search": "TMDB Serĉo",
|
||||
"tmdb_studio": "TMDB Studio",
|
||||
"tmdb_network": "TMDB Reto",
|
||||
"tmdb_movie_streaming_services": "TMDB Filmaj Fluservoj",
|
||||
"tmdb_tv_streaming_services": "TMDB Televidaj Fluservoj"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Neniuj eroj trovitaj",
|
||||
"no_results": "Neniuj rezultoj",
|
||||
"no_libraries_found": "Neniuj bibliotekoj trovitaj",
|
||||
"item_types": {
|
||||
"movies": "filmoj",
|
||||
"series": "serioj",
|
||||
"boxsets": "skatolaj aroj",
|
||||
"items": "eroj"
|
||||
},
|
||||
"options": {
|
||||
"display": "Vidigi",
|
||||
"row": "Vico",
|
||||
"list": "Listo",
|
||||
"image_style": "Bildostilo",
|
||||
"poster": "Afiŝo",
|
||||
"cover": "Kovrilo",
|
||||
"show_titles": "Montri titolojn",
|
||||
"show_stats": "Montri statistikojn"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Ĝenroj",
|
||||
"years": "Jaroj",
|
||||
"sort_by": "Ordigi laŭ",
|
||||
"sort_order": "Orda ordo",
|
||||
"asc": "Supreniranta",
|
||||
"desc": "Malsupreniranta",
|
||||
"tags": "Etikedoj"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Serioj",
|
||||
"movies": "Filmoj",
|
||||
"episodes": "Epizodoj",
|
||||
"videos": "Videoj",
|
||||
"boxsets": "Skatolaj aroj",
|
||||
"playlists": "Ludlistoj",
|
||||
"noDataTitle": "Ankoraŭ neniuj favoratoj",
|
||||
"noData": "Marku erojn kiel favoratojn por vidi ilin aperi ĉi tie por rapida aliro."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Neniuj ligiloj"
|
||||
},
|
||||
"player": {
|
||||
"error": "Eraro",
|
||||
"failed_to_get_stream_url": "Malsukcesis akiri la fluan URL-on",
|
||||
"an_error_occured_while_playing_the_video": "Eraro okazis dum ludado de la video. Kontrolu protokolojn en agordoj.",
|
||||
"client_error": "Klienta eraro",
|
||||
"could_not_create_stream_for_chromecast": "Ne povis krei fluon por Chromecast",
|
||||
"message_from_server": "Mesaĝo de servilo: {{message}}",
|
||||
"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:",
|
||||
"audio_tracks": "Aŭdiaj Trakoj:",
|
||||
"playback_state": "Ludada Stato:",
|
||||
"no_data_available": "Neniuj datumoj disponeblaj",
|
||||
"index": "Indekso:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Sekva",
|
||||
"no_items_to_display": "Neniuj eroj por montri",
|
||||
"cast_and_crew": "Rolantaro & Skiparo",
|
||||
"series": "Serioj",
|
||||
"seasons": "Sezonoj",
|
||||
"season": "Sezono",
|
||||
"no_episodes_for_this_season": "Neniuj epizodoj por ĉi tiu sezono",
|
||||
"overview": "Superrigardo",
|
||||
"more_with": "Pli kun {{name}}",
|
||||
"similar_items": "Similaj eroj",
|
||||
"no_similar_items_found": "Neniuj similaj eroj trovitaj",
|
||||
"video": "Video",
|
||||
"more_details": "Pli da detaloj",
|
||||
"quality": "Kvalito",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subteksto",
|
||||
"show_more": "Montri pli",
|
||||
"show_less": "Montri malpli",
|
||||
"appeared_in": "Aperis en",
|
||||
"could_not_load_item": "Ne povis ŝarĝi eron",
|
||||
"none": "Neniu",
|
||||
"download": {
|
||||
"download_season": "Elŝuti Sezonon",
|
||||
"download_series": "Elŝuti Serion",
|
||||
"download_episode": "Elŝuti Epizodon",
|
||||
"download_movie": "Elŝuti Filmon",
|
||||
"download_x_item": "Elŝuti {{item_count}} erojn",
|
||||
"download_button": "Elŝuti",
|
||||
"using_optimized_server": "Uzante optimumigitan servilon",
|
||||
"using_default_method": "Uzante defaŭltan metodon"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Sekva",
|
||||
"previous": "Antaŭa",
|
||||
"live_tv": "Viva Televido",
|
||||
"coming_soon": "Baldaŭ",
|
||||
"on_now": "Nun",
|
||||
"shows": "Spektakloj",
|
||||
"movies": "Filmoj",
|
||||
"sports": "Sportoj",
|
||||
"for_kids": "Por Infanoj",
|
||||
"news": "Novaĵoj"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "Konfirmi",
|
||||
"cancel": "Nuligi",
|
||||
"yes": "Jes",
|
||||
"whats_wrong": "Kio estas malĝusta?",
|
||||
"issue_type": "Problema tipo",
|
||||
"select_an_issue": "Elektu problemon",
|
||||
"types": "Tipoj",
|
||||
"describe_the_issue": "(laŭvola) Priskribu la problemon...",
|
||||
"submit_button": "Sendi",
|
||||
"report_issue_button": "Raporti problemon",
|
||||
"request_button": "Peti",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Ĉu vi certas, ke vi volas peti ĉiujn sezonojn?",
|
||||
"failed_to_login": "Malsukcesis ensaluti",
|
||||
"cast": "Rolantaro",
|
||||
"details": "Detaloj",
|
||||
"status": "Stato",
|
||||
"original_title": "Originala Titolo",
|
||||
"series_type": "Seria Tipo",
|
||||
"release_dates": "Eldondatoj",
|
||||
"first_air_date": "Unua Elsendo-dato",
|
||||
"next_air_date": "Sekva Elsendo-dato",
|
||||
"revenue": "Enspezo",
|
||||
"budget": "Buĝeto",
|
||||
"original_language": "Originala Lingvo",
|
||||
"production_country": "Produktada Lando",
|
||||
"studios": "Studioj",
|
||||
"network": "Reto",
|
||||
"currently_streaming_on": "Nuntempe Flusanta ĉe",
|
||||
"advanced": "Altnivela",
|
||||
"request_as": "Peti Kiel",
|
||||
"tags": "Etikedoj",
|
||||
"quality_profile": "Kvalita Profilo",
|
||||
"root_folder": "Radika Dosierujo",
|
||||
"season_all": "Sezono (ĉiuj)",
|
||||
"season_number": "Sezono {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Epizodoj",
|
||||
"born": "Naskiĝis",
|
||||
"appearances": "Aperoj",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr-servilo ne plenumas minimumajn versiajn postulojn! Bonvolu ĝisdatigi al almenaŭ 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr-testo malsukcesis. Bonvolu provi denove.",
|
||||
"failed_to_test_jellyseerr_server_url": "Malsukcesis testi jellyseerr-servilan url-on",
|
||||
"issue_submitted": "Problemo sendita!",
|
||||
"requested_item": "Petis {{item}}!",
|
||||
"you_dont_have_permission_to_request": "Vi ne havas permeson peti!",
|
||||
"something_went_wrong_requesting_media": "Io misfunkciis petante medion!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Hejmo",
|
||||
"search": "Serĉi",
|
||||
"library": "Biblioteko",
|
||||
"custom_links": "Propraj Ligiloj",
|
||||
"favorites": "Favoratoj"
|
||||
}
|
||||
}
|
||||
@@ -138,8 +138,7 @@
|
||||
"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",
|
||||
"default_quality": "Calidad por defecto",
|
||||
"disabled": "Deshabilitado"
|
||||
"default_quality": "Calidad por defecto"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas",
|
||||
@@ -371,9 +370,7 @@
|
||||
"audio_tracks": "Pistas de audio:",
|
||||
"playback_state": "Estado de la reproducción:",
|
||||
"no_data_available": "No hay datos disponibles",
|
||||
"index": "Índice:",
|
||||
"continue_watching": "Continuar viendo",
|
||||
"go_back": "Volver"
|
||||
"index": "Índice:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "A continuación",
|
||||
|
||||
@@ -138,8 +138,7 @@
|
||||
"hide_libraries": "Cacher des bibliothèques",
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||
"default_quality": "Qualité par défaut",
|
||||
"disabled": "Désactivé"
|
||||
"default_quality": "Qualité par défaut"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements",
|
||||
@@ -371,9 +370,7 @@
|
||||
"audio_tracks": "Pistes audio:",
|
||||
"playback_state": "État de lecture:",
|
||||
"no_data_available": "Aucune donnée disponible",
|
||||
"index": "Index :",
|
||||
"continue_watching": "Continuer à regarder",
|
||||
"go_back": "Retour"
|
||||
"index": "Index:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "À suivre",
|
||||
|
||||
@@ -138,8 +138,7 @@
|
||||
"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",
|
||||
"default_quality": "Qualità predefinita",
|
||||
"disabled": "Disabilitato"
|
||||
"default_quality": "Qualità predefinita"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Scaricamento",
|
||||
@@ -371,9 +370,7 @@
|
||||
"audio_tracks": "Tracce audio:",
|
||||
"playback_state": "Stato della riproduzione:",
|
||||
"no_data_available": "Nessun dato disponibile",
|
||||
"index": "Indice:",
|
||||
"continue_watching": "Continua a guardare",
|
||||
"go_back": "Indietro"
|
||||
"index": "Indice:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Il prossimo",
|
||||
|
||||
@@ -152,9 +152,7 @@
|
||||
"optimized_version_hint": "OptimizeサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
|
||||
"read_more_about_optimized_server": "Optimizeサーバーの詳細をご覧ください。",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:ポート",
|
||||
"default_quality": "デフォルトの品質",
|
||||
"disabled": "無効"
|
||||
"server_url_placeholder": "http(s)://domain.org:ポート"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "プラグイン",
|
||||
@@ -371,9 +369,7 @@
|
||||
"audio_tracks": "音声トラック:",
|
||||
"playback_state": "再生状態:",
|
||||
"no_data_available": "データなし",
|
||||
"index": "インデックス:",
|
||||
"continue_watching": "視聴を続ける",
|
||||
"go_back": "戻る"
|
||||
"index": "インデックス:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "次",
|
||||
|
||||
@@ -138,8 +138,7 @@
|
||||
"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",
|
||||
"default_quality": "Standaard kwaliteit",
|
||||
"disabled": "Uitgeschakeld"
|
||||
"default_quality": "Standaard kwaliteit"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
@@ -371,9 +370,7 @@
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Afspeelstatus:",
|
||||
"no_data_available": "Geen data beschikbaar",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Verder kijken",
|
||||
"go_back": "Terug"
|
||||
"index": "Index:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Volgende",
|
||||
|
||||
@@ -138,8 +138,7 @@
|
||||
"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",
|
||||
"default_quality": "Domyślna jakość",
|
||||
"disabled": "Wyłączone"
|
||||
"default_quality": "Domyślna jakość"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Pobieranie",
|
||||
@@ -375,9 +374,7 @@
|
||||
"audio_tracks": "Ścieżki audio:",
|
||||
"playback_state": "Stan odtwarzania:",
|
||||
"no_data_available": "Brak dostępnych danych",
|
||||
"index": "Indeks:",
|
||||
"continue_watching": "Kontynuuj oglądanie",
|
||||
"go_back": "Wstecz"
|
||||
"index": "Indeks:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Następne",
|
||||
|
||||
@@ -138,8 +138,7 @@
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
"select_liraries_you_want_to_hide": "Selecione as bibliotecas que você deseja ocultar das abas Biblioteca e Início.",
|
||||
"disable_haptic_feedback": "Desativar o feedback háptico",
|
||||
"default_quality": "Qualidade padrão",
|
||||
"disabled": "Desativado"
|
||||
"default_quality": "Qualidade padrão"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
@@ -372,9 +371,7 @@
|
||||
"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"
|
||||
"index": "Índice:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Próximo em",
|
||||
|
||||
@@ -1,480 +1,478 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Имя пользователя обязательно",
|
||||
"error_title": "Ошибка",
|
||||
"login_title": "Вход",
|
||||
"login_to_title": "Вход в",
|
||||
"username_placeholder": "Имя пользователя",
|
||||
"password_placeholder": "Пароль",
|
||||
"login_button": "Войти",
|
||||
"quick_connect": "Быстрое подключение",
|
||||
"enter_code_to_login": "Введите код {{code}} чтобы войти",
|
||||
"failed_to_initiate_quick_connect": "Не удалось инициировать быстрое подключение",
|
||||
"got_it": "Принято",
|
||||
"connection_failed": "Соединение не удалось",
|
||||
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста проверьте URL и ваше интернет соединение.",
|
||||
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
|
||||
"change_server": "Поменять сервер",
|
||||
"invalid_username_or_password": "Неправильное имя пользователя или пароль",
|
||||
"user_does_not_have_permission_to_log_in": "Пользователь не имеет прав на вход",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже.",
|
||||
"server_received_too_many_requests_try_again_later": "Сервер получил слишком много запросов, попробуйте позже.",
|
||||
"there_is_a_server_error": "Возникла ошибка сервера",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Укажите URL на ваш Jellyfin сервер",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
"connect_button": "Подключиться",
|
||||
"previous_servers": "предыдущие серверы",
|
||||
"clear_button": "Очистить",
|
||||
"search_for_local_servers": "Поиск локальных серверов",
|
||||
"searching": "Поиск...",
|
||||
"servers": "Сервера"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "Нет интернета",
|
||||
"no_items": "Нет элементов",
|
||||
"no_internet_message": "Не переживайте, Вы всё ещё можете смотреть\nскачанный контент.",
|
||||
"go_to_downloads": "В загрузки",
|
||||
"oops": "Упс!",
|
||||
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
|
||||
"continue_watching": "Продолжить просмотр",
|
||||
"next_up": "Следующее",
|
||||
"recently_added_in": "Недавно добавлено в {{libraryName}}",
|
||||
"suggested_movies": "Предложенные фильмы",
|
||||
"suggested_episodes": "Предложенные серии",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Добро пожаловать в Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом",
|
||||
"features_title": "Функции",
|
||||
"features_description": "Streamyfin имеет множество функций и интегрируется с широким спектром программ, которое вы можете найти в меню настроек:",
|
||||
"jellyseerr_feature_description": "Подключитесь к Jellyseerr и запрашивайте фильмы прямо в приложении.",
|
||||
"downloads_feature_title": "Загрузки",
|
||||
"downloads_feature_description": "Скачивайте фильмы и сериалы для просмотра без интернета. Используйте стандартный способ или установите сервер оптимизации для загрузки файлов в фоновом режиме.",
|
||||
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройста с поддержкой Chromecast.",
|
||||
"centralised_settings_plugin_title": "Плагин для централизованной настройки",
|
||||
"centralised_settings_plugin_description": "Настраивайте параметры из централизованного места на сервере Jellyfin. Все настройки клиента для всех пользователей будут синхронизированы автоматически.",
|
||||
"done_button": "Готово",
|
||||
"go_to_settings_button": "Перейти в настройки",
|
||||
"read_more": "Узнать больше"
|
||||
"login": {
|
||||
"username_required": "Имя пользователя обязательно",
|
||||
"error_title": "Ошибка",
|
||||
"login_title": "Вход",
|
||||
"login_to_title": "Вход в",
|
||||
"username_placeholder": "Имя пользователя",
|
||||
"password_placeholder": "Пароль",
|
||||
"login_button": "Войти",
|
||||
"quick_connect": "Быстрое подключение",
|
||||
"enter_code_to_login": "Введите код {{code}} чтобы войти",
|
||||
"failed_to_initiate_quick_connect": "Не удалось инициировать быстрое подключение",
|
||||
"got_it": "Принято",
|
||||
"connection_failed": "Соединение не удалось",
|
||||
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста проверьте URL и ваше интернет соединение.",
|
||||
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
|
||||
"change_server": "Поменять сервер",
|
||||
"invalid_username_or_password": "Неправильное имя пользователя или пароль",
|
||||
"user_does_not_have_permission_to_log_in": "Пользователь не имеет прав на вход",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже.",
|
||||
"server_received_too_many_requests_try_again_later": "Сервер получил слишком много запросов, попробуйте позже.",
|
||||
"there_is_a_server_error": "Возникла ошибка сервера",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Настройки",
|
||||
"log_out_button": "Выйти",
|
||||
"user_info": {
|
||||
"user_info_title": "Информация о пользователе",
|
||||
"user": "Пользователь",
|
||||
"server": "Сервер",
|
||||
"token": "Токен",
|
||||
"app_version": "Версия приложения"
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Укажите URL на ваш Jellyfin сервер",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
"connect_button": "Подключиться",
|
||||
"previous_servers": "предыдущие серверы",
|
||||
"clear_button": "Очистить",
|
||||
"search_for_local_servers": "Поиск локальных серверов",
|
||||
"searching": "Поиск...",
|
||||
"servers": "Сервера"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "Нет интернета",
|
||||
"no_items": "Нет элементов",
|
||||
"no_internet_message": "Не переживайте, Вы всё ещё можете смотреть\nскачанный контент.",
|
||||
"go_to_downloads": "В загрузки",
|
||||
"oops": "Упс!",
|
||||
"error_message": "Что-то пошло не так.\nПожалуйста выйдите и зайдите снова.",
|
||||
"continue_watching": "Продолжить просмотр",
|
||||
"next_up": "Следующее",
|
||||
"recently_added_in": "Недавно добавлено в {{libraryName}}",
|
||||
"suggested_movies": "Предложенные фильмы",
|
||||
"suggested_episodes": "Предложенные серии",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Добро пожаловать в Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Бесплатный клиент для Jellyfin с открытым кодом",
|
||||
"features_title": "Функции",
|
||||
"features_description": "Streamyfin имеет множество функций и интегрируется с широким спектром программ, которое вы можете найти в меню настроек:",
|
||||
"jellyseerr_feature_description": "Подключитесь к Jellyseerr и запрашивайте фильмы прямо в приложении.",
|
||||
"downloads_feature_title": "Загрузки",
|
||||
"downloads_feature_description": "Скачивайте фильмы и сериалы для просмотра без интернета. Используйте стандартный способ или установите сервер оптимизации для загрузки файлов в фоновом режиме.",
|
||||
"chromecast_feature_description": "Транслируйте фильмы и сериалы на ваши устройста с поддержкой Chromecast.",
|
||||
"centralised_settings_plugin_title": "Плагин для централизованной настройки",
|
||||
"centralised_settings_plugin_description": "Настраивайте параметры из централизованного места на сервере Jellyfin. Все настройки клиента для всех пользователей будут синхронизированы автоматически.",
|
||||
"done_button": "Готово",
|
||||
"go_to_settings_button": "Перейти в настройки",
|
||||
"read_more": "Узнать больше"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Быстрое подключение",
|
||||
"authorize_button": "Авторизировать через быстрое подключение",
|
||||
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
|
||||
"success": "Успех",
|
||||
"quick_connect_autorized": "Быстрое подключение авторизовано",
|
||||
"error": "Ошибка",
|
||||
"invalid_code": "Неверный код",
|
||||
"authorize": "Авторизировать"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Медиа-контроль",
|
||||
"forward_skip_length": "Длина пропуска вперед",
|
||||
"rewind_length": "Длина перемотки",
|
||||
"seconds_unit": "c"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Аудио",
|
||||
"set_audio_track": "Устанавливать аудио дорожку из предыдущего элемента",
|
||||
"audio_language": "Язык аудио",
|
||||
"audio_hint": "Выберите стандартный язык аудио.",
|
||||
"none": "Отсутствует",
|
||||
"language": "Язык"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Субтитры",
|
||||
"subtitle_language": "Язык субтитров",
|
||||
"subtitle_mode": "Режим субтитров",
|
||||
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
|
||||
"subtitle_size": "Размер субтитров",
|
||||
"subtitle_hint": "Настроить субтитры.",
|
||||
"none": "Отсутствует",
|
||||
"language": "Язык",
|
||||
"loading": "Загрузка",
|
||||
"modes": {
|
||||
"Default": "Стандартный",
|
||||
"Smart": "Умный",
|
||||
"Always": "Всегда",
|
||||
"None": "Отсутствует",
|
||||
"OnlyForced": "Только принудительные"
|
||||
"settings": {
|
||||
"settings_title": "Настройки",
|
||||
"log_out_button": "Выйти",
|
||||
"user_info": {
|
||||
"user_info_title": "Информация о пользователе",
|
||||
"user": "Пользователь",
|
||||
"server": "Сервер",
|
||||
"token": "Токен",
|
||||
"app_version": "Версия приложения"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Быстрое подключение",
|
||||
"authorize_button": "Авторизировать через быстрое подключение",
|
||||
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
|
||||
"success": "Успех",
|
||||
"quick_connect_autorized": "Быстрое подключение авторизовано",
|
||||
"error": "Ошибка",
|
||||
"invalid_code": "Неверный код",
|
||||
"authorize": "Авторизировать"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Медиа-контроль",
|
||||
"forward_skip_length": "Длина пропуска вперед",
|
||||
"rewind_length": "Длина перемотки",
|
||||
"seconds_unit": "c"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Аудио",
|
||||
"set_audio_track": "Устанавливать аудио дорожку из предыдущего элемента",
|
||||
"audio_language": "Язык аудио",
|
||||
"audio_hint": "Выберите стандартный язык аудио.",
|
||||
"none": "Отсутствует",
|
||||
"language": "Язык"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Субтитры",
|
||||
"subtitle_language": "Язык субтитров",
|
||||
"subtitle_mode": "Режим субтитров",
|
||||
"set_subtitle_track": "Устанавливать субтитры из предыдущего элемента",
|
||||
"subtitle_size": "Размер субтитров",
|
||||
"subtitle_hint": "Настроить субтитры.",
|
||||
"none": "Отсутствует",
|
||||
"language": "Язык",
|
||||
"loading": "Загрузка",
|
||||
"modes": {
|
||||
"Default": "Стандартный",
|
||||
"Smart": "Умный",
|
||||
"Always": "Всегда",
|
||||
"None": "Отсутствует",
|
||||
"OnlyForced": "Только принудительные"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Другое",
|
||||
"follow_device_orientation": "Авто-поворот",
|
||||
"video_orientation": "Ориентация видео",
|
||||
"orientation": "Ориентация",
|
||||
"orientations": {
|
||||
"DEFAULT": "Стандартный",
|
||||
"ALL": "Все",
|
||||
"PORTRAIT": "Портретный",
|
||||
"PORTRAIT_UP": "Портрет вверх",
|
||||
"PORTRAIT_DOWN": "Портрет вниз",
|
||||
"LANDSCAPE": "Ландшафтный",
|
||||
"LANDSCAPE_LEFT": "Ландшафтный слева",
|
||||
"LANDSCAPE_RIGHT": "Ландшафтный справа",
|
||||
"OTHER": "Другое",
|
||||
"UNKNOWN": "Неизвестное"
|
||||
},
|
||||
"safe_area_in_controls": "Безопасная зона в элементах управления",
|
||||
"video_player": "Видео прейер",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Показать ссылки кастомного меню",
|
||||
"hide_libraries": "Скрыть библиотеки",
|
||||
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
|
||||
"disable_haptic_feedback": "Отключить тактильную обратную связь",
|
||||
"default_quality": "Качество по умолчанию"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Загрузки",
|
||||
"download_method": "способ загрузки",
|
||||
"remux_max_download": "Remux max скачать",
|
||||
"auto_download": "Авто-загрузка",
|
||||
"optimized_versions_server": "Оптимизированные версии сервера",
|
||||
"save_button": "Сохранить",
|
||||
"optimized_server": "Оптимизированный сервер",
|
||||
"optimized": "Оптимизированный",
|
||||
"default": "По умолчанию",
|
||||
"optimized_version_hint": "Укажите URL на оптимизированный сервер. URL должен включать http or https и опционально порт.",
|
||||
"read_more_about_optimized_server": "Узнать больше про оптимизацию сервера.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Плагины",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Эта интеграция находится на ранней стадии. Ожидайте изменений.",
|
||||
"server_url": "URL сервера",
|
||||
"server_url_hint": "Пример: http(s)://your-host.url\n(Добавьте порт если необходимо)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Пароль",
|
||||
"password_placeholder": "Введите пароль для пользователя Jellyfin {{username}}",
|
||||
"save_button": "Сохранить",
|
||||
"clear_button": "Очистить",
|
||||
"login_button": "Войти",
|
||||
"total_media_requests": "Всего запросов на медиа",
|
||||
"movie_quota_limit": "Ограничение квоты на фильмы",
|
||||
"movie_quota_days": "Дни квоты на фильмы",
|
||||
"tv_quota_limit": "Ограничение квоты на сериалы",
|
||||
"tv_quota_days": "Дни квоты на сериалы",
|
||||
"reset_jellyseerr_config_button": "Сбросить конфигурацию Jellyseerr",
|
||||
"unlimited": "Неограниченно",
|
||||
"plus_n_more": "+{{n}} больше",
|
||||
"order_by": {
|
||||
"DEFAULT": "По умолчанию",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Количеству голосов и среднему",
|
||||
"POPULARITY": "Популярности"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Включить Marlin Search ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Введите URL для Marlin сервера. URL должен включать http or https и опционально порт.",
|
||||
"read_more_about_marlin": "Узнать больше о Marlin.",
|
||||
"save_button": "Сохранить",
|
||||
"toasts": {
|
||||
"saved": "Сохранено"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Хранилище",
|
||||
"app_usage": "Приложение {{usedSpace}}%",
|
||||
"device_usage": "Устройство {{availableSpace}}%",
|
||||
"size_used": "{{used}} из {{total}} использовано",
|
||||
"delete_all_downloaded_files": "Удалить все загруженные файлы",
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Показать вступление",
|
||||
"reset_intro": "Сбросить вступление"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Логи",
|
||||
"no_logs_available": "Логи не доступны",
|
||||
"delete_all_logs": "Удалить все логи",
|
||||
},
|
||||
"languages": {
|
||||
"title": "Языки",
|
||||
"app_language": "Язык приложения",
|
||||
"app_language_description": "Выберите язык для приложения.",
|
||||
"system": "Системный"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Ошибка при удалении файлов",
|
||||
"background_downloads_enabled": "Фоновая загрузка включена",
|
||||
"background_downloads_disabled": "Фоновая загрузка отключена",
|
||||
"connected": "Подключено",
|
||||
"could_not_connect": "Не удалось подключиться",
|
||||
"invalid_url": "Неверный URL"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Другое",
|
||||
"follow_device_orientation": "Авто-поворот",
|
||||
"video_orientation": "Ориентация видео",
|
||||
"orientation": "Ориентация",
|
||||
"orientations": {
|
||||
"DEFAULT": "Стандартный",
|
||||
"ALL": "Все",
|
||||
"PORTRAIT": "Портретный",
|
||||
"PORTRAIT_UP": "Портрет вверх",
|
||||
"PORTRAIT_DOWN": "Портрет вниз",
|
||||
"LANDSCAPE": "Ландшафтный",
|
||||
"LANDSCAPE_LEFT": "Ландшафтный слева",
|
||||
"LANDSCAPE_RIGHT": "Ландшафтный справа",
|
||||
"OTHER": "Другое",
|
||||
"UNKNOWN": "Неизвестное"
|
||||
},
|
||||
"safe_area_in_controls": "Безопасная зона в элементах управления",
|
||||
"video_player": "Видео прейер",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Экспериментальный + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Показать ссылки кастомного меню",
|
||||
"hide_libraries": "Скрыть библиотеки",
|
||||
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
|
||||
"disable_haptic_feedback": "Отключить тактильную обратную связь",
|
||||
"default_quality": "Качество по умолчанию",
|
||||
"disabled": "Отключено"
|
||||
"sessions": {
|
||||
"title": "Сессии",
|
||||
"no_active_sessions": "Нет активных сессий",
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Загрузки",
|
||||
"download_method": "способ загрузки",
|
||||
"remux_max_download": "Remux max скачать",
|
||||
"auto_download": "Авто-загрузка",
|
||||
"optimized_versions_server": "Оптимизированные версии сервера",
|
||||
"save_button": "Сохранить",
|
||||
"optimized_server": "Оптимизированный сервер",
|
||||
"optimized": "Оптимизированный",
|
||||
"default": "По умолчанию",
|
||||
"optimized_version_hint": "Укажите URL на оптимизированный сервер. URL должен включать http or https и опционально порт.",
|
||||
"read_more_about_optimized_server": "Узнать больше про оптимизацию сервера.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Плагины",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Эта интеграция находится на ранней стадии. Ожидайте изменений.",
|
||||
"server_url": "URL сервера",
|
||||
"server_url_hint": "Пример: http(s)://your-host.url\n(Добавьте порт если необходимо)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Пароль",
|
||||
"password_placeholder": "Введите пароль для пользователя Jellyfin {{username}}",
|
||||
"save_button": "Сохранить",
|
||||
"clear_button": "Очистить",
|
||||
"login_button": "Войти",
|
||||
"total_media_requests": "Всего запросов на медиа",
|
||||
"movie_quota_limit": "Ограничение квоты на фильмы",
|
||||
"movie_quota_days": "Дни квоты на фильмы",
|
||||
"tv_quota_limit": "Ограничение квоты на сериалы",
|
||||
"tv_quota_days": "Дни квоты на сериалы",
|
||||
"reset_jellyseerr_config_button": "Сбросить конфигурацию Jellyseerr",
|
||||
"unlimited": "Неограниченно",
|
||||
"plus_n_more": "+{{n}} больше",
|
||||
"order_by": {
|
||||
"DEFAULT": "По умолчанию",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Количеству голосов и среднему",
|
||||
"POPULARITY": "Популярности"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Включить Marlin Search ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Введите URL для Marlin сервера. URL должен включать http or https и опционально порт.",
|
||||
"read_more_about_marlin": "Узнать больше о Marlin.",
|
||||
"save_button": "Сохранить",
|
||||
"toasts": {
|
||||
"saved": "Сохранено"
|
||||
}
|
||||
"tvseries": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"queue": "Очередь",
|
||||
"queue_hint": "Очередь и загрузки будут удалены при перезагрузке приложения",
|
||||
"no_items_in_queue": "Нет элементов в очереди",
|
||||
"no_downloaded_items": "Нет загруженых предметов",
|
||||
"delete_all_movies_button": "Удалить все фильмы",
|
||||
"delete_all_tvseries_button": "Удалить все сериалы",
|
||||
"delete_all_button": "Удалить все",
|
||||
"active_download": "Активно загружается",
|
||||
"no_active_downloads": "Нет активных загрузок",
|
||||
"active_downloads": "Активные загрузки",
|
||||
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
|
||||
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
|
||||
"back": "Назад",
|
||||
"delete": "Удалить",
|
||||
"something_went_wrong": "Что-то пошло не так",
|
||||
"could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Методы",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.",
|
||||
"deleted_all_movies_successfully": "Все фильмы были успешно удалены!",
|
||||
"failed_to_delete_all_movies": "Возникла ошибка при удалении всех фильмов",
|
||||
"deleted_all_tvseries_successfully": "Все сериалы были успешно удалены!",
|
||||
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
|
||||
"download_cancelled": "Загрузка отменена",
|
||||
"could_not_cancel_download": "Не удалось отменить загрузку",
|
||||
"download_completed": "Загрузка завершена",
|
||||
"download_started_for": "Загрузка {{item}} началась",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} готов к загрузке",
|
||||
"download_stated_for_item": "Загрузка {{item} началась",
|
||||
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
|
||||
"download_completed_for_item": "{{item}} успешно загружен",
|
||||
"queued_item_for_optimization": "{{item}} поставлен в очередь для оптимизации",
|
||||
"failed_to_start_download_for_item": "Не удалось начать загрузку {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Сервер ответил со статусом {{statusCode}}",
|
||||
"no_response_received_from_server": "Нет ответа от сервера",
|
||||
"error_setting_up_the_request": "Ошибка при создании запроса",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Не удалось начать загрузку {{item}}: Неожиданная ошибка",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Все файлы, папки, и задачи были успешно удалены",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Возникла ошибка при удалении файлов и работ",
|
||||
"go_to_downloads": "В загрузки"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Хранилище",
|
||||
"app_usage": "Приложение {{usedSpace}}%",
|
||||
"device_usage": "Устройство {{availableSpace}}%",
|
||||
"size_used": "{{used}} из {{total}} использовано",
|
||||
"delete_all_downloaded_files": "Удалить все загруженные файлы"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Показать вступление",
|
||||
"reset_intro": "Сбросить вступление"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Логи",
|
||||
"no_logs_available": "Логи не доступны",
|
||||
"delete_all_logs": "Удалить все логи"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Языки",
|
||||
"app_language": "Язык приложения",
|
||||
"app_language_description": "Выберите язык для приложения.",
|
||||
"system": "Системный"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Ошибка при удалении файлов",
|
||||
"background_downloads_enabled": "Фоновая загрузка включена",
|
||||
"background_downloads_disabled": "Фоновая загрузка отключена",
|
||||
"connected": "Подключено",
|
||||
"could_not_connect": "Не удалось подключиться",
|
||||
"invalid_url": "Неверный URL"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Сессии",
|
||||
"no_active_sessions": "Нет активных сессий"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Загрузки",
|
||||
"tvseries": "Сериалы",
|
||||
"search": {
|
||||
"search_here": "Искать здесь...",
|
||||
"search": "Поиск...",
|
||||
"x_items": "{{count}} предметов",
|
||||
"library": "Библиотека",
|
||||
"discover": "Найти новое",
|
||||
"no_results": "Нет результатов",
|
||||
"no_results_found_for": "Не было результатов при поиске",
|
||||
"movies": "Фильмы",
|
||||
"queue": "Очередь",
|
||||
"queue_hint": "Очередь и загрузки будут удалены при перезагрузке приложения",
|
||||
"no_items_in_queue": "Нет элементов в очереди",
|
||||
"no_downloaded_items": "Нет загруженых предметов",
|
||||
"delete_all_movies_button": "Удалить все фильмы",
|
||||
"delete_all_tvseries_button": "Удалить все сериалы",
|
||||
"delete_all_button": "Удалить все",
|
||||
"active_download": "Активно загружается",
|
||||
"no_active_downloads": "Нет активных загрузок",
|
||||
"active_downloads": "Активные загрузки",
|
||||
"new_app_version_requires_re_download": "Новая версия приложения требует повторной загрузки",
|
||||
"new_app_version_requires_re_download_description": "Новая версия приложения требует повторной загрузки. Пожалуйста удалите всё и попробуйте заново.",
|
||||
"back": "Назад",
|
||||
"delete": "Удалить",
|
||||
"something_went_wrong": "Что-то пошло не так",
|
||||
"could_not_get_stream_url_from_jellyfin": "Не удалось получить ссылку трансляции из Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Методы",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Нет разрешения на скачивание файлов.",
|
||||
"deleted_all_movies_successfully": "Все фильмы были успешно удалены!",
|
||||
"failed_to_delete_all_movies": "Возникла ошибка при удалении всех фильмов",
|
||||
"deleted_all_tvseries_successfully": "Все сериалы были успешно удалены!",
|
||||
"failed_to_delete_all_tvseries": "Возникла ошибка при удалении всех сериалов",
|
||||
"download_cancelled": "Загрузка отменена",
|
||||
"could_not_cancel_download": "Не удалось отменить загрузку",
|
||||
"download_completed": "Загрузка завершена",
|
||||
"download_started_for": "Загрузка {{item}} началась",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} готов к загрузке",
|
||||
"download_stated_for_item": "Загрузка {{item} началась",
|
||||
"download_failed_for_item": "Загрузка {{item}} провалилась с ошибкой: {{error}}",
|
||||
"download_completed_for_item": "{{item}} успешно загружен",
|
||||
"queued_item_for_optimization": "{{item}} поставлен в очередь для оптимизации",
|
||||
"failed_to_start_download_for_item": "Не удалось начать загрузку {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Сервер ответил со статусом {{statusCode}}",
|
||||
"no_response_received_from_server": "Нет ответа от сервера",
|
||||
"error_setting_up_the_request": "Ошибка при создании запроса",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Не удалось начать загрузку {{item}}: Неожиданная ошибка",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Все файлы, папки, и задачи были успешно удалены",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Возникла ошибка при удалении файлов и работ",
|
||||
"go_to_downloads": "В загрузки"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Искать здесь...",
|
||||
"search": "Поиск...",
|
||||
"x_items": "{{count}} предметов",
|
||||
"library": "Библиотека",
|
||||
"discover": "Найти новое",
|
||||
"no_results": "Нет результатов",
|
||||
"no_results_found_for": "Не было результатов при поиске",
|
||||
"movies": "Фильмы",
|
||||
"series": "Сериалы",
|
||||
"episodes": "Серии",
|
||||
"collections": "Коллекции",
|
||||
"actors": "Актеры",
|
||||
"request_movies": "Запросить фильмы",
|
||||
"request_series": "Запросить сериалы",
|
||||
"recently_added": "Недавно добавлено",
|
||||
"recent_requests": "Недавно запрошено",
|
||||
"plex_watchlist": "Список просмотра с Plex",
|
||||
"trending": "В тренде",
|
||||
"popular_movies": "Популярные фильмы",
|
||||
"movie_genres": "Популярные жанры",
|
||||
"upcoming_movies": "Предстоящие фильмы",
|
||||
"studios": "Студии",
|
||||
"popular_tv": "Популярные сериалы",
|
||||
"tv_genres": "жанры сериалов",
|
||||
"upcoming_tv": "Предстоящие сериалы",
|
||||
"networks": "Сети",
|
||||
"tmdb_movie_keyword": "TMDB Ключевые слова фильмов",
|
||||
"tmdb_movie_genre": "TMDB Жанры фильмов",
|
||||
"tmdb_tv_keyword": "TMDB Ключевые слова сериалов",
|
||||
"tmdb_tv_genre": "TMDB Жанры сериалов",
|
||||
"tmdb_search": "TMDB Поиск",
|
||||
"tmdb_studio": "TMDB Студии",
|
||||
"tmdb_network": "TMDB Сеть",
|
||||
"tmdb_movie_streaming_services": "TMDB Потоковые сервисы фильмов",
|
||||
"tmdb_tv_streaming_services": "TMDB Потоковые сервисы сериалов"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "элементы не найдены",
|
||||
"no_results": "Нет результатов",
|
||||
"no_libraries_found": "Библиотеки не найдены",
|
||||
"item_types": {
|
||||
"movies": "фильмы",
|
||||
"series": "Сериалы",
|
||||
"episodes": "Серии",
|
||||
"collections": "Коллекции",
|
||||
"actors": "Актеры",
|
||||
"request_movies": "Запросить фильмы",
|
||||
"request_series": "Запросить сериалы",
|
||||
"recently_added": "Недавно добавлено",
|
||||
"recent_requests": "Недавно запрошено",
|
||||
"plex_watchlist": "Список просмотра с Plex",
|
||||
"trending": "В тренде",
|
||||
"popular_movies": "Популярные фильмы",
|
||||
"movie_genres": "Популярные жанры",
|
||||
"upcoming_movies": "Предстоящие фильмы",
|
||||
"studios": "Студии",
|
||||
"popular_tv": "Популярные сериалы",
|
||||
"tv_genres": "жанры сериалов",
|
||||
"upcoming_tv": "Предстоящие сериалы",
|
||||
"networks": "Сети",
|
||||
"tmdb_movie_keyword": "TMDB Ключевые слова фильмов",
|
||||
"tmdb_movie_genre": "TMDB Жанры фильмов",
|
||||
"tmdb_tv_keyword": "TMDB Ключевые слова сериалов",
|
||||
"tmdb_tv_genre": "TMDB Жанры сериалов",
|
||||
"tmdb_search": "TMDB Поиск",
|
||||
"tmdb_studio": "TMDB Студии",
|
||||
"tmdb_network": "TMDB Сеть",
|
||||
"tmdb_movie_streaming_services": "TMDB Потоковые сервисы фильмов",
|
||||
"tmdb_tv_streaming_services": "TMDB Потоковые сервисы сериалов",
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "элементы не найдены",
|
||||
"no_results": "Нет результатов",
|
||||
"no_libraries_found": "Библиотеки не найдены",
|
||||
"item_types": {
|
||||
"movies": "фильмы",
|
||||
"series": "Сериалы",
|
||||
"boxsets": "Коллекции",
|
||||
"items": "элементы"
|
||||
},
|
||||
"options": {
|
||||
"display": "Отображать",
|
||||
"row": "Ряд",
|
||||
"list": "Список",
|
||||
"image_style": "Стиль изображения",
|
||||
"poster": "Постер",
|
||||
"cover": "Обложка",
|
||||
"show_titles": "Показывать загаловки",
|
||||
"show_stats": "Показывать статистику",
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Жанры",
|
||||
"years": "Года",
|
||||
"sort_by": "Сортировать по",
|
||||
"sort_order": "Порядок сортировки",
|
||||
"asc": "По Возрастанию",
|
||||
"desc": "По убыванию",
|
||||
"tags": "Тэги"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"episodes": "Серии",
|
||||
"videos": "Видео",
|
||||
"boxsets": "Коллекции",
|
||||
"items": "элементы"
|
||||
"playlists": "Плейлисты",
|
||||
"noDataTitle": "Пока нет избранных",
|
||||
"noData": "Отметьте элементы как избранные, чтобы они отображались здесь для быстрого доступа."
|
||||
},
|
||||
"options": {
|
||||
"display": "Отображать",
|
||||
"row": "Ряд",
|
||||
"list": "Список",
|
||||
"image_style": "Стиль изображения",
|
||||
"poster": "Постер",
|
||||
"cover": "Обложка",
|
||||
"show_titles": "Показывать загаловки",
|
||||
"show_stats": "Показывать статистику"
|
||||
"custom_links": {
|
||||
"no_links": "Нет ссылок"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Жанры",
|
||||
"years": "Года",
|
||||
"sort_by": "Сортировать по",
|
||||
"sort_order": "Порядок сортировки",
|
||||
"asc": "По Возрастанию",
|
||||
"desc": "По убыванию",
|
||||
"tags": "Тэги"
|
||||
"player": {
|
||||
"error": "Ошибка",
|
||||
"failed_to_get_stream_url": "Не удалось получить URL потока",
|
||||
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
|
||||
"client_error": "Ошибка клиента",
|
||||
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
|
||||
"message_from_server": "Сообщение от сервера: {{message}}",
|
||||
"video_has_finished_playing": "Видео закончило воспроизводиться!",
|
||||
"no_video_source": "Нет источника видео...",
|
||||
"next_episode": "Следующая серия",
|
||||
"refresh_tracks": "Обновить дорожки",
|
||||
"subtitle_tracks": "Субтитры:",
|
||||
"audio_tracks": "Аудио дорожки:",
|
||||
"playback_state": "Состояние воспроизведения:",
|
||||
"no_data_available": "Данные не доступны",
|
||||
"index": "Индекс:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Следующее",
|
||||
"no_items_to_display": "Нет элементов для отображения",
|
||||
"cast_and_crew": "Актеры и съемочная группа",
|
||||
"series": "Серии",
|
||||
"seasons": "Сезоны",
|
||||
"season": "Сезон",
|
||||
"no_episodes_for_this_season": "В этом сезоне нет серий",
|
||||
"overview": "Обзор",
|
||||
"more_with": "Больше с {{name}}",
|
||||
"similar_items": "Похожие элементы",
|
||||
"no_similar_items_found": "Похожие элементы не найдены",
|
||||
"video": "Видео",
|
||||
"more_details": "Больше деталей",
|
||||
"quality": "Качество",
|
||||
"audio": "Звук",
|
||||
"subtitles": "Субтитры",
|
||||
"show_more": "Показать больше",
|
||||
"show_less": "Показать меньше",
|
||||
"appeared_in": "Появлялся в",
|
||||
"could_not_load_item": "Не удалось загрузить элемент",
|
||||
"none": "Отсутствует",
|
||||
"download": {
|
||||
"download_season": "Загрузить сезон",
|
||||
"download_series": "Загрузить сериал",
|
||||
"download_episode": "Загрузить серию",
|
||||
"download_movie": "Скачать фильм",
|
||||
"download_x_item": "Загрузить {{item_count}} элементов",
|
||||
"download_button": "Загрузить",
|
||||
"using_optimized_server": "Использовать оптимизированный сервер",
|
||||
"using_default_method": "Использовать стандартный метод",
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Следующая",
|
||||
"previous": "Предыдущая",
|
||||
"live_tv": "Прямой эфир ТВ",
|
||||
"coming_soon": "Скоро",
|
||||
"on_now": "Сейчас в эфире",
|
||||
"shows": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"sports": "Спорт",
|
||||
"for_kids": "Для детей",
|
||||
"news": "Новости"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "Подтвердить",
|
||||
"cancel": "Отменить",
|
||||
"yes": "Да",
|
||||
"whats_wrong": "В чем дело?",
|
||||
"issue_type": "Вид проблемы",
|
||||
"select_an_issue": "Выберите проблему",
|
||||
"types": "Типы",
|
||||
"describe_the_issue": "(опционально) Опишите проблему...",
|
||||
"submit_button": "Подать",
|
||||
"report_issue_button": "Сообщить о проблеме",
|
||||
"request_button": "Запросить",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Вы уверены, что хотите запросить все сезоны?",
|
||||
"failed_to_login": "Не удалось войти",
|
||||
"cast": "Транслировать",
|
||||
"details": "Детали",
|
||||
"status": "Статус",
|
||||
"original_title": "Оригинальное название",
|
||||
"series_type": "Тип сериала",
|
||||
"release_dates": "Дата релиза",
|
||||
"first_air_date": "Первая дата выхода в эфир",
|
||||
"next_air_date": "Следующая дата выхода в эфир",
|
||||
"revenue": "Прибыль",
|
||||
"budget": "Бюджет",
|
||||
"original_language": "Оригинальный язык",
|
||||
"production_country": "Страна производства",
|
||||
"studios": "Студия",
|
||||
"network": "Сеть",
|
||||
"currently_streaming_on": "Сейчас доступно на",
|
||||
"advanced": "Продвинутое",
|
||||
"request_as": "Запросить как",
|
||||
"tags": "Тэги",
|
||||
"quality_profile": "Профиль качества",
|
||||
"root_folder": "Корневая папка",
|
||||
"season_all": "Сезон (все)",
|
||||
"season_number": "Сезон {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} серий",
|
||||
"born": "Рожден",
|
||||
"appearances": "Появления",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
|
||||
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
|
||||
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера jellyseerr",
|
||||
"issue_submitted": "Проблема отправлена!",
|
||||
"requested_item": "Запрошено {{item}}!",
|
||||
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
|
||||
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Дом",
|
||||
"search": "Поиск",
|
||||
"library": "Библиотека",
|
||||
"custom_links": "Кастомные ссылки",
|
||||
"favorites": "Избранное"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"episodes": "Серии",
|
||||
"videos": "Видео",
|
||||
"boxsets": "Коллекции",
|
||||
"playlists": "Плейлисты",
|
||||
"noDataTitle": "Пока нет избранных",
|
||||
"noData": "Отметьте элементы как избранные, чтобы они отображались здесь для быстрого доступа."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Нет ссылок"
|
||||
},
|
||||
"player": {
|
||||
"error": "Ошибка",
|
||||
"failed_to_get_stream_url": "Не удалось получить URL потока",
|
||||
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
|
||||
"client_error": "Ошибка клиента",
|
||||
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
|
||||
"message_from_server": "Сообщение от сервера: {{message}}",
|
||||
"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": "Назад"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Следующее",
|
||||
"no_items_to_display": "Нет элементов для отображения",
|
||||
"cast_and_crew": "Актеры и съемочная группа",
|
||||
"series": "Серии",
|
||||
"seasons": "Сезоны",
|
||||
"season": "Сезон",
|
||||
"no_episodes_for_this_season": "В этом сезоне нет серий",
|
||||
"overview": "Обзор",
|
||||
"more_with": "Больше с {{name}}",
|
||||
"similar_items": "Похожие элементы",
|
||||
"no_similar_items_found": "Похожие элементы не найдены",
|
||||
"video": "Видео",
|
||||
"more_details": "Больше деталей",
|
||||
"quality": "Качество",
|
||||
"audio": "Звук",
|
||||
"subtitles": "Субтитры",
|
||||
"show_more": "Показать больше",
|
||||
"show_less": "Показать меньше",
|
||||
"appeared_in": "Появлялся в",
|
||||
"could_not_load_item": "Не удалось загрузить элемент",
|
||||
"none": "Отсутствует",
|
||||
"download": {
|
||||
"download_season": "Загрузить сезон",
|
||||
"download_series": "Загрузить сериал",
|
||||
"download_episode": "Загрузить серию",
|
||||
"download_movie": "Скачать фильм",
|
||||
"download_x_item": "Загрузить {{item_count}} элементов",
|
||||
"download_button": "Загрузить",
|
||||
"using_optimized_server": "Использовать оптимизированный сервер",
|
||||
"using_default_method": "Использовать стандартный метод"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Следующая",
|
||||
"previous": "Предыдущая",
|
||||
"live_tv": "Прямой эфир ТВ",
|
||||
"coming_soon": "Скоро",
|
||||
"on_now": "Сейчас в эфире",
|
||||
"shows": "Сериалы",
|
||||
"movies": "Фильмы",
|
||||
"sports": "Спорт",
|
||||
"for_kids": "Для детей",
|
||||
"news": "Новости"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "Подтвердить",
|
||||
"cancel": "Отменить",
|
||||
"yes": "Да",
|
||||
"whats_wrong": "В чем дело?",
|
||||
"issue_type": "Вид проблемы",
|
||||
"select_an_issue": "Выберите проблему",
|
||||
"types": "Типы",
|
||||
"describe_the_issue": "(опционально) Опишите проблему...",
|
||||
"submit_button": "Подать",
|
||||
"report_issue_button": "Сообщить о проблеме",
|
||||
"request_button": "Запросить",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Вы уверены, что хотите запросить все сезоны?",
|
||||
"failed_to_login": "Не удалось войти",
|
||||
"cast": "Транслировать",
|
||||
"details": "Детали",
|
||||
"status": "Статус",
|
||||
"original_title": "Оригинальное название",
|
||||
"series_type": "Тип сериала",
|
||||
"release_dates": "Дата релиза",
|
||||
"first_air_date": "Первая дата выхода в эфир",
|
||||
"next_air_date": "Следующая дата выхода в эфир",
|
||||
"revenue": "Прибыль",
|
||||
"budget": "Бюджет",
|
||||
"original_language": "Оригинальный язык",
|
||||
"production_country": "Страна производства",
|
||||
"studios": "Студия",
|
||||
"network": "Сеть",
|
||||
"currently_streaming_on": "Сейчас доступно на",
|
||||
"advanced": "Продвинутое",
|
||||
"request_as": "Запросить как",
|
||||
"tags": "Тэги",
|
||||
"quality_profile": "Профиль качества",
|
||||
"root_folder": "Корневая папка",
|
||||
"season_all": "Сезон (все)",
|
||||
"season_number": "Сезон {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} серий",
|
||||
"born": "Рожден",
|
||||
"appearances": "Появления",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не соответствует минимальным требованиям версии! Пожалуйста, обновите до версии не ниже 2.0.0",
|
||||
"jellyseerr_test_failed": "Тест Jellyseerr не пройден. Попробуйте еще раз.",
|
||||
"failed_to_test_jellyseerr_server_url": "Не удалось проверить URL-адрес сервера jellyseerr",
|
||||
"issue_submitted": "Проблема отправлена!",
|
||||
"requested_item": "Запрошено {{item}}!",
|
||||
"you_dont_have_permission_to_request": "У вас нет разрешения на запрос!",
|
||||
"something_went_wrong_requesting_media": "Что-то пошло не так при запросе медиафайлов!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Дом",
|
||||
"search": "Поиск",
|
||||
"library": "Библиотека",
|
||||
"custom_links": "Кастомные ссылки",
|
||||
"favorites": "Избранное"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,24 +30,5 @@
|
||||
"home": "Hem",
|
||||
"search": "Sök",
|
||||
"library": "Bibliotek"
|
||||
},
|
||||
"player": {
|
||||
"error": "Fel",
|
||||
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
|
||||
"an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
|
||||
"client_error": "Klientfel",
|
||||
"could_not_create_stream_for_chromecast": "Kunde inte skapa stream för Chromecast",
|
||||
"message_from_server": "Meddelande från servern: {{message}}",
|
||||
"video_has_finished_playing": "Videon har spelat klart!",
|
||||
"no_video_source": "Ingen videokälla...",
|
||||
"next_episode": "Nästa avsnitt",
|
||||
"refresh_tracks": "Uppdatera spår",
|
||||
"subtitle_tracks": "Textspår:",
|
||||
"audio_tracks": "Ljudspår:",
|
||||
"playback_state": "Uppspelningsstatus:",
|
||||
"no_data_available": "Inga data tillgängliga",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Fortsätt titta",
|
||||
"go_back": "Tillbaka"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "tlhIngan DaneH",
|
||||
"error_title": "ghIq",
|
||||
"login_title": "lut 'el",
|
||||
"login_to_title": "lut 'el",
|
||||
"username_placeholder": "tlhIngan",
|
||||
"password_placeholder": "ngoq De'",
|
||||
"login_button": "yI'el!",
|
||||
"quick_connect": "parmaq ngoQ",
|
||||
"enter_code_to_login": "yI'elDI' De' {{code}} yIlaD",
|
||||
"failed_to_initiate_quick_connect": "parmaq ngoQ yIchu'laHbe'",
|
||||
"got_it": "jIyaj",
|
||||
"connection_failed": "ngoQlaHbe'",
|
||||
"could_not_connect_to_server": "SeHlaw veS Ho'Do'laHbe'. URL 'ej ret ghun mej.",
|
||||
"an_unexpected_error_occured": "num ghIq Doch",
|
||||
"change_server": "Ho'Do' veS yIghoS",
|
||||
"invalid_username_or_password": "tlhIngan pagh ngoq De' law'be'",
|
||||
"user_does_not_have_permission_to_log_in": "tlhIngan lut 'el je'laHbe'",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Ho'Do' veS jachrup. pItlh yIHaD.",
|
||||
"server_received_too_many_requests_try_again_later": "Ho'Do' veS lutlh ngeb petlh law'. pItlh yIHaD.",
|
||||
"there_is_a_server_error": "Ho'Do' veS ghIq maS",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Jellyfin Ho'Do' veS URL yI'el",
|
||||
"server_url_placeholder": "http(s)://HoDo-veS.com",
|
||||
"connect_button": "yIngoq!",
|
||||
"previous_servers": "namen Ho'Do' veS",
|
||||
"clear_button": "yIQaw'",
|
||||
"search_for_local_servers": "val Ho'Do' veS yISam",
|
||||
"searching": "Sam...",
|
||||
"servers": "Ho'Do' veS"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "ret pagh",
|
||||
"no_items": "Doch pagh",
|
||||
"no_internet_message": "QublaHbe'.\nDoch Qaw'laHnIS SoH.",
|
||||
"go_to_downloads": "Qaw' Doch yIghoS",
|
||||
"oops": "QI'ya!",
|
||||
"error_message": "Doch rurbe'.\nyIQo' 'ej yI'elqa'.",
|
||||
"continue_watching": "tlhol yIHaDqa'",
|
||||
"next_up": "wej",
|
||||
"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.",
|
||||
"features_title": "mIw",
|
||||
"features_description": "Streamyfin mIw law' tu'. men menuDaq yISam:",
|
||||
"jellyseerr_feature_description": "Jellyseerr yIngoq 'ej DIS pe'vIl yISov.",
|
||||
"downloads_feature_title": "Qaw' Doch",
|
||||
"downloads_feature_description": "DIS 'ej Hem Qaw'laH. Qaw' mIw tu'lu'.",
|
||||
"chromecast_feature_description": "DIS 'ej Hem Chromecast vI' ghoS.",
|
||||
"centralised_settings_plugin_title": "wa'DIch men mIw",
|
||||
"centralised_settings_plugin_description": "Jellyfin Ho'Do' veSDaq men yISeH. tlhIngan chIch.",
|
||||
"done_button": "Qapla'",
|
||||
"go_to_settings_button": "men yIghoS",
|
||||
"read_more": "yIlaDqa'"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "men",
|
||||
"log_out_button": "yIQo'",
|
||||
"user_info": {
|
||||
"user_info_title": "tlhIngan De'",
|
||||
"user": "tlhIngan",
|
||||
"server": "Ho'Do' veS",
|
||||
"token": "per De'",
|
||||
"app_version": "ghun wej",
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "parmaq ngoQ",
|
||||
"authorize_button": "parmaq ngoQ yIje'",
|
||||
"enter_the_quick_connect_code": "parmaq ngoQ De' yI'el...",
|
||||
"success": "Qapla'",
|
||||
"quick_connect_autorized": "parmaq ngoQ je'laH",
|
||||
"error": "ghIq",
|
||||
"invalid_code": "De' law'be'",
|
||||
"authorize": "yIje'"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "tlhol SeHlaw",
|
||||
"forward_skip_length": "Du'Hom vum",
|
||||
"rewind_length": "bavHom vum",
|
||||
"seconds_unit": "tera' rep"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "QoQ",
|
||||
"set_audio_track": "namen Doch QoQ ret yISeH",
|
||||
"audio_language": "QoQ Hol",
|
||||
"audio_hint": "QoQ Hol wa' yIwIv.",
|
||||
"none": "pagh",
|
||||
"language": "Hol"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "De' chu'",
|
||||
"subtitle_language": "De' chu' Hol",
|
||||
"subtitle_mode": "De' chu' mIw",
|
||||
"set_subtitle_track": "namen Doch De' chu' ret yISeH",
|
||||
"subtitle_size": "De' chu' qIt",
|
||||
"subtitle_hint": "De' chu' wIvlaw' yISeH.",
|
||||
"none": "pagh",
|
||||
"language": "Hol",
|
||||
"loading": "tlha'... ",
|
||||
"modes": {
|
||||
"Default": "wa'",
|
||||
"Smart": "SonchIy",
|
||||
"Always": "reH",
|
||||
"None": "pagh",
|
||||
"OnlyForced": "Dun je'"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "patlh",
|
||||
"follow_device_orientation": "naDevvo' pegh",
|
||||
"video_orientation": "mu'tlhegh pegh",
|
||||
"orientation": "pegh",
|
||||
"orientations": {
|
||||
"DEFAULT": "wa'",
|
||||
"ALL": "Hoch",
|
||||
"PORTRAIT": "leng ret",
|
||||
"PORTRAIT_UP": "leng ret Dung",
|
||||
"PORTRAIT_DOWN": "leng ret nuq",
|
||||
"LANDSCAPE": "leng yot",
|
||||
"LANDSCAPE_LEFT": "leng yot poS",
|
||||
"LANDSCAPE_RIGHT": "leng yot nIH",
|
||||
"OTHER": "patlh",
|
||||
"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",
|
||||
"hide_libraries": "De'wI' bom yIQIj",
|
||||
"select_liraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
|
||||
"disable_haptic_feedback": "Qub quvHa' yIQIj",
|
||||
"default_quality": "wa' luj"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Qaw' Doch",
|
||||
"download_method": "Qaw' mIw",
|
||||
"remux_max_download": "Remux Qaw' Dun",
|
||||
"auto_download": "chIch Qaw'",
|
||||
"optimized_versions_server": "luj wej Ho'Do' veS",
|
||||
"save_button": "yIqIp",
|
||||
"optimized_server": "luj Ho'Do' veS",
|
||||
"optimized": "luj",
|
||||
"default": "wa'",
|
||||
"optimized_version_hint": "luj Ho'Do' veS URL yI'el.",
|
||||
"read_more_about_optimized_server": "luj Ho'Do' veS latlh yIlaD",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domajn.org:pord"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "mIwHom",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "mIwHomvam chu'. ghoSlaH.",
|
||||
"server_url": "Ho'Do' veS URL",
|
||||
"server_url_hint": "ghu': http(s)://HoDo-veS.url\n(pord yIbel)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "ngoq De'",
|
||||
"password_placeholder": "tlhIngan {{username}} ngoq De' yI'el",
|
||||
"save_button": "yIqIp",
|
||||
"clear_button": "yIQaw'",
|
||||
"login_button": "yI'el!",
|
||||
"total_media_requests": "Hoch tlhol petlh",
|
||||
"movie_quota_limit": "DIS petlh Dun",
|
||||
"movie_quota_days": "DIS petlh jaj",
|
||||
"tv_quota_limit": "TV petlh Dun",
|
||||
"tv_quota_days": "TV petlh jaj",
|
||||
"reset_jellyseerr_config_button": "Jellyseerr men yIQaw'qa'",
|
||||
"unlimited": "Dun pagh",
|
||||
"plus_n_more": "+{{n}} latlh",
|
||||
"order_by": {
|
||||
"DEFAULT": "wa'",
|
||||
"VOTE_COUNT_AND_AVERAGE": "nem chIm 'ej mev",
|
||||
"POPULARITY": "ruch"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Marlin Sam yIchu'",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domajn.org:pord",
|
||||
"marlin_search_hint": "Marlin Ho'Do' veS URL yI'el.",
|
||||
"read_more_about_marlin": "Marlin latlh yIlaD",
|
||||
"save_button": "yIqIp",
|
||||
"toasts": {
|
||||
"saved": "qIp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "ram",
|
||||
"app_usage": "ghun {{usedSpace}}%",
|
||||
"device_usage": "naDev {{availableSpace}}%",
|
||||
"size_used": "{{used}} / {{total}} ram",
|
||||
"delete_all_downloaded_files": "Hoch Qaw' Doch yIQaw'",
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "chu' Doch yIHoch",
|
||||
"reset_intro": "chu' Doch yIQaw'qa'"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "De' qon",
|
||||
"export_logs": "De' qon yISamqa'",
|
||||
"click_for_more_info": "latlh De' yIchIch",
|
||||
"level": "quv",
|
||||
"no_logs_available": "De' qon pagh",
|
||||
"delete_all_logs": "Hoch De' qon yIQaw'"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Holmey",
|
||||
"app_language": "ghun Hol",
|
||||
"app_language_description": "ghun Hol yIwIv.",
|
||||
"system": "mIw'a'"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Qaw' ghIq",
|
||||
"background_downloads_enabled": "tlhegh Qaw' chu'",
|
||||
"background_downloads_disabled": "tlhegh Qaw' QIj",
|
||||
"connected": "ngoQ",
|
||||
"could_not_connect": "ngoQlaHbe'",
|
||||
"invalid_url": "URL law'be'"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "tlholrap",
|
||||
"no_active_sessions": "tlholrap pagh chu'"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Qaw' Doch",
|
||||
"tvseries": "TV Hem",
|
||||
"movies": "DIS",
|
||||
"queue": "ghom",
|
||||
"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'",
|
||||
"delete_all_button": "Hoch yIQaw'",
|
||||
"active_download": "chu' Qaw'",
|
||||
"no_active_downloads": "chu' Qaw' pagh",
|
||||
"active_downloads": "chu' Qaw'",
|
||||
"new_app_version_requires_re_download": "ghun wej chu' Qaw'qa' DaneH",
|
||||
"new_app_version_requires_re_download_description": "wej chu' Doch Qaw'qa' DaneH. Hoch Qaw' Doch yIQaw' 'ej yIHaDqa'.",
|
||||
"back": "yIbav",
|
||||
"delete": "yIQaw'",
|
||||
"something_went_wrong": "Doch rurbe'",
|
||||
"could_not_get_stream_url_from_jellyfin": "Jellyfin tlhol ret URL tu'laHbe'",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "mIw",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Doch Qaw' je'laHbe'.",
|
||||
"deleted_all_movies_successfully": "Hoch DIS Qaw' Qapla'!",
|
||||
"failed_to_delete_all_movies": "Hoch DIS Qaw'laHbe'",
|
||||
"deleted_all_tvseries_successfully": "Hoch TV Hem Qaw' Qapla'!",
|
||||
"failed_to_delete_all_tvseries": "Hoch TV Hem Qaw'laHbe'",
|
||||
"download_cancelled": "Qaw' ghIm",
|
||||
"could_not_cancel_download": "Qaw' ghImlaHbe'",
|
||||
"download_completed": "Qaw' Qapla'",
|
||||
"download_started_for": "{{item}} Qaw' vIlchu'",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} Qaw'laHnIS",
|
||||
"download_stated_for_item": "{{item}} Qaw' vIlchu'",
|
||||
"download_failed_for_item": "{{item}} Qaw'laHbe' - {{error}}",
|
||||
"download_completed_for_item": "{{item}} Qaw' Qapla'",
|
||||
"queued_item_for_optimization": "{{item}} luj ghom",
|
||||
"failed_to_start_download_for_item": "{{item}} Qaw' vIlchu'laHbe': {{message}}",
|
||||
"server_responded_with_status_code": "Ho'Do' veS jachrup {{statusCode}}",
|
||||
"no_response_received_from_server": "Ho'Do' veS jachbe'",
|
||||
"error_setting_up_the_request": "petlh SeH ghIq",
|
||||
"failed_to_start_download_for_item_unexpected_error": "{{item}} Qaw' vIlchu'laHbe': num ghIq",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Hoch De', ram 'ej vum Qaw' Qapla'",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "De', ram 'ej vum Qaw'DI' ghIq",
|
||||
"go_to_downloads": "Qaw' Doch yIghoS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "DaH yISam...",
|
||||
"search": "yISam...",
|
||||
"x_items": "{{count}} Doch",
|
||||
"library": "De'wI' bom",
|
||||
"discover": "yISamqa'",
|
||||
"no_results": "Doch pagh tu'",
|
||||
"no_results_found_for": "Doch pagh tu' <...>",
|
||||
"movies": "DIS",
|
||||
"series": "Hem",
|
||||
"episodes": "HemHom",
|
||||
"collections": "ghom",
|
||||
"actors": "tlholwI'",
|
||||
"request_movies": "DIS yIpetlh",
|
||||
"request_series": "Hem yIpetlh",
|
||||
"recently_added": "num tu'",
|
||||
"recent_requests": "num petlh",
|
||||
"plex_watchlist": "Plex tlhol ghom",
|
||||
"trending": "chu' ruch",
|
||||
"popular_movies": "ruch DIS",
|
||||
"movie_genres": "DIS qorDu'",
|
||||
"upcoming_movies": "DIS wej",
|
||||
"studios": "DIS qonwI'",
|
||||
"popular_tv": "ruch TV",
|
||||
"tv_genres": "TV qorDu'",
|
||||
"upcoming_tv": "TV wej",
|
||||
"networks": "ret",
|
||||
"tmdb_movie_keyword": "TMDB DIS De'",
|
||||
"tmdb_movie_genre": "TMDB DIS qorDu'",
|
||||
"tmdb_tv_keyword": "TMDB TV De'",
|
||||
"tmdb_tv_genre": "TMDB TV qorDu'",
|
||||
"tmdb_search": "TMDB Sam",
|
||||
"tmdb_studio": "TMDB qonwI'",
|
||||
"tmdb_network": "TMDB ret",
|
||||
"tmdb_movie_streaming_services": "TMDB DIS tlhol mIw",
|
||||
"tmdb_tv_streaming_services": "TMDB TV tlhol mIw"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Doch pagh tu'",
|
||||
"no_results": "Doch pagh tu'",
|
||||
"no_libraries_found": "De'wI' bom pagh tu'",
|
||||
"item_types": {
|
||||
"movies": "DIS",
|
||||
"series": "Hem",
|
||||
"boxsets": "Hem ghom",
|
||||
"items": "Doch"
|
||||
},
|
||||
"options": {
|
||||
"display": "yIHoch",
|
||||
"row": "ret",
|
||||
"list": "ghom",
|
||||
"image_style": "nagh bep",
|
||||
"poster": "nagh",
|
||||
"cover": "nagh chop",
|
||||
"show_titles": "pab HoS yIHoch",
|
||||
"show_stats": "chIm De' yIHoch"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "qorDu'",
|
||||
"years": "DIS",
|
||||
"sort_by": "yIwIv",
|
||||
"sort_order": "wIv mIw",
|
||||
"asc": "Dung",
|
||||
"desc": "nuq",
|
||||
"tags": "De'Hom"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Hem",
|
||||
"movies": "DIS",
|
||||
"episodes": "HemHom",
|
||||
"videos": "mu'tlhegh",
|
||||
"boxsets": "Hem ghom",
|
||||
"playlists": "bom ghom",
|
||||
"noDataTitle": "wIv Doch pagh",
|
||||
"noData": "Doch wIv DaneH. DaH tu'laH."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "ret pagh"
|
||||
},
|
||||
"player": {
|
||||
"error": "ghIq",
|
||||
"failed_to_get_stream_url": "tlhol ret URL tu'laHbe'",
|
||||
"an_error_occured_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
|
||||
"client_error": "lut 'el ghIq",
|
||||
"could_not_create_stream_for_chromecast": "Chromecast tlhol ret qonlaHbe'",
|
||||
"message_from_server": "Ho'Do' veS jach: {{message}}",
|
||||
"video_has_finished_playing": "mu'tlhegh tlhol Qapla'!",
|
||||
"no_video_source": "mu'tlhegh wang pagh",
|
||||
"next_episode": "wej HemHom",
|
||||
"refresh_tracks": "ret yIchu'qa'",
|
||||
"subtitle_tracks": "De' chu' ret:",
|
||||
"audio_tracks": "QoQ ret:",
|
||||
"playback_state": "tlhol mIw:",
|
||||
"no_data_available": "De' pagh tu'",
|
||||
"index": "nem:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "wej",
|
||||
"no_items_to_display": "Doch pagh HochlaH",
|
||||
"cast_and_crew": "tlholwI' 'ej qonwI'",
|
||||
"series": "Hem",
|
||||
"seasons": "muv",
|
||||
"season": "muv",
|
||||
"no_episodes_for_this_season": "muvvam HemHom pagh",
|
||||
"overview": "Hoch Sov",
|
||||
"more_with": "{{name}} latlh",
|
||||
"similar_items": "Doch rur",
|
||||
"no_similar_items_found": "Doch rur pagh tu'",
|
||||
"video": "mu'tlhegh",
|
||||
"more_details": "latlh De'",
|
||||
"quality": "luj",
|
||||
"audio": "QoQ",
|
||||
"subtitles": "De' chu'",
|
||||
"show_more": "latlh yIHoch",
|
||||
"show_less": "Hom yIHoch",
|
||||
"appeared_in": "tlholvam",
|
||||
"could_not_load_item": "Doch tlha'laHbe'",
|
||||
"none": "pagh",
|
||||
"download": {
|
||||
"download_season": "muv yIQaw'",
|
||||
"download_series": "Hem yIQaw'",
|
||||
"download_episode": "HemHom yIQaw'",
|
||||
"download_movie": "DIS yIQaw'",
|
||||
"download_x_item": "{{item_count}} Doch yIQaw'",
|
||||
"download_button": "yIQaw'",
|
||||
"using_optimized_server": "luj Ho'Do' veS tu'lu'",
|
||||
"using_default_method": "wa' mIw tu'lu'"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "wej",
|
||||
"previous": "namen",
|
||||
"live_tv": "chu' TV",
|
||||
"coming_soon": "wej lup",
|
||||
"on_now": "DaH",
|
||||
"shows": "tlhol",
|
||||
"movies": "DIS",
|
||||
"sports": "QI'",
|
||||
"for_kids": "puqbeq",
|
||||
"news": "De'"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "yInej",
|
||||
"cancel": "yIQo'",
|
||||
"yes": "HIja'",
|
||||
"whats_wrong": "Doch rurbe' 'Iv?",
|
||||
"issue_type": "ghIq bep",
|
||||
"select_an_issue": "ghIq yIwIv",
|
||||
"types": "bep",
|
||||
"describe_the_issue": "(num) ghIq yIqon...",
|
||||
"submit_button": "yInejqa'",
|
||||
"report_issue_button": "ghIq yIqon",
|
||||
"request_button": "yIpetlh",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Hoch muv Danej petlh'a'?",
|
||||
"failed_to_login": "'ellaHbe'",
|
||||
"cast": "tlholwI'",
|
||||
"details": "De'",
|
||||
"status": "mIw",
|
||||
"original_title": "wa'DIch pab HoS",
|
||||
"series_type": "Hem bep",
|
||||
"release_dates": "Qaw' jaj",
|
||||
"first_air_date": "wa'DIch tlhol jaj",
|
||||
"next_air_date": "wej tlhol jaj",
|
||||
"revenue": "boj De'",
|
||||
"budget": "boj nem",
|
||||
"original_language": "wa'DIch Hol",
|
||||
"production_country": "qonwI' qo'",
|
||||
"studios": "qonwI'",
|
||||
"network": "ret",
|
||||
"currently_streaming_on": "DaH tlhol <...>",
|
||||
"advanced": "SonchIy",
|
||||
"request_as": "yIpetlh <...>",
|
||||
"tags": "De'Hom",
|
||||
"quality_profile": "luj wIvlaw'",
|
||||
"root_folder": "wa'DIch ram",
|
||||
"season_all": "muv (Hoch)",
|
||||
"season_number": "muv {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} HemHom",
|
||||
"born": "poS",
|
||||
"appearances": "tlholvam",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr Ho'Do' veS wej law'be'! 2.0.0 yIchu'!",
|
||||
"jellyseerr_test_failed": "Jellyseerr nejlaHbe'. yIHaDqa'.",
|
||||
"failed_to_test_jellyseerr_server_url": "Jellyseerr Ho'Do' veS URL nejlaHbe'",
|
||||
"issue_submitted": "ghIq nejqa'!",
|
||||
"requested_item": "{{item}} petlh!",
|
||||
"you_dont_have_permission_to_request": "petlh je'laHbe'!",
|
||||
"something_went_wrong_requesting_media": "tlhol petlhDI' Doch rurbe'!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "juH",
|
||||
"search": "Sam",
|
||||
"library": "De'wI' bom",
|
||||
"custom_links": "teqlu' ret",
|
||||
"favorites": "wIv Doch"
|
||||
}
|
||||
}
|
||||
@@ -137,9 +137,7 @@
|
||||
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
|
||||
"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",
|
||||
"default_quality": "Varsayılan kalite",
|
||||
"disabled": "Devre dışı"
|
||||
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirmeler",
|
||||
@@ -371,9 +369,7 @@
|
||||
"audio_tracks": "Ses Parçaları:",
|
||||
"playback_state": "Oynatma Durumu:",
|
||||
"no_data_available": "Veri bulunamadı",
|
||||
"index": "İndeks:",
|
||||
"continue_watching": "İzlemeye devam et",
|
||||
"go_back": "Geri"
|
||||
"index": "İndeks:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Sıradaki",
|
||||
|
||||
478
translations/ua.json
Normal file
478
translations/ua.json
Normal file
@@ -0,0 +1,478 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Імʼя користувача необхідне",
|
||||
"error_title": "Помилка",
|
||||
"login_title": "Вхід",
|
||||
"login_to_title": "Увійти в",
|
||||
"username_placeholder": "Імʼя користувача",
|
||||
"password_placeholder": "Пароль",
|
||||
"login_button": "Вхід",
|
||||
"quick_connect": "Швидке Зʼєднання",
|
||||
"enter_code_to_login": "Введіть код {{code}} для входу",
|
||||
"failed_to_initiate_quick_connect": "Не вдалося ініціалізувати Швидке Зʼєднання",
|
||||
"got_it": "Готово",
|
||||
"connection_failed": "Помилка зʼєднання",
|
||||
"could_not_connect_to_server": "Неможливо підʼєднатися до серверу. Будь ласка перевірте URL і ваше зʼєднання з мережею",
|
||||
"an_unexpected_error_occured": "Сталася несподівана помилка",
|
||||
"change_server": "Змінити сервер",
|
||||
"invalid_username_or_password": "Неправильні імʼя користувача або пароль",
|
||||
"user_does_not_have_permission_to_log_in": "Користувач не маю дозволу на вхід",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Сервер відповідає занадто довго, будь-ласка спробуйте пізніше",
|
||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||
"there_is_a_server_error": "Відбулася помилка на стороні сервера",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Введіть URL вашого Jellyfin сервера",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
"connect_button": "Підʼєднатися",
|
||||
"previous_servers": "попередні сервери",
|
||||
"clear_button": "Очистити",
|
||||
"search_for_local_servers": "Пошук локальних серверів",
|
||||
"searching": "Пошук...",
|
||||
"servers": "Сервери"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "Інтернет відсутній",
|
||||
"no_items": "Пусто",
|
||||
"no_internet_message": "Не хвилюйтеся, ви все ще можете переглядати\nзавантажений контент.",
|
||||
"go_to_downloads": "Перейти в завантаження",
|
||||
"oops": "Упс!",
|
||||
"error_message": "Щось пішло не так.\nБудь ласка вийдіть і увійдіть знов.",
|
||||
"continue_watching": "Продовжити перегляд",
|
||||
"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.",
|
||||
"features_title": "Функції",
|
||||
"features_description": "Streamyfin має безліч функцій та інтегрується з широким спектром програмного забезпечення, яке ви можете знайти в меню налаштувань, зокрема:",
|
||||
"jellyseerr_feature_description": "Підключіться до вашого екземпляру Jellyseerr і запитуватуйте фільми безпосередньо в застосунку.",
|
||||
"downloads_feature_title": "Завантаження",
|
||||
"downloads_feature_description": "Завантажуйте фільми і серіали для перегляду офлайн. Використовуйте або метод за замовчуванням або встановіть оптимізований сервер для завантаження файлів у фоні.",
|
||||
"chromecast_feature_description": "Транслюйте фільми і серіали но ваші Chromecast прилади.",
|
||||
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
||||
"centralised_settings_plugin_description": "Налаштуйте параметри з централізованої локації на вашому сервері Jellyfin. Всі налаштування клієнтів для всіх користувачів будуть синхронізовані автоматично.",
|
||||
"done_button": "Готово",
|
||||
"go_to_settings_button": "Перейти до параметрів",
|
||||
"read_more": "Прочитати більше"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Параметри",
|
||||
"log_out_button": "Вихід",
|
||||
"user_info": {
|
||||
"user_info_title": "Інформація користувача",
|
||||
"user": "Користувач",
|
||||
"server": "Сервер",
|
||||
"token": "Токен",
|
||||
"app_version": "Версія Застосунку"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Швидке Зʼєднання",
|
||||
"authorize_button": "Авторизуйте Швидке Зʼєднання",
|
||||
"enter_the_quick_connect_code": "Введіть код для швидкого зʼєднання...",
|
||||
"success": "Успіх",
|
||||
"quick_connect_autorized": "Швидке Зʼєднання авторизовано",
|
||||
"error": "Помилка",
|
||||
"invalid_code": "Не правильний код",
|
||||
"authorize": "Авторизувати"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Керування Медія",
|
||||
"forward_skip_length": "Тривалість перемотування вперед",
|
||||
"rewind_length": "Довжина перемотування назад",
|
||||
"seconds_unit": "с"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Аудіо",
|
||||
"set_audio_track": "Виставити аудіо доріжку як в попередньому епізоду",
|
||||
"audio_language": "Мова аудіо",
|
||||
"audio_hint": "Вибрати мову аудіо за замовчуванням.",
|
||||
"none": "Ніяка",
|
||||
"language": "Мова"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Субтитри",
|
||||
"subtitle_language": "Мова субтитрів",
|
||||
"subtitle_mode": "Режим субтитрів",
|
||||
"set_subtitle_track": "Виставити доріжку субтитрів як в попередньому епізоду",
|
||||
"subtitle_size": "Розмір субтитрів",
|
||||
"subtitle_hint": "Налаштуйте параметри субтитрів.",
|
||||
"none": "Ніякі",
|
||||
"language": "Мова",
|
||||
"loading": "Завантаження",
|
||||
"modes": {
|
||||
"Default": "За замовчування",
|
||||
"Smart": "Smart",
|
||||
"Always": "Завжди",
|
||||
"None": "Някий",
|
||||
"OnlyForced": "Виключно Форсовані"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Інші",
|
||||
"follow_device_orientation": "Дотримуйтесь орієнтації пристрою",
|
||||
"video_orientation": "Орієнтація відео",
|
||||
"orientation": "Orientation",
|
||||
"orientations": {
|
||||
"DEFAULT": "За змовчуванням",
|
||||
"ALL": "Всі",
|
||||
"PORTRAIT": "Портретна",
|
||||
"PORTRAIT_UP": "Портретна Догори",
|
||||
"PORTRAIT_DOWN": "Портретна Донизу",
|
||||
"LANDSCAPE": "Альбомна",
|
||||
"LANDSCAPE_LEFT": "Альбомна Ліва",
|
||||
"LANDSCAPE_RIGHT": "Альбомна Права",
|
||||
"OTHER": "Інше",
|
||||
"UNKNOWN": "Невідомо"
|
||||
},
|
||||
"safe_area_in_controls": "Безпечна зона в елементах керування",
|
||||
"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": "Вимкнути тактильний зворотний зв'язок",
|
||||
"default_quality": "Якість за замовченням"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Завантаження",
|
||||
"download_method": "Метод завантаження",
|
||||
"remux_max_download": "Remux max download",
|
||||
"auto_download": "Авто-завантаження",
|
||||
"optimized_versions_server": "Optimized versions server",
|
||||
"save_button": "Зберегти",
|
||||
"optimized_server": "Оптимізований Сервер",
|
||||
"optimized": "Оптимізований",
|
||||
"default": "За замовченням",
|
||||
"optimized_version_hint": "Введіть URL-адресу сервера для оптимізації. URL-адреса має містити http або https і, за бажанням, порт.",
|
||||
"read_more_about_optimized_server": "Дізнайтеся більше про сервер для оптимізації.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Плагіни",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Ця інтеграція перебуває на початковій стадії. Очікуйте, що все зміниться.",
|
||||
"server_url": "URL Сервера",
|
||||
"server_url_hint": "Наприклад: http(s)://your-host.url\n(додайте порт якщо необхідно)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Пароль",
|
||||
"password_placeholder": "Введіть Jellyfin пароль для користувача {{username}}",
|
||||
"save_button": "Зберегти",
|
||||
"clear_button": "Очистити",
|
||||
"login_button": "Вхід",
|
||||
"total_media_requests": "Загальна кількість медіа запитів",
|
||||
"movie_quota_limit": "Дні квоти на фільми",
|
||||
"movie_quota_days": "Дні квоти на фільми",
|
||||
"tv_quota_limit": "Дні квоти на серіали",
|
||||
"tv_quota_days": "Дні квоти на серіали",
|
||||
"reset_jellyseerr_config_button": "Скинути конфігурацію Jellyseerr",
|
||||
"unlimited": "Необмежене",
|
||||
"plus_n_more": "+{{n}} ще",
|
||||
"order_by": {
|
||||
"DEFAULT": "За замовченням",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Кількість голосів і середнє",
|
||||
"POPULARITY": "Популярність"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Увімкнути Marlin Search ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Введіть URL-адресу сервера Marlin. Адреса повинна містити http або https і, за бажанням, порт.",
|
||||
"read_more_about_marlin": "Дізнайтеся більше про Marlin.",
|
||||
"save_button": "Зберегти",
|
||||
"toasts": {
|
||||
"saved": "Збережено"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Сховище",
|
||||
"app_usage": "Застосунок {{usedSpace}}%",
|
||||
"device_usage": "Гаджет {{availableSpace}}%",
|
||||
"size_used": "{{used}} з {{total}} використано",
|
||||
"delete_all_downloaded_files": "Видалити усі завантаженні файли"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Показати інтро",
|
||||
"reset_intro": "Скинути інтро"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Журнал",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Нема доступних журналів",
|
||||
"delete_all_logs": "Видалити усі журнали"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Мова",
|
||||
"app_language": "Мова застосунку",
|
||||
"app_language_description": "Виберіть мову застосунку.",
|
||||
"system": "Системна"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Помилка при видалені файлів",
|
||||
"background_downloads_enabled": "Завантаження в фоні увімкнене",
|
||||
"background_downloads_disabled": "Завантаження в фоні вимкнене",
|
||||
"connected": "Зʼєднано",
|
||||
"could_not_connect": "Неможливо зʼєднатися",
|
||||
"invalid_url": "Неправльий URL"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Сесії",
|
||||
"no_active_sessions": "Нема активних сесій"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Завантаження",
|
||||
"tvseries": "ТБ-Серіали",
|
||||
"movies": "Фільми",
|
||||
"queue": "Черга",
|
||||
"queue_hint": "Черга і завантаження буде втрачене при перезапуску застосунку",
|
||||
"no_items_in_queue": "Нема елементів в черзі",
|
||||
"no_downloaded_items": "Нема завантажених елементів",
|
||||
"delete_all_movies_button": "Видалити всі Фільми",
|
||||
"delete_all_tvseries_button": "Видалити всі ТБ-Серіали",
|
||||
"delete_all_button": "Видалити Все",
|
||||
"active_download": "Активне завантаження",
|
||||
"no_active_downloads": "Нема активних завантажень",
|
||||
"active_downloads": "Активні завантаження",
|
||||
"new_app_version_requires_re_download": "Нова версія застосунку вимагає завантажити заново",
|
||||
"new_app_version_requires_re_download_description": "Нове оновлення вимагає повторного завантаження вмісту. Будь ласка, видаліть весь завантажений вміст і повторіть спробу.",
|
||||
"back": "Назад",
|
||||
"delete": "Видалити",
|
||||
"something_went_wrong": "Щось пішло не так",
|
||||
"could_not_get_stream_url_from_jellyfin": "Не вдалося отримати URL-адресу потоку від Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Методи",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Вам не дозволено завантажувати файли.",
|
||||
"deleted_all_movies_successfully": "Видалення всіх фільмів було успішне!",
|
||||
"failed_to_delete_all_movies": "Не вдалося видалити усі фільми",
|
||||
"deleted_all_tvseries_successfully": "Успішно видалено всі серіали!",
|
||||
"failed_to_delete_all_tvseries": "Не вдалося видалити всі телесеріали",
|
||||
"download_cancelled": "Завантаження скасоване",
|
||||
"could_not_cancel_download": "Неможливо скасувати завантаження",
|
||||
"download_completed": "Завантаження завершено",
|
||||
"download_started_for": "Почалося завантаження {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} вже завантажено",
|
||||
"download_stated_for_item": "Почалося завантаження {{item}}",
|
||||
"download_failed_for_item": "Не вдалося завантажити {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Завантаження завершено {{item}}",
|
||||
"queued_item_for_optimization": "{{item}} в черзі на оптимізацію",
|
||||
"failed_to_start_download_for_item": "Не вдалося почати завантаження {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Сервер відповів зі статусом {{statusCode}}",
|
||||
"no_response_received_from_server": "Не отримано відповіді від сервера",
|
||||
"error_setting_up_the_request": "Помилка налаштування запиту",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Не вдалося почати завантаження {{item}}: Несподівана помилка",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Усі файли, папки та завдання успішно видалено",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Виникла помилка під час видалення файлів і завдань",
|
||||
"go_to_downloads": "Перейти до завантаження"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Шукати тут...",
|
||||
"search": "Шукати...",
|
||||
"x_items": "{{count}} елементів",
|
||||
"library": "Медіатека",
|
||||
"discover": "Відкрийте для себе",
|
||||
"no_results": "Без результатів",
|
||||
"no_results_found_for": "Жодних результатів не знайдено для",
|
||||
"movies": "Фільми",
|
||||
"series": "Серіали",
|
||||
"episodes": "Епізоди",
|
||||
"collections": "Колекції",
|
||||
"actors": "Актори",
|
||||
"request_movies": "Запитати Фільми",
|
||||
"request_series": "Запитати Серіали",
|
||||
"recently_added": "Нещодавно Додане",
|
||||
"recent_requests": "Нещодавні Запити",
|
||||
"plex_watchlist": "Список перегляду Plex",
|
||||
"trending": "У Тренді",
|
||||
"popular_movies": "Популярні Фільми",
|
||||
"movie_genres": "Жанри Кіно",
|
||||
"upcoming_movies": "Майбутні Фільми",
|
||||
"studios": "Студії",
|
||||
"popular_tv": "Популярні Серіали",
|
||||
"tv_genres": "Жанри Серіалів",
|
||||
"upcoming_tv": "Майбутні Серіали",
|
||||
"networks": "ТБ Канали",
|
||||
"tmdb_movie_keyword": "TMDB Ключові слова Фільмів",
|
||||
"tmdb_movie_genre": "TMDB Жанри Кіно",
|
||||
"tmdb_tv_keyword": "TMDB ТБ Ключові слова",
|
||||
"tmdb_tv_genre": "TMDB ТБ Жанри",
|
||||
"tmdb_search": "TMDB Пошук",
|
||||
"tmdb_studio": "TMDB Студії",
|
||||
"tmdb_network": "TMDB ТБ Канали",
|
||||
"tmdb_movie_streaming_services": "TMDB Стрімінгові Сервіси Фільмів",
|
||||
"tmdb_tv_streaming_services": "TMDB Стрімінгові Сервіси Серіалів"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Елементів не знайдено",
|
||||
"no_results": "Без результатів",
|
||||
"no_libraries_found": "Не знайдено медіатек",
|
||||
"item_types": {
|
||||
"movies": "фільми",
|
||||
"series": "серіали",
|
||||
"boxsets": "бокс-сети",
|
||||
"items": "елементи"
|
||||
},
|
||||
"options": {
|
||||
"display": "Показати",
|
||||
"row": "Ряд",
|
||||
"list": "Список",
|
||||
"image_style": "Стиль зображення",
|
||||
"poster": "Постер",
|
||||
"cover": "Обкладинка",
|
||||
"show_titles": "Показати заголовки",
|
||||
"show_stats": "Показати статистику"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Жанри",
|
||||
"years": "Роки",
|
||||
"sort_by": "Відсортувати за",
|
||||
"sort_order": "Порядок сортування",
|
||||
"asc": "За зростанням",
|
||||
"desc": "За спаданням",
|
||||
"tags": "Теги"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Серіали",
|
||||
"movies": "Фільми",
|
||||
"episodes": "Епізоди",
|
||||
"videos": "Відео",
|
||||
"boxsets": "Бокс-сети",
|
||||
"playlists": "Плейлісти"
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Немає посилань"
|
||||
},
|
||||
"player": {
|
||||
"error": "Помилка",
|
||||
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
|
||||
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнали в налаштуваннях.",
|
||||
"client_error": "Помилка клієнту",
|
||||
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
|
||||
"message_from_server": "Повідомлення від серверу: {{message}}",
|
||||
"video_has_finished_playing": "Відтворення відео завершено!",
|
||||
"no_video_source": "Немає джерела відео...",
|
||||
"next_episode": "Наступний Епізод",
|
||||
"refresh_tracks": "Оновити доріжки",
|
||||
"subtitle_tracks": "Доріжки Субтитрів:",
|
||||
"audio_tracks": "Аудіо-доріжки:",
|
||||
"playback_state": "Стан відтворення:",
|
||||
"no_data_available": "Дані відсутні",
|
||||
"index": "Індекс:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Далі",
|
||||
"no_items_to_display": "Немає елементів для відображення",
|
||||
"cast_and_crew": "Акторський склад та команда",
|
||||
"series": "Серіали",
|
||||
"seasons": "Сезони",
|
||||
"season": "Сезон",
|
||||
"no_episodes_for_this_season": "У цьому сезоні немає епізодів",
|
||||
"overview": "Огляд",
|
||||
"more_with": "Більше з {{name}}",
|
||||
"similar_items": "Схожі елементи",
|
||||
"no_similar_items_found": "Не знайдено схожих елементів",
|
||||
"video": "Відео",
|
||||
"more_details": "Більше деталей",
|
||||
"quality": "Якість",
|
||||
"audio": "Аудіо",
|
||||
"subtitles": "Субтитри",
|
||||
"show_more": "Показати більше",
|
||||
"show_less": "Показати менше",
|
||||
"appeared_in": "Зʼявлявся у",
|
||||
"could_not_load_item": "Неможливо завантажити елемент",
|
||||
"none": "Нічого",
|
||||
"download": {
|
||||
"download_season": "Завантажити Сезон",
|
||||
"download_series": "Завантажити Серіал",
|
||||
"download_episode": "Завантажити Епізод",
|
||||
"download_movie": "Завантажити Фільм",
|
||||
"download_x_item": "Завантажено {{item_count}} елементів",
|
||||
"download_button": "Завантажити",
|
||||
"using_optimized_server": "Використовуючи сервер оптимізації",
|
||||
"using_default_method": "Використовуючи метод за замовченням"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Наступний",
|
||||
"previous": "Попередній",
|
||||
"live_tv": "Live TV",
|
||||
"coming_soon": "Скоро",
|
||||
"on_now": "Просто зараз",
|
||||
"shows": "Серіали",
|
||||
"movies": "Фільми",
|
||||
"sports": "Спорт",
|
||||
"for_kids": "Для дітей",
|
||||
"news": "Новини"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "Підтвердити",
|
||||
"cancel": "Скасувати",
|
||||
"yes": "Так",
|
||||
"whats_wrong": "Щось сталося?",
|
||||
"issue_type": "Тип проблеми",
|
||||
"select_an_issue": "Виберіть проблему",
|
||||
"types": "Типи",
|
||||
"describe_the_issue": "(опціонально) Опишіть проблему...",
|
||||
"submit_button": "Надіслати",
|
||||
"report_issue_button": "Звіт про проблему",
|
||||
"request_button": "Запити",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Ви впевнені, що хочете запросити всі сезони?",
|
||||
"failed_to_login": "Не вдалося увійти",
|
||||
"cast": "Акторський склад",
|
||||
"details": "Деталі",
|
||||
"status": "Статус",
|
||||
"original_title": "Оригінальна Назва",
|
||||
"series_type": "Тип Серіалу",
|
||||
"release_dates": "Дата Виходу",
|
||||
"first_air_date": "Дата першого етеру",
|
||||
"next_air_date": "Дата наступного етеру",
|
||||
"revenue": "Збори",
|
||||
"budget": "Бюджет",
|
||||
"original_language": "Мова Оригіналу",
|
||||
"production_country": "Країна Виробництва",
|
||||
"studios": "Студії",
|
||||
"network": "ТБ Канали",
|
||||
"currently_streaming_on": "Наразі транслюється на",
|
||||
"advanced": "Просунуте",
|
||||
"request_as": "Запит Як",
|
||||
"tags": "Теги",
|
||||
"quality_profile": "Профіль якості",
|
||||
"root_folder": "Корнева Тека",
|
||||
"season_all": "Сезон (всі)",
|
||||
"season_number": "Сезон {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Епізодів",
|
||||
"born": "Дата народження",
|
||||
"appearances": "Зовнішній вигляд",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Сервер Jellyseerr не відповідає мінімальним вимогам до версії! Будь ласка, оновіть версію принаймні до 2.0.0",
|
||||
"jellyseerr_test_failed": "Тест Jellyseerr завершився невдало. Спробуйте ще раз.",
|
||||
"failed_to_test_jellyseerr_server_url": "Не вдалося перевірити URL-адресу сервера jellyseerr",
|
||||
"issue_submitted": "Звіт про проблему відправлено",
|
||||
"requested_item": "Запитано {{item}}!",
|
||||
"you_dont_have_permission_to_request": "У вас нема дозволу на запит медіа!",
|
||||
"something_went_wrong_requesting_media": "Щось пішло не так під час запиту медіа!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Головна",
|
||||
"search": "Пошук",
|
||||
"library": "Медіатека",
|
||||
"custom_links": "Ваші Посилання",
|
||||
"favorites": "Улюблене"
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Імʼя користувача необхідне",
|
||||
"error_title": "Помилка",
|
||||
"login_title": "Вхід",
|
||||
"login_to_title": "Увійти в",
|
||||
"username_placeholder": "Імʼя користувача",
|
||||
"password_placeholder": "Пароль",
|
||||
"login_button": "Вхід",
|
||||
"quick_connect": "Швидке Зʼєднання",
|
||||
"enter_code_to_login": "Введіть код {{code}} для входу",
|
||||
"failed_to_initiate_quick_connect": "Не вдалося ініціалізувати Швидке Зʼєднання",
|
||||
"got_it": "Готово",
|
||||
"connection_failed": "Помилка зʼєднання",
|
||||
"could_not_connect_to_server": "Неможливо підʼєднатися до серверу. Будь ласка перевірте URL і ваше зʼєднання з мережею",
|
||||
"an_unexpected_error_occured": "Сталася несподівана помилка",
|
||||
"change_server": "Змінити сервер",
|
||||
"invalid_username_or_password": "Неправильні імʼя користувача або пароль",
|
||||
"user_does_not_have_permission_to_log_in": "Користувач не маю дозволу на вхід",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Сервер відповідає занадто довго, будь-ласка спробуйте пізніше",
|
||||
"server_received_too_many_requests_try_again_later": "Сервер отримав забагато запитів, будь ласка спробуйте пізніше.",
|
||||
"there_is_a_server_error": "Відбулася помилка на стороні сервера",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Введіть URL вашого Jellyfin сервера",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
"connect_button": "Підʼєднатися",
|
||||
"previous_servers": "попередні сервери",
|
||||
"clear_button": "Очистити",
|
||||
"search_for_local_servers": "Пошук локальних серверів",
|
||||
"searching": "Пошук...",
|
||||
"servers": "Сервери"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "Інтернет відсутній",
|
||||
"no_items": "Пусто",
|
||||
"no_internet_message": "Не хвилюйтеся, ви все ще можете переглядати\nзавантажений контент.",
|
||||
"go_to_downloads": "Перейти в завантаження",
|
||||
"oops": "Упс!",
|
||||
"error_message": "Щось пішло не так.\nБудь ласка вийдіть і увійдіть знов.",
|
||||
"continue_watching": "Продовжити перегляд",
|
||||
"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.",
|
||||
"features_title": "Функції",
|
||||
"features_description": "Streamyfin має безліч функцій та інтегрується з широким спектром програмного забезпечення, яке ви можете знайти в меню налаштувань, зокрема:",
|
||||
"jellyseerr_feature_description": "Підключіться до вашого екземпляру Jellyseerr і запитуватуйте фільми безпосередньо в застосунку.",
|
||||
"downloads_feature_title": "Завантаження",
|
||||
"downloads_feature_description": "Завантажуйте фільми і серіали для перегляду офлайн. Використовуйте або метод за замовчуванням або встановіть оптимізований сервер для завантаження файлів у фоні.",
|
||||
"chromecast_feature_description": "Транслюйте фільми і серіали на ваші Chromecast прилади.",
|
||||
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
||||
"centralised_settings_plugin_description": "Налаштуйте параметри з централізованої локації на вашому сервері Jellyfin. Всі налаштування клієнтів для всіх користувачів будуть синхронізовані автоматично.",
|
||||
"done_button": "Готово",
|
||||
"go_to_settings_button": "Перейти до параметрів",
|
||||
"read_more": "Прочитати більше"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Параметри",
|
||||
"log_out_button": "Вихід",
|
||||
"user_info": {
|
||||
"user_info_title": "Інформація користувача",
|
||||
"user": "Користувач",
|
||||
"server": "Сервер",
|
||||
"token": "Токен",
|
||||
"app_version": "Версія Застосунку"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Швидке Зʼєднання",
|
||||
"authorize_button": "Авторизуйте Швидке Зʼєднання",
|
||||
"enter_the_quick_connect_code": "Введіть код для швидкого зʼєднання...",
|
||||
"success": "Успіх",
|
||||
"quick_connect_autorized": "Швидке Зʼєднання авторизовано",
|
||||
"error": "Помилка",
|
||||
"invalid_code": "Не правильний код",
|
||||
"authorize": "Авторизувати"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Керування Медіа",
|
||||
"forward_skip_length": "Довжина перемотування вперед",
|
||||
"rewind_length": "Довжина перемотування назад",
|
||||
"seconds_unit": "с"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Аудіо",
|
||||
"set_audio_track": "Аудіо доріжка як в попередньому епізоді",
|
||||
"audio_language": "Мова аудіо",
|
||||
"audio_hint": "Вибрати мову аудіо за замовчуванням.",
|
||||
"none": "Ніяка",
|
||||
"language": "Мова"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Субтитри",
|
||||
"subtitle_language": "Мова субтитрів",
|
||||
"subtitle_mode": "Режим субтитрів",
|
||||
"set_subtitle_track": "Виставити доріжку субтитрів як в попередньому епізоду",
|
||||
"subtitle_size": "Розмір субтитрів",
|
||||
"subtitle_hint": "Налаштуйте параметри субтитрів.",
|
||||
"none": "Ніякі",
|
||||
"language": "Мова",
|
||||
"loading": "Завантаження",
|
||||
"modes": {
|
||||
"Default": "За замовчування",
|
||||
"Smart": "Smart",
|
||||
"Always": "Завжди",
|
||||
"None": "Някий",
|
||||
"OnlyForced": "Виключно Форсовані"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Інші",
|
||||
"follow_device_orientation": "Дотримуйтесь орієнтації пристрою",
|
||||
"video_orientation": "Орієнтація відео",
|
||||
"orientation": "Orientation",
|
||||
"orientations": {
|
||||
"DEFAULT": "За змовчуванням",
|
||||
"ALL": "Всі",
|
||||
"PORTRAIT": "Портретна",
|
||||
"PORTRAIT_UP": "Портретна Догори",
|
||||
"PORTRAIT_DOWN": "Портретна Донизу",
|
||||
"LANDSCAPE": "Альбомна",
|
||||
"LANDSCAPE_LEFT": "Альбомна Ліва",
|
||||
"LANDSCAPE_RIGHT": "Альбомна Права",
|
||||
"OTHER": "Інше",
|
||||
"UNKNOWN": "Невідомо"
|
||||
},
|
||||
"safe_area_in_controls": "Безпечна зона в елементах керування",
|
||||
"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": "Вимкнути тактильний зворотний зв'язок",
|
||||
"default_quality": "Якість за замовченням",
|
||||
"disabled": "Вимкнено"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Завантаження",
|
||||
"download_method": "Метод завантаження",
|
||||
"remux_max_download": "Remux max download",
|
||||
"auto_download": "Авто-завантаження",
|
||||
"optimized_versions_server": "Сервер оптимізованих версій",
|
||||
"save_button": "Зберегти",
|
||||
"optimized_server": "Оптимізований Сервер",
|
||||
"optimized": "Оптимізований",
|
||||
"default": "За замовченням",
|
||||
"optimized_version_hint": "Введіть URL-адресу сервера для оптимізації. URL-адреса має містити http або https і, за бажанням, порт.",
|
||||
"read_more_about_optimized_server": "Дізнайтеся більше про сервер для оптимізації.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Плагіни",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Ця інтеграція перебуває на початковій стадії. Очікуйте, що все зміниться.",
|
||||
"server_url": "URL Сервера",
|
||||
"server_url_hint": "Наприклад: http(s)://your-host.url\n(додайте порт якщо необхідно)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Пароль",
|
||||
"password_placeholder": "Введіть Jellyfin пароль для користувача {{username}}",
|
||||
"save_button": "Зберегти",
|
||||
"clear_button": "Очистити",
|
||||
"login_button": "Вхід",
|
||||
"total_media_requests": "Загальна кількість медіа запитів",
|
||||
"movie_quota_limit": "Дні квоти на фільми",
|
||||
"movie_quota_days": "Дні квоти на фільми",
|
||||
"tv_quota_limit": "Дні квоти на серіали",
|
||||
"tv_quota_days": "Дні квоти на серіали",
|
||||
"reset_jellyseerr_config_button": "Скинути конфігурацію Jellyseerr",
|
||||
"unlimited": "Необмежене",
|
||||
"plus_n_more": "+{{n}} ще",
|
||||
"order_by": {
|
||||
"DEFAULT": "За замовченням",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Кількість голосів і середнє",
|
||||
"POPULARITY": "Популярність"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Увімкнути Marlin Search ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Введіть URL-адресу сервера Marlin. Адреса повинна містити http або https і, за бажанням, порт.",
|
||||
"read_more_about_marlin": "Дізнайтеся більше про Marlin.",
|
||||
"save_button": "Зберегти",
|
||||
"toasts": {
|
||||
"saved": "Збережено"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Сховище",
|
||||
"app_usage": "Застосунок {{usedSpace}}%",
|
||||
"device_usage": "Гаджет {{availableSpace}}%",
|
||||
"size_used": "{{used}} з {{total}} використано",
|
||||
"delete_all_downloaded_files": "Видалити усі завантаженні файли"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Показати інтро",
|
||||
"reset_intro": "Скинути інтро"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Журнал",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Нема доступних журналів",
|
||||
"delete_all_logs": "Видалити усі журнали"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Мова",
|
||||
"app_language": "Мова застосунку",
|
||||
"app_language_description": "Виберіть мову застосунку.",
|
||||
"system": "Системна"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Помилка при видалені файлів",
|
||||
"background_downloads_enabled": "Завантаження в фоні увімкнене",
|
||||
"background_downloads_disabled": "Завантаження в фоні вимкнене",
|
||||
"connected": "Зʼєднано",
|
||||
"could_not_connect": "Неможливо зʼєднатися",
|
||||
"invalid_url": "Неправльий URL"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Сесії",
|
||||
"no_active_sessions": "Нема активних сесій"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Завантаження",
|
||||
"tvseries": "ТБ-Серіали",
|
||||
"movies": "Фільми",
|
||||
"queue": "Черга",
|
||||
"queue_hint": "Черга і завантаження буде втрачене при перезапуску застосунку",
|
||||
"no_items_in_queue": "Нема елементів в черзі",
|
||||
"no_downloaded_items": "Нема завантажених елементів",
|
||||
"delete_all_movies_button": "Видалити всі Фільми",
|
||||
"delete_all_tvseries_button": "Видалити всі ТБ-Серіали",
|
||||
"delete_all_button": "Видалити Все",
|
||||
"active_download": "Активне завантаження",
|
||||
"no_active_downloads": "Нема активних завантажень",
|
||||
"active_downloads": "Активні завантаження",
|
||||
"new_app_version_requires_re_download": "Нова версія застосунку вимагає завантажити заново",
|
||||
"new_app_version_requires_re_download_description": "Нове оновлення вимагає повторного завантаження вмісту. Будь ласка, видаліть весь завантажений вміст і повторіть спробу.",
|
||||
"back": "Назад",
|
||||
"delete": "Видалити",
|
||||
"something_went_wrong": "Щось пішло не так",
|
||||
"could_not_get_stream_url_from_jellyfin": "Не вдалося отримати URL-адресу потоку від Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Методи",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Вам не дозволено завантажувати файли.",
|
||||
"deleted_all_movies_successfully": "Видалення всіх фільмів було успішне!",
|
||||
"failed_to_delete_all_movies": "Не вдалося видалити усі фільми",
|
||||
"deleted_all_tvseries_successfully": "Успішно видалено всі серіали!",
|
||||
"failed_to_delete_all_tvseries": "Не вдалося видалити всі телесеріали",
|
||||
"download_cancelled": "Завантаження скасоване",
|
||||
"could_not_cancel_download": "Неможливо скасувати завантаження",
|
||||
"download_completed": "Завантаження завершено",
|
||||
"download_started_for": "Почалося завантаження {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} вже завантажено",
|
||||
"download_stated_for_item": "Почалося завантаження {{item}}",
|
||||
"download_failed_for_item": "Не вдалося завантажити {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Завантаження завершено {{item}}",
|
||||
"queued_item_for_optimization": "{{item}} в черзі на оптимізацію",
|
||||
"failed_to_start_download_for_item": "Не вдалося почати завантаження {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Сервер відповів зі статусом {{statusCode}}",
|
||||
"no_response_received_from_server": "Не отримано відповіді від сервера",
|
||||
"error_setting_up_the_request": "Помилка налаштування запиту",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Не вдалося почати завантаження {{item}}: Несподівана помилка",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Усі файли, папки та завдання успішно видалено",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Виникла помилка під час видалення файлів і завдань",
|
||||
"go_to_downloads": "Перейти до завантаження"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Шукати тут...",
|
||||
"search": "Шукати...",
|
||||
"x_items": "{{count}} елементів",
|
||||
"library": "Медіатека",
|
||||
"discover": "Відкрийте для себе",
|
||||
"no_results": "Без результатів",
|
||||
"no_results_found_for": "Жодних результатів не знайдено для",
|
||||
"movies": "Фільми",
|
||||
"series": "Серіали",
|
||||
"episodes": "Епізоди",
|
||||
"collections": "Колекції",
|
||||
"actors": "Актори",
|
||||
"request_movies": "Запитати Фільми",
|
||||
"request_series": "Запитати Серіали",
|
||||
"recently_added": "Нещодавно Додане",
|
||||
"recent_requests": "Нещодавні Запити",
|
||||
"plex_watchlist": "Список перегляду Plex",
|
||||
"trending": "У Тренді",
|
||||
"popular_movies": "Популярні Фільми",
|
||||
"movie_genres": "Жанри Кіно",
|
||||
"upcoming_movies": "Майбутні Фільми",
|
||||
"studios": "Студії",
|
||||
"popular_tv": "Популярні Серіали",
|
||||
"tv_genres": "Жанри Серіалів",
|
||||
"upcoming_tv": "Майбутні Серіали",
|
||||
"networks": "ТБ Канали",
|
||||
"tmdb_movie_keyword": "TMDB Ключові слова Фільмів",
|
||||
"tmdb_movie_genre": "TMDB Жанри Кіно",
|
||||
"tmdb_tv_keyword": "TMDB ТБ Ключові слова",
|
||||
"tmdb_tv_genre": "TMDB ТБ Жанри",
|
||||
"tmdb_search": "TMDB Пошук",
|
||||
"tmdb_studio": "TMDB Студії",
|
||||
"tmdb_network": "TMDB ТБ Канали",
|
||||
"tmdb_movie_streaming_services": "TMDB Стрімінгові Сервіси Фільмів",
|
||||
"tmdb_tv_streaming_services": "TMDB Стрімінгові Сервіси Серіалів"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Елементів не знайдено",
|
||||
"no_results": "Без результатів",
|
||||
"no_libraries_found": "Не знайдено медіатек",
|
||||
"item_types": {
|
||||
"movies": "фільми",
|
||||
"series": "серіали",
|
||||
"boxsets": "бокс-сети",
|
||||
"items": "елементи"
|
||||
},
|
||||
"options": {
|
||||
"display": "Показати",
|
||||
"row": "Ряд",
|
||||
"list": "Список",
|
||||
"image_style": "Стиль зображення",
|
||||
"poster": "Постер",
|
||||
"cover": "Обкладинка",
|
||||
"show_titles": "Показати заголовки",
|
||||
"show_stats": "Показати статистику"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Жанри",
|
||||
"years": "Роки",
|
||||
"sort_by": "Відсортувати за",
|
||||
"sort_order": "Порядок сортування",
|
||||
"asc": "За зростанням",
|
||||
"desc": "За спаданням",
|
||||
"tags": "Теги"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Серіали",
|
||||
"movies": "Фільми",
|
||||
"episodes": "Епізоди",
|
||||
"videos": "Відео",
|
||||
"boxsets": "Бокс-сети",
|
||||
"playlists": "Плейлісти",
|
||||
"noDataTitle": "Поки що нема обраного",
|
||||
"noData": "Відмітьте як улюблене що би побачити це тут в швидкому доступі."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Немає посилань"
|
||||
},
|
||||
"player": {
|
||||
"error": "Помилка",
|
||||
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
|
||||
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
|
||||
"client_error": "Помилка клієнту",
|
||||
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
|
||||
"message_from_server": "Повідомлення від серверу: {{message}}",
|
||||
"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": "Назад"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Далі",
|
||||
"no_items_to_display": "Немає елементів для відображення",
|
||||
"cast_and_crew": "Акторський склад та команда",
|
||||
"series": "Серіали",
|
||||
"seasons": "Сезони",
|
||||
"season": "Сезон",
|
||||
"no_episodes_for_this_season": "У цьому сезоні немає епізодів",
|
||||
"overview": "Огляд",
|
||||
"more_with": "Більше з {{name}}",
|
||||
"similar_items": "Схожі елементи",
|
||||
"no_similar_items_found": "Не знайдено схожих елементів",
|
||||
"video": "Відео",
|
||||
"more_details": "Більше деталей",
|
||||
"quality": "Якість",
|
||||
"audio": "Аудіо",
|
||||
"subtitles": "Субтитри",
|
||||
"show_more": "Показати більше",
|
||||
"show_less": "Показати менше",
|
||||
"appeared_in": "Зʼявлявся у",
|
||||
"could_not_load_item": "Неможливо завантажити елемент",
|
||||
"none": "Нічого",
|
||||
"download": {
|
||||
"download_season": "Завантажити Сезон",
|
||||
"download_series": "Завантажити Серіал",
|
||||
"download_episode": "Завантажити Епізод",
|
||||
"download_movie": "Завантажити Фільм",
|
||||
"download_x_item": "Завантажено {{item_count}} елементів",
|
||||
"download_button": "Завантажити",
|
||||
"using_optimized_server": "Використовуючи сервер оптимізації",
|
||||
"using_default_method": "Використовуючи метод за замовченням"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Наступний",
|
||||
"previous": "Попередній",
|
||||
"live_tv": "Live TV",
|
||||
"coming_soon": "Скоро",
|
||||
"on_now": "Просто зараз",
|
||||
"shows": "Серіали",
|
||||
"movies": "Фільми",
|
||||
"sports": "Спорт",
|
||||
"for_kids": "Для дітей",
|
||||
"news": "Новини"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "Підтвердити",
|
||||
"cancel": "Скасувати",
|
||||
"yes": "Так",
|
||||
"whats_wrong": "Щось сталося?",
|
||||
"issue_type": "Тип проблеми",
|
||||
"select_an_issue": "Виберіть проблему",
|
||||
"types": "Типи",
|
||||
"describe_the_issue": "(опціонально) Опишіть проблему...",
|
||||
"submit_button": "Надіслати",
|
||||
"report_issue_button": "Звіт про проблему",
|
||||
"request_button": "Запити",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Ви впевнені, що хочете запросити всі сезони?",
|
||||
"failed_to_login": "Не вдалося увійти",
|
||||
"cast": "Акторський склад",
|
||||
"details": "Деталі",
|
||||
"status": "Статус",
|
||||
"original_title": "Оригінальна Назва",
|
||||
"series_type": "Тип Серіалу",
|
||||
"release_dates": "Дата Виходу",
|
||||
"first_air_date": "Дата першого етеру",
|
||||
"next_air_date": "Дата наступного етеру",
|
||||
"revenue": "Збори",
|
||||
"budget": "Бюджет",
|
||||
"original_language": "Мова Оригіналу",
|
||||
"production_country": "Країна Виробництва",
|
||||
"studios": "Студії",
|
||||
"network": "ТБ Канали",
|
||||
"currently_streaming_on": "Наразі транслюється на",
|
||||
"advanced": "Просунуте",
|
||||
"request_as": "Запит Як",
|
||||
"tags": "Теги",
|
||||
"quality_profile": "Профіль якості",
|
||||
"root_folder": "Корнева Тека",
|
||||
"season_all": "Сезон (всі)",
|
||||
"season_number": "Сезон {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Епізодів",
|
||||
"born": "Дата народження",
|
||||
"appearances": "Зовнішній вигляд",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Версія Jellyseerr не відповідає мінімальним вимогам! Будь ласка, оновіться принаймні до 2.0.0",
|
||||
"jellyseerr_test_failed": "Тест Jellyseerr завершився невдало. Спробуйте ще раз.",
|
||||
"failed_to_test_jellyseerr_server_url": "Не вдалося перевірити URL-адресу сервера jellyseerr",
|
||||
"issue_submitted": "Звіт про проблему відправлено",
|
||||
"requested_item": "Запитано {{item}}!",
|
||||
"you_dont_have_permission_to_request": "Ви не маєте дозволу на запит медіа!",
|
||||
"something_went_wrong_requesting_media": "Щось пішло не так під час запиту медіа!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Головна",
|
||||
"search": "Пошук",
|
||||
"library": "Медіатека",
|
||||
"custom_links": "Ваші Посилання",
|
||||
"favorites": "Улюблене"
|
||||
}
|
||||
}
|
||||
@@ -369,9 +369,7 @@
|
||||
"audio_tracks": "音频轨道:",
|
||||
"playback_state": "播放状态:",
|
||||
"no_data_available": "无可用数据",
|
||||
"index": "索引:",
|
||||
"continue_watching": "继续观看",
|
||||
"go_back": "返回"
|
||||
"index": "索引:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "下一个",
|
||||
|
||||
@@ -137,9 +137,7 @@
|
||||
"show_custom_menu_links": "顯示自定義菜單鏈接",
|
||||
"hide_libraries": "隱藏媒體庫",
|
||||
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
|
||||
"disable_haptic_feedback": "禁用觸覺回饋",
|
||||
"default_quality": "預設品質",
|
||||
"disabled": "已停用"
|
||||
"disable_haptic_feedback": "禁用觸覺回饋"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下載",
|
||||
@@ -371,9 +369,7 @@
|
||||
"audio_tracks": "音頻軌道:",
|
||||
"playback_state": "播放狀態:",
|
||||
"no_data_available": "無可用數據",
|
||||
"index": "索引:",
|
||||
"continue_watching": "繼續觀看",
|
||||
"go_back": "返回"
|
||||
"index": "索引:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "下一個",
|
||||
|
||||
@@ -114,11 +114,6 @@ export type HomeSectionNextUpResolver = {
|
||||
enableRewatching?: boolean;
|
||||
};
|
||||
|
||||
export interface MaxAutoPlayEpisodeCount {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type HomeSectionLatestResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
@@ -168,8 +163,6 @@ export type Settings = {
|
||||
hiddenLibraries?: string[];
|
||||
enableH265ForChromecast: boolean;
|
||||
defaultPlayer: VideoPlayer;
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
};
|
||||
|
||||
export interface Lockable<T> {
|
||||
@@ -224,9 +217,7 @@ const defaultValues: Settings = {
|
||||
jellyseerrServerUrl: undefined,
|
||||
hiddenLibraries: [],
|
||||
enableH265ForChromecast: false,
|
||||
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
defaultPlayer: VideoPlayer.VLC_3, // ios only setting. does not matter what this is for android
|
||||
};
|
||||
|
||||
const loadSettings = (): Partial<Settings> => {
|
||||
@@ -245,11 +236,11 @@ const loadSettings = (): Partial<Settings> => {
|
||||
const EXCLUDE_FROM_SAVE = ["home"];
|
||||
|
||||
const saveSettings = (settings: Settings) => {
|
||||
for (const key of Object.keys(settings)) {
|
||||
Object.keys(settings).forEach((key) => {
|
||||
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
||||
delete settings[key as keyof Settings];
|
||||
}
|
||||
}
|
||||
});
|
||||
const jsonValue = JSON.stringify(settings);
|
||||
storage.set("settings", jsonValue);
|
||||
};
|
||||
@@ -280,9 +271,7 @@ export const useSettings = () => {
|
||||
);
|
||||
|
||||
const refreshStreamyfinPluginSettings = useCallback(async () => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
if (!api) return;
|
||||
const settings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog("Got plugin settings", data?.settings);
|
||||
@@ -295,9 +284,7 @@ export const useSettings = () => {
|
||||
}, [api]);
|
||||
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
if (!_settings) {
|
||||
return;
|
||||
}
|
||||
if (!_settings) return;
|
||||
const hasChanges = Object.entries(update).some(
|
||||
([key, value]) => _settings[key as keyof Settings] !== value,
|
||||
);
|
||||
@@ -318,31 +305,34 @@ export const useSettings = () => {
|
||||
// If admin sets locked to false but provides a value,
|
||||
// use user settings first and fallback on admin setting if required.
|
||||
const settings: Settings = useMemo(() => {
|
||||
const unlockedPluginDefaults = {} as Settings;
|
||||
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
||||
Partial<Settings>
|
||||
>((acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
const settingsKey = key as keyof Settings;
|
||||
let unlockedPluginDefaults = {} as Settings;
|
||||
const overrideSettings = Object.entries(pluginSettings || {}).reduce(
|
||||
(acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
|
||||
// Make sure we override default settings with plugin settings when they are not locked.
|
||||
if (
|
||||
!locked &&
|
||||
value !== undefined &&
|
||||
_settings?.[settingsKey] !== value
|
||||
) {
|
||||
Object.assign(unlockedPluginDefaults, {
|
||||
[settingsKey]: value,
|
||||
// Make sure we override default settings with plugin settings when they are not locked.
|
||||
// Admin decided what users defaults should be and grants them the ability to change them too.
|
||||
if (
|
||||
locked === false &&
|
||||
value &&
|
||||
_settings?.[key as keyof Settings] !== value
|
||||
) {
|
||||
unlockedPluginDefaults = Object.assign(unlockedPluginDefaults, {
|
||||
[key as keyof Settings]: value,
|
||||
});
|
||||
}
|
||||
|
||||
acc = Object.assign(acc, {
|
||||
[key]: locked
|
||||
? value
|
||||
: (_settings?.[key as keyof Settings] ?? value),
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(acc, {
|
||||
[settingsKey]: locked ? value : (_settings?.[settingsKey] ?? value),
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return acc;
|
||||
},
|
||||
{} as Settings,
|
||||
);
|
||||
|
||||
return {
|
||||
...defaultValues,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import native from "@/utils/profiles/native";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
@@ -14,27 +14,23 @@ export const getStreamUrl = async ({
|
||||
userId,
|
||||
startTimeTicks = 0,
|
||||
maxStreamingBitrate,
|
||||
playSessionId,
|
||||
deviceProfile = generateDeviceProfile(),
|
||||
sessionData,
|
||||
deviceProfile = native,
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = undefined,
|
||||
mediaSourceId,
|
||||
download = false,
|
||||
deviceId,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
startTimeTicks: number;
|
||||
maxStreamingBitrate?: number;
|
||||
playSessionId?: string | null;
|
||||
sessionData?: PlaybackInfoResponse | null;
|
||||
deviceProfile?: any;
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
height?: number;
|
||||
mediaSourceId?: string | null;
|
||||
download?: bool;
|
||||
deviceId?: string | null;
|
||||
}): Promise<{
|
||||
url: string | null;
|
||||
sessionId: string | null;
|
||||
@@ -48,84 +44,111 @@ export const getStreamUrl = async ({
|
||||
let mediaSource: MediaSourceInfo | undefined;
|
||||
let sessionId: string | null | undefined;
|
||||
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
if (item.Type === "Program") {
|
||||
console.log("Item is of type program...");
|
||||
const res0 = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
userId,
|
||||
itemId: item.ChannelId!,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
params: {
|
||||
startTimeTicks: 0,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
maxStreamingBitrate,
|
||||
audioStreamIndex,
|
||||
},
|
||||
data: {
|
||||
deviceProfile,
|
||||
},
|
||||
},
|
||||
);
|
||||
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
|
||||
sessionId = res0.data.PlaySessionId || null;
|
||||
|
||||
if (transcodeUrl) {
|
||||
return {
|
||||
url: `${api.basePath}${transcodeUrl}`,
|
||||
sessionId,
|
||||
mediaSource: res0.data.MediaSources?.[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const itemId = item.Id;
|
||||
|
||||
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
itemId: item.Id!,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
userId,
|
||||
deviceProfile,
|
||||
subtitleStreamIndex,
|
||||
startTimeTicks,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
userId,
|
||||
maxStreamingBitrate,
|
||||
audioStreamIndex,
|
||||
startTimeTicks,
|
||||
autoOpenLiveStream: true,
|
||||
mediaSourceId,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error("Error getting playback info:", res.status, res.statusText);
|
||||
if (res2.status !== 200) {
|
||||
console.error("Error getting playback info:", res2.status, res2.statusText);
|
||||
}
|
||||
|
||||
sessionId = res.data.PlaySessionId || null;
|
||||
mediaSource = res.data.MediaSources[0];
|
||||
let transcodeUrl = mediaSource.TranscodingUrl;
|
||||
sessionId = res2.data.PlaySessionId || null;
|
||||
|
||||
if (transcodeUrl) {
|
||||
if (download) {
|
||||
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream");
|
||||
mediaSource = res2.data.MediaSources?.find(
|
||||
(source: MediaSourceInfo) => source.Id === mediaSourceId,
|
||||
);
|
||||
|
||||
if (item.MediaType === "Video") {
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||
|
||||
// Get the updated URL
|
||||
const transcodeUrl = urlObj.toString();
|
||||
|
||||
console.log("Video has transcoding URL:", `${transcodeUrl}`);
|
||||
return {
|
||||
url: transcodeUrl,
|
||||
sessionId: sessionId,
|
||||
mediaSource,
|
||||
};
|
||||
}
|
||||
console.log("Video is being transcoded:", transcodeUrl);
|
||||
const searchParams = new URLSearchParams({
|
||||
playSessionId: sessionData?.PlaySessionId || "",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
static: "true",
|
||||
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: audioStreamIndex?.toString() || "",
|
||||
deviceId: api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: startTimeTicks.toString(),
|
||||
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||
userId: userId || "",
|
||||
});
|
||||
|
||||
const directPlayUrl = `${
|
||||
api.basePath
|
||||
}/Videos/${itemId}/stream.mp4?${searchParams.toString()}`;
|
||||
|
||||
console.log("Video is being direct played:", directPlayUrl);
|
||||
|
||||
return {
|
||||
url: `${api.basePath}${transcodeUrl}`,
|
||||
sessionId,
|
||||
url: directPlayUrl,
|
||||
sessionId: sessionId,
|
||||
mediaSource,
|
||||
};
|
||||
}
|
||||
|
||||
let downloadParams = {};
|
||||
Alert.alert("Error", "Could not play this item");
|
||||
|
||||
if (download) {
|
||||
// We need to disable static so we can have a remux with subtitle.
|
||||
downloadParams = {
|
||||
subtitleMethod: "Embed",
|
||||
enableSubtitlesInManifest: true,
|
||||
static: "false",
|
||||
allowVideoStreamCopy: true,
|
||||
allowAudioStreamCopy: true,
|
||||
playSessionId: sessionId || "",
|
||||
container: "ts",
|
||||
};
|
||||
}
|
||||
|
||||
const streamParams = new URLSearchParams({
|
||||
static: "true",
|
||||
container: "mp4",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: audioStreamIndex?.toString() || "",
|
||||
deviceId: deviceId || api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: startTimeTicks.toString(),
|
||||
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||
userId: userId || "",
|
||||
...downloadParams,
|
||||
});
|
||||
|
||||
const directPlayUrl = `${
|
||||
api.basePath
|
||||
}/Videos/${item.Id}/stream?${streamParams.toString()}`;
|
||||
|
||||
console.log("Video is being direct played:", directPlayUrl);
|
||||
|
||||
return {
|
||||
url: directPlayUrl,
|
||||
sessionId: sessionId || playSessionId,
|
||||
mediaSource,
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import native from "@/utils/profiles/native";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
@@ -43,7 +43,7 @@ export const postCapabilities = async ({
|
||||
],
|
||||
supportsMediaControl: true,
|
||||
id: sessionId,
|
||||
DeviceProfile: generateDeviceProfile(),
|
||||
DeviceProfile: native,
|
||||
},
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
|
||||
143
utils/profiles/android.js
Normal file
143
utils/profiles/android.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
/**
|
||||
* Device profile for Native video player
|
||||
*/
|
||||
export default {
|
||||
Name: "1. Native iOS Video Profile",
|
||||
MaxStaticBitrate: 100000000,
|
||||
MaxStreamingBitrate: 120000000,
|
||||
MusicStreamingTranscodingBitrate: 384000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp",
|
||||
VideoCodec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
AudioCodec: "aac,mp3,flac,alac,opus,vorbis,wma",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec: "mp3,aac,flac,alac,opus,vorbis,wma,pcm",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
MaxAudioChannels: "8",
|
||||
MinSegments: "2",
|
||||
BreakOnNonKeyFrames: true,
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
ResponseProfiles: [
|
||||
{
|
||||
Container: "mkv",
|
||||
MimeType: "video/x-matroska",
|
||||
Type: MediaTypes.Video,
|
||||
},
|
||||
{
|
||||
Container: "mp4",
|
||||
MimeType: "video/mp4",
|
||||
Type: MediaTypes.Video,
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
{ Format: "srt", Method: "Embed" },
|
||||
{ Format: "srt", Method: "External" },
|
||||
{ Format: "srt", Method: "Encode" },
|
||||
{ Format: "ass", Method: "Embed" },
|
||||
{ Format: "ass", Method: "External" },
|
||||
{ Format: "ass", Method: "Encode" },
|
||||
{ Format: "ssa", Method: "Embed" },
|
||||
{ Format: "ssa", Method: "External" },
|
||||
{ Format: "ssa", Method: "Encode" },
|
||||
{ Format: "sub", Method: "Embed" },
|
||||
{ Format: "sub", Method: "External" },
|
||||
{ Format: "sub", Method: "Encode" },
|
||||
{ Format: "vtt", Method: "Embed" },
|
||||
{ Format: "vtt", Method: "External" },
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
{ Format: "ttml", Method: "Embed" },
|
||||
{ Format: "ttml", Method: "External" },
|
||||
{ Format: "ttml", Method: "Encode" },
|
||||
{ Format: "pgs", Method: "Embed" },
|
||||
{ Format: "pgs", Method: "External" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
{ Format: "dvdsub", Method: "Embed" },
|
||||
{ Format: "dvdsub", Method: "External" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
{ Format: "dvbsub", Method: "Embed" },
|
||||
{ Format: "dvbsub", Method: "External" },
|
||||
{ Format: "dvbsub", Method: "Encode" },
|
||||
{ Format: "xsub", Method: "Embed" },
|
||||
{ Format: "xsub", Method: "External" },
|
||||
{ Format: "xsub", Method: "Encode" },
|
||||
{ Format: "mov_text", Method: "Embed" },
|
||||
{ Format: "mov_text", Method: "External" },
|
||||
{ Format: "mov_text", Method: "Encode" },
|
||||
{ Format: "scc", Method: "Embed" },
|
||||
{ Format: "scc", Method: "External" },
|
||||
{ Format: "scc", Method: "Encode" },
|
||||
{ Format: "smi", Method: "Embed" },
|
||||
{ Format: "smi", Method: "External" },
|
||||
{ Format: "smi", Method: "Encode" },
|
||||
{ Format: "teletext", Method: "Embed" },
|
||||
{ Format: "teletext", Method: "External" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
{ Format: "microdvd", Method: "Embed" },
|
||||
{ Format: "microdvd", Method: "External" },
|
||||
{ Format: "microdvd", Method: "Encode" },
|
||||
{ Format: "mpl2", Method: "Embed" },
|
||||
{ Format: "mpl2", Method: "External" },
|
||||
{ Format: "mpl2", Method: "Encode" },
|
||||
{ Format: "pjs", Method: "Embed" },
|
||||
{ Format: "pjs", Method: "External" },
|
||||
{ Format: "pjs", Method: "Encode" },
|
||||
{ Format: "realtext", Method: "Embed" },
|
||||
{ Format: "realtext", Method: "External" },
|
||||
{ Format: "realtext", Method: "Encode" },
|
||||
{ Format: "stl", Method: "Embed" },
|
||||
{ Format: "stl", Method: "External" },
|
||||
{ Format: "stl", Method: "Encode" },
|
||||
{ Format: "subrip", Method: "Embed" },
|
||||
{ Format: "subrip", Method: "External" },
|
||||
{ Format: "subrip", Method: "Encode" },
|
||||
{ Format: "subviewer", Method: "Embed" },
|
||||
{ Format: "subviewer", Method: "External" },
|
||||
{ Format: "subviewer", Method: "Encode" },
|
||||
{ Format: "text", Method: "Embed" },
|
||||
{ Format: "text", Method: "External" },
|
||||
{ Format: "text", Method: "Encode" },
|
||||
{ Format: "vplayer", Method: "Embed" },
|
||||
{ Format: "vplayer", Method: "External" },
|
||||
{ Format: "vplayer", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
86
utils/profiles/base.js
Normal file
86
utils/profiles/base.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
export default {
|
||||
Name: "Expo Base Video Profile",
|
||||
MaxStaticBitrate: 100000000,
|
||||
MaxStreamingBitrate: 120000000,
|
||||
MusicStreamingTranscodingBitrate: 384000,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Codec: "h264",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
IsRequired: false,
|
||||
Property: "IsAnamorphic",
|
||||
Value: "true",
|
||||
},
|
||||
{
|
||||
Condition: "EqualsAny",
|
||||
IsRequired: false,
|
||||
Property: "VideoProfile",
|
||||
Value: "high|main|baseline|constrained baseline",
|
||||
},
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
IsRequired: false,
|
||||
Property: "VideoLevel",
|
||||
Value: "51",
|
||||
},
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
IsRequired: false,
|
||||
Property: "IsInterlaced",
|
||||
Value: "true",
|
||||
},
|
||||
],
|
||||
Type: MediaTypes.Video,
|
||||
},
|
||||
{
|
||||
Codec: "hevc",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
IsRequired: false,
|
||||
Property: "IsAnamorphic",
|
||||
Value: "true",
|
||||
},
|
||||
{
|
||||
Condition: "EqualsAny",
|
||||
IsRequired: false,
|
||||
Property: "VideoProfile",
|
||||
Value: "main|main 10",
|
||||
},
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
IsRequired: false,
|
||||
Property: "VideoLevel",
|
||||
Value: "183",
|
||||
},
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
IsRequired: false,
|
||||
Property: "IsInterlaced",
|
||||
Value: "true",
|
||||
},
|
||||
],
|
||||
Type: MediaTypes.Video,
|
||||
},
|
||||
],
|
||||
ContainerProfiles: [],
|
||||
DirectPlayProfiles: [],
|
||||
ResponseProfiles: [
|
||||
{
|
||||
Container: "m4v",
|
||||
MimeType: "video/mp4",
|
||||
Type: MediaTypes.Video,
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
{
|
||||
Format: "vtt",
|
||||
Method: "Hls",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [],
|
||||
};
|
||||
149
utils/profiles/ios.js
Normal file
149
utils/profiles/ios.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
import BaseProfile from "./base";
|
||||
|
||||
/**
|
||||
* Device profile for Expo Video player on iOS 13+
|
||||
*/
|
||||
export default {
|
||||
...BaseProfile,
|
||||
Name: "Expo iOS Video Profile",
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
|
||||
Container: "mp4,m4v",
|
||||
Type: MediaTypes.Video,
|
||||
VideoCodec: "hevc,h264",
|
||||
},
|
||||
{
|
||||
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
|
||||
Container: "mov",
|
||||
Type: MediaTypes.Video,
|
||||
VideoCodec: "hevc,h264",
|
||||
},
|
||||
{
|
||||
Container: "mp3",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
Container: "aac",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "aac",
|
||||
Container: "m4a",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "aac",
|
||||
Container: "m4b",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
Container: "flac",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
Container: "alac",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "alac",
|
||||
Container: "m4a",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "alac",
|
||||
Container: "m4b",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
Container: "wav",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
AudioCodec: "aac",
|
||||
BreakOnNonKeyFrames: true,
|
||||
Container: "aac",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "6",
|
||||
MinSegments: "2",
|
||||
Protocol: "hls",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "aac",
|
||||
Container: "aac",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "6",
|
||||
Protocol: "http",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "mp3",
|
||||
Container: "mp3",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "6",
|
||||
Protocol: "http",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "wav",
|
||||
Container: "wav",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "6",
|
||||
Protocol: "http",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "mp3",
|
||||
Container: "mp3",
|
||||
Context: "Static",
|
||||
MaxAudioChannels: "6",
|
||||
Protocol: "http",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "aac",
|
||||
Container: "aac",
|
||||
Context: "Static",
|
||||
MaxAudioChannels: "6",
|
||||
Protocol: "http",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "wav",
|
||||
Container: "wav",
|
||||
Context: "Static",
|
||||
MaxAudioChannels: "6",
|
||||
Protocol: "http",
|
||||
Type: MediaTypes.Audio,
|
||||
},
|
||||
{
|
||||
AudioCodec: "aac,mp3",
|
||||
BreakOnNonKeyFrames: true,
|
||||
Container: "ts",
|
||||
Context: "Streaming",
|
||||
MaxAudioChannels: "6",
|
||||
MinSegments: "2",
|
||||
Protocol: "hls",
|
||||
Type: MediaTypes.Video,
|
||||
VideoCodec: "h264",
|
||||
},
|
||||
{
|
||||
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
|
||||
Container: "mp4",
|
||||
Context: "Static",
|
||||
Protocol: "http",
|
||||
Type: MediaTypes.Video,
|
||||
VideoCodec: "h264",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Platform } from "react-native";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
@@ -7,190 +5,132 @@ import DeviceInfo from "react-native-device-info";
|
||||
*/
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
// Helper function to detect Dolby Vision support
|
||||
const supportsDolbyVision = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const deviceModel = await DeviceInfo.getModel();
|
||||
// iPhone 12 and newer generally support Dolby Vision
|
||||
const modelNumber = Number.parseInt(deviceModel.replace(/iPhone/, ""), 10);
|
||||
return !Number.isNaN(modelNumber) && modelNumber >= 12;
|
||||
}
|
||||
/**
|
||||
* Device profile for Native video player
|
||||
*/
|
||||
export default {
|
||||
Name: "1. Vlc Player",
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "fmp4",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Embed" },
|
||||
{ Format: "vtt", Method: "External" },
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
const apiLevel = await DeviceInfo.getApiLevel();
|
||||
const isHighEndDevice =
|
||||
(await DeviceInfo.getTotalMemory()) > 4 * 1024 * 1024 * 1024; // >4GB RAM
|
||||
// Very rough approximation - Android 10+ on higher-end devices may support it
|
||||
return apiLevel >= 29 && isHighEndDevice;
|
||||
}
|
||||
{ Format: "webvtt", Method: "Embed" },
|
||||
{ Format: "webvtt", Method: "External" },
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const generateDeviceProfile = async () => {
|
||||
const dolbyVisionSupported = await supportsDolbyVision();
|
||||
/**
|
||||
* Device profile for Native video player
|
||||
*/
|
||||
const profile = {
|
||||
Name: "1. Vlc Player",
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "hevc,h265",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "VideoLevel",
|
||||
Value: "153",
|
||||
IsRequired: false,
|
||||
},
|
||||
// We'll add Dolby Vision condition below if not supported
|
||||
],
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "fmp4",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Embed" },
|
||||
{ Format: "vtt", Method: "External" },
|
||||
|
||||
{ Format: "webvtt", Method: "Embed" },
|
||||
{ Format: "webvtt", Method: "External" },
|
||||
|
||||
{ Format: "srt", Method: "Embed" },
|
||||
{ Format: "srt", Method: "External" },
|
||||
|
||||
{ Format: "subrip", Method: "Embed" },
|
||||
{ Format: "subrip", Method: "External" },
|
||||
|
||||
{ Format: "ttml", Method: "Embed" },
|
||||
{ Format: "ttml", Method: "External" },
|
||||
|
||||
{ Format: "dvbsub", Method: "Embed" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
|
||||
{ Format: "ass", Method: "Embed" },
|
||||
{ Format: "ass", Method: "External" },
|
||||
|
||||
{ Format: "idx", Method: "Embed" },
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
|
||||
{ Format: "pgs", Method: "Embed" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
|
||||
{ Format: "pgssub", Method: "Embed" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
|
||||
{ Format: "ssa", Method: "Embed" },
|
||||
{ Format: "ssa", Method: "External" },
|
||||
|
||||
// Other formats
|
||||
{ Format: "microdvd", Method: "Embed" },
|
||||
{ Format: "microdvd", Method: "External" },
|
||||
|
||||
{ Format: "mov_text", Method: "Embed" },
|
||||
{ Format: "mov_text", Method: "External" },
|
||||
|
||||
{ Format: "mpl2", Method: "Embed" },
|
||||
{ Format: "mpl2", Method: "External" },
|
||||
|
||||
{ Format: "pjs", Method: "Embed" },
|
||||
{ Format: "pjs", Method: "External" },
|
||||
|
||||
{ Format: "realtext", Method: "Embed" },
|
||||
{ Format: "realtext", Method: "External" },
|
||||
|
||||
{ Format: "scc", Method: "Embed" },
|
||||
{ Format: "scc", Method: "External" },
|
||||
|
||||
{ Format: "smi", Method: "Embed" },
|
||||
{ Format: "smi", Method: "External" },
|
||||
|
||||
{ Format: "stl", Method: "Embed" },
|
||||
{ Format: "stl", Method: "External" },
|
||||
|
||||
{ Format: "sub", Method: "Embed" },
|
||||
{ Format: "sub", Method: "External" },
|
||||
|
||||
{ Format: "subviewer", Method: "Embed" },
|
||||
{ Format: "subviewer", Method: "External" },
|
||||
|
||||
{ Format: "teletext", Method: "Embed" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
|
||||
{ Format: "text", Method: "Embed" },
|
||||
{ Format: "text", Method: "External" },
|
||||
|
||||
{ Format: "vplayer", Method: "Embed" },
|
||||
{ Format: "vplayer", Method: "External" },
|
||||
|
||||
{ Format: "xsub", Method: "Embed" },
|
||||
{ Format: "xsub", Method: "External" },
|
||||
],
|
||||
};
|
||||
|
||||
// Add Dolby Vision restriction if not supported
|
||||
if (!dolbyVisionSupported) {
|
||||
const hevcProfile = profile.CodecProfiles.find(
|
||||
(p) => p.Type === MediaTypes.Video && p.Codec.includes("hevc"),
|
||||
);
|
||||
|
||||
if (hevcProfile) {
|
||||
hevcProfile.Conditions.push({
|
||||
Condition: "NotEquals",
|
||||
Property: "VideoRangeType",
|
||||
Value: "DOVI", //no dolby vision at all
|
||||
IsRequired: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
export default async () => {
|
||||
return await generateDeviceProfile();
|
||||
{ Format: "srt", Method: "Embed" },
|
||||
{ Format: "srt", Method: "External" },
|
||||
|
||||
{ Format: "subrip", Method: "Embed" },
|
||||
{ Format: "subrip", Method: "External" },
|
||||
|
||||
{ Format: "ttml", Method: "Embed" },
|
||||
{ Format: "ttml", Method: "External" },
|
||||
|
||||
{ Format: "dvbsub", Method: "Embed" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
|
||||
{ Format: "ass", Method: "Embed" },
|
||||
{ Format: "ass", Method: "External" },
|
||||
|
||||
{ Format: "idx", Method: "Embed" },
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
|
||||
{ Format: "pgs", Method: "Embed" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
|
||||
{ Format: "pgssub", Method: "Embed" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
|
||||
{ Format: "ssa", Method: "Embed" },
|
||||
{ Format: "ssa", Method: "External" },
|
||||
|
||||
// Other formats
|
||||
{ Format: "microdvd", Method: "Embed" },
|
||||
{ Format: "microdvd", Method: "External" },
|
||||
|
||||
{ Format: "mov_text", Method: "Embed" },
|
||||
{ Format: "mov_text", Method: "External" },
|
||||
|
||||
{ Format: "mpl2", Method: "Embed" },
|
||||
{ Format: "mpl2", Method: "External" },
|
||||
|
||||
{ Format: "pjs", Method: "Embed" },
|
||||
{ Format: "pjs", Method: "External" },
|
||||
|
||||
{ Format: "realtext", Method: "Embed" },
|
||||
{ Format: "realtext", Method: "External" },
|
||||
|
||||
{ Format: "scc", Method: "Embed" },
|
||||
{ Format: "scc", Method: "External" },
|
||||
|
||||
{ Format: "smi", Method: "Embed" },
|
||||
{ Format: "smi", Method: "External" },
|
||||
|
||||
{ Format: "stl", Method: "Embed" },
|
||||
{ Format: "stl", Method: "External" },
|
||||
|
||||
{ Format: "sub", Method: "Embed" },
|
||||
{ Format: "sub", Method: "External" },
|
||||
|
||||
{ Format: "subviewer", Method: "Embed" },
|
||||
{ Format: "subviewer", Method: "External" },
|
||||
|
||||
{ Format: "teletext", Method: "Embed" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
|
||||
{ Format: "text", Method: "Embed" },
|
||||
{ Format: "text", Method: "External" },
|
||||
|
||||
{ Format: "vplayer", Method: "Embed" },
|
||||
{ Format: "vplayer", Method: "External" },
|
||||
|
||||
{ Format: "xsub", Method: "Embed" },
|
||||
{ Format: "xsub", Method: "External" },
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user