forked from Ninjalama/streamyfin_mirror
Compare commits
20 Commits
v0.25.0
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6494049b66 | ||
|
|
adb20f4b10 | ||
|
|
7dc8132e7a | ||
|
|
ecb9b90163 | ||
|
|
9fb04518b0 | ||
|
|
33a2be24f4 | ||
|
|
e8b0d52515 | ||
|
|
9faa0de2d6 | ||
|
|
221155d002 | ||
|
|
46be3c9465 | ||
|
|
4a37e17324 | ||
|
|
52b2a3418e | ||
|
|
2753b243e5 | ||
|
|
f22b356b7c | ||
|
|
d8ba5af8d9 | ||
|
|
505ef39ee7 | ||
|
|
e71d5cc176 | ||
|
|
74e57bbd88 | ||
|
|
76eaeb9820 | ||
|
|
9a70f98dd5 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,8 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.26.1
|
||||||
|
- 0.26.0
|
||||||
- 0.25.0
|
- 0.25.0
|
||||||
- 0.24.0
|
- 0.24.0
|
||||||
- 0.23.0
|
- 0.23.0
|
||||||
|
|||||||
84
README.md
84
README.md
@@ -38,7 +38,7 @@ Chromecast support is still in development, and we're working on improving it. C
|
|||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anythin
|
- Auto log in to Jellyseerr without the user having to do anythin
|
||||||
- Choose the default languages
|
- Choose the default languages
|
||||||
- Set download method and search provider
|
- Set download method and search provider
|
||||||
- Customize homescreen
|
- Customize homescreen
|
||||||
- And more...
|
- And more...
|
||||||
@@ -68,7 +68,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
|
|||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -123,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
|
|||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
I'd like to thank the following people and projects for their contributions to Streamyfin:
|
### Core Developers
|
||||||
|
|
||||||
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Alexk2309">
|
||||||
|
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Alexk2309</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/herrrta">
|
||||||
|
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@herrrta</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lostb1t">
|
||||||
|
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@lostb1t</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Simon-Eklundh">
|
||||||
|
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/topiga">
|
||||||
|
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@topiga</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/simoncaron">
|
||||||
|
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@simoncaron</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/jakequade">
|
||||||
|
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@jakequade</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Ryan0204">
|
||||||
|
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Ryan0204</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/retardgerman">
|
||||||
|
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@retardgerman</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/whoopsi-daisy">
|
||||||
|
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||||
|
|
||||||
|
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||||
|
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||||
|
|||||||
35
app.json
35
app.json
@@ -2,24 +2,19 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.25.0",
|
"version": "0.26.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": [
|
"assetBundlePatterns": ["**/*"],
|
||||||
"**/*"
|
|
||||||
],
|
|
||||||
"ios": {
|
"ios": {
|
||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": ["audio", "fetch"],
|
||||||
"audio",
|
|
||||||
"fetch"
|
|
||||||
],
|
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
@@ -36,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 50,
|
"versionCode": 53,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -111,21 +106,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
["react-native-bottom-tabs"],
|
||||||
"react-native-bottom-tabs"
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
],
|
["./plugins/withAndroidManifest.js"],
|
||||||
[
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
"./plugins/withChangeNativeAndroidTextToWhite.js"
|
["./plugins/withGradleProperties.js"],
|
||||||
],
|
|
||||||
[
|
|
||||||
"./plugins/withGoogleCastActivity.js"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"./plugins/withTrustLocalCerts.js"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"./plugins/withGradleProperties.js"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -155,4 +140,4 @@
|
|||||||
},
|
},
|
||||||
"newArchEnabled": false
|
"newArchEnabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,18 @@ export default function SearchLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
<Stack.Screen
|
||||||
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
|
name="jellyseerr/person/[personId]"
|
||||||
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/company/[companyId]"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/genre/[genreId]"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function search() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { q } = params as { q: string };
|
||||||
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
@@ -122,18 +122,17 @@ export default function search() {
|
|||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (Platform.OS === "ios")
|
navigation.setOptions({
|
||||||
navigation.setOptions({
|
headerSearchBarOptions: {
|
||||||
headerSearchBarOptions: {
|
placeholder: t("search.search"),
|
||||||
placeholder: t("search.search"),
|
onChangeText: (e: any) => {
|
||||||
onChangeText: (e: any) => {
|
router.setParams({ q: "" });
|
||||||
router.setParams({ q: "" });
|
setSearch(e.nativeEvent.text);
|
||||||
setSearch(e.nativeEvent.text);
|
|
||||||
},
|
|
||||||
hideWhenScrolling: false,
|
|
||||||
autoFocus: true,
|
|
||||||
},
|
},
|
||||||
});
|
hideWhenScrolling: false,
|
||||||
|
autoFocus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const { data: movies, isFetching: l1 } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
@@ -211,18 +210,6 @@ export default function search() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
{Platform.OS === "android" && (
|
|
||||||
<View className="mb-4 px-4">
|
|
||||||
<Input
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType="done"
|
|
||||||
keyboardType="web-search"
|
|
||||||
placeholder={t("search.search_here")}
|
|
||||||
value={search}
|
|
||||||
onChangeText={(text) => setSearch(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
|
|||||||
@@ -36,15 +36,6 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="transcoding-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||||
import {
|
import {
|
||||||
|
PipStartedPayload,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
@@ -18,20 +17,17 @@ const downloadProvider = !Platform.isTV
|
|||||||
? require("@/providers/DownloadProvider")
|
? require("@/providers/DownloadProvider")
|
||||||
: null;
|
: null;
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
import {
|
||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -40,19 +36,12 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
|
||||||
Alert,
|
|
||||||
BackHandler,
|
|
||||||
View,
|
|
||||||
AppState,
|
|
||||||
AppStateStatus,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import settings from "../(tabs)/(home)/settings";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
console.log("Direct Player");
|
console.log("Direct Player");
|
||||||
@@ -60,6 +49,7 @@ export default function page() {
|
|||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
@@ -67,6 +57,7 @@ export default function page() {
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
@@ -132,57 +123,80 @@ export default function page() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const [stream, setStream] = useState<{
|
||||||
data: stream,
|
mediaSource: MediaSourceInfo;
|
||||||
isLoading: isLoadingStreamUrl,
|
url: string;
|
||||||
isError: isErrorStreamUrl,
|
sessionId: string | undefined;
|
||||||
} = useQuery({
|
} | null>(null);
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
const [isLoadingStream, setIsLoadingStream] = useState(true);
|
||||||
queryFn: async () => {
|
const [isErrorStream, setIsErrorStream] = useState(false);
|
||||||
if (offline && !Platform.isTV) {
|
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
|
||||||
if (!data?.mediaSource) return null;
|
|
||||||
|
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
useEffect(() => {
|
||||||
|
const fetchStream = async () => {
|
||||||
|
setIsLoadingStream(true);
|
||||||
|
setIsErrorStream(false);
|
||||||
|
|
||||||
if (item)
|
try {
|
||||||
return {
|
if (offline && !Platform.isTV) {
|
||||||
mediaSource: data.mediaSource,
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
url,
|
if (!data?.mediaSource) {
|
||||||
sessionId: undefined,
|
setStream(null);
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
setStream({
|
||||||
|
mediaSource: data.mediaSource as MediaSourceInfo,
|
||||||
|
url,
|
||||||
|
sessionId: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: bitrateValue,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: native,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
setStream(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||||
|
setStream(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStream({
|
||||||
|
mediaSource,
|
||||||
|
sessionId,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching stream:", error);
|
||||||
|
setIsErrorStream(true);
|
||||||
|
setStream(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingStream(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
fetchStream();
|
||||||
api,
|
}, [itemId, mediaSourceId]);
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -190,37 +204,21 @@ export default function page() {
|
|||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
if (!offline && stream) {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(progress.value),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream.url?.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
if (!offline && stream) {
|
}
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
if (!offline && stream) {
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
itemId: item?.Id!,
|
||||||
mediaSourceId: mediaSourceId,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
positionTicks: msToTicks(progress.value),
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
isPaused: false,
|
mediaSourceId: mediaSourceId,
|
||||||
playMethod: stream?.url.includes("m3u8")
|
positionTicks: msToTicks(progress.get()),
|
||||||
? "Transcode"
|
isPaused: !isPlaying,
|
||||||
: "DirectStream",
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@@ -232,13 +230,13 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
offline,
|
offline,
|
||||||
progress.value,
|
progress,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -256,25 +254,9 @@ export default function page() {
|
|||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
// TODO: unused should remove.
|
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
if (!stream) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
|
||||||
});
|
|
||||||
}, [api, item, mediaSourceId, stream]);
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
if (isSeeking.value === true) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { currentTime } = data.nativeEvent;
|
||||||
|
|
||||||
@@ -282,7 +264,7 @@ export default function page() {
|
|||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.value = currentTime;
|
progress.set(currentTime);
|
||||||
|
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
@@ -301,7 +283,7 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
@@ -311,6 +293,11 @@ export default function page() {
|
|||||||
offline,
|
offline,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
|
const { pipStarted } = e.nativeEvent;
|
||||||
|
setIsPipStarted(pipStarted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
|
||||||
@@ -340,25 +327,13 @@ export default function page() {
|
|||||||
: 0;
|
: 0;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
return async () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const [appState, setAppState] = useState(AppState.currentState);
|
const [appState, setAppState] = useState(AppState.currentState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
// Handle app going to the background
|
||||||
// Handle app coming to the foreground
|
if (nextAppState.match(/inactive|background/)) {
|
||||||
} else if (nextAppState.match(/inactive|background/)) {
|
_setShowControls(false);
|
||||||
// Handle app going to the background
|
|
||||||
if (videoRef.current && videoRef.current.pause) {
|
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setAppState(nextAppState);
|
setAppState(nextAppState);
|
||||||
};
|
};
|
||||||
@@ -373,71 +348,71 @@ export default function page() {
|
|||||||
// Cleanup the event listener when the component is unmounted
|
// Cleanup the event listener when the component is unmounted
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [appState]);
|
}, [appState, isPipStarted, isPlaying]);
|
||||||
|
|
||||||
// Preselection of audio and subtitle tracks.
|
// Preselection of audio and subtitle tracks.
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub: { Type: string }) => sub.Type === "Subtitle"
|
|
||||||
) || [];
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
|
||||||
(sub: { Index: number }) => sub.Index === subtitleIndex
|
|
||||||
);
|
|
||||||
const allAudio =
|
const allAudio =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(audio: { Type: string }) => audio.Type === "Audio"
|
(audio) => audio.Type === "Audio"
|
||||||
) || [];
|
) || [];
|
||||||
const chosenAudioTrack = allAudio.find(
|
const allSubs =
|
||||||
(audio: { Index: number | undefined }) => audio.Index === audioIndex
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
|
(sub) => sub.Type === "Subtitle"
|
||||||
|
) || [];
|
||||||
|
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||||
|
|
||||||
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
|
(sub) => sub.Index === subtitleIndex
|
||||||
);
|
);
|
||||||
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
// Direct playback CASE
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
if (!bitrateValue) {
|
if (
|
||||||
// If Subtitle is embedded we can use the position to select it straight away.
|
chosenSubtitleTrack &&
|
||||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
) {
|
||||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
const finalIndex = notTranscoding
|
||||||
// If Subtitle is external we need to pass the URL to the player.
|
? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
externalTrack = {
|
: textSubs.indexOf(chosenSubtitleTrack);
|
||||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenAudioTrack)
|
if (notTranscoding && chosenAudioTrack) {
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
} else {
|
|
||||||
// Transcoded playback CASE
|
|
||||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
|
||||||
externalTrack = {
|
|
||||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
|
||||||
DeliveryUrl: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
useEffect(() => {
|
||||||
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
|
return () => {
|
||||||
|
beforeRemoveListener();
|
||||||
|
};
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
if (!item || isLoadingItem || !stream)
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
if (isErrorItem || isErrorStream)
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Text className="text-white">{t("player.error")}</Text>
|
<Text className="text-white">{t("player.error")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const externalSubtitles = allSubs
|
||||||
|
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||||
|
.map((sub: any) => ({
|
||||||
|
name: sub.DisplayTitle,
|
||||||
|
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
<View
|
<View
|
||||||
@@ -455,17 +430,18 @@ export default function page() {
|
|||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={{
|
source={{
|
||||||
uri: stream.url,
|
uri: stream?.url || "",
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
externalTrack,
|
externalSubtitles,
|
||||||
initOptions,
|
initOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
|
onPipStarted={onPipStarted}
|
||||||
onVideoLoadStart={() => {}}
|
onVideoLoadStart={() => {}}
|
||||||
onVideoLoadEnd={() => {
|
onVideoLoadEnd={() => {
|
||||||
setIsVideoLoaded(true);
|
setIsVideoLoaded(true);
|
||||||
@@ -496,6 +472,7 @@ export default function page() {
|
|||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
|
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={videoRef.current?.play}
|
||||||
pause={videoRef.current?.pause}
|
pause={videoRef.current?.pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={videoRef.current?.seekTo}
|
||||||
@@ -513,22 +490,3 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,546 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { TrackInfo } from "@/modules/vlc-player";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import transcoding from "@/utils/profiles/transcoding";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, {
|
|
||||||
OnProgressData,
|
|
||||||
SelectedTrack,
|
|
||||||
SelectedTrackType,
|
|
||||||
VideoRef,
|
|
||||||
} from "react-native-video";
|
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const Player = () => {
|
|
||||||
console.log("Transcoding Player");
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
|
||||||
_setShowControls(show);
|
|
||||||
lightHapticFeedback();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
console.warn("No itemId");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
|
||||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
|
||||||
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
console.warn("No item", itemId, item);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: transcoding,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
console.warn("No sessionId or mediaSource or url", url);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
stream,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
revalidateProgressCache();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
reportPlaybackStopped();
|
|
||||||
videoRef.current?.pause();
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = secondsToTicks(data.currentTime);
|
|
||||||
|
|
||||||
progress.value = ticks;
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
|
||||||
// TODO: since playable duration is always 0 then.
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isSeeking,
|
|
||||||
stream,
|
|
||||||
mediaSourceId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
togglePlay: togglePlay,
|
|
||||||
stopPlayback: stop,
|
|
||||||
offline: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
|
||||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTextTrack === undefined) {
|
|
||||||
const subtitleHelper = new SubtitleHelper(
|
|
||||||
stream?.mediaSource.MediaStreams ?? []
|
|
||||||
);
|
|
||||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
|
||||||
subtitleIndex!
|
|
||||||
);
|
|
||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
|
||||||
if (embeddedTrackIndex === -1) return;
|
|
||||||
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: embeddedTrackIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [embededTextTracks]);
|
|
||||||
|
|
||||||
const getAudioTracks = (): TrackInfo[] => {
|
|
||||||
return audioTracks.map((t) => ({
|
|
||||||
name: t.name,
|
|
||||||
index: t.index,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitleTracks = (): TrackInfo[] => {
|
|
||||||
return embededTextTracks.map((t) => ({
|
|
||||||
name: t.title ?? "",
|
|
||||||
index: t.index,
|
|
||||||
language: t.language,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
return async () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">{t("player.error")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{videoSource ? (
|
|
||||||
<>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error("Error playing video", e);
|
|
||||||
}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
onTextTracks={(data) => {
|
|
||||||
setEmbededTextTracks(data.textTracks as any);
|
|
||||||
}}
|
|
||||||
onBuffer={(e) => {
|
|
||||||
setIsBuffering(e.isBuffering);
|
|
||||||
}}
|
|
||||||
onAudioTracks={(e) => {
|
|
||||||
setAudioTracks(
|
|
||||||
e.audioTracks.map((t) => ({
|
|
||||||
index: t.index,
|
|
||||||
name: t.title ?? "",
|
|
||||||
language: t.language,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selectedTextTrack={selectedTextTrack}
|
|
||||||
selectedAudioTrack={selectedAudioTrack}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text>{t("player.no_video_source")}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream?.mediaSource}
|
|
||||||
videoRef={videoRef}
|
|
||||||
enableTrickplay={true}
|
|
||||||
item={item}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
seek={seek}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
stop={stop}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setSubtitleTrack={(i) => {
|
|
||||||
if (i === -1) {
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.DISABLED,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
setAudioTrack={(i) => {
|
|
||||||
setSelectedAudioTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster, url]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Player;
|
|
||||||
@@ -66,7 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
@@ -194,10 +194,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (itemsNotDownloaded.length > 1) {
|
if (itemsNotDownloaded.length > 1) {
|
||||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
const defaults = getDefaultPlaySettings(item, settings!);
|
||||||
item,
|
mediaSource = defaults.mediaSource;
|
||||||
settings!
|
audioIndex = defaults.audioIndex;
|
||||||
));
|
subtitleIndex = defaults.subtitleIndex;
|
||||||
|
// Keep using the selected bitrate for consistency across all downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import {
|
||||||
@@ -118,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
|
||||||
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
|
||||||
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
|
||||||
if (isTranscoding) {
|
|
||||||
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
|
||||||
const subHelper = new SubtitleHelper(
|
|
||||||
selectedOptions?.mediaSource?.MediaStreams ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
|
||||||
selectedOptions?.subtitleIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
setSelectedOptions((prev) => ({
|
|
||||||
...prev!,
|
|
||||||
subtitleIndex: newSubtitleIndex ?? -1,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
|
||||||
setSelectedOptions((prev) => ({
|
|
||||||
...prev!,
|
|
||||||
subtitleIndex: previouslyChosenSubtitleIndex,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
setIsTranscoding(isTranscoding);
|
|
||||||
}, [selectedOptions?.bitrate]);
|
|
||||||
|
|
||||||
if (!selectedOptions) return null;
|
if (!selectedOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -239,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
selected={selectedOptions.audioIndex}
|
selected={selectedOptions.audioIndex}
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
<SubtitleTrackSelector
|
||||||
isTranscoding={isTranscoding}
|
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
|
|||||||
@@ -73,11 +73,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
if (!bitrateValue) {
|
router.push(`/player/direct-player?${q}`);
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/player/transcoding-player?${q}`);
|
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,11 +58,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
if (!bitrateValue) {
|
router.push(`/player/direct-player?${q}`);
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/player/transcoding-player?${q}`);
|
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,40 +4,31 @@ import { useMemo } from "react";
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected?: number | undefined;
|
selected?: number | undefined;
|
||||||
isTranscoding?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
source,
|
source,
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
isTranscoding,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
if (Platform.isTV) return null;
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
|
}, [source]);
|
||||||
if (isTranscoding && Platform.OS === "ios") {
|
|
||||||
return subtitleHelper.getUniqueSubtitles();
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtitleHelper.getSubtitles();
|
|
||||||
}, [source, isTranscoding]);
|
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
() => subtitleStreams.find((x) => x.Index === selected),
|
() => subtitleStreams?.find((x) => x.Index === selected),
|
||||||
[subtitleStreams, selected]
|
[subtitleStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
@@ -163,6 +164,32 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Default quality"
|
||||||
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
data={BITRATES}
|
||||||
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
titleExtractor={(item) => item.key}
|
||||||
|
selected={settings.defaultBitrate}
|
||||||
|
title={
|
||||||
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{settings.defaultBitrate?.key}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
label={t("home.settings.other.quality")}
|
||||||
|
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
ticksToMs,
|
ticksToMs,
|
||||||
ticksToSeconds,
|
ticksToSeconds,
|
||||||
} from "@/utils/time";
|
} from "@/utils/time";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -35,7 +35,12 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
|
import {
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import {
|
import {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -49,8 +54,7 @@ import AudioSlider from "./AudioSlider";
|
|||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
import { ControlProvider } from "./contexts/ControlContext";
|
import { ControlProvider } from "./contexts/ControlContext";
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
import { VideoProvider } from "./contexts/VideoContext";
|
||||||
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
|
||||||
import { EpisodeList } from "./EpisodeList";
|
import { EpisodeList } from "./EpisodeList";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
@@ -75,6 +79,7 @@ interface Props {
|
|||||||
isVideoLoaded?: boolean;
|
isVideoLoaded?: boolean;
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
seek: (ticks: number) => void;
|
seek: (ticks: number) => void;
|
||||||
|
startPictureInPicture: () => Promise<void>;
|
||||||
play: (() => Promise<void>) | (() => void);
|
play: (() => Promise<void>) | (() => void);
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||||
@@ -91,6 +96,7 @@ const CONTROLS_TIMEOUT = 4000;
|
|||||||
export const Controls: React.FC<Props> = ({
|
export const Controls: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
seek,
|
seek,
|
||||||
|
startPictureInPicture,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
@@ -212,13 +218,10 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrateValue.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
if (!bitrateValue) {
|
stop();
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
@@ -250,13 +253,10 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrateValue.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
if (!bitrateValue) {
|
stop();
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
@@ -413,13 +413,10 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrateValue.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
if (!bitrateValue) {
|
stop();
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in gotoEpisode:", error);
|
console.error("Error in gotoEpisode:", error);
|
||||||
}
|
}
|
||||||
@@ -499,6 +496,15 @@ export const Controls: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, [trickPlayUrl, trickplayInfo, time]);
|
}, [trickPlayUrl, trickplayInfo, time]);
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
stop();
|
||||||
|
lightHapticFeedback();
|
||||||
|
await ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlProvider
|
<ControlProvider
|
||||||
item={item}
|
item={item}
|
||||||
@@ -542,15 +548,22 @@ export const Controls: React.FC<Props> = ({
|
|||||||
setSubtitleTrack={setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
>
|
>
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
<DropdownView showControls={showControls} />
|
||||||
<DropdownViewDirect showControls={showControls} />
|
|
||||||
) : (
|
|
||||||
<DropdownViewTranscoding showControls={showControls} />
|
|
||||||
)}
|
|
||||||
</VideoProvider>
|
</VideoProvider>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row items-center space-x-2 ">
|
<View className="flex flex-row items-center space-x-2 ">
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<TouchableOpacity onPress={startPictureInPicture}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="picture-in-picture"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: showControls ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{item?.Type === "Episode" && !offline && (
|
{item?.Type === "Episode" && !offline && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -592,13 +605,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{/* )} */}
|
{/* )} */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={onClose}
|
||||||
lightHapticFeedback();
|
|
||||||
await ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="white" />
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useControlContext } from "./ControlContext";
|
import { useControlContext } from "./ControlContext";
|
||||||
|
import { Track } from "../types";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
interface VideoContextProps {
|
interface VideoContextProps {
|
||||||
audioTracks: TrackInfo[] | null;
|
audioTracks: Track[] | null;
|
||||||
subtitleTracks: TrackInfo[] | null;
|
subtitleTracks: Track[] | null;
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
setAudioTrack: ((index: number) => void) | undefined;
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||||
@@ -45,30 +48,155 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
setSubtitleURL,
|
setSubtitleURL,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||||
|
const mediaSource = ControlContext?.mediaSource;
|
||||||
|
|
||||||
|
const allSubs =
|
||||||
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||||
|
|
||||||
|
const { itemId, audioIndex, bitrateValue, subtitleIndex } =
|
||||||
|
useLocalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const onTextBasedSubtitle = useMemo(
|
||||||
|
() =>
|
||||||
|
allSubs.find(
|
||||||
|
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream
|
||||||
|
) || subtitleIndex === "-1",
|
||||||
|
[allSubs, subtitleIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPlayerParams = ({
|
||||||
|
chosenAudioIndex = audioIndex,
|
||||||
|
chosenSubtitleIndex = subtitleIndex,
|
||||||
|
}: {
|
||||||
|
chosenAudioIndex?: string;
|
||||||
|
chosenSubtitleIndex?: string;
|
||||||
|
}) => {
|
||||||
|
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: itemId ?? "",
|
||||||
|
audioIndex: chosenAudioIndex,
|
||||||
|
subtitleIndex: chosenSubtitleIndex,
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue,
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTrackParams = (
|
||||||
|
type: "audio" | "subtitle",
|
||||||
|
index: number,
|
||||||
|
serverIndex: number
|
||||||
|
) => {
|
||||||
|
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
|
||||||
|
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
|
||||||
|
|
||||||
|
// If we're transcoding and we're going from a image based subtitle
|
||||||
|
// to a text based subtitle, we need to change the player params.
|
||||||
|
|
||||||
|
const shouldChangePlayerParams =
|
||||||
|
type === "subtitle" &&
|
||||||
|
mediaSource?.TranscodingUrl &&
|
||||||
|
!onTextBasedSubtitle;
|
||||||
|
|
||||||
|
console.log("Set player params", index, serverIndex);
|
||||||
|
if (shouldChangePlayerParams) {
|
||||||
|
setPlayerParams({
|
||||||
|
chosenSubtitleIndex: serverIndex.toString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTrack && setTrack(index);
|
||||||
|
router.setParams({
|
||||||
|
[paramKey]: serverIndex.toString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (
|
if (getSubtitleTracks) {
|
||||||
getSubtitleTracks &&
|
const subtitleData = await getSubtitleTracks();
|
||||||
(subtitleTracks === null || subtitleTracks.length === 0)
|
|
||||||
) {
|
let textSubIndex = 0;
|
||||||
const subtitles = await getSubtitleTracks();
|
const subtitles: Track[] = allSubs?.map((sub) => {
|
||||||
console.log("Getting embeded subtitles...", subtitles);
|
// Always increment for non-transcoding subtitles
|
||||||
|
// Only increment for text-based subtitles when transcoding
|
||||||
|
const shouldIncrement =
|
||||||
|
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
||||||
|
|
||||||
|
const displayTitle = sub.DisplayTitle || "Undefined Subtitle";
|
||||||
|
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
||||||
|
|
||||||
|
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
|
||||||
|
|
||||||
|
if (shouldIncrement) textSubIndex++;
|
||||||
|
return {
|
||||||
|
name: displayTitle,
|
||||||
|
index: sub.Index ?? -1,
|
||||||
|
originalIndex: finalIndex,
|
||||||
|
setTrack: () =>
|
||||||
|
shouldIncrement
|
||||||
|
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
||||||
|
: setPlayerParams({
|
||||||
|
chosenSubtitleIndex: sub.Index?.toString(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a "Disable Subtitles" option
|
||||||
|
subtitles.unshift({
|
||||||
|
name: "Disable",
|
||||||
|
index: -1,
|
||||||
|
setTrack: () =>
|
||||||
|
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||||
|
? setTrackParams("subtitle", -1, -1)
|
||||||
|
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||||
|
});
|
||||||
|
|
||||||
setSubtitleTracks(subtitles);
|
setSubtitleTracks(subtitles);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
getAudioTracks &&
|
getAudioTracks &&
|
||||||
(audioTracks === null || audioTracks.length === 0)
|
(audioTracks === null || audioTracks.length === 0)
|
||||||
) {
|
) {
|
||||||
const audio = await getAudioTracks();
|
const audioData = await getAudioTracks();
|
||||||
setAudioTracks(audio);
|
if (!audioData) return;
|
||||||
|
|
||||||
|
console.log("audioData", audioData);
|
||||||
|
|
||||||
|
const allAudio =
|
||||||
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
|
|
||||||
|
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||||
|
if (!mediaSource?.TranscodingUrl) {
|
||||||
|
const vlcIndex = audioData?.at(idx)?.index ?? -1;
|
||||||
|
return {
|
||||||
|
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||||
|
index: audio.Index ?? -1,
|
||||||
|
setTrack: () =>
|
||||||
|
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||||
|
index: audio.Index ?? -1,
|
||||||
|
setTrack: () =>
|
||||||
|
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setAudioTracks(audioTracks);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
|
|||||||
@@ -1,67 +1,21 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React from "react";
|
||||||
import { View, TouchableOpacity, Platform } from "react-native";
|
import { TouchableOpacity, Platform } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
|
||||||
|
|
||||||
interface DropdownViewDirectProps {
|
interface DropdownViewProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
offline?: boolean; // used to disable external subs for downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
const DropdownView: React.FC<DropdownViewProps> = ({
|
||||||
showControls,
|
showControls,
|
||||||
offline = false,
|
offline = false,
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
const item = ControlContext?.item;
|
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
|
||||||
|
|
||||||
const videoContext = useVideoContext();
|
const videoContext = useVideoContext();
|
||||||
const {
|
const { subtitleTracks, audioTracks } = videoContext;
|
||||||
subtitleTracks,
|
|
||||||
audioTracks,
|
|
||||||
setSubtitleURL,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setAudioTrack,
|
|
||||||
} = videoContext;
|
|
||||||
|
|
||||||
const allSubtitleTracksForDirectPlay = useMemo(() => {
|
|
||||||
if (mediaSource?.TranscodingUrl) return null;
|
|
||||||
const embeddedSubs =
|
|
||||||
subtitleTracks
|
|
||||||
?.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
index: s.index,
|
|
||||||
deliveryUrl: undefined,
|
|
||||||
}))
|
|
||||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
|
||||||
|
|
||||||
const externalSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter(
|
|
||||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
|
||||||
).map((s) => ({
|
|
||||||
name: s.DisplayTitle! + " [External]",
|
|
||||||
index: s.Index!,
|
|
||||||
deliveryUrl: s.DeliveryUrl,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
// Combine embedded subs with external subs only if not offline
|
|
||||||
if (!offline) {
|
|
||||||
return [...embeddedSubs, ...externalSubs] as (
|
|
||||||
| EmbeddedSubtitle
|
|
||||||
| ExternalSubtitle
|
|
||||||
)[];
|
|
||||||
}
|
|
||||||
return embeddedSubs as EmbeddedSubtitle[];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -98,21 +52,11 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
loop={true}
|
loop={true}
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
{subtitleTracks?.map((sub, idx: number) => (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key={`subtitle-item-${idx}`}
|
key={`subtitle-item-${idx}`}
|
||||||
value={subtitleIndex === sub.index.toString()}
|
value={subtitleIndex === sub.index.toString()}
|
||||||
onValueChange={() => {
|
onValueChange={() => sub.setTrack()}
|
||||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
|
||||||
setSubtitleURL &&
|
|
||||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
|
||||||
} else {
|
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
|
||||||
}
|
|
||||||
router.setParams({
|
|
||||||
subtitleIndex: sub.index.toString(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||||
{sub.name}
|
{sub.name}
|
||||||
@@ -136,12 +80,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key={`audio-item-${idx}`}
|
key={`audio-item-${idx}`}
|
||||||
value={audioIndex === track.index.toString()}
|
value={audioIndex === track.index.toString()}
|
||||||
onValueChange={() => {
|
onValueChange={() => track.setTrack()}
|
||||||
setAudioTrack && setAudioTrack(track.index);
|
|
||||||
router.setParams({
|
|
||||||
audioIndex: track.index.toString(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||||
{track.name}
|
{track.name}
|
||||||
@@ -155,4 +94,4 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DropdownViewDirect;
|
export default DropdownView;
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
|
||||||
import { View, TouchableOpacity, Platform } from "react-native";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
|
||||||
import { TranscodedSubtitle } from "../types";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
|
|
||||||
interface DropdownViewProps {
|
|
||||||
showControls: boolean;
|
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
const item = ControlContext?.item;
|
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
|
||||||
|
|
||||||
const videoContext = useVideoContext();
|
|
||||||
const { subtitleTracks, setSubtitleTrack } = videoContext;
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
|
||||||
|
|
||||||
const isOnTextSubtitle = useMemo(() => {
|
|
||||||
const res = Boolean(
|
|
||||||
mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
|
||||||
) || subtitleIndex === "-1"
|
|
||||||
);
|
|
||||||
return res;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
|
||||||
|
|
||||||
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
|
|
||||||
|
|
||||||
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
|
||||||
const disableSubtitle = {
|
|
||||||
name: "Disable",
|
|
||||||
index: -1,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
} as TranscodedSubtitle;
|
|
||||||
if (isOnTextSubtitle) {
|
|
||||||
const textSubtitles =
|
|
||||||
subtitleTracks?.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
index: s.index,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
|
||||||
|
|
||||||
return [disableSubtitle, ...sortedSubtitles];
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
|
||||||
name: x.DisplayTitle!,
|
|
||||||
index: x.Index!,
|
|
||||||
IsTextSubtitleStream: x.IsTextSubtitleStream!,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [disableSubtitle, ...transcodedSubtitle];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
|
||||||
|
|
||||||
const changeToImageBasedSub = useCallback(
|
|
||||||
(subtitleIndex: number) => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
},
|
|
||||||
[mediaSource]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Audio tracks for transcoding streams.
|
|
||||||
const allAudio =
|
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
|
|
||||||
name: x.DisplayTitle!,
|
|
||||||
index: x.Index!,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const ChangeTranscodingAudio = useCallback(
|
|
||||||
(audioIndex: number) => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
},
|
|
||||||
[mediaSource, subtitleIndex, audioIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
|
||||||
Subtitle
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allSubtitleTracksForTranscodingStream?.map(
|
|
||||||
(sub, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={
|
|
||||||
subtitleIndex ===
|
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
|
||||||
? subtitleHelper
|
|
||||||
.getSourceSubtitleIndex(sub.index)
|
|
||||||
.toString()
|
|
||||||
: sub?.index.toString())
|
|
||||||
}
|
|
||||||
key={`subtitle-item-${idx}`}
|
|
||||||
onValueChange={() => {
|
|
||||||
if (
|
|
||||||
subtitleIndex ===
|
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
|
||||||
? subtitleHelper
|
|
||||||
.getSourceSubtitleIndex(sub.index)
|
|
||||||
.toString()
|
|
||||||
: sub?.index.toString())
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
router.setParams({
|
|
||||||
subtitleIndex: subtitleHelper
|
|
||||||
.getSourceSubtitleIndex(sub.index)
|
|
||||||
.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
changeToImageBasedSub(sub.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
|
||||||
{sub.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
|
||||||
Audio
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allAudio?.map((track, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key={`audio-item-${idx}`}
|
|
||||||
value={audioIndex === track.index.toString()}
|
|
||||||
onValueChange={() => {
|
|
||||||
if (audioIndex === track.index.toString()) return;
|
|
||||||
router.setParams({
|
|
||||||
audioIndex: track.index.toString(),
|
|
||||||
});
|
|
||||||
ChangeTranscodingAudio(track.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
|
||||||
{track.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DropdownView;
|
|
||||||
@@ -13,7 +13,14 @@ type ExternalSubtitle = {
|
|||||||
type TranscodedSubtitle = {
|
type TranscodedSubtitle = {
|
||||||
name: string;
|
name: string;
|
||||||
index: number;
|
index: number;
|
||||||
|
deliveryUrl: string;
|
||||||
IsTextSubtitleStream: boolean;
|
IsTextSubtitleStream: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
type Track = {
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
setTrack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -32,20 +32,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.25.0",
|
"channel": "0.26.1",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.25.0",
|
"channel": "0.26.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"channel": "0.25.0",
|
"channel": "0.26.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
--- expo.js.original 2024-11-10 09:08:19
|
|
||||||
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
|
|
||||||
@@ -19,10 +19,8 @@
|
|
||||||
const {
|
|
||||||
barStyle
|
|
||||||
} = androidStatusBar;
|
|
||||||
+ const android = props?.android || {};
|
|
||||||
const {
|
|
||||||
- android = {}
|
|
||||||
- } = props;
|
|
||||||
- const {
|
|
||||||
parentTheme = "Default"
|
|
||||||
} = android;
|
|
||||||
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
|
|
||||||
\ No newline at end of file
|
|
||||||
@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
|
|||||||
(x) => x.Type === "Audio"
|
(x) => x.Type === "Audio"
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
// 4. Get default bitrate
|
// 4. Get default bitrate from settings or fallback to max
|
||||||
const bitrate = BITRATES[0];
|
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultAudioIndex:
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ class VlcPlayerModule : Module() {
|
|||||||
"onVideoLoadStart",
|
"onVideoLoadStart",
|
||||||
"onVideoLoadEnd",
|
"onVideoLoadEnd",
|
||||||
"onVideoProgress",
|
"onVideoProgress",
|
||||||
"onVideoError"
|
"onVideoError",
|
||||||
|
"onPipStarted"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
||||||
|
view.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("play") { view: VlcPlayerView ->
|
AsyncFunction("play") { view: VlcPlayerView ->
|
||||||
view.play()
|
view.play()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
package expo.modules.vlcplayer
|
package expo.modules.vlcplayer
|
||||||
|
|
||||||
|
import android.R
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
|
import android.app.RemoteAction
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import androidx.annotation.RequiresApi
|
||||||
import android.widget.FrameLayout
|
import androidx.core.app.ComponentActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LifecycleObserver
|
import androidx.lifecycle.LifecycleObserver
|
||||||
import android.net.Uri
|
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.views.ExpoView
|
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
import org.videolan.libvlc.LibVLC
|
import org.videolan.libvlc.LibVLC
|
||||||
import org.videolan.libvlc.Media
|
import org.videolan.libvlc.Media
|
||||||
import org.videolan.libvlc.interfaces.IMedia
|
|
||||||
import org.videolan.libvlc.MediaPlayer
|
import org.videolan.libvlc.MediaPlayer
|
||||||
|
import org.videolan.libvlc.interfaces.IMedia
|
||||||
import org.videolan.libvlc.util.VLCVideoLayout
|
import org.videolan.libvlc.util.VLCVideoLayout
|
||||||
|
|
||||||
|
|
||||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
|
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
|
||||||
|
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
|
||||||
|
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
|
||||||
|
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
|
||||||
|
|
||||||
private var libVLC: LibVLC? = null
|
private var libVLC: LibVLC? = null
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
@@ -30,6 +47,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private val onVideoProgress by EventDispatcher()
|
private val onVideoProgress by EventDispatcher()
|
||||||
private val onVideoStateChange by EventDispatcher()
|
private val onVideoStateChange by EventDispatcher()
|
||||||
private val onVideoLoadEnd by EventDispatcher()
|
private val onVideoLoadEnd by EventDispatcher()
|
||||||
|
private val onPipStarted by EventDispatcher()
|
||||||
|
|
||||||
private var startPosition: Int? = 0
|
private var startPosition: Int? = 0
|
||||||
private var isMediaReady: Boolean = false
|
private var isMediaReady: Boolean = false
|
||||||
@@ -44,9 +62,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
handler.postDelayed(this, updateInterval)
|
handler.postDelayed(this, updateInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private val currentActivity get() = context.findActivity()
|
||||||
|
private val actions: MutableList<RemoteAction> = mutableListOf()
|
||||||
|
|
||||||
|
private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
PIP_PLAY_PAUSE_ACTION -> if (isPaused) play() else pause()
|
||||||
|
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
|
||||||
|
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupView()
|
setupView()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
setupPipActions()
|
||||||
|
currentActivity.apply {
|
||||||
|
setPictureInPictureParams(getPipParams()!!)
|
||||||
|
addOnPictureInPictureModeChangedListener { info ->
|
||||||
|
onPipStarted(mapOf(
|
||||||
|
"pipStarted" to info.isInPictureInPictureMode
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupView() {
|
private fun setupView() {
|
||||||
@@ -59,6 +100,76 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
Log.d("VlcPlayerView", "View setup complete")
|
Log.d("VlcPlayerView", "View setup complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun setupPipActions() {
|
||||||
|
val remoteActionFilter = IntentFilter()
|
||||||
|
val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
||||||
|
val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
||||||
|
val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
||||||
|
|
||||||
|
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
||||||
|
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
||||||
|
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
||||||
|
|
||||||
|
actions.addAll(
|
||||||
|
listOf(
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(context, R.drawable.ic_media_rew),
|
||||||
|
"Rewind",
|
||||||
|
"Rewind Video",
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
rewindIntent,
|
||||||
|
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
),
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(context, R.drawable.ic_media_play),
|
||||||
|
"Play",
|
||||||
|
"Play Video",
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
playPauseIntent,
|
||||||
|
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
),
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(context, R.drawable.ic_media_ff),
|
||||||
|
"Skip",
|
||||||
|
"Skip Forward",
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
forwardIntent,
|
||||||
|
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context,
|
||||||
|
actionReceiver,
|
||||||
|
remoteActionFilter,
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPipParams(): PictureInPictureParams? {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
var builder = PictureInPictureParams.Builder()
|
||||||
|
.setActions(actions)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
builder = builder.setAutoEnterEnabled(true)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
fun setSource(source: Map<String, Any>) {
|
fun setSource(source: Map<String, Any>) {
|
||||||
if (hasSource) {
|
if (hasSource) {
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||||
@@ -112,6 +223,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startPictureInPicture() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
currentActivity.enterPictureInPictureMode(getPipParams()!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
mediaPlayer?.play()
|
mediaPlayer?.play()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
@@ -283,4 +400,13 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
|
||||||
|
var context = this
|
||||||
|
while (context is ContextWrapper) {
|
||||||
|
if (context is androidx.activity.ComponentActivity) return context
|
||||||
|
context = context.baseContext
|
||||||
|
}
|
||||||
|
throw IllegalStateException("Failed to find ComponentActivity")
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
NativeModulesProxy,
|
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Subscription,
|
EventSubscription,
|
||||||
} from "expo-modules-core";
|
} from "expo-modules-core";
|
||||||
|
|
||||||
import VlcPlayerModule from "./src/VlcPlayerModule";
|
import VlcPlayerModule from "./src/VlcPlayerModule";
|
||||||
@@ -19,13 +18,11 @@ import {
|
|||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "./src/VlcPlayer.types";
|
} from "./src/VlcPlayer.types";
|
||||||
|
|
||||||
const emitter = new EventEmitter(
|
const emitter = new EventEmitter(VlcPlayerModule);
|
||||||
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
|
|
||||||
);
|
|
||||||
|
|
||||||
export function addPlaybackStateListener(
|
export function addPlaybackStateListener(
|
||||||
listener: (event: PlaybackStatePayload) => void
|
listener: (event: PlaybackStatePayload) => void
|
||||||
): Subscription {
|
): EventSubscription {
|
||||||
return emitter.addListener<PlaybackStatePayload>(
|
return emitter.addListener<PlaybackStatePayload>(
|
||||||
"onPlaybackStateChanged",
|
"onPlaybackStateChanged",
|
||||||
listener
|
listener
|
||||||
@@ -34,7 +31,7 @@ export function addPlaybackStateListener(
|
|||||||
|
|
||||||
export function addVideoLoadStartListener(
|
export function addVideoLoadStartListener(
|
||||||
listener: (event: VideoLoadStartPayload) => void
|
listener: (event: VideoLoadStartPayload) => void
|
||||||
): Subscription {
|
): EventSubscription {
|
||||||
return emitter.addListener<VideoLoadStartPayload>(
|
return emitter.addListener<VideoLoadStartPayload>(
|
||||||
"onVideoLoadStart",
|
"onVideoLoadStart",
|
||||||
listener
|
listener
|
||||||
@@ -43,7 +40,7 @@ export function addVideoLoadStartListener(
|
|||||||
|
|
||||||
export function addVideoStateChangeListener(
|
export function addVideoStateChangeListener(
|
||||||
listener: (event: VideoStateChangePayload) => void
|
listener: (event: VideoStateChangePayload) => void
|
||||||
): Subscription {
|
): EventSubscription {
|
||||||
return emitter.addListener<VideoStateChangePayload>(
|
return emitter.addListener<VideoStateChangePayload>(
|
||||||
"onVideoStateChange",
|
"onVideoStateChange",
|
||||||
listener
|
listener
|
||||||
@@ -52,7 +49,7 @@ export function addVideoStateChangeListener(
|
|||||||
|
|
||||||
export function addVideoProgressListener(
|
export function addVideoProgressListener(
|
||||||
listener: (event: VideoProgressPayload) => void
|
listener: (event: VideoProgressPayload) => void
|
||||||
): Subscription {
|
): EventSubscription {
|
||||||
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'VlcPlayer'
|
s.name = 'VlcPlayer'
|
||||||
s.version = '1.0.0'
|
s.version = '4.0.0a10'
|
||||||
s.summary = 'A sample project summary'
|
s.summary = 'A sample project summary'
|
||||||
s.description = 'A sample project description'
|
s.description = 'A sample project description'
|
||||||
s.author = ''
|
s.author = ''
|
||||||
@@ -10,8 +10,8 @@ Pod::Spec.new do |s|
|
|||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1'
|
s.ios.dependency 'VLCKit', s.version
|
||||||
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1'
|
s.tvos.dependency 'VLCKit', s.version
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
# Swift/Objective-C compatibility
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
|
|||||||
@@ -16,27 +16,20 @@ public class VlcPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prop("muted") { (view: VlcPlayerView, muted: Bool) in
|
|
||||||
// view.setMuted(muted)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Prop("volume") { (view: VlcPlayerView, volume: Int) in
|
|
||||||
// view.setVolume(volume)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Prop("videoAspectRatio") { (view: VlcPlayerView, ratio: String) in
|
|
||||||
// view.setVideoAspectRatio(ratio)
|
|
||||||
// }
|
|
||||||
|
|
||||||
Events(
|
Events(
|
||||||
"onPlaybackStateChanged",
|
"onPlaybackStateChanged",
|
||||||
"onVideoStateChange",
|
"onVideoStateChange",
|
||||||
"onVideoLoadStart",
|
"onVideoLoadStart",
|
||||||
"onVideoLoadEnd",
|
"onVideoLoadEnd",
|
||||||
"onVideoProgress",
|
"onVideoProgress",
|
||||||
"onVideoError"
|
"onVideoError",
|
||||||
|
"onPipStarted"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
|
||||||
|
view.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("play") { (view: VlcPlayerView) in
|
AsyncFunction("play") { (view: VlcPlayerView) in
|
||||||
view.play()
|
view.play()
|
||||||
}
|
}
|
||||||
@@ -69,14 +62,6 @@ public class VlcPlayerModule: Module {
|
|||||||
return view.getSubtitleTracks()
|
return view.getSubtitleTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
|
|
||||||
// view.setVideoCropGeometry(geometry)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
|
|
||||||
// return view.getVideoCropGeometry()
|
|
||||||
// }
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") {
|
AsyncFunction("setSubtitleURL") {
|
||||||
(view: VlcPlayerView, url: String, name: String) in
|
(view: VlcPlayerView, url: String, name: String) in
|
||||||
view.setSubtitleURL(url, name: name)
|
view.setSubtitleURL(url, name: name)
|
||||||
|
|||||||
@@ -1,54 +1,171 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
#if os(tvOS)
|
|
||||||
import TVVLCKit
|
|
||||||
#else
|
|
||||||
import MobileVLCKit
|
|
||||||
#endif
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import VLCKit
|
||||||
|
|
||||||
|
public class VLCPlayerView: UIView {
|
||||||
|
func setupView(parent: UIView) {
|
||||||
|
self.backgroundColor = .black
|
||||||
|
self.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
||||||
|
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
||||||
|
self.topAnchor.constraint(equalTo: parent.topAnchor),
|
||||||
|
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
for subview in subviews {
|
||||||
|
subview.frame = bounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VLCPlayerWrapper: NSObject {
|
||||||
|
private var lastProgressCall = Date().timeIntervalSince1970
|
||||||
|
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
||||||
|
private var updatePlayerState: (() -> Void)?
|
||||||
|
private var updateVideoProgress: (() -> Void)?
|
||||||
|
private var playerView: VLCPlayerView = VLCPlayerView()
|
||||||
|
public weak var pipController: VLCPictureInPictureWindowControlling?
|
||||||
|
|
||||||
|
override public init() {
|
||||||
|
super.init()
|
||||||
|
player.delegate = self
|
||||||
|
player.drawable = self
|
||||||
|
player.scaleFactor = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setup(
|
||||||
|
parent: UIView,
|
||||||
|
updatePlayerState: (() -> Void)?,
|
||||||
|
updateVideoProgress: (() -> Void)?
|
||||||
|
) {
|
||||||
|
self.updatePlayerState = updatePlayerState
|
||||||
|
self.updateVideoProgress = updateVideoProgress
|
||||||
|
|
||||||
|
player.delegate = self
|
||||||
|
parent.addSubview(playerView)
|
||||||
|
playerView.setupView(parent: parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getPlayerView() -> UIView {
|
||||||
|
return playerView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VLCPictureInPictureDrawable
|
||||||
|
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
||||||
|
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
||||||
|
{
|
||||||
|
return { [weak self] controller in
|
||||||
|
self?.pipController = controller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VLCPictureInPictureMediaControlling
|
||||||
|
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
||||||
|
func mediaTime() -> Int64 {
|
||||||
|
return player.time.value?.int64Value ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaLength() -> Int64 {
|
||||||
|
return player.media?.length.value?.int64Value ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
||||||
|
player.jump(withOffset: Int32(offset), completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMediaSeekable() -> Bool {
|
||||||
|
return player.isSeekable
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMediaPlaying() -> Bool {
|
||||||
|
return player.isPlaying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VLCDrawable
|
||||||
|
extension VLCPlayerWrapper: VLCDrawable {
|
||||||
|
public func addSubview(_ view: UIView) {
|
||||||
|
playerView.addSubview(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func bounds() -> CGRect {
|
||||||
|
return playerView.bounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VLCMediaPlayerDelegate
|
||||||
|
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
||||||
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let timeNow = Date().timeIntervalSince1970
|
||||||
|
if timeNow - self.lastProgressCall >= 1 {
|
||||||
|
self.lastProgressCall = timeNow
|
||||||
|
self.updateVideoProgress?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.updatePlayerState?()
|
||||||
|
|
||||||
|
guard let pipController = self.pipController else { return }
|
||||||
|
pipController.invalidatePlaybackState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VLCMediaDelegate
|
||||||
|
extension VLCPlayerWrapper: VLCMediaDelegate {
|
||||||
|
// Implement VLCMediaDelegate methods if needed
|
||||||
|
}
|
||||||
|
|
||||||
class VlcPlayerView: ExpoView {
|
class VlcPlayerView: ExpoView {
|
||||||
private var mediaPlayer: VLCMediaPlayer?
|
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
||||||
private var videoView: UIView?
|
|
||||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||||
private var isPaused: Bool = false
|
private var isPaused: Bool = false
|
||||||
private var currentGeometryCString: [CChar]?
|
|
||||||
private var lastReportedState: VLCMediaPlayerState?
|
|
||||||
private var lastReportedIsPlaying: Bool?
|
|
||||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||||
private var startPosition: Int32 = 0
|
private var startPosition: Int32 = 0
|
||||||
private var isMediaReady: Bool = false
|
|
||||||
private var externalTrack: [String: String]?
|
private var externalTrack: [String: String]?
|
||||||
private var progressTimer: DispatchSourceTimer?
|
|
||||||
private var isStopping: Bool = false // Define isStopping here
|
private var isStopping: Bool = false // Define isStopping here
|
||||||
private var lastProgressCall = Date().timeIntervalSince1970
|
private var externalSubtitles: [[String: String]]?
|
||||||
var hasSource = false
|
var hasSource = false
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
required init(appContext: AppContext? = nil) {
|
||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupView()
|
setupVLC()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
private func setupVLC() {
|
||||||
private func setupView() {
|
vlc.setup(
|
||||||
DispatchQueue.main.async {
|
parent: self,
|
||||||
self.backgroundColor = .black
|
updatePlayerState: updatePlayerState,
|
||||||
self.videoView = UIView()
|
updateVideoProgress: updateVideoProgress
|
||||||
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
|
)
|
||||||
|
|
||||||
if let videoView = self.videoView {
|
|
||||||
self.addSubview(videoView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
||||||
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
||||||
videoView.topAnchor.constraint(equalTo: self.topAnchor),
|
|
||||||
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotifications() {
|
private func setupNotifications() {
|
||||||
@@ -61,37 +178,44 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
func startPictureInPicture() {
|
||||||
|
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
|
||||||
|
self.onPipStarted?(["pipStarted": isStarted])
|
||||||
|
}
|
||||||
|
self.vlc.pipController?.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
@objc func play() {
|
@objc func play() {
|
||||||
self.mediaPlayer?.play()
|
self.vlc.player.play()
|
||||||
self.isPaused = false
|
self.isPaused = false
|
||||||
print("Play")
|
print("Play")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func pause() {
|
@objc func pause() {
|
||||||
self.mediaPlayer?.pause()
|
self.vlc.player.pause()
|
||||||
self.isPaused = true
|
self.isPaused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
@objc func seekTo(_ time: Int32) {
|
||||||
guard let player = self.mediaPlayer else { return }
|
let wasPlaying = vlc.player.isPlaying
|
||||||
|
|
||||||
let wasPlaying = player.isPlaying
|
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
self.pause()
|
self.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
if let duration = player.media?.length.intValue {
|
if let duration = vlc.player.media?.length.intValue {
|
||||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
print("Seeking to time: \(time) Video Duration \(duration)")
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
// If the specified time is greater than the duration, seek to the end
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
let seekTime = time > duration ? duration - 1000 : time
|
||||||
player.time = VLCTime(int: seekTime)
|
vlc.player.time = VLCTime(int: seekTime)
|
||||||
|
|
||||||
if wasPlaying {
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
self.updatePlayerState()
|
self.updatePlayerState()
|
||||||
|
|
||||||
|
// Let mediaPlayerStateChanged handle play state change
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
if wasPlaying {
|
||||||
|
self.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
print("Error: Unable to retrieve video duration")
|
print("Error: Unable to retrieve video duration")
|
||||||
}
|
}
|
||||||
@@ -104,11 +228,17 @@ class VlcPlayerView: ExpoView {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||||
initOptions.append("--start-time=\(self.startPosition)")
|
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||||
|
|
||||||
|
for item in initOptions {
|
||||||
|
let option = item.components(separatedBy: "=")
|
||||||
|
mediaOptions.updateValue(
|
||||||
|
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
||||||
|
}
|
||||||
|
|
||||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||||
print("Error: Invalid or empty URI")
|
print("Error: Invalid or empty URI")
|
||||||
@@ -120,12 +250,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||||
|
|
||||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
|
||||||
self.mediaPlayer?.delegate = self
|
|
||||||
self.mediaPlayer?.drawable = self.videoView
|
|
||||||
self.mediaPlayer?.scaleFactor = 0
|
|
||||||
|
|
||||||
let media: VLCMedia
|
let media: VLCMedia!
|
||||||
if isNetwork {
|
if isNetwork {
|
||||||
print("Loading network file: \(uri)")
|
print("Loading network file: \(uri)")
|
||||||
media = VLCMedia(url: URL(string: uri)!)
|
media = VLCMedia(url: URL(string: uri)!)
|
||||||
@@ -141,38 +267,41 @@ class VlcPlayerView: ExpoView {
|
|||||||
print("Debug: Media options: \(mediaOptions)")
|
print("Debug: Media options: \(mediaOptions)")
|
||||||
media.addOptions(mediaOptions)
|
media.addOptions(mediaOptions)
|
||||||
|
|
||||||
self.mediaPlayer?.media = media
|
self.vlc.player.media = media
|
||||||
|
self.setInitialExternalSubtitles()
|
||||||
self.hasSource = true
|
self.hasSource = true
|
||||||
|
|
||||||
if autoplay {
|
if autoplay {
|
||||||
print("Playing...")
|
print("Playing...")
|
||||||
self.play()
|
self.play()
|
||||||
|
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
print("Setting audio track: \(trackIndex)")
|
||||||
|
let track = self.vlc.player.audioTracks[trackIndex]
|
||||||
|
track.isSelectedExclusively = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||||
guard let trackNames = mediaPlayer?.audioTrackNames,
|
return vlc.player.audioTracks.enumerated().map {
|
||||||
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
return ["name": $1.trackName, "index": $0]
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return zip(trackNames, trackIndexes).map { name, index in
|
|
||||||
return ["name": name, "index": index]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
if trackIndex == -1 {
|
||||||
print(
|
print("Debug: Disabling all subtitles")
|
||||||
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
for track in self.vlc.player.textTracks {
|
||||||
)
|
track.isSelected = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let track = self.vlc.player.textTracks[trackIndex]
|
||||||
|
track.isSelectedExclusively = true
|
||||||
|
print("Debug: Current subtitle track index after setting: \(track.trackName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||||
@@ -180,66 +309,35 @@ class VlcPlayerView: ExpoView {
|
|||||||
print("Error: Invalid subtitle URL")
|
print("Error: Invalid subtitle URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
if result == 0 {
|
||||||
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))
|
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||||
|
print("Subtitle added with result: \(result) \(internalName)")
|
||||||
} else {
|
} else {
|
||||||
print("Failed to add subtitle")
|
print("Failed to add subtitle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||||
guard let mediaPlayer = self.mediaPlayer else {
|
if self.vlc.player.textTracks.count == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = mediaPlayer.numberOfSubtitlesTracks
|
print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
||||||
print("Debug: Number of subtitle tracks: \(count)")
|
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
||||||
|
if let customSubtitle = customSubtitles.first(where: {
|
||||||
guard count > 0 else {
|
$0.internalName == track.trackName
|
||||||
return nil
|
}) {
|
||||||
}
|
return ["name": customSubtitle.originalName, "index": index]
|
||||||
|
} else {
|
||||||
var tracks: [[String: Any]] = []
|
return ["name": track.trackName, "index": index]
|
||||||
|
|
||||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
{
|
|
||||||
for (index, name) in zip(indexes, names) {
|
|
||||||
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
|
|
||||||
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
|
|
||||||
} else {
|
|
||||||
tracks.append(["name": name, "index": index.intValue])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print("Debug: Subtitle tracks: \(tracks)")
|
print("Debug: Subtitle tracks: \(tracks)")
|
||||||
return tracks
|
return tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setSubtitleTrackByName(_ trackName: String) {
|
|
||||||
guard let mediaPlayer = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
// Get the subtitle tracks and their indexes
|
|
||||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
{
|
|
||||||
for (index, name) in zip(indexes, names) {
|
|
||||||
if name.starts(with: trackName) {
|
|
||||||
let trackIndex = index.intValue
|
|
||||||
print("Track Index setting to: \(trackIndex)")
|
|
||||||
setSubtitleTrack(trackIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
print("Track not found for name: \(trackName)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
@objc func stop(completion: (() -> Void)? = nil) {
|
||||||
guard !isStopping else {
|
guard !isStopping else {
|
||||||
completion?()
|
completion?()
|
||||||
@@ -267,58 +365,67 @@ class VlcPlayerView: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func performStop(completion: (() -> Void)? = nil) {
|
private func performStop(completion: (() -> Void)? = nil) {
|
||||||
// Stop the media player
|
// Stop the media player
|
||||||
mediaPlayer?.stop()
|
vlc.player.stop()
|
||||||
|
|
||||||
// Remove observer
|
// Remove observer
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
|
||||||
// Clear the video view
|
// Clear the video view
|
||||||
videoView?.removeFromSuperview()
|
vlc.getPlayerView().removeFromSuperview()
|
||||||
videoView = nil
|
|
||||||
|
|
||||||
// Release the media player
|
|
||||||
mediaPlayer?.delegate = nil
|
|
||||||
mediaPlayer = nil
|
|
||||||
|
|
||||||
isStopping = false
|
isStopping = false
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateVideoProgress() {
|
private func updateVideoProgress() {
|
||||||
guard let player = self.mediaPlayer else { return }
|
guard let media = self.vlc.player.media else { return }
|
||||||
|
|
||||||
let currentTimeMs = player.time.intValue
|
let currentTimeMs = self.vlc.player.time.intValue
|
||||||
let durationMs = player.media?.length.intValue ?? 0
|
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
||||||
|
|
||||||
print("Debug: Current time: \(currentTimeMs)")
|
print("Debug: Current time: \(currentTimeMs)")
|
||||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
self.onVideoProgress?([
|
||||||
if player.isPlaying && !self.isMediaReady {
|
"currentTime": currentTimeMs,
|
||||||
self.isMediaReady = true
|
"duration": durationMs,
|
||||||
// Set external track subtitle when starting.
|
])
|
||||||
if let externalTrack = self.externalTrack {
|
}
|
||||||
if let name = externalTrack["name"], !name.isEmpty {
|
|
||||||
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
|
private func updatePlayerState() {
|
||||||
self.setSubtitleURL(deliveryUrl, name: name)
|
let player = self.vlc.player
|
||||||
}
|
self.onVideoStateChange?([
|
||||||
}
|
"target": self.reactTag ?? NSNull(),
|
||||||
}
|
"currentTime": player.time.intValue,
|
||||||
self.onVideoProgress?([
|
"duration": player.media?.length.intValue ?? 0,
|
||||||
"currentTime": currentTimeMs,
|
"error": false,
|
||||||
"duration": durationMs,
|
"isPlaying": player.isPlaying,
|
||||||
])
|
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
||||||
}
|
"state": player.state.description,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Expo Events
|
// MARK: - Expo Events
|
||||||
|
|
||||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||||
@objc var onVideoError: RCTDirectEventBlock?
|
@objc var onVideoError: RCTDirectEventBlock?
|
||||||
|
@objc var onPipStarted: RCTDirectEventBlock?
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
// MARK: - Deinitialization
|
||||||
|
|
||||||
@@ -327,67 +434,6 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
// self?.updateVideoProgress()
|
|
||||||
let timeNow = Date().timeIntervalSince1970
|
|
||||||
if timeNow - lastProgressCall >= 1 {
|
|
||||||
lastProgressCall = timeNow
|
|
||||||
updateVideoProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
||||||
self.updatePlayerState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePlayerState() {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
let currentState = player.state
|
|
||||||
|
|
||||||
var stateInfo: [String: Any] = [
|
|
||||||
"target": self.reactTag ?? NSNull(),
|
|
||||||
"currentTime": player.time.intValue,
|
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
|
||||||
"error": false,
|
|
||||||
]
|
|
||||||
|
|
||||||
if player.isPlaying {
|
|
||||||
stateInfo["isPlaying"] = true
|
|
||||||
stateInfo["isBuffering"] = false
|
|
||||||
stateInfo["state"] = "Playing"
|
|
||||||
} else {
|
|
||||||
stateInfo["isPlaying"] = false
|
|
||||||
stateInfo["state"] = "Paused"
|
|
||||||
}
|
|
||||||
|
|
||||||
if player.state == VLCMediaPlayerState.buffering {
|
|
||||||
stateInfo["isBuffering"] = true
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
} else if player.state == VLCMediaPlayerState.error {
|
|
||||||
print("player.state ~ error")
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
self.onVideoLoadEnd?(stateInfo)
|
|
||||||
} else if player.state == VLCMediaPlayerState.opening {
|
|
||||||
print("player.state ~ opening")
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.lastReportedState != currentState
|
|
||||||
|| self.lastReportedIsPlaying != player.isPlaying
|
|
||||||
{
|
|
||||||
self.lastReportedState = currentState
|
|
||||||
self.lastReportedIsPlaying = player.isPlaying
|
|
||||||
self.onVideoStateChange?(stateInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaDelegate {
|
|
||||||
// Implement VLCMediaDelegate methods if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VLCMediaPlayerState {
|
extension VLCMediaPlayerState {
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -396,9 +442,7 @@ extension VLCMediaPlayerState {
|
|||||||
case .playing: return "Playing"
|
case .playing: return "Playing"
|
||||||
case .paused: return "Paused"
|
case .paused: return "Paused"
|
||||||
case .stopped: return "Stopped"
|
case .stopped: return "Stopped"
|
||||||
case .ended: return "Ended"
|
|
||||||
case .error: return "Error"
|
case .error: return "Error"
|
||||||
case .esAdded: return "ESAdded"
|
|
||||||
@unknown default: return "Unknown"
|
@unknown default: return "Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export type VideoLoadStartPayload = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PipStartedPayload = {
|
||||||
|
nativeEvent: {
|
||||||
|
pipStarted: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
export type VideoStateChangePayload = PlaybackStatePayload;
|
||||||
|
|
||||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
export type VideoProgressPayload = ProgressUpdatePayload;
|
||||||
@@ -33,7 +39,7 @@ export type VlcPlayerSource = {
|
|||||||
type?: string;
|
type?: string;
|
||||||
isNetwork?: boolean;
|
isNetwork?: boolean;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
externalTrack?: { name: string, DeliveryUrl: string };
|
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
||||||
initOptions?: any[];
|
initOptions?: any[];
|
||||||
mediaOptions?: { [key: string]: any };
|
mediaOptions?: { [key: string]: any };
|
||||||
startPosition?: number;
|
startPosition?: number;
|
||||||
@@ -64,9 +70,11 @@ export type VlcPlayerViewProps = {
|
|||||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
||||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
onVideoError?: (event: PlaybackStatePayload) => void;
|
||||||
|
onPipStarted?: (event: PipStartedPayload) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface VlcPlayerViewRef {
|
export interface VlcPlayerViewRef {
|
||||||
|
startPictureInPicture: () => Promise<void>;
|
||||||
play: () => Promise<void>;
|
play: () => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
const nativeRef = React.useRef<NativeViewRef>(null);
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
startPictureInPicture: async () => {
|
||||||
|
await nativeRef.current?.startPictureInPicture()
|
||||||
|
},
|
||||||
play: async () => {
|
play: async () => {
|
||||||
await nativeRef.current?.play();
|
await nativeRef.current?.play();
|
||||||
},
|
},
|
||||||
@@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
onVideoProgress,
|
onVideoProgress,
|
||||||
onVideoLoadEnd,
|
onVideoLoadEnd,
|
||||||
onVideoError,
|
onVideoError,
|
||||||
|
onPipStarted,
|
||||||
...otherProps
|
...otherProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
onVideoStateChange={onVideoStateChange}
|
onVideoStateChange={onVideoStateChange}
|
||||||
onVideoProgress={onVideoProgress}
|
onVideoProgress={onVideoProgress}
|
||||||
onVideoError={onVideoError}
|
onVideoError={onVideoError}
|
||||||
|
onPipStarted={onPipStarted}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -27,7 +27,7 @@
|
|||||||
"@gorhom/bottom-sheet": "^5.1.0",
|
"@gorhom/bottom-sheet": "^5.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
||||||
"@react-native-async-storage/async-storage": "2.1.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.4.1",
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
"@react-native-menu/menu": "^1.2.2",
|
"@react-native-menu/menu": "^1.2.2",
|
||||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
@@ -75,8 +75,8 @@
|
|||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
|
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "0.8.6",
|
"react-native-bottom-tabs": "0.8.6",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
@@ -84,19 +84,19 @@
|
|||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^14.0.4",
|
"react-native-device-info": "^14.0.4",
|
||||||
"react-native-edge-to-edge": "^1.4.3",
|
"react-native-edge-to-edge": "^1.4.3",
|
||||||
"react-native-gesture-handler": "~2.23.0",
|
"react-native-gesture-handler": "2.22.0",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
"react-native-google-cast": "^4.8.3",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^3.1.0",
|
"react-native-ios-context-menu": "^3.1.0",
|
||||||
"react-native-ios-utilities": "5.1.1",
|
"react-native-ios-utilities": "5.1.1",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "^2.12.2",
|
||||||
"react-native-pager-view": "6.7.0",
|
"react-native-pager-view": "6.5.1",
|
||||||
"react-native-progress": "^5.0.1",
|
"react-native-progress": "^5.0.1",
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "3.5.1",
|
"react-native-reanimated-carousel": "3.5.1",
|
||||||
"react-native-safe-area-context": "5.2.0",
|
"react-native-safe-area-context": "5.1.0",
|
||||||
"react-native-screens": "4.6.0",
|
"react-native-screens": "~4.5.0",
|
||||||
"react-native-svg": "15.11.1",
|
"react-native-svg": "15.11.1",
|
||||||
"react-native-tab-view": "^4.0.5",
|
"react-native-tab-view": "^4.0.5",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -115,11 +115,11 @@
|
|||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.26.8",
|
||||||
"@react-native-community/cli": "15.1.3",
|
"@react-native-community/cli": "15.1.3",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "^0.1.1",
|
||||||
"@babel/core": "^7.26.8",
|
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/react": "~19.0.8",
|
"@types/react": "~18.3.12",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "^19.0.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
@@ -134,4 +134,4 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
plugins/withAndroidManifest.js
Normal file
38
plugins/withAndroidManifest.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { withAndroidManifest: NativeAndroidManifest } = require("@expo/config-plugins");
|
||||||
|
|
||||||
|
const withAndroidManifest = (config) =>
|
||||||
|
NativeAndroidManifest(config, async (config) => {
|
||||||
|
const mainApplication = config.modResults.manifest.application[0];
|
||||||
|
|
||||||
|
// Initialize activity array if it doesn't exist
|
||||||
|
if (!mainApplication.activity) {
|
||||||
|
mainApplication.activity = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleCastActivityExists = mainApplication.activity.some(activity =>
|
||||||
|
activity.$?.["android:name"] === "com.reactnative.googlecast.RNGCExpandedControllerActivity"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only add the activity if it doesn't already exist
|
||||||
|
if (!googleCastActivityExists) {
|
||||||
|
mainApplication.activity.push({
|
||||||
|
$: {
|
||||||
|
"android:name": "com.reactnative.googlecast.RNGCExpandedControllerActivity",
|
||||||
|
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
|
||||||
|
"android:launchMode": "singleTask",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainActivity = mainApplication.activity.find(activity =>
|
||||||
|
activity.$?.["android:name"] === ".MainActivity"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mainActivity) {
|
||||||
|
mainActivity.$["android:supportsPictureInPicture"] = "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = withAndroidManifest;
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
const { withAndroidManifest } = require("@expo/config-plugins");
|
|
||||||
|
|
||||||
const withGoogleCastActivity = (config) =>
|
|
||||||
withAndroidManifest(config, async (config) => {
|
|
||||||
const mainApplication = config.modResults.manifest.application[0];
|
|
||||||
|
|
||||||
// Initialize activity array if it doesn't exist
|
|
||||||
if (!mainApplication.activity) {
|
|
||||||
mainApplication.activity = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the activity already exists
|
|
||||||
const activityExists = mainApplication.activity.some(
|
|
||||||
(activity) =>
|
|
||||||
activity.$?.["android:name"] ===
|
|
||||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only add the activity if it doesn't already exist
|
|
||||||
if (!activityExists) {
|
|
||||||
mainApplication.activity.push({
|
|
||||||
$: {
|
|
||||||
"android:name":
|
|
||||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity",
|
|
||||||
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
|
|
||||||
"android:launchMode": "singleTask",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = withGoogleCastActivity;
|
|
||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.25.0" },
|
clientInfo: { name: "Streamyfin", version: "0.26.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -109,7 +109,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.25.0"`,
|
}, DeviceId="${deviceId}", Version="0.26.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
457
translations/tr.json
Normal file
457
translations/tr.json
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"username_required": "Kullanıcı adı gereklidir",
|
||||||
|
"error_title": "Hata",
|
||||||
|
"login_title": "Giriş yap",
|
||||||
|
"login_to_title": " 'e giriş yap",
|
||||||
|
"username_placeholder": "Kullanıcı adı",
|
||||||
|
"password_placeholder": "Şifre",
|
||||||
|
"login_button": "Giriş yap",
|
||||||
|
"quick_connect": "Quick Connect",
|
||||||
|
"enter_code_to_login": "Giriş yapmak için {{code}} kodunu girin",
|
||||||
|
"failed_to_initiate_quick_connect": "Quick Connect başlatılamadı",
|
||||||
|
"got_it": "Anlaşıldı",
|
||||||
|
"connection_failed": "Bağlantı başarısız",
|
||||||
|
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin",
|
||||||
|
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
|
||||||
|
"change_server": "Sunucuyu değiştir",
|
||||||
|
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
|
||||||
|
"user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok",
|
||||||
|
"server_is_taking_too_long_to_respond_try_again_later": "Sunucu yanıt vermekte çok uzun sürüyor, lütfen tekrar deneyin",
|
||||||
|
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen tekrar deneyin.",
|
||||||
|
"there_is_a_server_error": "Sunucu hatası var",
|
||||||
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL'sini girin",
|
||||||
|
"server_url_placeholder": "http(s)://sunucunuz.com",
|
||||||
|
"connect_button": "Bağlan",
|
||||||
|
"previous_servers": "Önceki sunucular",
|
||||||
|
"clear_button": "Temizle",
|
||||||
|
"search_for_local_servers": "Yerel sunucuları ara",
|
||||||
|
"searching": "Aranıyor...",
|
||||||
|
"servers": "Sunucular"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"no_internet": "İnternet Yok",
|
||||||
|
"no_items": "Öge Yok",
|
||||||
|
"no_internet_message": "Endişelenmeyin, hala\ndownloaded içerik izleyebilirsiniz.",
|
||||||
|
"go_to_downloads": "İndirmelere Git",
|
||||||
|
"oops": "Hups!",
|
||||||
|
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapın ve tekrar giriş yapın.",
|
||||||
|
"continue_watching": "İzlemeye Devam Et",
|
||||||
|
"next_up": "Sonraki",
|
||||||
|
"recently_added_in": "{{libraryName}}'de Yakınlarda Eklendi",
|
||||||
|
"suggested_movies": "Önerilen Filmler",
|
||||||
|
"suggested_episodes": "Önerilen Bölümler",
|
||||||
|
"intro": {
|
||||||
|
"welcome_to_streamyfin": "Streamyfin'e Hoş Geldiniz",
|
||||||
|
"a_free_and_open_source_client_for_jellyfin": "Jellyfin için ücretsiz ve açık kaynak bir istemci.",
|
||||||
|
"features_title": "Özellikler",
|
||||||
|
"features_description": "Streamyfin birçok özelliğe sahip ve ayarlar menüsünde bulabileceğiniz çeşitli yazılımlarla entegre olabiliyor. Bunlar arasında şunlar bulunuyor:",
|
||||||
|
"jellyseerr_feature_description": "Jellyseerr örneğinizle bağlantı kurun ve uygulama içinde doğrudan film talep edin.",
|
||||||
|
"downloads_feature_title": "İndirmeler",
|
||||||
|
"downloads_feature_description": "Filmleri ve TV dizilerini çevrimdışı izlemek için indirin. Varsayılan yöntemi veya dosyaları arka planda indirmek için optimize sunucuyu kurabilirsiniz.",
|
||||||
|
"chromecast_feature_description": "Filmleri ve TV dizilerini Chromecast cihazlarınıza aktarın.",
|
||||||
|
"centralised_settings_plugin_title": "Merkezi Ayarlar Eklentisi",
|
||||||
|
"centralised_settings_plugin_description": "Jellyfin sunucunuzda merkezi bir yerden ayarları yapılandırın. Tüm istemci ayarları tüm kullanıcılar için otomatik olarak senkronize edilecektir.",
|
||||||
|
"done_button": "Tamam",
|
||||||
|
"go_to_settings_button": "Ayrıntılara Git",
|
||||||
|
"read_more": "Daha fazla oku"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings_title": "Ayarlar",
|
||||||
|
"log_out_button": "Çıkış Yap",
|
||||||
|
"user_info": {
|
||||||
|
"user_info_title": "Kullanıcı Bilgisi",
|
||||||
|
"user": "Kullanıcı",
|
||||||
|
"server": "Sunucu",
|
||||||
|
"token": "Token",
|
||||||
|
"app_version": "Uygulama Sürümü"
|
||||||
|
},
|
||||||
|
"quick_connect": {
|
||||||
|
"quick_connect_title": "Hızlı Bağlantı",
|
||||||
|
"authorize_button": "Hızlı Bağlantıyı Yetkilendir",
|
||||||
|
"enter_the_quick_connect_code": "Hızlı bağlantı kodunu girin...",
|
||||||
|
"success": "Başarılı",
|
||||||
|
"quick_connect_autorized": "Hızlı Bağlantı Yetkilendirildi",
|
||||||
|
"error": "Hata",
|
||||||
|
"invalid_code": "Geçersiz kod",
|
||||||
|
"authorize": "Yetkilendir"
|
||||||
|
},
|
||||||
|
"media_controls": {
|
||||||
|
"media_controls_title": "Medya Kontrolleri",
|
||||||
|
"forward_skip_length": "İleri Sarma Uzunluğu",
|
||||||
|
"rewind_length": "Geri Sarma Uzunluğu",
|
||||||
|
"seconds_unit": "s"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"audio_title": "Ses",
|
||||||
|
"set_audio_track": "Önceki Öğeden Ses Parçası Ayarla",
|
||||||
|
"audio_language": "Ses Dili",
|
||||||
|
"audio_hint": "Varsayılan ses dilini seçin.",
|
||||||
|
"none": "Yok",
|
||||||
|
"language": "Dil"
|
||||||
|
},
|
||||||
|
"subtitles": {
|
||||||
|
"subtitle_title": "Altyazılar",
|
||||||
|
"subtitle_language": "Altyazı Dili",
|
||||||
|
"subtitle_mode": "Altyazı Modu",
|
||||||
|
"set_subtitle_track": "Önceki Öğeden Altyazı Parçası Ayarla",
|
||||||
|
"subtitle_size": "Altyazı Boyutu",
|
||||||
|
"subtitle_hint": "Altyazı tercihini yapılandırın.",
|
||||||
|
"none": "Yok",
|
||||||
|
"language": "Dil",
|
||||||
|
"loading": "Yükleniyor",
|
||||||
|
"modes": {
|
||||||
|
"Default": "Varsayılan",
|
||||||
|
"Smart": "Akıllı",
|
||||||
|
"Always": "Her Zaman",
|
||||||
|
"None": "Yok",
|
||||||
|
"OnlyForced": "Sadece Zorunlu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"other_title": "Diğer",
|
||||||
|
"auto_rotate": "Otomatik Döndürme",
|
||||||
|
"video_orientation": "Video Yönü",
|
||||||
|
"orientation": "Yön",
|
||||||
|
"orientations": {
|
||||||
|
"DEFAULT": "Varsayılan",
|
||||||
|
"ALL": "Tümü",
|
||||||
|
"PORTRAIT": "Dikey",
|
||||||
|
"PORTRAIT_UP": "Dikey Yukarı",
|
||||||
|
"PORTRAIT_DOWN": "Dikey Aşağı",
|
||||||
|
"LANDSCAPE": "Yatay",
|
||||||
|
"LANDSCAPE_LEFT": "Yatay Sol",
|
||||||
|
"LANDSCAPE_RIGHT": "Yatay Sağ",
|
||||||
|
"OTHER": "Diğer",
|
||||||
|
"UNKNOWN": "Bilinmeyen"
|
||||||
|
},
|
||||||
|
"safe_area_in_controls": "Kontrollerde Güvenli Alan",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "İndirmeler",
|
||||||
|
"download_method": "İndirme Yöntemi",
|
||||||
|
"remux_max_download": "Remux max indirme",
|
||||||
|
"auto_download": "Otomatik İndirme",
|
||||||
|
"optimized_versions_server": "Optimize edilmiş sürümler sunucusu",
|
||||||
|
"save_button": "Kaydet",
|
||||||
|
"optimized_server": "Optimize Sunucu",
|
||||||
|
"optimized": "Optimize",
|
||||||
|
"default": "Varsayılan",
|
||||||
|
"optimized_version_hint": "Optimize sunucusu için URL girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
|
||||||
|
"read_more_about_optimized_server": "Optimize sunucusu hakkında daha fazla oku.",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"plugins_title": "Eklentiler",
|
||||||
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "Bu entegrasyon erken aşamalardadır. Değişiklikler olabilir.",
|
||||||
|
"server_url": "Sunucu URL'si",
|
||||||
|
"server_url_hint": "Örnek: http(s)://your-host.url\n(port gerekiyorsa ekleyin)",
|
||||||
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
|
"password": "Şifre",
|
||||||
|
"password_placeholder": "Jellyfin kullanıcısı {{username}} için şifre girin",
|
||||||
|
"save_button": "Kaydet",
|
||||||
|
"clear_button": "Temizle",
|
||||||
|
"login_button": "Giriş Yap",
|
||||||
|
"total_media_requests": "Toplam medya istekleri",
|
||||||
|
"movie_quota_limit": "Film kota limiti",
|
||||||
|
"movie_quota_days": "Film kota günleri",
|
||||||
|
"tv_quota_limit": "TV kota limiti",
|
||||||
|
"tv_quota_days": "TV kota günleri",
|
||||||
|
"reset_jellyseerr_config_button": "Jellyseerr yapılandırmasını sıfırla",
|
||||||
|
"unlimited": "Sınırsız"
|
||||||
|
},
|
||||||
|
"marlin_search": {
|
||||||
|
"enable_marlin_search": "Marlin Aramasını Etkinleştir ",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port",
|
||||||
|
"marlin_search_hint": "Marlin sunucu URL'sini girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
|
||||||
|
"read_more_about_marlin": "Marlin hakkında daha fazla oku.",
|
||||||
|
"save_button": "Kaydet",
|
||||||
|
"toasts": {
|
||||||
|
"saved": "Kaydedildi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"storage_title": "Depolama",
|
||||||
|
"app_usage": "Uygulama {{usedSpace}}%",
|
||||||
|
"device_usage": "Cihaz {{availableSpace}}%",
|
||||||
|
"size_used": "{{used}} / {{total}} kullanıldı",
|
||||||
|
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"show_intro": "Tanıtımı Göster",
|
||||||
|
"reset_intro": "Tanıtımı Sıfırla"
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"logs_title": "Günlükler",
|
||||||
|
"no_logs_available": "Günlükler mevcut değil",
|
||||||
|
"delete_all_logs": "Tüm günlükleri sil"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"title": "Diller",
|
||||||
|
"app_language": "Uygulama dili",
|
||||||
|
"app_language_description": "Uygulama dilini seçin.",
|
||||||
|
"system": "Sistem"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"error_deleting_files": "Dosyalar silinirken hata oluştu",
|
||||||
|
"background_downloads_enabled": "Arka plan indirmeleri etkinleştirildi",
|
||||||
|
"background_downloads_disabled": "Arka plan indirmeleri devre dışı bırakıldı",
|
||||||
|
"connected": "Bağlandı",
|
||||||
|
"could_not_connect": "Bağlanılamadı",
|
||||||
|
"invalid_url": "Geçersiz URL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "İndirilenler",
|
||||||
|
"tvseries": "Diziler",
|
||||||
|
"movies": "Filmler",
|
||||||
|
"queue": "Sıra",
|
||||||
|
"queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır",
|
||||||
|
"no_items_in_queue": "Sırada öğe yok",
|
||||||
|
"no_downloaded_items": "İndirilen öğe yok",
|
||||||
|
"delete_all_movies_button": "Tüm Filmleri Sil",
|
||||||
|
"delete_all_tvseries_button": "Tüm Dizileri Sil",
|
||||||
|
"delete_all_button": "Tümünü Sil",
|
||||||
|
"active_download": "Aktif indirme",
|
||||||
|
"no_active_downloads": "Aktif indirme yok",
|
||||||
|
"active_downloads": "Aktif indirmeler",
|
||||||
|
"new_app_version_requires_re_download": "Yeni uygulama sürümü yeniden indirme gerektiriyor",
|
||||||
|
"new_app_version_requires_re_download_description": "Yeni güncelleme, içeriğin yeniden indirilmesini gerektiriyor. Lütfen tüm indirilen içerikleri kaldırıp tekrar deneyin.",
|
||||||
|
"back": "Geri",
|
||||||
|
"delete": "Sil",
|
||||||
|
"something_went_wrong": "Bir şeyler ters gitti",
|
||||||
|
"could_not_get_stream_url_from_jellyfin": "Jellyfin'den yayın URL'si alınamadı",
|
||||||
|
"eta": "Tahmini Süre {{eta}}",
|
||||||
|
"methods": "Yöntemler",
|
||||||
|
"toasts": {
|
||||||
|
"you_are_not_allowed_to_download_files": "Dosyaları indirme izniniz yok.",
|
||||||
|
"deleted_all_movies_successfully": "Tüm filmler başarıyla silindi!",
|
||||||
|
"failed_to_delete_all_movies": "Filmler silinemedi",
|
||||||
|
"deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!",
|
||||||
|
"failed_to_delete_all_tvseries": "Diziler silinemedi",
|
||||||
|
"download_cancelled": "İndirme iptal edildi",
|
||||||
|
"could_not_cancel_download": "İndirme iptal edilemedi",
|
||||||
|
"download_completed": "İndirme tamamlandı",
|
||||||
|
"download_started_for": "{{item}} için indirme başlatıldı",
|
||||||
|
"item_is_ready_to_be_downloaded": "{{item}} indirmeye hazır",
|
||||||
|
"download_stated_for_item": "{{item}} için indirme başlatıldı",
|
||||||
|
"download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}",
|
||||||
|
"download_completed_for_item": "{{item}} için indirme tamamlandı",
|
||||||
|
"queued_item_for_optimization": "{{item}} optimizasyon için sıraya alındı",
|
||||||
|
"failed_to_start_download_for_item": "{{item}} için indirme başlatılamadı: {{message}}",
|
||||||
|
"server_responded_with_status_code": "Sunucu şu durum koduyla yanıt verdi: {{statusCode}}",
|
||||||
|
"no_response_received_from_server": "Sunucudan yanıt alınamadı",
|
||||||
|
"error_setting_up_the_request": "İstek ayarlanırken hata oluştu",
|
||||||
|
"failed_to_start_download_for_item_unexpected_error": "{{item}} için indirme başlatılamadı: Beklenmeyen hata",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Tüm dosyalar, klasörler ve işler başarıyla silindi",
|
||||||
|
"an_error_occured_while_deleting_files_and_jobs": "Dosyalar ve işler silinirken hata oluştu",
|
||||||
|
"go_to_downloads": "İndirmelere git"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"search_here": "Burada ara...",
|
||||||
|
"search": "Ara...",
|
||||||
|
"x_items": "{{count}} öge(ler)",
|
||||||
|
"library": "Kütüphane",
|
||||||
|
"discover": "Keşfet",
|
||||||
|
"no_results": "Sonuç bulunamadı",
|
||||||
|
"no_results_found_for": "\"{{query}}\" için sonuç bulunamadı",
|
||||||
|
"movies": "Filmler",
|
||||||
|
"series": "Diziler",
|
||||||
|
"episodes": "Bölümler",
|
||||||
|
"collections": "Koleksiyonlar",
|
||||||
|
"actors": "Oyuncular",
|
||||||
|
"request_movies": "Film Talep Et",
|
||||||
|
"request_series": "Dizi Talep Et",
|
||||||
|
"recently_added": "Son Eklenenler",
|
||||||
|
"recent_requests": "Son Talepler",
|
||||||
|
"plex_watchlist": "Plex İzleme Listesi",
|
||||||
|
"trending": "Şu An Popüler",
|
||||||
|
"popular_movies": "Popüler Filmler",
|
||||||
|
"movie_genres": "Film Türleri",
|
||||||
|
"upcoming_movies": "Yaklaşan Filmler",
|
||||||
|
"studios": "Stüdyolar",
|
||||||
|
"popular_tv": "Popüler Diziler",
|
||||||
|
"tv_genres": "Dizi Türleri",
|
||||||
|
"upcoming_tv": "Yaklaşan Diziler",
|
||||||
|
"networks": "Ağlar",
|
||||||
|
"tmdb_movie_keyword": "TMDB Film Anahtar Kelimesi",
|
||||||
|
"tmdb_movie_genre": "TMDB Film Türü",
|
||||||
|
"tmdb_tv_keyword": "TMDB Dizi Anahtar Kelimesi",
|
||||||
|
"tmdb_tv_genre": "TMDB Dizi Türü",
|
||||||
|
"tmdb_search": "TMDB Arama",
|
||||||
|
"tmdb_studio": "TMDB Stüdyo",
|
||||||
|
"tmdb_network": "TMDB Ağ",
|
||||||
|
"tmdb_movie_streaming_services": "TMDB Film Yayın Servisleri",
|
||||||
|
"tmdb_tv_streaming_services": "TMDB Dizi Yayın Servisleri"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"no_items_found": "Öğe bulunamadı",
|
||||||
|
"no_results": "Sonuç bulunamadı",
|
||||||
|
"no_libraries_found": "Kütüphane bulunamadı",
|
||||||
|
"item_types": {
|
||||||
|
"movies": "filmler",
|
||||||
|
"series": "diziler",
|
||||||
|
"boxsets": "koleksiyonlar",
|
||||||
|
"items": "ögeler"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"display": "Görüntüleme",
|
||||||
|
"row": "Satır",
|
||||||
|
"list": "Liste",
|
||||||
|
"image_style": "Görsel stili",
|
||||||
|
"poster": "Poster",
|
||||||
|
"cover": "Kapak",
|
||||||
|
"show_titles": "Başlıkları göster",
|
||||||
|
"show_stats": "İstatistikleri göster"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"genres": "Türler",
|
||||||
|
"years": "Yıllar",
|
||||||
|
"sort_by": "Sırala",
|
||||||
|
"sort_order": "Sıralama düzeni",
|
||||||
|
"tags": "Etiketler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"series": "Diziler",
|
||||||
|
"movies": "Filmler",
|
||||||
|
"episodes": "Bölümler",
|
||||||
|
"videos": "Videolar",
|
||||||
|
"boxsets": "Koleksiyonlar",
|
||||||
|
"playlists": "Çalma listeleri"
|
||||||
|
},
|
||||||
|
"custom_links": {
|
||||||
|
"no_links": "Bağlantı yok"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Hata",
|
||||||
|
"failed_to_get_stream_url": "Yayın URL'si alınamadı",
|
||||||
|
"an_error_occured_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
|
||||||
|
"client_error": "İstemci hatası",
|
||||||
|
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
|
||||||
|
"message_from_server": "Sunucudan mesaj: {{message}}",
|
||||||
|
"video_has_finished_playing": "Video oynatıldı!",
|
||||||
|
"no_video_source": "Video kaynağı yok...",
|
||||||
|
"next_episode": "Sonraki bölüm",
|
||||||
|
"refresh_tracks": "Parçaları yenile",
|
||||||
|
"subtitle_tracks": "Altyazı Parçaları:",
|
||||||
|
"audio_tracks": "Ses Parçaları:",
|
||||||
|
"playback_state": "Oynatma Durumu:",
|
||||||
|
"no_data_available": "Veri bulunamadı",
|
||||||
|
"index": "İndeks:"
|
||||||
|
},
|
||||||
|
"item_card": {
|
||||||
|
"next_up": "Sıradaki",
|
||||||
|
"no_items_to_display": "Görüntülenecek öğe yok",
|
||||||
|
"cast_and_crew": "Oyuncular & Ekip",
|
||||||
|
"series": "Dizi",
|
||||||
|
"seasons": "Sezonlar",
|
||||||
|
"season": "Sezon",
|
||||||
|
"no_episodes_for_this_season": "Bu sezona ait bölüm yok",
|
||||||
|
"overview": "Özet",
|
||||||
|
"more_with": "Daha fazla {{name}}",
|
||||||
|
"similar_items": "Benzer ögeler",
|
||||||
|
"no_similar_items_found": "Benzer öge bulunamadı",
|
||||||
|
"video": "Video",
|
||||||
|
"more_details": "Daha fazla detay",
|
||||||
|
"quality": "Kalite",
|
||||||
|
"audio": "Ses",
|
||||||
|
"subtitles": "Altyazı",
|
||||||
|
"show_more": "Daha fazla göster",
|
||||||
|
"show_less": "Daha az göster",
|
||||||
|
"appeared_in": "Şurada yer aldı",
|
||||||
|
"could_not_load_item": "Öge yüklenemedi",
|
||||||
|
"none": "Hiçbiri",
|
||||||
|
"download": {
|
||||||
|
"download_season": "Sezonu indir",
|
||||||
|
"download_series": "Diziyi indir",
|
||||||
|
"download_episode": "Bölümü indir",
|
||||||
|
"download_movie": "Filmi indir",
|
||||||
|
"download_x_item": "{{item_count}} tane ögeyi indir",
|
||||||
|
"download_button": "İndir",
|
||||||
|
"using_optimized_server": "Optimize edilmiş sunucu kullanılıyor",
|
||||||
|
"using_default_method": "Varsayılan yöntem kullanılıyor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"live_tv": {
|
||||||
|
"next": "Sonraki",
|
||||||
|
"previous": "Önceki",
|
||||||
|
"live_tv": "Canlı TV",
|
||||||
|
"coming_soon": "Yakında",
|
||||||
|
"on_now": "Şu anda yayında",
|
||||||
|
"shows": "Programlar",
|
||||||
|
"movies": "Filmler",
|
||||||
|
"sports": "Spor",
|
||||||
|
"for_kids": "Çocuklar İçin",
|
||||||
|
"news": "Haberler"
|
||||||
|
},
|
||||||
|
"jellyseerr": {
|
||||||
|
"confirm": "Onayla",
|
||||||
|
"cancel": "İptal",
|
||||||
|
"yes": "Evet",
|
||||||
|
"whats_wrong": "Problem nedir?",
|
||||||
|
"issue_type": "Sorun türü",
|
||||||
|
"select_an_issue": "Bir sorun seçin",
|
||||||
|
"types": "Türler",
|
||||||
|
"describe_the_issue": "(isteğe bağlı) Sorunu açıklayın...",
|
||||||
|
"submit_button": "Gönder",
|
||||||
|
"report_issue_button": "Sorunu bildir",
|
||||||
|
"request_button": "Talep et",
|
||||||
|
"are_you_sure_you_want_to_request_all_seasons": "Tüm sezonları talep etmek istediğinizden emin misiniz?",
|
||||||
|
"failed_to_login": "Giriş yapılamadı",
|
||||||
|
"cast": "Oyuncular",
|
||||||
|
"details": "Detaylar",
|
||||||
|
"status": "Durum",
|
||||||
|
"original_title": "Orijinal Başlık",
|
||||||
|
"series_type": "Dizi Türü",
|
||||||
|
"release_dates": "Yayın Tarihleri",
|
||||||
|
"first_air_date": "İlk Yayın Tarihi",
|
||||||
|
"next_air_date": "Sonraki Yayın Tarihi",
|
||||||
|
"revenue": "Gelir",
|
||||||
|
"budget": "Bütçe",
|
||||||
|
"original_language": "Orijinal Dil",
|
||||||
|
"production_country": "Yapım Ülkesi",
|
||||||
|
"studios": "Stüdyolar",
|
||||||
|
"network": "Ağ",
|
||||||
|
"currently_streaming_on": "Şu anda yayınlanıyor",
|
||||||
|
"advanced": "Gelişmiş",
|
||||||
|
"request_as": "Şu olarak iste",
|
||||||
|
"tags": "Etiketler",
|
||||||
|
"quality_profile": "Kalite Profili",
|
||||||
|
"root_folder": "Kök Klasör",
|
||||||
|
"season_x": "Sezon {{seasons}}",
|
||||||
|
"season_number": "Sezon {{season_number}}",
|
||||||
|
"number_episodes": "Bölüm {{episode_number}}",
|
||||||
|
"born": "Doğum",
|
||||||
|
"appearances": "Görünmeler",
|
||||||
|
"toasts": {
|
||||||
|
"jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin",
|
||||||
|
"jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.",
|
||||||
|
"failed_to_test_jellyseerr_server_url": "Jellyseerr sunucu URL'si test edilemedi",
|
||||||
|
"issue_submitted": "Sorun gönderildi!",
|
||||||
|
"requested_item": "{{item}} talep edildi!",
|
||||||
|
"you_dont_have_permission_to_request": "İstek göndermeye izniniz yok!",
|
||||||
|
"something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"home": "Ana Sayfa",
|
||||||
|
"search": "Ara",
|
||||||
|
"library": "Kütüphane",
|
||||||
|
"custom_links": "Özel Bağlantılar",
|
||||||
|
"favorites": "Favoriler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { TranscodedSubtitle } from "@/components/video-player/controls/types";
|
|
||||||
import { TrackInfo } from "@/modules/vlc-player";
|
|
||||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
const disableSubtitle = {
|
|
||||||
name: "Disable",
|
|
||||||
index: -1,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
} as TranscodedSubtitle;
|
|
||||||
|
|
||||||
export class SubtitleHelper {
|
|
||||||
private mediaStreams: MediaStream[];
|
|
||||||
|
|
||||||
constructor(mediaStreams: MediaStream[]) {
|
|
||||||
this.mediaStreams = mediaStreams.filter((x) => x.Type === "Subtitle");
|
|
||||||
}
|
|
||||||
|
|
||||||
getSubtitles(): MediaStream[] {
|
|
||||||
return this.mediaStreams;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUniqueSubtitles(): MediaStream[] {
|
|
||||||
const uniqueSubs: MediaStream[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
this.mediaStreams.forEach((x) => {
|
|
||||||
if (!seen.has(x.DisplayTitle!)) {
|
|
||||||
seen.add(x.DisplayTitle!);
|
|
||||||
uniqueSubs.push(x);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return uniqueSubs;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentSubtitle(subtitleIndex?: number): MediaStream | undefined {
|
|
||||||
return this.mediaStreams.find((x) => x.Index === subtitleIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
getMostCommonSubtitleByName(
|
|
||||||
subtitleIndex: number | undefined
|
|
||||||
): number | undefined {
|
|
||||||
if (subtitleIndex === undefined) -1;
|
|
||||||
const uniqueSubs = this.getUniqueSubtitles();
|
|
||||||
const currentSub = this.getCurrentSubtitle(subtitleIndex);
|
|
||||||
|
|
||||||
return uniqueSubs.find((x) => x.DisplayTitle === currentSub?.DisplayTitle)
|
|
||||||
?.Index;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTextSubtitles(): MediaStream[] {
|
|
||||||
return this.mediaStreams.filter((x) => x.IsTextSubtitleStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
getImageSubtitles(): MediaStream[] {
|
|
||||||
return this.mediaStreams.filter((x) => !x.IsTextSubtitleStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmbeddedTrackIndex(sourceSubtitleIndex: number): number {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
const textSubs = this.getTextSubtitles();
|
|
||||||
const matchingSubtitle = textSubs.find(
|
|
||||||
(sub) => sub.Index === sourceSubtitleIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!matchingSubtitle) return -1;
|
|
||||||
return textSubs.indexOf(matchingSubtitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get unique text-based subtitles because react-native-video removes hls text tracks duplicates. (iOS)
|
|
||||||
const uniqueTextSubs = this.getUniqueTextBasedSubtitles();
|
|
||||||
const matchingSubtitle = uniqueTextSubs.find(
|
|
||||||
(sub) => sub.Index === sourceSubtitleIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!matchingSubtitle) return -1;
|
|
||||||
return uniqueTextSubs.indexOf(matchingSubtitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
sortSubtitles(
|
|
||||||
textSubs: TranscodedSubtitle[],
|
|
||||||
allSubs: MediaStream[]
|
|
||||||
): TranscodedSubtitle[] {
|
|
||||||
let textIndex = 0; // To track position in textSubtitles
|
|
||||||
// Merge text and image subtitles in the order of allSubs
|
|
||||||
const sortedSubtitles = allSubs.map((sub) => {
|
|
||||||
if (sub.IsTextSubtitleStream) {
|
|
||||||
if (textSubs.length === 0) return disableSubtitle;
|
|
||||||
const textSubtitle = textSubs[textIndex];
|
|
||||||
if (!textSubtitle) return disableSubtitle;
|
|
||||||
textIndex++;
|
|
||||||
return textSubtitle;
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: sub.DisplayTitle!,
|
|
||||||
index: sub.Index!,
|
|
||||||
IsTextSubtitleStream: sub.IsTextSubtitleStream,
|
|
||||||
} as TranscodedSubtitle;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortedSubtitles;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSortedSubtitles(subtitleTracks: TrackInfo[]): TranscodedSubtitle[] {
|
|
||||||
const textSubtitles =
|
|
||||||
subtitleTracks.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
index: s.index,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const sortedSubs =
|
|
||||||
Platform.OS === "android"
|
|
||||||
? this.sortSubtitles(textSubtitles, this.mediaStreams)
|
|
||||||
: this.sortSubtitles(textSubtitles, this.getUniqueSubtitles());
|
|
||||||
|
|
||||||
return sortedSubs;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUniqueTextBasedSubtitles(): MediaStream[] {
|
|
||||||
return this.getUniqueSubtitles().filter((x) => x.IsTextSubtitleStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HLS stream indexes are not the same as the actual source indexes.
|
|
||||||
// This function aims to get the source subtitle index from the embedded track index.
|
|
||||||
getSourceSubtitleIndex = (embeddedTrackIndex: number): number => {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
return this.getTextSubtitles()[embeddedTrackIndex]?.Index ?? -1;
|
|
||||||
}
|
|
||||||
return this.getUniqueTextBasedSubtitles()[embeddedTrackIndex]?.Index ?? -1;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
ItemFilter,
|
ItemFilter,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { writeInfoLog } from "@/utils/log";
|
import { writeInfoLog } from "@/utils/log";
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ export type Settings = {
|
|||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
|
defaultBitrate?: Bitrate;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
defaultAudioLanguage: CultureDto | null;
|
defaultAudioLanguage: CultureDto | null;
|
||||||
playDefaultAudioTrack: boolean;
|
playDefaultAudioTrack: boolean;
|
||||||
@@ -168,6 +170,7 @@ const defaultValues: Settings = {
|
|||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
openInVLC: false,
|
openInVLC: false,
|
||||||
downloadQuality: DownloadOptions[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
|
defaultBitrate: BITRATES[0],
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
display: "list",
|
display: "list",
|
||||||
cardStyle: "detailed",
|
cardStyle: "detailed",
|
||||||
|
|||||||
@@ -92,10 +92,8 @@ export function getDefaultPlaySettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Get default bitrate
|
// 4. Get default bitrate from settings or fallback to max
|
||||||
const bitrate = BITRATES.sort(
|
const bitrate = settings.defaultBitrate ?? BITRATES[0];
|
||||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
|
|||||||
@@ -111,15 +111,6 @@ export const getStreamUrl = async ({
|
|||||||
if (mediaSource?.TranscodingUrl) {
|
if (mediaSource?.TranscodingUrl) {
|
||||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||||
|
|
||||||
// If there is no subtitle stream index, add it to the URL.
|
|
||||||
if (subtitleStreamIndex == -1) {
|
|
||||||
urlObj.searchParams.set("SubtitleMethod", "Hls");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add 'SubtitleMethod=Hls' if it doesn't exist
|
|
||||||
if (!urlObj.searchParams.has("SubtitleMethod")) {
|
|
||||||
urlObj.searchParams.append("SubtitleMethod", "Hls");
|
|
||||||
}
|
|
||||||
// Get the updated URL
|
// Get the updated URL
|
||||||
const transcodeUrl = urlObj.toString();
|
const transcodeUrl = urlObj.toString();
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,9 @@ export default {
|
|||||||
Type: MediaTypes.Video,
|
Type: MediaTypes.Video,
|
||||||
Context: "Streaming",
|
Context: "Streaming",
|
||||||
Protocol: "hls",
|
Protocol: "hls",
|
||||||
Container: "ts",
|
Container: "fmp4",
|
||||||
VideoCodec: "h264, hevc",
|
VideoCodec: "h264, hevc",
|
||||||
AudioCodec: "aac,mp3,ac3",
|
AudioCodec: "aac,mp3,ac3,dts",
|
||||||
CopyTimestamps: false,
|
|
||||||
EnableSubtitlesInManifest: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: MediaTypes.Audio,
|
Type: MediaTypes.Audio,
|
||||||
@@ -58,131 +56,81 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: [
|
SubtitleProfiles: [
|
||||||
// Official foramts
|
// Official formats
|
||||||
{ Format: "vtt", Method: "Embed" },
|
{ Format: "vtt", Method: "Embed" },
|
||||||
{ Format: "vtt", Method: "Hls" },
|
|
||||||
{ Format: "vtt", Method: "External" },
|
{ Format: "vtt", Method: "External" },
|
||||||
{ Format: "vtt", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "webvtt", Method: "Embed" },
|
{ Format: "webvtt", Method: "Embed" },
|
||||||
{ Format: "webvtt", Method: "Hls" },
|
|
||||||
{ Format: "webvtt", Method: "External" },
|
{ Format: "webvtt", Method: "External" },
|
||||||
{ Format: "webvtt", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "srt", Method: "Embed" },
|
{ Format: "srt", Method: "Embed" },
|
||||||
{ Format: "srt", Method: "Hls" },
|
|
||||||
{ Format: "srt", Method: "External" },
|
{ Format: "srt", Method: "External" },
|
||||||
{ Format: "srt", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "subrip", Method: "Embed" },
|
{ Format: "subrip", Method: "Embed" },
|
||||||
{ Format: "subrip", Method: "Hls" },
|
|
||||||
{ Format: "subrip", Method: "External" },
|
{ Format: "subrip", Method: "External" },
|
||||||
{ Format: "subrip", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "ttml", Method: "Embed" },
|
{ Format: "ttml", Method: "Embed" },
|
||||||
{ Format: "ttml", Method: "Hls" },
|
|
||||||
{ Format: "ttml", Method: "External" },
|
{ Format: "ttml", Method: "External" },
|
||||||
{ Format: "ttml", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "dvbsub", Method: "Embed" },
|
{ Format: "dvbsub", Method: "Embed" },
|
||||||
{ Format: "dvbsub", Method: "Hls" },
|
|
||||||
{ Format: "dvbsub", Method: "External" },
|
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
{ Format: "dvdsub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ass", Method: "Embed" },
|
{ Format: "ass", Method: "Embed" },
|
||||||
{ Format: "ass", Method: "Hls" },
|
|
||||||
{ Format: "ass", Method: "External" },
|
{ Format: "ass", Method: "External" },
|
||||||
{ Format: "ass", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "idx", Method: "Embed" },
|
{ Format: "idx", Method: "Embed" },
|
||||||
{ Format: "idx", Method: "Hls" },
|
|
||||||
{ Format: "idx", Method: "External" },
|
|
||||||
{ Format: "idx", Method: "Encode" },
|
{ Format: "idx", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pgs", Method: "Embed" },
|
{ Format: "pgs", Method: "Embed" },
|
||||||
{ Format: "pgs", Method: "Hls" },
|
|
||||||
{ Format: "pgs", Method: "External" },
|
|
||||||
{ Format: "pgs", Method: "Encode" },
|
{ Format: "pgs", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pgssub", Method: "Embed" },
|
{ Format: "pgssub", Method: "Embed" },
|
||||||
{ Format: "pgssub", Method: "Hls" },
|
|
||||||
{ Format: "pgssub", Method: "External" },
|
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
{ Format: "pgssub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ssa", Method: "Embed" },
|
{ Format: "ssa", Method: "Embed" },
|
||||||
{ Format: "ssa", Method: "Hls" },
|
|
||||||
{ Format: "ssa", Method: "External" },
|
{ Format: "ssa", Method: "External" },
|
||||||
{ Format: "ssa", Method: "Encode" },
|
|
||||||
|
|
||||||
// Other formats
|
// Other formats
|
||||||
{ Format: "microdvd", Method: "Embed" },
|
{ Format: "microdvd", Method: "Embed" },
|
||||||
{ Format: "microdvd", Method: "Hls" },
|
|
||||||
{ Format: "microdvd", Method: "External" },
|
{ Format: "microdvd", Method: "External" },
|
||||||
{ Format: "microdvd", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "mov_text", Method: "Embed" },
|
{ Format: "mov_text", Method: "Embed" },
|
||||||
{ Format: "mov_text", Method: "Hls" },
|
|
||||||
{ Format: "mov_text", Method: "External" },
|
{ Format: "mov_text", Method: "External" },
|
||||||
{ Format: "mov_text", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "mpl2", Method: "Embed" },
|
{ Format: "mpl2", Method: "Embed" },
|
||||||
{ Format: "mpl2", Method: "Hls" },
|
|
||||||
{ Format: "mpl2", Method: "External" },
|
{ Format: "mpl2", Method: "External" },
|
||||||
{ Format: "mpl2", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "pjs", Method: "Embed" },
|
{ Format: "pjs", Method: "Embed" },
|
||||||
{ Format: "pjs", Method: "Hls" },
|
|
||||||
{ Format: "pjs", Method: "External" },
|
{ Format: "pjs", Method: "External" },
|
||||||
{ Format: "pjs", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "realtext", Method: "Embed" },
|
{ Format: "realtext", Method: "Embed" },
|
||||||
{ Format: "realtext", Method: "Hls" },
|
|
||||||
{ Format: "realtext", Method: "External" },
|
{ Format: "realtext", Method: "External" },
|
||||||
{ Format: "realtext", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "scc", Method: "Embed" },
|
{ Format: "scc", Method: "Embed" },
|
||||||
{ Format: "scc", Method: "Hls" },
|
|
||||||
{ Format: "scc", Method: "External" },
|
{ Format: "scc", Method: "External" },
|
||||||
{ Format: "scc", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "smi", Method: "Embed" },
|
{ Format: "smi", Method: "Embed" },
|
||||||
{ Format: "smi", Method: "Hls" },
|
|
||||||
{ Format: "smi", Method: "External" },
|
{ Format: "smi", Method: "External" },
|
||||||
{ Format: "smi", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "stl", Method: "Embed" },
|
{ Format: "stl", Method: "Embed" },
|
||||||
{ Format: "stl", Method: "Hls" },
|
|
||||||
{ Format: "stl", Method: "External" },
|
{ Format: "stl", Method: "External" },
|
||||||
{ Format: "stl", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "sub", Method: "Embed" },
|
{ Format: "sub", Method: "Embed" },
|
||||||
{ Format: "sub", Method: "Hls" },
|
|
||||||
{ Format: "sub", Method: "External" },
|
{ Format: "sub", Method: "External" },
|
||||||
{ Format: "sub", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "subviewer", Method: "Embed" },
|
{ Format: "subviewer", Method: "Embed" },
|
||||||
{ Format: "subviewer", Method: "Hls" },
|
|
||||||
{ Format: "subviewer", Method: "External" },
|
{ Format: "subviewer", Method: "External" },
|
||||||
{ Format: "subviewer", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "teletext", Method: "Embed" },
|
{ Format: "teletext", Method: "Embed" },
|
||||||
{ Format: "teletext", Method: "Hls" },
|
|
||||||
{ Format: "teletext", Method: "External" },
|
|
||||||
{ Format: "teletext", Method: "Encode" },
|
{ Format: "teletext", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "text", Method: "Embed" },
|
{ Format: "text", Method: "Embed" },
|
||||||
{ Format: "text", Method: "Hls" },
|
|
||||||
{ Format: "text", Method: "External" },
|
{ Format: "text", Method: "External" },
|
||||||
{ Format: "text", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "vplayer", Method: "Embed" },
|
{ Format: "vplayer", Method: "Embed" },
|
||||||
{ Format: "vplayer", Method: "Hls" },
|
|
||||||
{ Format: "vplayer", Method: "External" },
|
{ Format: "vplayer", Method: "External" },
|
||||||
{ Format: "vplayer", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "xsub", Method: "Embed" },
|
{ Format: "xsub", Method: "Embed" },
|
||||||
{ Format: "xsub", Method: "Hls" },
|
|
||||||
{ Format: "xsub", Method: "External" },
|
{ Format: "xsub", Method: "External" },
|
||||||
{ Format: "xsub", Method: "Encode" },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Name: "Vlc Player for HLS streams.",
|
|
||||||
MaxStaticBitrate: 20_000_000,
|
|
||||||
MaxStreamingBitrate: 12_000_000,
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
CopyTimestamps: false,
|
|
||||||
EnableSubtitlesInManifest: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: MediaTypes.Audio,
|
|
||||||
Context: "Streaming",
|
|
||||||
Protocol: "http",
|
|
||||||
Container: "mp3",
|
|
||||||
AudioCodec: "mp3",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
SubtitleProfiles: [
|
|
||||||
// Text based subtitles must use HLS.
|
|
||||||
{ Format: "ass", Method: "Hls" },
|
|
||||||
{ Format: "microdvd", Method: "Hls" },
|
|
||||||
{ Format: "mov_text", Method: "Hls" },
|
|
||||||
{ Format: "mpl2", Method: "Hls" },
|
|
||||||
{ Format: "pjs", Method: "Hls" },
|
|
||||||
{ Format: "realtext", Method: "Hls" },
|
|
||||||
{ Format: "scc", Method: "Hls" },
|
|
||||||
{ Format: "smi", Method: "Hls" },
|
|
||||||
{ Format: "srt", Method: "Hls" },
|
|
||||||
{ Format: "ssa", Method: "Hls" },
|
|
||||||
{ Format: "stl", Method: "Hls" },
|
|
||||||
{ Format: "sub", Method: "Hls" },
|
|
||||||
{ Format: "subrip", Method: "Hls" },
|
|
||||||
{ Format: "subviewer", Method: "Hls" },
|
|
||||||
{ Format: "teletext", Method: "Hls" },
|
|
||||||
{ Format: "text", Method: "Hls" },
|
|
||||||
{ Format: "ttml", Method: "Hls" },
|
|
||||||
{ Format: "vplayer", Method: "Hls" },
|
|
||||||
{ Format: "vtt", Method: "Hls" },
|
|
||||||
{ Format: "webvtt", Method: "Hls" },
|
|
||||||
|
|
||||||
// Image based subs use encode.
|
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
|
||||||
{ Format: "pgs", Method: "Encode" },
|
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
|
||||||
{ Format: "xsub", Method: "Encode" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user