Compare commits

..

66 Commits

Author SHA1 Message Date
Fredrik Burmester
ef851ae3f5 chore: debugging counter 2025-02-21 13:29:40 +01:00
Fredrik Burmester
349899d840 Merge branch 'develop' into feat/recently-added-notifications 2025-02-21 12:06:00 +01:00
Fredrik Burmester
e1561dbd82 fix: return NewData for initial run 2025-02-21 11:26:32 +01:00
tkymmm
b0c5255bd7 feat: add japanese translations (#552) 2025-02-21 11:09:36 +01:00
Fredrik Burmester
e217fcff36 fix: refactor settings location for notifications 2025-02-21 10:49:26 +01:00
Fredrik Burmester
b402cf7f10 fix: add return type 2025-02-21 10:49:14 +01:00
Fredrik Burmester
f3b77b8547 fix: return correct type for notifications to work 2025-02-21 10:49:02 +01:00
Fredrik Burmester
c0643f564d fix: include count 2025-02-20 21:42:34 +01:00
Fredrik Burmester
893cedcf36 Merge branch 'develop' into feat/recently-added-notifications 2025-02-20 20:34:40 +01:00
Fredrik Burmester
69db54a66c fix: include series, increase to 30 items 2025-02-20 20:34:20 +01:00
Fredrik Burmester
268e93effb fix: time 2025-02-20 20:34:08 +01:00
Fredrik Burmester
8fe0089131 fix: task not registered properly 2025-02-20 20:34:02 +01:00
Fredrik Burmester
82ced0f101 fix: don't rerender function 2025-02-20 17:25:47 +01:00
Fredrik Burmester
5447c36bcd chore 2025-02-20 17:24:03 +01:00
Fredrik Burmester
988eede36f fix: change to 10 min as thie is the default and min value 2025-02-20 17:23:55 +01:00
Fredrik Burmester
a472d48b3b fix: 3 min not seconds interval 2025-02-20 17:21:33 +01:00
Fredrik Burmester
1fd4598ba8 chore 2025-02-20 16:31:32 +01:00
Fredrik Burmester
73dd171987 chore: version bump 2025-02-20 16:30:36 +01:00
Fredrik Burmester
63ea7d128f feat: recently added notifications 2025-02-20 15:08:14 +01:00
Ahmed Sbai
ff35559687 fix: remove unused imports and optimize keepAwake usage in the player (#548) 2025-02-20 11:18:37 +01:00
Fredrik Burmester
5aadd50946 chore 2025-02-20 11:16:10 +01:00
herrrta
63b5ba2112 feat: add upcoming air dates for episodes 2025-02-19 21:49:28 -05:00
herrrta
8b955578a2 fix: Jellyseerr url input 2025-02-19 21:07:50 -05:00
herrrta
1e5c021c93 fix: Reset ios vout when media is not playing 2025-02-19 20:58:39 -05:00
Fredrik Burmester
0b86f56486 fix: spelling 2025-02-19 20:36:51 +01:00
Fredrik Burmester
728b93f4e5 fix: up stale issue time to 90 days, ignore feature requests 2025-02-19 20:36:31 +01:00
Fredrik Burmester
2fc483b24e Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-02-19 20:19:39 +01:00
Fredrik Burmester
fc901bc01e fix: jellyseerr login without password 2025-02-19 20:19:34 +01:00
lostb1t
2b0884b154 Update README.md 2025-02-19 17:14:01 +01:00
lostb1t
307d20e538 Update README.md 2025-02-19 17:13:12 +01:00
Fredrik Burmester
a2f03908f6 fix: remove splashscreen provider and handle loading in jellyfinprovider 2025-02-19 14:44:39 +01:00
Fredrik Burmester
77aef8877e fix: jellyseerr better login 2025-02-19 11:36:47 +01:00
Fredrik Burmester
0cf930d6e1 test: fix controls loading android? 2025-02-19 11:06:57 +01:00
Fredrik Burmester
4b0b949541 fix: button alignment pip 2025-02-19 10:55:29 +01:00
Fredrik Burmester
14b717f985 fix: half screen black on login 2025-02-19 10:54:05 +01:00
Fredrik Burmester
cfbac538f8 chore: refactor for tv stuff 2025-02-19 10:49:18 +01:00
Fredrik Burmester
1ac6b7e3df fix: chromecast not working 2025-02-18 17:56:10 +01:00
Davide Sirico
c9f6e8676b feat: add italian translations (#545) 2025-02-18 16:10:15 +01:00
Fredrik Burmester
5aab1450cd chore 2025-02-18 16:06:35 +01:00
Fredrik Burmester
1e7080a136 chore 2025-02-18 16:06:32 +01:00
Théo FORTIN
993cec4138 feat: remove stale issues (#515) 2025-02-17 16:54:25 +01:00
Ahmed Sbai
6c524499f9 chore: remove async-storage && moved @types/xxx dependencies to dev-deps (#538) 2025-02-17 16:54:12 +01:00
Maarten
b3463ffdfc feat: add dutch translations (#539)
Co-authored-by: Maarten Schroeven <maarten.schroeven@ae.be>
2025-02-17 16:53:10 +01:00
Edmond
50942b44f1 feat: Add Chinese (Traditional) Translation (#522) 2025-02-17 16:52:58 +01:00
Mustafa
f602f8919f fix: translation de.json grammar (#516) 2025-02-17 16:52:44 +01:00
herrrta
0e86d8a00f fix: IOS video player black screens pt2
- Looks like re-adding subview was not enough. We have to toggle the video tracks selection and play the media to trigger the re-render
2025-02-16 15:05:32 -05:00
Adrián
56b1e1977c fix: Change too long texts in the Spanish translation (#535) 2025-02-16 13:22:13 +01:00
herrrta
30e23b9079 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 22:18:16 -05:00
herrrta
d83ecb881b fix: Android PiP support fully working
- fixed black screen on re-entering
- ensured screen stays alive when video is playing
- PiP button states now reflect media status
2025-02-15 15:11:57 -05:00
Fredrik Burmester
4c14c08b35 fix: move from react-native-video -> VLC for transcoded streams (#529)
Co-authored-by: Alex Kim <alexkim5682@gmail.com>
2025-02-16 07:10:36 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
Fredrik Burmester
33a2be24f4 fix: hidden be default ios 2025-02-15 12:00:58 +01:00
Fredrik Burmester
e8b0d52515 feat: change to native searchbar on android 2025-02-15 11:59:17 +01:00
Fredrik Burmester
9faa0de2d6 chore: bump version 2025-02-15 11:48:54 +01:00
Fredrik Burmester
221155d002 fix: deps 2025-02-15 11:34:50 +01:00
Fredrik Burmester
4a37e17324 chore 2025-02-14 13:28:06 +01:00
Fredrik Burmester
52b2a3418e fix: wrong deps 2025-02-13 10:35:58 +01:00
Fredrik Burmester
2753b243e5 chore: remove yarn lock file 2025-02-13 10:33:56 +01:00
Fredrik Burmester
f22b356b7c feat: turkish translations 2025-02-13 10:33:41 +01:00
Fredrik Burmester
d8ba5af8d9 chore: remove old patch 2025-02-13 10:33:34 +01:00
herrrta
505ef39ee7 ios VLCKit 4.0 & All platform PiP support 2025-02-12 23:21:24 -05:00
Théo FORTIN
e71d5cc176 feat: Add default quality setting (#509) 2025-02-12 08:32:26 +01:00
Fredrik Burmester
74e57bbd88 fix: add contributor avatars to readme (#512) 2025-02-12 08:31:55 +01:00
Fredrik Burmester
76eaeb9820 chore 2025-02-12 08:31:38 +01:00
Fredrik Burmester
9a70f98dd5 chore 2025-02-12 08:25:36 +01:00
herrrta
f28f1d8736 Fix android discover page crash 2025-02-11 10:16:36 -05:00
84 changed files with 8184 additions and 12270 deletions

View File

@@ -43,6 +43,9 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- 0.24.0
- 0.23.0

39
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Handle Stale Issues
on:
schedule:
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
# Issue specific settings
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: |
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
If this issue is still relevant, please leave a comment to keep it open.
Otherwise, it will be closed in 7 days if no further activity occurs.
Thank you for your contributions!
close-issue-message: |
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
# Pull request settings (disabled)
days-before-pr-stale: -1
days-before-pr-close: -1
# Other settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 100
exempt-issue-labels: "Roadmap v1,help needed,enhancement"

3
.gitignore vendored
View File

@@ -41,4 +41,5 @@ credentials.json
.vscode/
.idea/
.ruby-lsp
.ruby-lsp
modules/hls-downloader/android/build

View File

@@ -85,9 +85,9 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed.
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
For the TV version suffix the npm commands with `:tv`.

View File

@@ -2,24 +2,19 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.25.0",
"version": "0.27.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"jsEngine": "hermes",
"assetBundlePatterns": [
"**/*"
],
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": [
"audio",
"fetch"
],
"UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@@ -36,7 +31,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 50,
"versionCode": 53,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -111,21 +106,11 @@
}
}
],
[
"react-native-bottom-tabs"
],
[
"./plugins/withChangeNativeAndroidTextToWhite.js"
],
[
"./plugins/withGoogleCastActivity.js"
],
[
"./plugins/withTrustLocalCerts.js"
],
[
"./plugins/withGradleProperties.js"
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{
@@ -155,4 +140,4 @@
},
"newArchEnabled": false
}
}
}

View File

@@ -1,498 +1,5 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "@/providers/SplashScreenProvider";
import { HomeIndex } from "@/components/settings/HomeIndex";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
if (!Platform.isTV) {
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
}
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
// show splash screen until query loaded
useSplashScreenLoading(l1);
const splashScreenVisible = useSplashScreenVisible();
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
// this spinner should only show up, when user navigates here
// on launch the splash screen is used for loading
if (l1 && !splashScreenVisible)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
}
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
export default function page() {
return <HomeIndex />;
}

View File

@@ -1,8 +1,9 @@
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
@@ -10,20 +11,26 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import React, { useCallback, useEffect, useMemo } from "react";
import {
ScrollView,
StyleSheet,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
import * as TaskManager from "expo-task-manager";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { RecentlyAddedNotificationsSettings } from "@/components/settings/RecentlyAddedNotifications";
export default function settings() {
const router = useRouter();
@@ -72,7 +79,7 @@ export default function settings() {
<OtherSettings />
{!Platform.isTV && <DownloadSettings />}
<DownloadSettings />
<PluginSettings />
@@ -94,7 +101,7 @@ export default function settings() {
/>
</ListGroup>
<View className="mb-4">
<View className="">
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
@@ -109,7 +116,21 @@ export default function settings() {
</ListGroup>
</View>
<StorageSettings />
<RecentlyAddedNotificationsSettings />
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: "white",
overflow: "hidden",
marginVertical: 16,
opacity: 0.3,
}}
></View>
<View className="">
<StorageSettings />
</View>
</View>
</ScrollView>
);

View File

@@ -19,7 +19,7 @@ export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { personId } = local as { personId: string };
@@ -32,15 +32,6 @@ export default function page() {
enabled: !!jellyseerrApi && !!personId,
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() =>
uniqBy(orderBy(

View File

@@ -38,9 +38,18 @@ export default function SearchLayout() {
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
<Stack.Screen
name="jellyseerr/person/[personId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/company/[companyId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/genre/[genreId]"
options={commonScreenOptions}
/>
</Stack>
);
}

View File

@@ -50,7 +50,7 @@ export default function search() {
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 [search, setSearch] = useState<string>("");
@@ -122,18 +122,17 @@ export default function search() {
const navigation = useNavigation();
useLayoutEffect(() => {
if (Platform.OS === "ios")
navigation.setOptions({
headerSearchBarOptions: {
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
navigation.setOptions({
headerSearchBarOptions: {
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
});
hideWhenScrolling: false,
autoFocus: true,
},
});
}, [navigation]);
const { data: movies, isFetching: l1 } = useQuery({
@@ -210,19 +209,12 @@ export default function search() {
paddingRight: insets.right,
}}
>
<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>
)}
<View
className="flex flex-col"
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>

View File

@@ -55,7 +55,9 @@ export default function TabLayout() {
<NativeTabs
sidebarAdaptable={false}
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarStyle={{
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
>

View File

@@ -36,15 +36,6 @@ export default function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);

View File

@@ -3,12 +3,11 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
@@ -18,20 +17,18 @@ const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
@@ -40,19 +37,12 @@ import React, {
useState,
useEffect,
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() {
console.log("Direct Player");
@@ -60,6 +50,7 @@ export default function page() {
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -67,6 +58,7 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -132,57 +124,80 @@ export default function page() {
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const [stream, setStream] = useState<{
mediaSource: MediaSourceInfo;
url: string;
sessionId: string | undefined;
} | null>(null);
const [isLoadingStream, setIsLoadingStream] = useState(true);
const [isErrorStream, setIsErrorStream] = useState(false);
const url = await getDownloadedFileUrl(data.item.Id!);
useEffect(() => {
const fetchStream = async () => {
setIsLoadingStream(true);
setIsErrorStream(false);
if (item)
return {
mediaSource: data.mediaSource,
url,
sessionId: undefined,
};
try {
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) {
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({
api,
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,
});
fetchStream();
}, [itemId, mediaSourceId]);
const togglePlay = useCallback(async () => {
if (!api) return;
@@ -190,37 +205,21 @@ export default function page() {
lightHapticFeedback();
if (isPlaying) {
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 {
videoRef.current?.play();
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: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
}
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
}
}, [
isPlaying,
@@ -232,13 +231,13 @@ export default function page() {
subtitleIndex,
mediaSourceId,
offline,
progress.value,
progress,
]);
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.value);
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
@@ -256,25 +255,9 @@ export default function page() {
videoRef.current?.stop();
}, [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(
async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
@@ -282,7 +265,7 @@ export default function page() {
setIsBuffering(false);
}
progress.value = currentTime;
progress.set(currentTime);
if (offline) return;
@@ -301,7 +284,7 @@ export default function page() {
playSessionId: stream.sessionId,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
);
useWebSocket({
@@ -311,16 +294,23 @@ export default function page() {
offline,
});
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
if (!Platform.isTV) await activateKeepAwakeAsync()
return;
}
if (state === "Paused") {
setIsPlaying(false);
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
@@ -340,98 +330,71 @@ export default function page() {
: 0;
}, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
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 =
stream?.mediaSource.MediaStreams?.filter(
(audio: { Type: string }) => audio.Type === "Audio"
(audio) => audio.Type === "Audio"
) || [];
const chosenAudioTrack = allAudio.find(
(audio: { Index: number | undefined }) => audio.Index === audioIndex
const allSubs =
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
if (!bitrateValue) {
// If Subtitle is embedded we can use the position to select it straight away.
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
// If Subtitle is external we need to pass the URL to the player.
externalTrack = {
name: chosenSubtitleTrack.DisplayTitle || "",
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
};
}
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
const insets = useSafeAreaInsets();
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
const insets = useSafeAreaInsets();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
if (isErrorItem || isErrorStream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
@@ -455,18 +418,18 @@ export default function page() {
<VlcPlayerView
ref={videoRef}
source={{
uri: stream.url,
uri: stream?.url || "",
autoplay: true,
isNetwork: true,
startPosition,
externalTrack,
externalSubtitles,
initOptions,
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadStart={() => {}}
onPipStarted={onPipStarted}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
@@ -480,7 +443,7 @@ export default function page() {
}}
/>
</View>
{videoRef.current && (
{videoRef.current && !isPipStarted && isMounted === true ? (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -496,6 +459,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
@@ -509,26 +473,7 @@ export default function page() {
stop={stop}
isVlc
/>
)}
) : null}
</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;
}

View File

@@ -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;

View File

@@ -1,22 +1,24 @@
import "@/augmentations";
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getServerUrlFromStorage,
getTokenFromStorage,
getUserFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import {
BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_RECENTLY_ADDED,
registerBackgroundFetchAsyncRecentlyAdded,
unregisterBackgroundFetchAsyncRecentlyAdded,
} from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
@@ -32,21 +34,22 @@ const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { I18nextProvider, useTranslation } from "react-i18next";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
import { Jellyfin } from "@jellyfin/sdk";
import { fetchAndStoreRecentlyAdded } from "@/utils/recently-added-notifications";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -58,6 +61,15 @@ if (!Platform.isTV) {
});
}
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional.
SplashScreen.setOptions({
duration: 500,
fade: true,
});
function useNotificationObserver() {
if (Platform.isTV) return;
@@ -94,6 +106,30 @@ function useNotificationObserver() {
}
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, async () => {
const token = getTokenFromStorage();
const url = getServerUrlFromStorage();
const user = getUserFromStorage();
const c = storage.getNumber("notification_send_for_item_ids.count");
storage.set("notification_send_for_item_ids.count", (c || 0) + 1);
console.log(
"TaskManager ~ trigger ~ recently added notifications:",
token,
url,
user?.Id
);
if (!token || !url || !user?.Id) return;
const result = await fetchAndStoreRecentlyAdded(user.Id, url, token);
if (!result) return BackgroundFetch.BackgroundFetchResult.NoData;
return BackgroundFetch.BackgroundFetchResult.NewData;
});
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
@@ -224,17 +260,15 @@ export default function RootLayout() {
Appearance.setColorScheme("dark");
return (
<SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
);
}
@@ -261,15 +295,38 @@ function Layout() {
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => {
checkAndRequestPermissions();
}, []);
const checkStatusAsync = async () => {
if (Platform.isTV) return;
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(
BACKGROUND_FETCH_TASK_RECENTLY_ADDED
);
};
useEffect(() => {
(async () => {
const isRegistered = await checkStatusAsync();
if (settings.recentlyAddedNotifications === false && isRegistered) {
console.log("unregisterBackgroundFetchAsyncRecentlyAdded");
unregisterBackgroundFetchAsyncRecentlyAdded();
} else if (
settings.recentlyAddedNotifications === true &&
!isRegistered
) {
console.log("registerBackgroundFetchAsyncRecentlyAdded");
registerBackgroundFetchAsyncRecentlyAdded();
}
})();
}, [settings.recentlyAddedNotifications]);
useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation
if (settings.autoRotate === true) {
@@ -303,16 +360,6 @@ function Layout() {
}, []);
}
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useSplashScreenLoading(!loaded);
if (!loaded) {
return null;
}
return (
<QueryClientProvider client={queryClient}>
<JobQueueProvider>
@@ -324,7 +371,7 @@ function Layout() {
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack initialRouteName="(auth)/(tabs)">
<Stack.Screen
name="(auth)/(tabs)"
options={{

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -19,17 +19,20 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod";
import { t } from 'i18next';
import { t } from "i18next";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),});
username: z.string().min(1, t("login.username_required")),
});
const Login: React.FC = () => {
const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const {
apiUrl: _apiUrl,
@@ -37,6 +40,8 @@ const CredentialsSchema = z.object({
password: _password,
} = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
@@ -47,10 +52,11 @@ const CredentialsSchema = z.object({
password: _password,
});
/**
* A way to auto login based on a link
*/
useEffect(() => {
(async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) {
setServer({
address: _apiUrl,
@@ -66,7 +72,6 @@ const CredentialsSchema = z.object({
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
@@ -79,15 +84,17 @@ const CredentialsSchema = z.object({
className="flex flex-row items-center"
>
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
<Text className="ml-2 text-purple-600">
{t("login.change_server")}
</Text>
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
Keyboard.dismiss();
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
@@ -98,15 +105,16 @@ const CredentialsSchema = z.object({
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured")
);
}
} finally {
setLoading(false);
}
};
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/**
* Checks the availability and validity of a Jellyfin server URL.
*
@@ -180,14 +188,21 @@ const CredentialsSchema = z.object({
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
{
text: t("login.got_it"),
},
]);
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
]
);
}
} catch (error) {
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect")
);
}
};
@@ -201,16 +216,18 @@ const CredentialsSchema = z.object({
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : t("login.login_title")}
</>
</Text>
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</>
</Text>
<Text className="text-xs text-neutral-400">
{api.basePath}
</Text>
@@ -220,7 +237,6 @@ const CredentialsSchema = z.object({
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
@@ -300,7 +316,9 @@ const CredentialsSchema = z.object({
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
onPress={async () => {
await handleConnect(serverURL);
}}
className="w-full grow"
>
{t("server.connect_button")}

2927
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -66,7 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
});
@@ -194,10 +194,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
for (const item of items) {
if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({

View File

@@ -16,7 +16,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
@@ -118,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [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;
return (
@@ -239,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(

View File

@@ -73,11 +73,7 @@ export const PlayButton: React.FC<Props> = ({
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
router.push(`/player/direct-player?${q}`);
},
[router]
);
@@ -119,96 +115,100 @@ export const PlayButton: React.FC<Props> = ({
case 0:
if (!Platform.isTV) {
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS)
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
else {
} else {
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
}
});
}

View File

@@ -58,11 +58,7 @@ export const PlayButton: React.FC<Props> = ({
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
router.push(`/player/direct-player?${q}`);
},
[router]
);

View File

@@ -4,40 +4,31 @@ import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
isTranscoding?: boolean;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
isTranscoding,
...props
}) => {
if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
() => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected]
);
if (subtitleStreams.length === 0) return null;
if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation();

View File

@@ -19,7 +19,7 @@ interface Release {
type: number;
}
const dateOpts: Intl.DateTimeFormatOptions = {
export const dateOpts: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
@@ -50,18 +50,9 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const { jellyseerrUser } = useJellyseerr();
const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { t } = useTranslation();
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const releases = useMemo(
() =>
(details as MovieDetails)?.releases?.results.find(

View File

@@ -48,7 +48,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
id={item.id.toString()}
title={item.name}
colors={[]}
colors={['transparent', 'transparent']}
contentFit={"cover"}
url={jellyseerrApi?.imageProxy(
item.backdrops?.[0],

View File

@@ -23,6 +23,8 @@ import { Loader } from "../Loader";
import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {dateOpts} from "@/components/jellyseerr/DetailFacts";
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
@@ -52,26 +54,51 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
const airDate = item.airDate;
if (airDate) {
let airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(
`${locale}-${region}`,
dateOpts
);
}
}
}, [item]);
return (
<View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? (
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
<>
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
{upcomingAirDate && (
<View className="absolute justify-center bottom-0 right-0.5 items-center">
<View className="rounded-full bg-purple-600/30 p-1">
<Text className="text-center text-xs" style={textShadowStyle.shadow}>
{upcomingAirDate}
</Text>
</View>
</View>
)}
</>
) : (
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
<Ionicons

View File

@@ -62,7 +62,7 @@ export default function DownloadSettings({ ...props }) {
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.downloads.methods")}
{t("home.settings.downloads.download_method")}
</DropdownMenu.Label>
<DropdownMenu.Item
key="1"

View File

@@ -0,0 +1,5 @@
import React from "react";
export default function DownloadSettings({ ...props }) {
return <></>;
}

View File

@@ -0,0 +1,485 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export const HomeIndex = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -26,9 +26,6 @@ export const JellyseerrSettings = () => {
const [user] = useAtom(userAtom);
const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
@@ -39,11 +36,16 @@ export const JellyseerrSettings = () => {
const loginToJellyseerrMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login");
}
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
);
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
},
onSuccess: (user) => {
setJellyseerrUser(user);
@@ -57,31 +59,11 @@ export const JellyseerrSettings = () => {
},
});
const testJellyseerrServerUrlMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || jellyseerrApi) return null;
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.test();
},
onSuccess: (result) => {
if (result && result.isValid) {
if (result.requiresPass) {
setPromptForJellyseerrPass(true);
} else {
updateSettings({ jellyseerrServerUrl });
}
} else {
setPromptForJellyseerrPass(false);
setjellyseerrServerUrl(undefined);
clearAllJellyseerData();
}
},
});
const clearData = () => {
clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
});
};
@@ -92,34 +74,46 @@ export const JellyseerrSettings = () => {
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
title={t(
"home.settings.plugins.jellyseerr.total_media_requests"
)}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
value={
jellyseerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
value={
jellyseerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
</ListGroup>
<View className="p-4">
<Button color="red" onPress={clearData}>
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
{t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
)}
</Button>
</View>
</>
@@ -128,15 +122,20 @@ export const JellyseerrSettings = () => {
<Text className="text-xs text-red-600 mb-2">
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
<Text className="font-bold mb-1">
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600">
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
className="border border-neutral-800 mb-2"
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder"
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
@@ -145,40 +144,20 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="URL"
onChangeText={setjellyseerrServerUrl}
editable={!testJellyseerrServerUrlMutation.isPending}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={testJellyseerrServerUrlMutation.isPending}
disabled={testJellyseerrServerUrlMutation.isPending}
color={promptForJellyseerrPass ? "red" : "purple"}
className="h-12 mt-2"
onPress={() => {
if (promptForJellyseerrPass) {
clearData();
return;
}
testJellyseerrServerUrlMutation.mutate();
}}
style={{
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button>
<View
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
style={{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<View>
<Text className="font-bold mb-2">
{t("home.settings.plugins.jellyseerr.password")}
</Text>
<Input
className="border border-neutral-800"
autoFocus={true}
focusable={true}
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name }
)}
value={jellyseerrPassword}
keyboardType="default"
secureTextEntry={true}
@@ -186,10 +165,7 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="password"
onChangeText={setJellyseerrPassword}
editable={
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={loginToJellyseerrMutation.isPending}

View File

@@ -1,5 +1,6 @@
import { Platform } from "react-native";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
@@ -21,6 +22,7 @@ import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
export const OtherSettings: React.FC = () => {
const router = useRouter();
@@ -163,6 +165,32 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
<ListItem
title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked}
>
<Dropdown
data={BITRATES}
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.defaultBitrate?.key}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.default_quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/>
</ListItem>
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}

View File

@@ -0,0 +1,56 @@
import settings from "@/app/(auth)/(tabs)/(home)/settings";
import { Switch, View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import React, { useCallback, useEffect, useMemo } from "react";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import * as TaskManager from "expo-task-manager";
import * as BackgroundFetch from "expo-background-fetch";
import { useMMKVNumber } from "react-native-mmkv";
export const RecentlyAddedNotificationsSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const clearRecentlyAddedNotifications = useCallback(() => {
storage.delete(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
}, []);
const recentlyAddedNotificationsItemIds = useMemo(() => {
const s = storage.getString(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
if (!s) return [] as string[];
try {
const t: string[] = JSON.parse(s);
return t;
} catch (e) {
throw new Error("Failed to parse recently added notifications item ids");
}
}, []);
const [triggerCount, setTriggerCount] = useMMKVNumber(
"notification_send_for_item_ids.count"
);
return (
<View className="mb-4" {...props}>
<ListGroup title={"Recently Added Notifications"}>
<ListItem title={"Recently added notifications"}>
<Switch
value={settings.recentlyAddedNotifications}
onValueChange={(recentlyAddedNotifications) =>
updateSettings({ recentlyAddedNotifications })
}
/>
</ListItem>
<ListItem title={`Trigger count (${triggerCount || 0})`} />
<ListItem
textColor="red"
onPress={clearRecentlyAddedNotifications}
title={`Reset recently added notifications (${recentlyAddedNotificationsItemIds.length})`}
/>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,453 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export const SettingsIndex = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const insets = useSafeAreaInsets();
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -8,6 +8,9 @@ import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useCallback, useMemo } from "react";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
@@ -41,6 +44,7 @@ export const StorageSettings = () => {
return ((value / total) * 100).toFixed(2);
};
return (
<View>
<View className="flex flex-col gap-y-1">
@@ -109,6 +113,7 @@ export const StorageSettings = () => {
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>
);
};

View File

@@ -24,7 +24,7 @@ import {
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import {
BaseItemDto,
MediaSourceInfo,
@@ -35,7 +35,12 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import { debounce } from "lodash";
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 {
runOnJS,
@@ -49,8 +54,7 @@ import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
@@ -75,6 +79,7 @@ interface Props {
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -91,6 +96,7 @@ const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
@@ -212,13 +218,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
stop();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => {
@@ -250,13 +253,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
stop();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback(
@@ -413,13 +413,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
stop();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
@@ -499,6 +496,15 @@ export const Controls: React.FC<Props> = ({
);
}, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
stop();
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
};
return (
<ControlProvider
item={item}
@@ -542,15 +548,25 @@ export const Controls: React.FC<Props> = ({
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
<DropdownView showControls={showControls} />
</VideoProvider>
</View>
<View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
<TouchableOpacity
onPress={startPictureInPicture}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<MaterialIcons
name="picture-in-picture"
size={24}
color="white"
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={() => {
@@ -592,13 +608,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity>
{/* )} */}
<TouchableOpacity
onPress={async () => {
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
onPress={onClose}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />

View File

@@ -9,12 +9,15 @@ import React, {
useState,
ReactNode,
useEffect,
useMemo,
} from "react";
import { useControlContext } from "./ControlContext";
import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router";
interface VideoContextProps {
audioTracks: TrackInfo[] | null;
subtitleTracks: TrackInfo[] | null;
audioTracks: Track[] | null;
subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -45,30 +48,155 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleURL,
setAudioTrack,
}) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const ControlContext = useControlContext();
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(() => {
const fetchTracks = async () => {
if (
getSubtitleTracks &&
(subtitleTracks === null || subtitleTracks.length === 0)
) {
const subtitles = await getSubtitleTracks();
console.log("Getting embeded subtitles...", subtitles);
if (getSubtitleTracks) {
const subtitleData = await getSubtitleTracks();
let textSubIndex = 0;
const subtitles: Track[] = allSubs?.map((sub) => {
// 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);
}
if (
getAudioTracks &&
(audioTracks === null || audioTracks.length === 0)
) {
const audio = await getAudioTracks();
setAudioTracks(audio);
const audioData = await getAudioTracks();
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();

View File

@@ -1,67 +1,21 @@
import React, { useMemo, useState } from "react";
import { View, TouchableOpacity, Platform } from "react-native";
import React from "react";
import { 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 { EmbeddedSubtitle, ExternalSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { router, useLocalSearchParams } from "expo-router";
import { useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
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 {
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 { subtitleTracks, audioTracks } = videoContext;
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string;
@@ -98,21 +52,11 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
{subtitleTracks?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => {
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(),
});
}}
onValueChange={() => sub.setTrack()}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
@@ -136,12 +80,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
onValueChange={() => track.setTrack()}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
@@ -155,4 +94,4 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
);
};
export default DropdownViewDirect;
export default DropdownView;

View File

@@ -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;

View File

@@ -13,7 +13,14 @@ type ExternalSubtitle = {
type TranscodedSubtitle = {
name: string;
index: number;
deliveryUrl: string;
IsTextSubtitleStream: boolean;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
type Track = {
name: string;
index: number;
setTrack: () => void;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };

View File

@@ -32,20 +32,20 @@
}
},
"production": {
"channel": "0.25.0",
"channel": "0.27.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.25.0",
"channel": "0.27.0",
"android": {
"buildType": "apk",
"image": "latest"
}
},
"production-apk-tv": {
"channel": "0.25.0",
"channel": "0.27.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -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

View File

@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
(x) => x.Type === "Audio"
)?.Index;
// 4. Get default bitrate
const bitrate = BITRATES[0];
// 4. Get default bitrate from settings or fallback to max
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
return {
defaultAudioIndex:

View File

@@ -449,12 +449,23 @@ export const useJellyseerr = () => {
);
};
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const jellyseerrLocale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
return {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrResult,
jellyseerrRegion,
jellyseerrLocale,
requestMedia,
};
};

12
i18n.ts
View File

@@ -5,7 +5,11 @@ import de from "./translations/de.json";
import en from "./translations/en.json";
import es from "./translations/es.json";
import fr from "./translations/fr.json";
import it from "./translations/it.json";
import ja from "./translations/ja.json";
import nl from "./translations/nl.json";
import sv from "./translations/sv.json";
import zhTW from './translations/zh-TW.json';
import { getLocales } from "expo-localization";
export const APP_LANGUAGES = [
@@ -13,7 +17,11 @@ export const APP_LANGUAGES = [
{ label: "English", value: "en" },
{ label: "Español", value: "es" },
{ label: "Français", value: "fr" },
{ label: "Italiano", value: "it" },
{ label: "日本語", value: "ja" },
{ label: "Nederlands", value: "nl" },
{ label: "Svenska", value: "sv" },
{ label: "繁體中文", value: "zh-TW" },
];
i18n.use(initReactI18next).init({
@@ -23,7 +31,11 @@ i18n.use(initReactI18next).init({
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
it: { translation: it },
ja: { translation: ja },
nl: { translation: nl },
sv: { translation: sv },
"zh-TW": { translation: zhTW },
},
lng: getLocales()[0].languageCode || "en",

View File

@@ -0,0 +1,38 @@
package expo.modules.vlcplayer
import expo.modules.core.interfaces.ReactActivityLifecycleListener
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
// https://docs.expo.dev/modules/android-lifecycle-listeners/
object VLCManager: ReactActivityLifecycleListener {
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
// listeners.forEach {
// it.onCreate(activity, savedInstanceState)
// }
// }
//
// override fun onResume(activity: Activity?) {
// listeners.forEach {
// it.onResume(activity)
// }
// }
//
// override fun onPause(activity: Activity?) {
// listeners.forEach {
// it.onPause(activity)
// }
// }
//
// override fun onUserLeaveHint(activity: Activity?) {
// listeners.forEach {
// it.onUserLeaveHint(activity)
// }
// }
//
// override fun onDestroy(activity: Activity?) {
// listeners.forEach {
// it.onDestroy(activity)
// }
// }
}

View File

@@ -1,5 +1,6 @@
package expo.modules.vlcplayer
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -7,6 +8,18 @@ class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source)
@@ -26,9 +39,14 @@ class VlcPlayerModule : Module() {
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView ->
view.play()
}

View File

@@ -1,23 +1,49 @@
package expo.modules.vlcplayer
import android.R
import android.app.Activity
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.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.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import android.view.View
import androidx.annotation.RequiresApi
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import android.net.Uri
import androidx.lifecycle.OnLifecycleEvent
import expo.modules.core.interfaces.ReactActivityLifecycleListener
import expo.modules.core.logging.LogHandlers
import expo.modules.core.logging.Logger
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
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, ReactActivityLifecycleListener {
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
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 mediaPlayer: MediaPlayer? = null
@@ -26,10 +52,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null
private var timeLeft: Long? = null
private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher()
private val onVideoLoadEnd by EventDispatcher()
private val onPipStarted by EventDispatcher()
private var startPosition: Int? = 0
private var isMediaReady: Boolean = false
@@ -44,23 +72,146 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
handler.postDelayed(this, updateInterval)
}
}
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
private val remoteActionFilter = IntentFilter()
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> {
if (isPaused) play() else pause()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.setPictureInPictureParams(getPipParams()!!)
}
}
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
}
}
}
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
log.debug("Exiting PiP")
timeLeft = mediaPlayer?.time
pause()
// Setting the media after reattaching the view allows for a fast video view render
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.media = media
mediaPlayer?.play()
timeLeft?.let { mediaPlayer?.time = it }
mediaPlayer?.pause()
}
}
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
init {
VLCManager.listeners.add(this)
setupView()
setupPiP()
}
private fun setupView() {
Log.d("VlcPlayerView", "Setting up view")
log.debug("Setting up view")
setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
videoLayout.keepScreenOn = true
addView(videoLayout)
Log.d("VlcPlayerView", "View setup complete")
log.debug("View setup complete")
}
private fun setupPiP() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentActivity.registerReceiver(
actionReceiver,
remoteActionFilter,
Context.RECEIVER_NOT_EXPORTED
)
}
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener(pipChangeListener)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() {
actions.clear()
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(
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
else Icon.createWithResource(context, R.drawable.ic_media_pause),
"Play",
"Play Video",
PendingIntent.getBroadcast(
context,
if (isPaused) 0 else 1,
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
)
)
)
)
}
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>) {
log.debug("setting source $source")
if (hasSource) {
log.debug("Source already set. Resuming")
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return
@@ -85,12 +236,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this)
Log.d("VlcPlayerView", "Loading network file: $uri")
log.debug("Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
log.debug("Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions)
// Apply subtitle options
@@ -107,11 +258,17 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
hasSource = true
if (autoplay) {
Log.d("VlcPlayerView", "Playing...")
log.debug("Playing...")
play()
}
}
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentActivity.enterPictureInPictureMode(getPipParams()!!)
}
}
fun play() {
mediaPlayer?.play()
isPaused = false
@@ -151,9 +308,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun getAudioTracks(): List<Map<String, Any>>? {
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription ->
@@ -177,19 +332,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// Debug statement to print the result
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
log.debug("Subtitle Tracks: $subtitleTracks")
return subtitleTracks
}
fun setSubtitleURL(subtitleURL: String, name: String) {
println("Setting subtitle URL: $subtitleURL, name: $name")
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
override fun onDetachedFromWindow() {
println("onDetachedFromWindow")
log.debug("onDetachedFromWindow")
super.onDetachedFromWindow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
currentActivity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
}
currentActivity.unregisterReceiver(actionReceiver)
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
VLCManager.listeners.clear()
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
@@ -202,6 +370,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
override fun onEvent(event: MediaPlayer.Event) {
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
when (event.type) {
MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused,
@@ -223,35 +392,27 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false
"error" to false,
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
)
// Todo: make enum - string to prevent this when statement from becoming exhaustive
when (currentState) {
MediaPlayer.Event.Playing -> {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
MediaPlayer.Event.Playing ->
stateInfo["state"] = "Playing"
}
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
MediaPlayer.Event.Paused ->
stateInfo["state"] = "Paused"
}
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
MediaPlayer.Event.Buffering ->
stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo);
}
MediaPlayer.Event.Opening -> {
Log.d("VlcPlayerView", "player.state ~ opening")
MediaPlayer.Event.Opening ->
stateInfo["state"] = "Opening"
}
}
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying
@@ -283,4 +444,23 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
));
}
}
override fun onPause(activity: Activity?) {
log.debug("Pausing activity...")
}
override fun onResume(activity: Activity?) {
log.debug("Resuming activity...")
if (isPaused) play()
}
}
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")
}

View File

@@ -1,7 +1,8 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
"modules": ["VlcPlayerModule"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]

View File

@@ -1,7 +1,6 @@
import {
NativeModulesProxy,
EventEmitter,
Subscription,
EventSubscription,
} from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule";
@@ -19,13 +18,11 @@ import {
VlcPlayerViewRef,
} from "./src/VlcPlayer.types";
const emitter = new EventEmitter(
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
);
const emitter = new EventEmitter(VlcPlayerModule);
export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged",
listener
@@ -34,7 +31,7 @@ export function addPlaybackStateListener(
export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart",
listener
@@ -43,7 +40,7 @@ export function addVideoLoadStartListener(
export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange",
listener
@@ -52,7 +49,7 @@ export function addVideoStateChangeListener(
export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
}

View File

@@ -0,0 +1,32 @@
import ExpoModulesCore
protocol SimpleAppLifecycleListener {
func applicationDidEnterBackground() -> Void
func applicationDidEnterForeground() -> Void
}
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
public func applicationDidBecomeActive(_ application: UIApplication) {
// The app has become active.
}
public func applicationWillResignActive(_ application: UIApplication) {
// The app is about to become inactive.
}
public func applicationDidEnterBackground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterBackground()
}
}
public func applicationWillEnterForeground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterForeground()
}
}
public func applicationWillTerminate(_ application: UIApplication) {
// The app is about to terminate.
}
}

View File

@@ -0,0 +1,4 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer'
s.version = '1.0.0'
s.version = '4.0.0a10'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
@@ -10,8 +10,8 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1'
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1'
s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {

View File

@@ -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(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayerView) in
view.play()
}
@@ -69,14 +62,6 @@ public class VlcPlayerModule: Module {
return view.getSubtitleTracks()
}
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
// view.setVideoCropGeometry(geometry)
// }
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
// return view.getVideoCropGeometry()
// }
AsyncFunction("setSubtitleURL") {
(view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)

View File

@@ -1,54 +1,176 @@
import ExpoModulesCore
#if os(tvOS)
import TVVLCKit
#else
import MobileVLCKit
#endif
import UIKit
import VLCKit
import os
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 {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
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 startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
private var externalSubtitles: [[String: String]]?
var hasSource = false
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
setupVLC()
setupNotifications()
VLCManager.shared.listeners.append(self)
}
// MARK: - Setup
private func setupView() {
DispatchQueue.main.async {
self.backgroundColor = .black
self.videoView = UIView()
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 setupVLC() {
vlc.setup(
parent: self,
updatePlayerState: updatePlayerState,
updateVideoProgress: updateVideoProgress
)
}
private func setupNotifications() {
@@ -61,57 +183,71 @@ class VlcPlayerView: ExpoView {
}
// MARK: - Public Methods
func startPictureInPicture() {
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
self.onPipStarted?(["pipStarted": isStarted])
}
self.vlc.pipController?.startPictureInPicture()
}
@objc func play() {
self.mediaPlayer?.play()
self.vlc.player.play()
self.isPaused = false
print("Play")
logger.debug("Play")
}
@objc func pause() {
self.mediaPlayer?.pause()
self.vlc.player.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
let wasPlaying = vlc.player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
if let duration = vlc.player.media?.length.intValue {
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
self.play()
}
vlc.player.time = VLCTime(int: seekTime)
self.updatePlayerState()
// Let mediaPlayerStateChanged handle play state change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if wasPlaying {
self.play()
}
}
} else {
print("Error: Unable to retrieve video duration")
logger.error("Unable to retrieve video duration")
}
}
@objc func setSource(_ source: [String: Any]) {
logger.debug("Setting source...")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
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
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 {
print("Error: Invalid or empty URI")
logger.error("Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
@@ -120,17 +256,13 @@ class VlcPlayerView: ExpoView {
let isNetwork = source["isNetwork"] as? Bool ?? false
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 {
print("Loading network file: \(uri)")
logger.debug("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
print("Loading local file: \(uri)")
logger.debug("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
@@ -138,109 +270,84 @@ class VlcPlayerView: ExpoView {
}
}
print("Debug: Media options: \(mediaOptions)")
logger.debug("Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.vlc.player.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
print("Playing...")
logger.info("Playing...")
self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
}
}
}
@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]]? {
guard let trackNames = mediaPlayer?.audioTrackNames,
let trackIndexes = mediaPlayer?.audioTrackIndexes
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
return vlc.player.audioTracks.enumerated().map {
return ["name": $1.trackName, "index": $0]
}
}
@objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
print(
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
)
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
if trackIndex == -1 {
logger.debug("Disabling all subtitles")
for track in self.vlc.player.textTracks {
track.isSelected = false
}
return
}
let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true;
logger.debug("Current subtitle track index after setting: \(track.trackName)")
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
logger.error("Invalid subtitle URL")
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
if result == 0 {
let internalName = "Track \(self.customSubtitles.count)"
self.customSubtitles.append((internalName: internalName, originalName: name))
logger.debug("Subtitle added with result: \(result) \(internalName)")
} else {
print("Failed to add subtitle")
logger.debug("Failed to add subtitle")
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
if self.vlc.player.textTracks.count == 0 {
return nil
}
let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
guard count > 0 else {
return nil
}
var tracks: [[String: Any]] = []
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])
}
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
if let customSubtitle = customSubtitles.first(where: {
$0.internalName == track.trackName
}) {
return ["name": customSubtitle.originalName, "index": index]
} else {
return ["name": track.trackName, "index": index]
}
}
print("Debug: Subtitle tracks: \(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)")
logger.debug("Subtitle tracks: \(tracks)")
return tracks
}
@objc func stop(completion: (() -> Void)? = nil) {
logger.debug("Stopping media...")
guard !isStopping else {
completion?()
return
@@ -267,125 +374,98 @@ 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) {
// Stop the media player
mediaPlayer?.stop()
vlc.player.stop()
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
videoView?.removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
vlc.getPlayerView().removeFromSuperview()
isStopping = false
completion?()
}
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 durationMs = player.media?.length.intValue ?? 0
let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
logger.debug("Current time: \(currentTimeMs)")
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
private func updatePlayerState() {
let player = self.vlc.player
self.onVideoStateChange?([
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
"isPlaying": player.isPlaying,
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
"state": player.state.description,
])
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization
deinit {
logger.debug("Deinitialization")
performStop()
VLCManager.shared.listeners.removeAll()
}
}
extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
// self?.updateVideoProgress()
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
}
// MARK: - SimpleAppLifecycleListener
extension VlcPlayerView: SimpleAppLifecycleListener {
func applicationDidEnterBackground() {
logger.debug("Entering background")
}
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
func applicationDidEnterForeground() {
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
if !self.vlc.getPlayerView().isDescendant(of: self) {
logger.debug("Player view is missing. Adding back as subview")
self.addSubview(self.vlc.getPlayerView())
}
// Current solution to fixing black screen when re-entering application
if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true }, !self.vlc.isMediaPlaying() {
videoTrack.isSelected = false
videoTrack.isSelectedExclusively = true
self.vlc.player.play()
self.vlc.player.pause()
}
}
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 {
@@ -396,9 +476,7 @@ extension VLCMediaPlayerState {
case .playing: return "Playing"
case .paused: return "Paused"
case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown"
}
}

View File

@@ -24,6 +24,12 @@ export type VideoLoadStartPayload = {
};
};
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
@@ -33,7 +39,7 @@ export type VlcPlayerSource = {
type?: string;
isNetwork?: boolean;
autoplay?: boolean;
externalTrack?: { name: string, DeliveryUrl: string };
externalSubtitles: { name: string; DeliveryUrl: string }[];
initOptions?: any[];
mediaOptions?: { [key: string]: any };
startPosition?: number;
@@ -64,9 +70,11 @@ export type VlcPlayerViewProps = {
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
};
export interface VlcPlayerViewRef {
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;
stop: () => Promise<void>;

View File

@@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture()
},
play: async () => {
await nativeRef.current?.play();
},
@@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoProgress,
onVideoLoadEnd,
onVideoError,
onPipStarted,
...otherProps
} = props;
@@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
/>
);
}

View File

@@ -27,7 +27,6 @@
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "2.1.1",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "^1.2.2",
"@react-navigation/bottom-tabs": "^7.2.0",
@@ -35,9 +34,6 @@
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@tanstack/react-query": "^5.66.0",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"expo": "^52.0.31",
@@ -62,7 +58,7 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-splash-screen": "~0.29.21",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5",
@@ -75,28 +71,28 @@
"nativewind": "^2.0.11",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "npm:react-native-tvos@~0.77.0-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-bottom-tabs": "0.8.6",
"react-native-bottom-tabs": "0.8.7",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4",
"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-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.1.0",
"react-native-ios-utilities": "5.1.1",
"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-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "3.5.1",
"react-native-safe-area-context": "5.2.0",
"react-native-screens": "4.6.0",
"react-native-safe-area-context": "5.1.0",
"react-native-screens": "~4.5.0",
"react-native-svg": "15.11.1",
"react-native-tab-view": "^4.0.5",
"react-native-udp": "^4.1.7",
@@ -115,16 +111,19 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@babel/core": "^7.26.8",
"@react-native-community/cli": "15.1.3",
"@react-native-tvos/config-tv": "^0.1.1",
"@babel/core": "^7.26.8",
"@types/jest": "^29.5.14",
"@types/react": "~19.0.8",
"@types/react": "~18.3.12",
"@types/react-test-renderer": "^19.0.0",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.0.0",
"typescript": "~5.7.3"
"typescript": "~5.7.3",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0"
},
"private": true,
"expo": {
@@ -134,4 +133,4 @@
]
}
}
}
}

File diff suppressed because one or more lines are too long

View 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;

View File

@@ -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;

View File

@@ -18,11 +18,13 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { FileInfo } from "expo-file-system";
import Notifications from "expo-notifications";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -36,11 +38,6 @@ import { useTranslation } from "react-i18next";
import { AppState, AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native";
import { apiAtom } from "./JellyfinProvider";
const BackGroundDownloader = !Platform.isTV
? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader"))
: null;
// import * as Notifications from "expo-notifications";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
export type DownloadedItem = {
item: Partial<BaseItemDto>;
@@ -58,8 +55,6 @@ const DownloadContext = createContext<ReturnType<
> | null>(null);
function useDownloadProvider() {
if (Platform.isTV) return;
const queryClient = useQueryClient();
const { t } = useTranslation();
const [settings] = useSettings();
@@ -747,5 +742,8 @@ export function useDownload() {
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
if (Platform.isTV) {
throw new Error("useDownload is not supported on TVOS");
}
return context;
}

View File

@@ -0,0 +1,107 @@
import { storage } from "@/utils/mmkv";
import { JobStatus } from "@/utils/optimize-server";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { atom, useAtom } from "jotai";
import React, { createContext, useCallback, useContext, useMemo } from "react";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
/**
* Dummy download provider for tvOS
*/
function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const downloadedFiles: DownloadedItem[] = [];
const removeProcess = useCallback(async (id: string) => {}, []);
const startDownload = useCallback(async (process: JobStatus) => {
return null;
}, []);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
return null;
},
[]
);
const deleteAllFiles = async (): Promise<void> => {};
const deleteFile = async (id: string): Promise<void> => {};
const deleteItems = async (items: BaseItemDto[]) => {};
const cleanCacheDirectory = async () => {};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
const appSizeUsage = useMemo(async () => {
return 0;
}, []);
function getDownloadedItem(itemId: string): DownloadedItem | null {
return null;
}
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString("downloadedItemSize-" + itemId);
return size ? parseInt(size) : 0;
}
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
APP_CACHE_DOWNLOAD_DIRECTORY,
cleanCacheDirectory,
};
}
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
return (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -1,5 +1,7 @@
import "@/augmentations";
import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -7,6 +9,7 @@ import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
import { router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { atom, useAtom } from "jotai";
import React, {
createContext,
@@ -17,16 +20,10 @@ import React, {
useMemo,
useState,
} from "react";
import { Platform } from "react-native";
import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info";
import { useTranslation } from "react-i18next";
import { useSettings } from "@/utils/atoms/settings";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "./SplashScreenProvider";
import { Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import uuid from "react-native-uuid";
interface Server {
address: string;
@@ -64,7 +61,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.25.0" },
clientInfo: { name: "Streamyfin", version: "0.27.0" },
deviceInfo: {
name: deviceName,
id,
@@ -88,28 +85,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
] = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({
queryKey: ["user", api],
queryFn: async () => {
if (!api) return null;
const response = await getUserApi(api).getCurrentUser();
if (response.data) setUser(response.data);
return user;
},
enabled: !!api,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: true,
refetchOnMount: true,
refetchOnReconnect: true,
});
const headers = useMemo(() => {
if (!deviceId) return {};
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.25.0"`,
}, DeviceId="${deviceId}", Version="0.27.0"`,
};
}, [deviceId]);
@@ -179,14 +160,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
}, [api, secret, headers]);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useEffect(() => {
(async () => {
await refreshStreamyfinPluginSettings();
})();
}, []);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
const discoverServers = async (url: string): Promise<Server[]> => {
@@ -303,6 +283,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => {
storage.delete("token");
setUser(null);
setApi(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
},
@@ -311,33 +292,44 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
const { isLoading, isFetching } = useQuery({
queryKey: [
"initializeJellyfin",
user?.Id,
api?.basePath,
jellyfin?.clientInfo,
],
queryFn: async () => {
const [loaded, setLoaded] = useState(false);
const [initialLoaded, setInitialLoaded] = useState(false);
useEffect(() => {
if (initialLoaded) {
setLoaded(true);
}
}, [initialLoaded]);
useEffect(() => {
const initializeJellyfin = async () => {
if (!jellyfin) return;
try {
const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage();
const user = getUserFromStorage();
if (serverUrl && token && user?.Id && jellyfin) {
const storedUser = getUserFromStorage();
if (serverUrl && token) {
const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance);
setUser(user);
}
return true;
if (storedUser?.Id) {
setUser(storedUser);
}
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
}
} catch (e) {
console.error(e);
return false;
} finally {
setInitialLoaded(true);
}
},
staleTime: 0,
enabled: !user?.Id || !api || !jellyfin,
});
};
initializeJellyfin();
}, [jellyfin]);
const contextValue: JellyfinContextValue = {
discoverServers,
@@ -349,17 +341,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
initiateQuickConnect,
};
let isLoadingOrFetching = isLoading || isFetching;
useProtectedRoute(user, isLoadingOrFetching);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
// show splash screen until everything loaded
useSplashScreenLoading(isLoadingOrFetching);
const splashScreenVisible = useSplashScreenVisible();
useProtectedRoute(user, loaded);
return (
<JellyfinContext.Provider value={contextValue}>
{/* don't render login page when loading and splash screen visible */}
{isLoadingOrFetching && splashScreenVisible ? undefined : children}
{children}
</JellyfinContext.Provider>
);
};
@@ -371,20 +363,24 @@ export const useJellyfin = (): JellyfinContextValue => {
return context;
};
function useProtectedRoute(user: UserDto | null, loading = false) {
function useProtectedRoute(user: UserDto | null, loaded = false) {
const segments = useSegments();
useEffect(() => {
if (loading) return;
if (loaded === false) return;
console.log("Loaded", user);
const inAuthGroup = segments[0] === "(auth)";
if (!user?.Id && inAuthGroup) {
console.log("Redirected to login");
router.replace("/login");
} else if (user?.Id && !inAuthGroup) {
console.log("Redirected to home");
router.replace("/(auth)/(tabs)/(home)/");
}
}, [user, segments, loading]);
}, [user, segments, loaded]);
}
export function getTokenFromStorage(): string | null {

View File

@@ -1,103 +0,0 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
useRef,
} from "react";
import * as SplashScreen from "expo-splash-screen";
type SplashScreenContextValue = {
registerLoadingComponent: () => () => void;
splashScreenVisible: boolean;
};
const SplashScreenContext = createContext<SplashScreenContextValue | undefined>(
undefined
);
// Prevent splash screen from auto-hiding
void SplashScreen.preventAutoHideAsync();
export const SplashScreenProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [splashScreenVisible, setSplashScreenVisible] = useState(true);
const loadingComponentsCount = useRef(0);
const isHidingRef = useRef(false);
const hideScreenIfNoLoadingComponents = async () => {
if (loadingComponentsCount.current === 0 && !isHidingRef.current) {
try {
isHidingRef.current = true;
await SplashScreen.hideAsync();
setSplashScreenVisible(false);
} catch (error) {
console.warn("Failed to hide splash screen:", error);
} finally {
isHidingRef.current = false;
}
}
};
const registerLoadingComponent = () => {
loadingComponentsCount.current += 1;
return () => {
loadingComponentsCount.current -= 1;
void hideScreenIfNoLoadingComponents();
};
};
const contextValue: SplashScreenContextValue = {
registerLoadingComponent,
splashScreenVisible,
};
return (
<SplashScreenContext.Provider value={contextValue}>
{children}
</SplashScreenContext.Provider>
);
};
/**
* Show the Splash Screen until component is ready to be displayed.
*
* @param isLoading The loading state of the component
*
* ## Usage
* ```
* const isLoading = loadSomething()
* useSplashScreenLoading(isLoading) // splash screen visible until isLoading is false
* ```
*/
export function useSplashScreenLoading(isLoading: boolean) {
const context = useContext(SplashScreenContext);
if (!context) {
throw new Error(
"useSplashScreenLoading must be used within a SplashScreenProvider"
);
}
useEffect(() => {
if (isLoading) {
return context.registerLoadingComponent();
}
}, [isLoading]);
}
/**
* Get the visibility of the Splash Screen.
* @returns the visibility of the Splash Screen
*/
export function useSplashScreenVisible() {
const context = useContext(SplashScreenContext);
if (!context) {
throw new Error(
"useSplashScreenVisible must be used within a SplashScreenProvider"
);
}
return context.splashScreenVisible;
}

View File

@@ -132,7 +132,8 @@
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
"hide_libraries": "Bibliotheken ausblenden",
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
"disable_haptic_feedback": "Haptisches Feedback deaktivieren"
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
"default_quality": "Standardqualität"
},
"downloads": {
"downloads_title": "Downloads",
@@ -354,7 +355,7 @@
"index": "Index:"
},
"item_card": {
"next_up": "Als nächstes",
"next_up": "Als Nächstes",
"no_items_to_display": "Keine Elemente zum Anzeigen",
"cast_and_crew": "Besetzung und Crew",
"series": "Serien",

View File

@@ -132,7 +132,8 @@
"show_custom_menu_links": "Show Custom Menu Links",
"hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback"
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default quality"
},
"downloads": {
"downloads_title": "Downloads",

View File

@@ -87,7 +87,7 @@
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Establecer pista de audio del elemento anterior",
"set_audio_track": "Establecer pista del elemento anterior",
"audio_language": "Idioma de audio",
"audio_hint": "Elige un idioma de audio por defecto.",
"none": "Ninguno",
@@ -97,7 +97,7 @@
"subtitle_title": "Subtítulos",
"subtitle_language": "Idioma de subtítulos",
"subtitle_mode": "Modo de subtítulos",
"set_subtitle_track": "Establecer pista de subtítulos del elemento anterior",
"set_subtitle_track": "Establecer pista del elemento anterior",
"subtitle_size": "Tamaño de subtítulos",
"subtitle_hint": "Configurar preferencias de subtítulos.",
"none": "Ninguno",
@@ -132,7 +132,8 @@
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
"hide_libraries": "Ocultar bibliotecas",
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
"disable_haptic_feedback": "Desactivar feedback háptico"
"disable_haptic_feedback": "Desactivar feedback háptico",
"default_quality": "Calidad por defecto"
},
"downloads": {
"downloads_title": "Descargas",

View File

@@ -132,7 +132,9 @@
"show_custom_menu_links": "Afficher les liens personnalisés",
"hide_libraries": "Cacher des bibliothèques",
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans longlet Bibliothèque et les sections de la page daccueil.",
"disable_haptic_feedback": "Désactiver le retour haptique"
"disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut"
},
"downloads": {
"downloads_title": "Téléchargements",

458
translations/it.json Normal file
View File

@@ -0,0 +1,458 @@
{
"login": {
"username_required": "Nome utente è obbligatorio",
"error_title": "Errore",
"login_title": "Accesso",
"login_to_title": "Accedi a",
"username_placeholder": "Nome utente",
"password_placeholder": "Password",
"login_button": "Accedi",
"quick_connect": "Connessione Rapida",
"enter_code_to_login": "Inserire {{code}} per accedere",
"failed_to_initiate_quick_connect": "Impossibile avviare la Connessione Rapida",
"got_it": "Capito",
"connection_failed": "Connessione fallita",
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
"an_unexpected_error_occured": "Si è verificato un errore inaspettato",
"change_server": "Cambiare il server",
"invalid_username_or_password": "Nome utente o password non validi",
"user_does_not_have_permission_to_log_in": "L'utente non ha il permesso di accedere",
"server_is_taking_too_long_to_respond_try_again_later": "Il server sta impiegando troppo tempo per rispondere, riprovare più tardi",
"server_received_too_many_requests_try_again_later": "Il server ha ricevuto troppe richieste, riprovare più tardi.",
"there_is_a_server_error": "Si è verificato un errore del server",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?"
},
"server": {
"enter_url_to_jellyfin_server": "Inserisci l'URL del tuo server Jellyfin",
"server_url_placeholder": "http(s)://tuo-server.com",
"connect_button": "Connetti",
"previous_servers": "server precedente",
"clear_button": "Cancella",
"search_for_local_servers": "Ricerca dei server locali",
"searching": "Cercando...",
"servers": "Servers"
},
"home": {
"no_internet": "Nessun Internet",
"no_items": "Nessun oggetto",
"no_internet_message": "Non c'è da preoccuparsi, è ancora possibile guardare\n i contenuti scaricati.",
"go_to_downloads": "Vai agli elementi scaricati",
"oops": "Oops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare",
"next_up": "Prossimo",
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
"suggested_movies": "Film consigliati",
"suggested_episodes": "Episodi consigliati",
"intro": {
"welcome_to_streamyfin": "Benvenuto a Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Un client gratuito e open-source per Jellyfin.",
"features_title": "Funzioni",
"features_description": "Streamyfin dispone di numerose funzioni e si integra con un'ampia gamma di software che si possono trovare nel menu delle impostazioni:",
"jellyseerr_feature_description": "Connettetevi alla vostra istanza Jellyseerr e richiedete i film direttamente nell'app.",
"downloads_feature_title": "Scaricamento",
"downloads_feature_description": "Scaricate film e serie tv da vedere offline. Utilizzate il metodo predefinito o installate il server di ottimizzazione per scaricare i file in background.",
"chromecast_feature_description": "Trasmettete film e serie tv ai vostri dispositivi Chromecast.",
"centralised_settings_plugin_title": "Impostazioni dei Plugin Centralizzate",
"centralised_settings_plugin_description": "Configura le impostazioni da una posizione centralizzata sul server Jellyfin. Tutte le impostazioni del client per tutti gli utenti saranno sincronizzate automaticamente.",
"done_button": "Fatto",
"go_to_settings_button": "Vai alle impostazioni",
"read_more": "Leggi di più"
},
"settings": {
"settings_title": "Impostazioni",
"log_out_button": "Esci",
"user_info": {
"user_info_title": "Info utente",
"user": "Utente",
"server": "Server",
"token": "Token",
"app_version": "Versione dell'App"
},
"quick_connect": {
"quick_connect_title": "Connessione Rapida",
"authorize_button": "Autorizza Connessione Rapida",
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
"success": "Successo",
"quick_connect_autorized": "Connessione Rapida autorizzata",
"error": "Errore",
"invalid_code": "Codice invalido",
"authorize": "Autorizza"
},
"media_controls": {
"media_controls_title": "Controlli multimediali",
"forward_skip_length": "Lunghezza del salto in avanti",
"rewind_length": "Lunghezza del riavvolgimento",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Imposta la traccia audio dall'elemento precedente",
"audio_language": "Lingua Audio",
"audio_hint": "Scegli la lingua audio predefinita.",
"none": "Nessuno",
"language": "Lingua"
},
"subtitles": {
"subtitle_title": "Sottotitoli",
"subtitle_language": "Lingua dei sottotitoli",
"subtitle_mode": "Modalità dei sottotitoli",
"set_subtitle_track": "Imposta la traccia dei sottotitoli dall'elemento precedente",
"subtitle_size": "Dimensione dei sottotitoli",
"subtitle_hint": "Configura la preferenza dei sottotitoli.",
"none": "Nessuno",
"language": "Lingua",
"loading": "Caricamento",
"modes": {
"Default": "Predefinito",
"Smart": "Intelligente",
"Always": "Sempre",
"None": "Nessuno",
"OnlyForced": "Solo forzati"
}
},
"other": {
"other_title": "Altro",
"auto_rotate": "Rotazione automatica",
"video_orientation": "Orientamento del video",
"orientation": "Orientamento",
"orientations": {
"DEFAULT": "Predefinito",
"ALL": "Tutto",
"PORTRAIT": "Verticale",
"PORTRAIT_UP": "Verticale sopra",
"PORTRAIT_DOWN": "Verticale sotto",
"LANDSCAPE": "Orizzontale",
"LANDSCAPE_LEFT": "Orizzontale sinitra",
"LANDSCAPE_RIGHT": "Orizzontale destra",
"OTHER": "Altro",
"UNKNOWN": "Sconosciuto"
},
"safe_area_in_controls": "Area sicura per i controlli",
"show_custom_menu_links": "Mostra i link del menu personalizzato",
"hide_libraries": "Nascondi Librerie",
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita"
},
"downloads": {
"downloads_title": "Scaricamento",
"download_method": "Metodo per lo scaricamento",
"remux_max_download": "Numero di Remux da scaricare al massimo",
"auto_download": "Scaricamento automatico",
"optimized_versions_server": "Versioni del server di ottimizzazione",
"save_button": "Salva",
"optimized_server": "Server di ottimizzazione",
"optimized": "Ottimizzato",
"default": "Predefinito",
"optimized_version_hint": "Inserire l'URL del server di ottimizzazione. L'URL deve includere http o https e, facoltativamente, la porta.",
"read_more_about_optimized_server": "Per saperne di più sul server di ottimizzazione.",
"url":"URL",
"server_url_placeholder": "http(s)://dominio.org:porta"
},
"plugins": {
"plugins_title": "Plugin",
"jellyseerr": {
"jellyseerr_warning": "Questa integrazione è in fase iniziale. Aspettarsi cambiamenti.",
"server_url": "URL del Server",
"server_url_hint": "Esempio: http(s)://tuo-host.url\n(aggiungere la porta se richiesto)",
"server_url_placeholder": "URL di Jellyseerr...",
"password": "Password",
"password_placeholder": "Inserire la password per l'utente {{username}} di Jellyfin",
"save_button": "Salva",
"clear_button": "Cancella",
"login_button": "Accedi",
"total_media_requests": "Totale di richieste di media",
"movie_quota_limit": "Limite di quota per i film",
"movie_quota_days": "Giorni di quota per i film",
"tv_quota_limit": "Limite di quota per le serie TV",
"tv_quota_days": "Giorni di quota per le serie TV",
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"unlimited": "Illimitato"
},
"marlin_search": {
"enable_marlin_search": "Abilita la ricerca Marlin ",
"url": "URL",
"server_url_placeholder": "http(s)://dominio.org:porta",
"marlin_search_hint": "Inserire l'URL del server Marlin. L'URL deve includere http o https e, facoltativamente, la porta.",
"read_more_about_marlin": "Leggi di più su Marlin.",
"save_button": "Salva",
"toasts": {
"saved": "Salvato"
}
}
},
"storage": {
"storage_title": "Spazio",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati"
},
"intro": {
"show_intro": "Mostra intro",
"reset_intro": "Ripristina intro"
},
"logs": {
"logs_title": "Log",
"no_logs_available": "Nessun log disponibile",
"delete_all_logs": "Cancella tutti i log"
},
"languages": {
"title": "Lingue",
"app_language": "Lingua dell'App",
"app_language_description": "Selezione la lingua dell'app.",
"system": "Sistema"
},
"toasts":{
"error_deleting_files": "Errore nella cancellazione dei file",
"background_downloads_enabled": "Scaricamento in background abilitato",
"background_downloads_disabled": "Scaricamento in background disabilitato",
"connected": "Connesso",
"could_not_connect": "Non è stato possibile connettersi",
"invalid_url": "URL invalido"
}
},
"downloads": {
"downloads_title": "Scaricati",
"tvseries": "Serie TV",
"movies": "Film",
"queue": "Coda",
"queue_hint": "La coda e gli elementi scaricati saranno persi con il riavvio dell'app",
"no_items_in_queue": "Nessun elemento in coda",
"no_downloaded_items": "Nessun elemento scaricato",
"delete_all_movies_button": "Cancella tutti i film",
"delete_all_tvseries_button": "Cancella tutte le serie TV",
"delete_all_button": "Cancella tutti",
"active_download": "Scaricamento in corso",
"no_active_downloads": "Nessun scaricamento in corso",
"active_downloads": "Scaricamenti in corso",
"new_app_version_requires_re_download": "La nuova verione dell'app richiede di scaricare nuovamente i contenuti",
"new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.",
"back": "Indietro",
"delete": "Cancella",
"something_went_wrong": "Qualcosa è andato storto",
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Metodi",
"toasts": {
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
"download_cancelled": "Scaricamento annullato",
"could_not_cancel_download": "Impossibile annullare lo scaricamento",
"download_completed": "Scaricamento completato",
"download_started_for": "Scaricamento iniziato per {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} è pronto per essere scaricato",
"download_stated_for_item": "Scaricamento iniziato per {{item}}",
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}",
"download_completed_for_item": "Scaricamento completato per {{item}}",
"queued_item_for_optimization": "Messo in coda {{item}} per l'ottimizzazione",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
"no_response_received_from_server": "No response received from the server",
"error_setting_up_the_request": "Error setting up the request",
"failed_to_start_download_for_item_unexpected_error": "Impossibile avviare il download per {{item}}: Errore imprevisto",
"all_files_folders_and_jobs_deleted_successfully": "Tutti i file, le cartelle e i processi sono stati eliminati con successo.",
"an_error_occured_while_deleting_files_and_jobs": "Si è verificato un errore durante l'eliminazione di file e processi",
"go_to_downloads": "Vai agli elementi scaricati"
}
}
},
"search": {
"search_here": "Cerca qui...",
"search": "Cerca...",
"x_items": "{{count}} elementi",
"library": "Libreria",
"discover": "Scopri",
"no_results": "Nessun risultato",
"no_results_found_for": "Nessun risultato trovato per",
"movies": "Film",
"series": "Serie",
"episodes": "Episodi",
"collections": "Collezioni",
"actors": "Attori",
"request_movies": "Film Richiesti",
"request_series": "Serie Richieste",
"recently_added": "Aggiunti di Recente",
"recent_requests": "Richiesti di Recente",
"plex_watchlist": "Plex Watchlist",
"trending": "In tendenza",
"popular_movies": "Film Popolari",
"movie_genres": "Generi Film",
"upcoming_movies": "Film in arrivo",
"studios": "Studio",
"popular_tv": "Serie Popolari",
"tv_genres": "Generi Televisivi",
"upcoming_tv": "Serie in Arrivo",
"networks": "Network",
"tmdb_movie_keyword": "TMDB Parola chiave del film",
"tmdb_movie_genre": "TMDB Genere Film",
"tmdb_tv_keyword": "TMDB Parola chiave della serie",
"tmdb_tv_genre": "TMDB Genere Televisivo",
"tmdb_search": "TMDB Cerca",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Network",
"tmdb_movie_streaming_services": "TMDB Servizi di Streaming di Film",
"tmdb_tv_streaming_services": "TMDB Servizi di Streaming di Serie"
},
"library": {
"no_items_found": "Nessun elemento trovato",
"no_results": "Nessun risultato",
"no_libraries_found": "Nessuna libreria trovata",
"item_types": {
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"items": "elementi"
},
"options": {
"display": "Display",
"row": "Fila",
"list": "Lista",
"image_style": "Stile dell'immagine",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche"
},
"filters": {
"genres": "Generi",
"years": "Anni",
"sort_by": "Ordina per",
"sort_order": "Criterio di ordinamento",
"tags": "Tag"
}
},
"favorites": {
"series": "Serie TV",
"movies": "Film",
"episodes": "Episodi",
"videos": "Video",
"boxsets": "Boxset",
"playlists": "Playlist"
},
"custom_links": {
"no_links": "Nessun link"
},
"player": {
"error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
"client_error": "Errore del client",
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
"message_from_server": "Messaggio dal server: {{messagge}}",
"video_has_finished_playing": "La riproduzione del video è terminata!",
"no_video_source": "Nessuna sorgente video...",
"next_episode": "Prossimo Episodio",
"refresh_tracks": "Aggiorna tracce",
"subtitle_tracks": "Tracce di sottotitoli:",
"audio_tracks": "Tracce audio:",
"playback_state": "Stato della riproduzione:",
"no_data_available": "Nessun dato disponibile",
"index": "Indice:"
},
"item_card": {
"next_up": "Il prossimo",
"no_items_to_display": "Nessun elemento da visualizzare",
"cast_and_crew": "Cast e Equipaggio",
"series": "Serie",
"seasons": "Stagioni",
"season": "Stagione",
"no_episodes_for_this_season": "Nessun episodio per questa stagione",
"overview": "Panoramica",
"more_with": "Altri con {{name}}",
"similar_items": "Elementi simili",
"no_similar_items_found": "Non sono stati trovati elementi simili",
"video": "Video",
"more_details": "Più dettagli",
"quality": "Qualità",
"audio": "Audio",
"subtitles": "Sottotitoli",
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"appeared_in": "Apparso in",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
"download_season": "Scarica Stagione",
"download_series": "Scarica Serie",
"download_episode": "Scarica Episodio",
"download_movie": "Scarica Film",
"download_x_item": "Scarica {{item_count}} elementi",
"download_button": "Scarica",
"using_optimized_server": "Utilizzando il server di ottimizzazione",
"using_default_method": "Utilizzando il metodo predefinito"
}
},
"live_tv": {
"next": "Prossimo",
"previous": "Precedente",
"live_tv": "TV in diretta",
"coming_soon": "Prossimamente",
"on_now": "In onda ora",
"shows": "Programmi",
"movies": "Film",
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari"
},
"jellyseerr":{
"confirm": "Conferma",
"cancel": "Cancella",
"yes": "Si",
"whats_wrong": "Cosa c'è che non va?",
"issue_type": "Tipo di problema",
"select_an_issue": "Seleziona un problema",
"types": "Tipi",
"describe_the_issue": "(facoltativo) Descrivere il problema...",
"submit_button": "Invia",
"report_issue_button": "Segnalare il problema",
"request_button": "Richiedi",
"are_you_sure_you_want_to_request_all_seasons": "Sei sicuro di voler richiedere tutte le stagioni?",
"failed_to_login": "Accesso non riuscito",
"cast": "Cast",
"details": "Dettagli",
"status": "Stato",
"original_title": "Titolo originale",
"series_type": "Tipo di Serie",
"release_dates": "Date di Uscita",
"first_air_date": "Prima Data di Messa in Onda",
"next_air_date": "Prossima Data di Messa in Onda",
"revenue": "Ricavi",
"budget": "Budget",
"original_language": "Lingua Originale",
"production_country": "Paese di Produzione",
"studios": "Studio",
"network": "Network",
"currently_streaming_on": "Attualmente in streaming su",
"advanced": "Avanzate",
"request_as": "Richiedi Come",
"tags": "Tag",
"quality_profile": "Profilo qualità",
"root_folder": "Cartella radice",
"season_x": "Stagione {{seasons}}",
"season_number": "Stagione {{season_number}}",
"number_episodes": "{{episode_number}} Episodio",
"born": "Nato",
"appearances": "Aspetto",
"toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
"failed_to_test_jellyseerr_server_url": "Fallito il test dell'url del server jellyseerr",
"issue_submitted": "Problema inviato!",
"requested_item": "Richiesto {{item}}!",
"you_dont_have_permission_to_request": "Non hai il permesso di richiedere!",
"something_went_wrong_requesting_media": "Qualcosa è andato storto nella richiesta dei media!"
}
},
"tabs": {
"home": "Home",
"search": "Cerca",
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti"
}
}

457
translations/ja.json Normal file
View File

@@ -0,0 +1,457 @@
{
"login": {
"username_required": "ユーザー名は必須です",
"error_title": "エラー",
"login_title": "ログイン",
"login_to_title": "ログイン先",
"username_placeholder": "ユーザー名",
"password_placeholder": "パスワード",
"login_button": "ログイン",
"quick_connect": "クイックコネクト",
"enter_code_to_login": "ログインするにはコード {{code}} を入力してください",
"failed_to_initiate_quick_connect": "クイックコネクトを開始できませんでした",
"got_it": "了解",
"connection_failed": "接続に失敗しました",
"could_not_connect_to_server": "サーバーに接続できませんでした。URLとネットワーク接続を確認してください。",
"an_unexpected_error_occured": "予期しないエラーが発生しました",
"change_server": "サーバーの変更",
"invalid_username_or_password": "ユーザー名またはパスワードが無効です",
"user_does_not_have_permission_to_log_in": "ユーザーにログイン権限がありません",
"server_is_taking_too_long_to_respond_try_again_later": "サーバーの応答に時間がかかりすぎています。しばらくしてからもう一度お試しください。",
"server_received_too_many_requests_try_again_later": "サーバーにリクエストが多すぎます。後でもう一度お試しください。",
"there_is_a_server_error": "サーバーエラーが発生しました",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか"
},
"server": {
"enter_url_to_jellyfin_server": "JellyfinサーバーのURLを入力してください",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "接続",
"previous_servers": "前のサーバー",
"clear_button": "クリア",
"search_for_local_servers": "ローカルサーバーを検索",
"searching": "検索中...",
"servers": "サーバー"
},
"home": {
"no_internet": "インターネット接続がありません",
"no_items": "アイテムはありません",
"no_internet_message": "心配しないでください。\nダウンロードしたコンテンツは引き続き視聴できます。",
"go_to_downloads": "ダウンロードに移動",
"oops": "おっと!",
"error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。",
"continue_watching": "続きを見る",
"next_up": "次の動画",
"recently_added_in": "{{libraryName}}に最近追加された",
"suggested_movies": "おすすめ映画",
"suggested_episodes": "おすすめエピソード",
"intro": {
"welcome_to_streamyfin": "Streamyfinへようこそ",
"a_free_and_open_source_client_for_jellyfin": "Jellyfinのためのフリーでオープンソースのクライアント。",
"features_title": "特長",
"features_description": "Streamyfinには多くの機能があり、設定メニューで見つけることができるさまざまなソフトウェアと統合されています。これには以下が含まれます。",
"jellyseerr_feature_description": "Jellyseerrインスタンスに接続し、アプリ内で直接映画をリクエストします。",
"downloads_feature_title": "ダウンロード",
"downloads_feature_description": "映画やテレビ番組をダウンロードしてオフラインで視聴します。デフォルトの方法を使用するか、バックグラウンドでファイルをダウンロードするために最適化されたサーバーをインストールしてください。",
"chromecast_feature_description": "映画とテレビ番組をChromecastデバイスにキャストします。",
"centralised_settings_plugin_title": "集中設定プラグイン",
"centralised_settings_plugin_description": "Jellyfinサーバーから設定を構成します。すべてのユーザーのすべてのクライアント設定は自動的に同期されます。",
"done_button": "完了",
"go_to_settings_button": "設定に移動",
"read_more": "続きを読む"
},
"settings": {
"settings_title": "設定",
"log_out_button": "ログアウト",
"user_info": {
"user_info_title": "ユーザー情報",
"user": "ユーザー",
"server": "サーバー",
"token": "トークン",
"app_version": "アプリバージョン"
},
"quick_connect": {
"quick_connect_title": "クイックコネクト",
"authorize_button": "クイックコネクトを承認する",
"enter_the_quick_connect_code": "クイックコネクトコードを入力...",
"success": "成功しました",
"quick_connect_autorized": "クイックコネクトが承認されました",
"error": "エラー",
"invalid_code": "無効なコードです",
"authorize": "承認"
},
"media_controls": {
"media_controls_title": "メディアコントロール",
"forward_skip_length": "スキップの長さ",
"rewind_length": "巻き戻しの長さ",
"seconds_unit": "s"
},
"audio": {
"audio_title": "オーディオ",
"set_audio_track": "前のアイテムからオーディオトラックを設定",
"audio_language": "オーディオ言語",
"audio_hint": "デフォルトのオーディオ言語を選択します。",
"none": "なし",
"language": "言語"
},
"subtitles": {
"subtitle_title": "字幕",
"subtitle_language": "字幕の言語",
"subtitle_mode": "字幕モード",
"set_subtitle_track": "前のアイテムから字幕トラックを設定",
"subtitle_size": "字幕サイズ",
"subtitle_hint": "字幕設定を構成します。",
"none": "なし",
"language": "言語",
"loading": "ロード中",
"modes": {
"Default": "デフォルト",
"Smart": "スマート",
"Always": "常に",
"None": "なし",
"OnlyForced": "強制のみ"
}
},
"other": {
"other_title": "その他",
"auto_rotate": "画面の自動回転",
"video_orientation": "動画の向き",
"orientation": "向き",
"orientations": {
"DEFAULT": "デフォルト",
"ALL": "すべて",
"PORTRAIT": "縦",
"PORTRAIT_UP": "縦向き(上)",
"PORTRAIT_DOWN": "縦方向",
"LANDSCAPE": "横方向",
"LANDSCAPE_LEFT": "横方向 左",
"LANDSCAPE_RIGHT": "横方向 右",
"OTHER": "その他",
"UNKNOWN": "不明"
},
"safe_area_in_controls": "コントロールの安全エリア",
"show_custom_menu_links": "カスタムメニューのリンクを表示",
"hide_libraries": "ライブラリを非表示",
"select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
"disable_haptic_feedback": "触覚フィードバックを無効にする"
},
"downloads": {
"downloads_title": "ダウンロード",
"download_method": "ダウンロード方法",
"remux_max_download": "Remux最大ダウンロード数",
"auto_download": "自動ダウンロード",
"optimized_versions_server": "Optimized versionsサーバー",
"save_button": "保存",
"optimized_server": "Optimizedサーバー",
"optimized": "最適化",
"default": "デフォルト",
"optimized_version_hint": "OptimizeサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
"read_more_about_optimized_server": "Optimizeサーバーの詳細をご覧ください。",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:ポート"
},
"plugins": {
"plugins_title": "プラグイン",
"jellyseerr": {
"jellyseerr_warning": "この統合はまだ初期段階です。状況が変化する可能性があります。",
"server_url": "サーバーURL",
"server_url_hint": "例: http(s)://your-host.url\n(必要に応じてポートを追加)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "パスワード",
"password_placeholder": "Jellyfinユーザー {{username}} のパスワードを入力してください",
"save_button": "保存",
"clear_button": "クリア",
"login_button": "ログイン",
"total_media_requests": "メディアリクエストの合計",
"movie_quota_limit": "映画のクオータ制限",
"movie_quota_days": "映画のクオータ日数",
"tv_quota_limit": "テレビのクオータ制限",
"tv_quota_days": "テレビのクオータ日数",
"reset_jellyseerr_config_button": "Jellyseerrの設定をリセット",
"unlimited": "無制限"
},
"marlin_search": {
"enable_marlin_search": "マーリン検索を有効にする ",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:ポート",
"marlin_search_hint": "MarlinサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
"read_more_about_marlin": "Marlinについて詳しく読む。",
"save_button": "保存",
"toasts": {
"saved": "保存しました"
}
}
},
"storage": {
"storage_title": "ストレージ",
"app_usage": "アプリ {{usedSpace}}%",
"phone_usage": "電話 {{availableSpace}}%",
"size_used": "{{used}} / {{total}} 使用済み",
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除"
},
"intro": {
"show_intro": "イントロを表示",
"reset_intro": "イントロをリセット"
},
"logs": {
"logs_title": "ログ",
"no_logs_available": "ログがありません",
"delete_all_logs": "すべてのログを削除"
},
"languages": {
"title": "言語",
"app_language": "アプリの言語",
"app_language_description": "アプリの言語を選択。",
"system": "システム"
},
"toasts": {
"error_deleting_files": "ファイルの削除エラー",
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です",
"connected": "接続済み",
"could_not_connect": "接続できません",
"invalid_url": "無効なURL"
}
},
"downloads": {
"downloads_title": "ダウンロード",
"tvseries": "TVシリーズ",
"movies": "映画",
"queue": "キュー",
"queue_hint": "アプリを再起動するとキューとダウンロードは失われます",
"no_items_in_queue": "キューにアイテムがありません",
"no_downloaded_items": "ダウンロードしたアイテムはありません",
"delete_all_movies_button": "すべての映画を削除",
"delete_all_tvseries_button": "すべてのシリーズを削除",
"delete_all_button": "すべて削除",
"active_download": "アクティブなダウンロード",
"no_active_downloads": "アクティブなダウンロードはありません",
"active_downloads": "アクティブなダウンロード",
"new_app_version_requires_re_download": "新しいアプリバージョンでは再ダウンロードが必要です",
"new_app_version_requires_re_download_description": "新しいアップデートではコンテンツを再度ダウンロードする必要があります。ダウンロードしたコンテンツをすべて削除してもう一度お試しください。",
"back": "戻る",
"delete": "削除",
"something_went_wrong": "問題が発生しました",
"could_not_get_stream_url_from_jellyfin": "JellyfinからストリームURLを取得できませんでした",
"eta": "ETA {{eta}}",
"methods": "方法",
"toasts": {
"you_are_not_allowed_to_download_files": "ファイルをダウンロードする権限がありません。",
"deleted_all_movies_successfully": "すべての映画を正常に削除しました!",
"failed_to_delete_all_movies": "すべての映画を削除できませんでした",
"deleted_all_tvseries_successfully": "すべてのシリーズを正常に削除しました!",
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
"download_cancelled": "ダウンロードをキャンセルしました",
"could_not_cancel_download": "ダウンロードをキャンセルできませんでした",
"download_completed": "ダウンロードが完了しました",
"download_started_for": "{{item}}のダウンロードが開始されました",
"item_is_ready_to_be_downloaded": "{{item}}をダウンロードする準備ができました",
"download_stated_for_item": "{{item}}のダウンロードが開始されました",
"download_failed_for_item": "{{item}}のダウンロードに失敗しました - {{error}}",
"download_completed_for_item": "{{item}}のダウンロードが完了しました",
"queued_item_for_optimization": "{{item}}をoptimizeのキューに追加しました",
"failed_to_start_download_for_item": "{{item}}のダウンロードを開始できませんでした: {{message}}",
"server_responded_with_status_code": "サーバーはステータス{{statusCode}}で応答しました",
"no_response_received_from_server": "サーバーからの応答がありません",
"error_setting_up_the_request": "リクエストの設定中にエラーが発生しました",
"failed_to_start_download_for_item_unexpected_error": "{{item}}のダウンロードを開始できませんでした: 予期しないエラーが発生しました",
"all_files_folders_and_jobs_deleted_successfully": "すべてのファイル、フォルダ、ジョブが正常に削除されました",
"an_error_occured_while_deleting_files_and_jobs": "ファイルとジョブの削除中にエラーが発生しました",
"go_to_downloads": "ダウンロードに移動"
}
}
},
"search": {
"search_here": "ここを検索...",
"search": "検索...",
"x_items": "{{count}}のアイテム",
"library": "ライブラリ",
"discover": "見つける",
"no_results": "結果はありません",
"no_results_found_for": "結果が見つかりませんでした:",
"movies": "映画",
"series": "シリーズ",
"episodes": "エピソード",
"collections": "コレクション",
"actors": "俳優",
"request_movies": "映画をリクエスト",
"request_series": "シリーズをリクエスト",
"recently_added": "最近の追加",
"recent_requests": "最近のリクエスト",
"plex_watchlist": "Plexウォッチリスト",
"trending": "トレンド",
"popular_movies": "人気の映画",
"movie_genres": "映画のジャンル",
"upcoming_movies": "今後リリースされる映画",
"studios": "制作会社",
"popular_tv": "人気のテレビ番組",
"tv_genres": "シリーズのジャンル",
"upcoming_tv": "今後リリースされるシリーズ",
"networks": "ネットワーク",
"tmdb_movie_keyword": "TMDB映画キーワード",
"tmdb_movie_genre": "TMDB映画ジャンル",
"tmdb_tv_keyword": "TMDBシリーズキーワード",
"tmdb_tv_genre": "TMDBシリーズジャンル",
"tmdb_search": "TMDB検索",
"tmdb_studio": "TMDB 制作会社",
"tmdb_network": "TMDB ネットワーク",
"tmdb_movie_streaming_services": "TMDB映画ストリーミングサービス",
"tmdb_tv_streaming_services": "TMDBシリーズストリーミングサービス"
},
"library": {
"no_items_found": "アイテムが見つかりません",
"no_results": "検索結果はありません",
"no_libraries_found": "ライブラリが見つかりません",
"item_types": {
"movies": "映画",
"series": "シリーズ",
"boxsets": "ボックスセット",
"items": "アイテム"
},
"options": {
"display": "表示",
"row": "行",
"list": "リスト",
"image_style": "画像のスタイル",
"poster": "ポスター",
"cover": "カバー",
"show_titles": "タイトルの表示",
"show_stats": "統計を表示"
},
"filters": {
"genres": "ジャンル",
"years": "年",
"sort_by": "ソート",
"sort_order": "ソート順",
"tags": "タグ"
}
},
"favorites": {
"series": "シリーズ",
"movies": "映画",
"episodes": "エピソード",
"videos": "ビデオ",
"boxsets": "ボックスセット",
"playlists": "プレイリスト"
},
"custom_links": {
"no_links": "リンクがありません"
},
"player": {
"error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
"client_error": "クライアントエラー",
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
"message_from_server": "サーバーからのメッセージ: {{message}}",
"video_has_finished_playing": "ビデオの再生が終了しました!",
"no_video_source": "動画ソースがありません...",
"next_episode": "次のエピソード",
"refresh_tracks": "トラックを更新",
"subtitle_tracks": "字幕トラック:",
"audio_tracks": "音声トラック:",
"playback_state": "再生状態:",
"no_data_available": "データなし",
"index": "インデックス:"
},
"item_card": {
"next_up": "次",
"no_items_to_display": "表示するアイテムがありません",
"cast_and_crew": "キャスト&クルー",
"series": "シリーズ",
"seasons": "シーズン",
"season": "シーズン",
"no_episodes_for_this_season": "このシーズンのエピソードはありません",
"overview": "ストーリー",
"more_with": "{{name}}の詳細",
"similar_items": "類似アイテム",
"no_similar_items_found": "類似のアイテムは見つかりませんでした",
"video": "映像",
"more_details": "さらに詳細を表示",
"quality": "画質",
"audio": "音声",
"subtitles": "字幕",
"show_more": "もっと見る",
"show_less": "少なく表示",
"appeared_in": "出演作品",
"could_not_load_item": "アイテムを読み込めませんでした",
"none": "なし",
"download": {
"download_season": "シーズンをダウンロード",
"download_series": "シリーズをダウンロード",
"download_episode": "エピソードをダウンロード",
"download_movie": "映画をダウンロード",
"download_x_item": "{{item_count}}のアイテムをダウンロード",
"download_button": "ダウンロード",
"using_optimized_server": "Optimizeサーバーを使用する",
"using_default_method": "デフォルトの方法を使用"
}
},
"live_tv": {
"next": "次",
"previous": "前",
"live_tv": "ライブTV",
"coming_soon": "近日公開",
"on_now": "現在",
"shows": "表示",
"movies": "映画",
"sports": "スポーツ",
"for_kids": "子供向け",
"news": "ニュース"
},
"jellyseerr": {
"confirm": "確認",
"cancel": "キャンセル",
"yes": "はい",
"whats_wrong": "どうしましたか?",
"issue_type": "問題の種類",
"select_an_issue": "問題を選択",
"types": "種類",
"describe_the_issue": "(オプション) 問題を説明してください...",
"submit_button": "送信",
"report_issue_button": "チケットを報告",
"request_button": "リクエスト",
"are_you_sure_you_want_to_request_all_seasons": "すべてのシーズンをリクエストしてもよろしいですか?",
"failed_to_login": "ログインに失敗しました",
"cast": "出演者",
"details": "詳細",
"status": "状態",
"original_title": "原題",
"series_type": "シリーズタイプ",
"release_dates": "公開日",
"first_air_date": "初放送日",
"next_air_date": "次回放送日",
"revenue": "収益",
"budget": "予算",
"original_language": "オリジナルの言語",
"production_country": "制作国",
"studios": "制作会社",
"network": "ネットワーク",
"currently_streaming_on": "ストリーミング中",
"advanced": "詳細",
"request_as": "別ユーザーとしてリクエスト",
"tags": "タグ",
"quality_profile": "画質プロファイル",
"root_folder": "ルートフォルダ",
"season_x": "シーズン{{seasons}}",
"season_number": "シーズン{{season_number}}",
"number_episodes": "エピソード{{episode_number}}",
"born": "生まれ",
"appearances": "出演",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。",
"jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。",
"failed_to_test_jellyseerr_server_url": "JellyseerrサーバーのURLをテストに失敗しました",
"issue_submitted": "チケットを送信しました!",
"requested_item": "{{item}}をリクエスト!",
"you_dont_have_permission_to_request": "リクエストする権限がありません!",
"something_went_wrong_requesting_media": "メディアのリクエスト中に問題が発生しました。"
}
},
"tabs": {
"home": "ホーム",
"search": "検索",
"library": "ライブラリ",
"custom_links": "カスタムリンク",
"favorites": "お気に入り"
}
}

458
translations/nl.json Normal file
View File

@@ -0,0 +1,458 @@
{
"login": {
"username_required": "Gebruikersnaam is verplicht",
"error_title": "Fout",
"login_title": "Aanmelden",
"login_to_title": "Aanmelden bij",
"username_placeholder": "Gebruikersnaam",
"password_placeholder": "Wachtwoord",
"login_button": "Aanmelden",
"quick_connect": "Snel Verbinden",
"enter_code_to_login": "Vul code {{code}} in om aan te melden",
"failed_to_initiate_quick_connect": "Gefaald om Snel Verbinden op te starten",
"got_it": "Begrepen",
"connection_failed": "Verbinding gefaald",
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
"an_unexpected_error_occured": "Er is een onverwachte fout opgetreden",
"change_server": "Verander server",
"invalid_username_or_password": "Ongeldige gebruikersnaam of wachtwoord",
"user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden",
"server_is_taking_too_long_to_respond_try_again_later": "De server doet er te lang over om te antwoorden, probeer later opnieuw",
"server_received_too_many_requests_try_again_later": "De server heeft te veel aanvragen ontvangen, probeer later opnieuw",
"there_is_a_server_error": "Er is een serverfout",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?"
},
"server": {
"enter_url_to_jellyfin_server": "Geef de URL van je Jellyfin server in",
"server_url_placeholder": "http(s)://je-server.com",
"connect_button": "Verbinden",
"previous_servers": "vorige servers",
"clear_button": "Wissen",
"search_for_local_servers": "Zoek naar lokale servers",
"searching": "Zoeken...",
"servers": "Servers"
},
"home": {
"no_internet": "Geen Internet",
"no_items": "Geen items",
"no_internet_message": "Geen zorgen, je kan nog steeds\ngedownloade content bekijken",
"go_to_downloads": "Ga naar downloads",
"oops": "Oeps!",
"error_message": "Er ging iets fout\nGelieve af en aan te melden.",
"continue_watching": "Verder Kijken",
"next_up": "Volgende",
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
"suggested_movies": "Voorgestelde Films",
"suggested_episodes": "Voorgestelde Afleveringen",
"intro": {
"welcome_to_streamyfin": "Welkom bij Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Een gratis en open-source client voor Jellyfin.",
"features_title": "Functies",
"features_description": "Streamyfin heeft een heleboel functies en integreert met een breed scala aan software die je kunt vinden in het instellingenmenu, onder andere:",
"jellyseerr_feature_description": "Verbind met je Jellyseerr instantie en vraag films direct in de app aan.",
"downloads_feature_title": "Downloads",
"downloads_feature_description": "Download films en series om offline te kijken. Gebruik de standaardmethode of installeer de optimalisatieserver om bestanden op de achtergrond te downloaden.",
"chromecast_feature_description": "Cast films en series naar je Chromecast toestellen.",
"centralised_settings_plugin_title": "Plugin voor gecentraliseerde instellingen",
"centralised_settings_plugin_description": "Configureer instellingen vanaf een centrale locatie op je Jellyfin server. Alle clientinstellingen voor alle gebruikers worden automatisch gesynchroniseerd.",
"done_button": "Gedaan",
"go_to_settings_button": "Go naar instellingen",
"read_more": "Lees meer"
},
"settings": {
"settings_title": "Instellingen",
"log_out_button": "Afmelden",
"user_info": {
"user_info_title": "Gebruiker Info",
"user": "Gebruiker",
"server": "Server",
"token": "Token",
"app_version": "App Versie"
},
"quick_connect": {
"quick_connect_title": "Snel Verbinden",
"authorize_button": "Snel Verbinden toestaan",
"enter_the_quick_connect_code": "Vul de Snel Verbinden code in...",
"success": "Succes",
"quick_connect_autorized": "Snel Verbinden toegestaan",
"error": "Fout",
"invalid_code": "Ongeldige code",
"authorize": "Toestaan"
},
"media_controls": {
"media_controls_title": "Media Bedieningen",
"forward_skip_length": "Duur voorwaarts overslaan",
"rewind_length": "Duur terugspeolen",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Gebruik Audio Track Van Vorig Item",
"audio_language": "Audio taal",
"audio_hint": "Kies een standaard audio taal.",
"none": "Geen",
"language": "Taal"
},
"subtitles": {
"subtitle_title": "Ondertitels",
"subtitle_language": "Ondertitel taal",
"subtitle_mode": "Ondertitle Modus",
"set_subtitle_track": "Gebruik Ondertitel Track Van Vorig Item",
"subtitle_size": "Ondertitel Grootte",
"subtitle_hint": "Stel ondertitel voorkeuren in.",
"none": "Geen",
"language": "Taal",
"loading": "Laden",
"modes": {
"Default": "Standaard",
"Smart": "Slim",
"Always": "Altijd",
"None": "Geen",
"OnlyForced": "Alleen Geforceeerd"
}
},
"other": {
"other_title": "Andere",
"auto_rotate": "Automatisch draaien",
"video_orientation": "Video oriëntatie",
"orientation": "Oriëntatie",
"orientations": {
"DEFAULT": "Standaard",
"ALL": "Alle",
"PORTRAIT": "Portret",
"PORTRAIT_UP": "Portret Omhoog",
"PORTRAIT_DOWN": "Portret Omlaag",
"LANDSCAPE": "Landschap",
"LANDSCAPE_LEFT": "Landschap Links",
"LANDSCAPE_RIGHT": "Landschap Rechts",
"OTHER": "Andere",
"UNKNOWN": "Onbekend"
},
"safe_area_in_controls": "Veilig gebied in bedieningen",
"show_custom_menu_links": "Aangepaste menulinks tonen",
"hide_libraries": "Verberg Bibliotheken",
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheek tab en hoofdpagina onderdelen.",
"disable_haptic_feedback": "Haptische feedback uitschakelen",
"default_quality": "Standaard kwaliteit"
},
"downloads": {
"downloads_title": "Downloads",
"download_method": "Download methode",
"remux_max_download": "Remux max download",
"auto_download": "Auto download",
"optimized_versions_server": "Geoptimaliseerde server versies",
"save_button": "Opslaan",
"optimized_server": "Geoptimailseerde Server",
"optimized": "Geoptimaliseerd",
"default": "Standaard",
"optimized_version_hint": "Vul de URL van de optimalisatieserver in. De URL moet http of https bevatten en eventueel de poort.",
"read_more_about_optimized_server": "Lees meer over de optimalisatieserver.",
"url":"URL",
"server_url_placeholder": "http(s)://domein.org:poort"
},
"plugins": {
"plugins_title": "Plugins",
"jellyseerr": {
"jellyseerr_warning": "Deze integratie is nog in een vroeg stadium. Verwacht dat zaken nog veranderen.",
"server_url": "Server URL",
"server_url_hint": "Voorbeeld: http(s)://je-host.url\n(indien nodig: voeg de poort toe)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Wachtwoord",
"password_placeholder": "Voeg het wachtwoord in voor de Jellyfin gebruiker {{username}}",
"save_button": "Opslaan",
"clear_button": "Wissen",
"login_button": "Aannmelden",
"total_media_requests": "Totaal aantal mediaverzoeken",
"movie_quota_limit": "Limiet filmquota",
"movie_quota_days": "Filmquota dagen",
"tv_quota_limit": "Limiet serie quota",
"tv_quota_days": "Serie Quota dagen",
"reset_jellyseerr_config_button": "Jellyseerr opnieuw instellen",
"unlimited": "Ongelimiteerd"
},
"marlin_search": {
"enable_marlin_search": "Marlin Search inschakeln ",
"url": "URL",
"server_url_placeholder": "http(s)://domein.org:poort",
"marlin_search_hint": "Vul de URL van de Marlin Search server in. De URL moet http of https bevatten en eventueel de poort.",
"read_more_about_marlin": "Lees meer over Marlin.",
"save_button": "Opslaan",
"toasts": {
"saved": "Opgeslagen"
}
}
},
"storage": {
"storage_title": "Opslag",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Toestel {{availableSpace}}%",
"size_used": "{{used}} van {{total}} gebruikt",
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden"
},
"intro": {
"show_intro": "Toon intro",
"reset_intro": "intro opnieuw instellen"
},
"logs": {
"logs_title": "Logs",
"no_logs_available": "Geen logs beschikbaar",
"delete_all_logs": "Verwijder alle logs"
},
"languages": {
"title": "Talen",
"app_language": "App taal",
"app_language_description": "Selecteer een taal voor de app.",
"system": "Systeem"
},
"toasts":{
"error_deleting_files": "Fout bij het verwijden van bestanden",
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld",
"connected": "Verbonden",
"could_not_connect": "Kon niet verbinden",
"invalid_url": "Ongeldige URL"
}
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "Series",
"movies": "Films",
"queue": "Wachtrij",
"queue_hint": "Wachtrij en downloads verdwijnen bij een herstart van de app",
"no_items_in_queue": "Geen items in wachtrij",
"no_downloaded_items": "Geen gedownloade items",
"delete_all_movies_button": "Verwijder alle films",
"delete_all_tvseries_button": "Verwijder alle Series",
"delete_all_button": "Verwijder alles",
"active_download": "Actieve download",
"no_active_downloads": "Geen actieve downloads",
"active_downloads": "Actieve downloads",
"new_app_version_requires_re_download": "Nieuwe app-versie vereist opnieuw downloaden",
"new_app_version_requires_re_download_description": "Voor de nieuwe update moet de content opnieuw worden gedownload. Verwijder alle gedownloade content en probeer het opnieuw.",
"back": "Terug",
"delete": "Verwijder",
"something_went_wrong": "Er ging iets mis",
"could_not_get_stream_url_from_jellyfin": "Kon de URL van de stream niet krijgen van Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Methoden",
"toasts": {
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
"deleted_all_movies_successfully": "Alle filns succesvol verwijderd!",
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
"download_cancelled": "Download geannuleerd",
"could_not_cancel_download": "Kon de download niet annuleren",
"download_completed": "Download afgerond",
"download_started_for": "Download gestart voor {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} is klaar op te downloaden",
"download_stated_for_item": "Download gestart voor {{item}}",
"download_failed_for_item": "Download gefaald voor {{item}} - {{error}}",
"download_completed_for_item": "Download afgerond voor {{item}}",
"queued_item_for_optimization": "{{item}} in de wachtrij gezet voor optimalisatie",
"failed_to_start_download_for_item": "Kon de download voor {{item}} niet starten: {{message}}",
"server_responded_with_status_code": "Server heeft geantwoord met {{statusCode}}",
"no_response_received_from_server": "Geen antwoord gekregen van de server",
"error_setting_up_the_request": "Fout bij het opstellen van de aanvraag",
"failed_to_start_download_for_item_unexpected_error": "Kon de download voor {{item}} niet starten: Onverwachte fout",
"all_files_folders_and_jobs_deleted_successfully": "Alle bestanden, mappen en taken succesvol verwijderd",
"an_error_occured_while_deleting_files_and_jobs": "Er is een fout opgetreden tijdens het verwijderen van bestanden en taken",
"go_to_downloads": "Ga naar downloads"
}
}
},
"search": {
"search_here": "Zoek hier...",
"search": "Zoek...",
"x_items": "{{count}} items",
"library": "Bibliotheek",
"discover": "Ontdek",
"no_results": "Geen resultaten",
"no_results_found_for": "Geen resultaten gevonden voor",
"movies": "Films",
"series": "Series",
"episodes": "Afleveringen",
"collections": "Collecties",
"actors": "Acteurs",
"request_movies": "Vraag films aan",
"request_series": "Vraag series aan",
"recently_added": "Recent Toegevoegd",
"recent_requests": "Recent Aangevraagd",
"plex_watchlist": "Plex Kijklijst",
"trending": "Trending",
"popular_movies": "Populaire Films",
"movie_genres": "Film Genres",
"upcoming_movies": "Aankomende Movies",
"studios": "Studios",
"popular_tv": "Populaire TV",
"tv_genres": "TV Genres",
"upcoming_tv": "Opkomend TV",
"networks": "Netwerken",
"tmdb_movie_keyword": "TMDB Film Trefwoord",
"tmdb_movie_genre": "TMDB Film Genre",
"tmdb_tv_keyword": "TMDB TV Trefwoord",
"tmdb_tv_genre": "TMDB TV Genre",
"tmdb_search": "TMDB Zoeken",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Netwerk",
"tmdb_movie_streaming_services": "TMDB Film Streaming Diensten",
"tmdb_tv_streaming_services": "TMDB TV Streaming Diensten"
},
"library": {
"no_items_found": "Geen items gevonden",
"no_results": "Geen resultaten",
"no_libraries_found": "Geen bibliotheken gevonden",
"item_types": {
"movies": "films",
"series": "series",
"boxsets": "box sets",
"items": "items"
},
"options": {
"display": "Weergave",
"row": "Rij",
"list": "Lijst",
"image_style": "Stijl van afbeelding",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Toon titels",
"show_stats": "Toon statistieken"
},
"filters": {
"genres": "Genres",
"years": "Jaren",
"sort_by": "Sorteren op",
"sort_order": "Sorteer volgorde",
"tags": "Labels"
}
},
"favorites": {
"series": "Series",
"movies": "Films",
"episodes": "Afleveringen",
"videos": "Videos",
"boxsets": "Boxsets",
"playlists": "Afspeellijsten"
},
"custom_links": {
"no_links": "Geen links"
},
"player": {
"error": "Fout",
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
"client_error": "Fout van de client",
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
"message_from_server": "Bericht van de server: {{message}}",
"video_has_finished_playing": "Video is gedaan met spelen!",
"no_video_source": "Geen video bron...",
"next_episode": "Volgende Aflevering",
"refresh_tracks": "Tracks verversen",
"subtitle_tracks": "Ondertitel Tracks:",
"audio_tracks": "Audio Tracks:",
"playback_state": "Afspeelstatus:",
"no_data_available": "Geen data beschikbaar",
"index": "Index:"
},
"item_card": {
"next_up": "Volgende",
"no_items_to_display": "Geen items om te tonen",
"cast_and_crew": "Cast & Crew",
"series": "Series",
"seasons": "Seizoenen",
"season": "Seizoen",
"no_episodes_for_this_season": "Geen afleveringen voor dit seizoen",
"overview": "Overzicht",
"more_with": "Meer met {{name}}",
"similar_items": "Gelijkaardige items",
"no_similar_items_found": "Geen gelijkaardige items gevonden",
"video": "Video",
"more_details": "Meer details",
"quality": "Kwaliteit",
"audio": "Audio",
"subtitles": "Ondertitel",
"show_more": "Toon meer",
"show_less": "Toon minden",
"appeared_in": "Verschenen in",
"could_not_load_item": "Kon item niet laden",
"none": "Geen",
"download": {
"download_season": "Download Seizoen",
"download_series": "Download Serie",
"download_episode": "Download Aflevering",
"download_movie": "Download Film",
"download_x_item": "Download {{item_count}} items",
"download_button": "Download",
"using_optimized_server": "Geoptimaliseerde server gebruiken",
"using_default_method": "Standaard methode gebruiken"
}
},
"live_tv": {
"next": "Volgende ",
"previous": "Vorige",
"live_tv": "Live TV",
"coming_soon": "Binnenkort beschikbaar",
"on_now": "Nu op",
"shows": "Shows",
"movies": "Films",
"sports": "Sport",
"for_kids": "Voor kinderen",
"news": "Nieuws"
},
"jellyseerr":{
"confirm": "Bevestig",
"cancel": "Annuleer",
"yes": "Ja",
"whats_wrong": "Wat is er mis?",
"issue_type": "Type probleem",
"select_an_issue": "Selecteer een probleem",
"types": "Types",
"describe_the_issue": "(optioneel) beschrijf het probleem...",
"submit_button": "Verzenden",
"report_issue_button": "Meld een probleem",
"request_button": "Aanvragen",
"are_you_sure_you_want_to_request_all_seasons": "Ben je zeker dat je alle seizoenen wil aanvragen?",
"failed_to_login": "Kon niet aanmelden",
"cast": "Cast",
"details": "Details",
"status": "Status",
"original_title": "Originele titel",
"series_type": "Serie Type",
"release_dates": "Verschijningsdatums",
"first_air_date": "Eerste uitzenddatum",
"next_air_date": "Volgende uitzenddatum",
"revenue": "Inkomsten",
"budget": "Budget",
"original_language": "Originele taal",
"production_country": "Land van productie",
"studios": "Studio",
"network": "Netwerk",
"currently_streaming_on": "Momenteel te streamen op",
"advanced": "Geavanceerd",
"request_as": "Vraag aan als",
"tags": "Labels",
"quality_profile": "Kwaliteitsprofiel",
"root_folder": "Hoofdmap",
"season_x": "Seizoen {{seasons}}",
"season_number": "Seizoen {{season_number}}",
"number_episodes": "{{episode_number}} Afleveringen",
"born": "Geboren",
"appearances": "Verschijningen",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr server voldoet niet aan de minimale versievereisten! Update naar minimaal 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test gefaald. Probeer opnieuw.",
"failed_to_test_jellyseerr_server_url": "Mislukt bij het testen van jellyseerr server url",
"issue_submitted": "Probleem ingediend!",
"requested_item": "{{item}} aangevraagd!",
"you_dont_have_permission_to_request": "Je hebt geen toestemming om aanvragen te doen!",
"something_went_wrong_requesting_media": "Er ging iets iets mis met het aavragen van media!"
}
},
"tabs": {
"home": "Thuis",
"search": "Zoeken",
"library": "Bibliotheek",
"custom_links": "Aangepaste links",
"favorites": "Favorieten"
}
}

457
translations/tr.json Normal file
View 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"
}
}

457
translations/zh-TW.json Normal file
View File

@@ -0,0 +1,457 @@
{
"login": {
"username_required": "需要用戶名",
"error_title": "錯誤",
"login_title": "登入",
"login_to_title": "登入至",
"username_placeholder": "用戶名",
"password_placeholder": "密碼",
"login_button": "登入",
"quick_connect": "快速連接",
"enter_code_to_login": "輸入代碼 {{code}} 以登入",
"failed_to_initiate_quick_connect": "無法啟動快速連接",
"got_it": "知道了",
"connection_failed": "連接失敗",
"could_not_connect_to_server": "無法連接到伺服器。請檢查 URL 和您的網絡連接。",
"an_unexpected_error_occured": "發生意外錯誤",
"change_server": "更改伺服器",
"invalid_username_or_password": "無效的用戶名或密碼",
"user_does_not_have_permission_to_log_in": "用戶無權登入",
"server_is_taking_too_long_to_respond_try_again_later": "伺服器響應時間過長,請稍後再試",
"server_received_too_many_requests_try_again_later": "伺服器收到太多請求,請稍後再試。",
"there_is_a_server_error": "伺服器出錯",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "發生意外錯誤。您是否正確輸入了伺服器 URL"
},
"server": {
"enter_url_to_jellyfin_server": "輸入您的 Jellyfin 伺服器的 URL",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "連接",
"previous_servers": "先前的伺服器",
"clear_button": "清除",
"search_for_local_servers": "搜尋本地伺服器",
"searching": "搜尋中...",
"servers": "伺服器"
},
"home": {
"no_internet": "無網絡",
"no_items": "無項目",
"no_internet_message": "別擔心,您仍然可以觀看\n已下載的內容。",
"go_to_downloads": "前往下載",
"oops": "哎呀!",
"error_message": "出錯了。\n請重新登出並登入。",
"continue_watching": "繼續觀看",
"next_up": "下一個",
"recently_added_in": "最近添加於 {{libraryName}}",
"suggested_movies": "推薦電影",
"suggested_episodes": "推薦劇集",
"intro": {
"welcome_to_streamyfin": "歡迎來到 Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "一個免費且開源的 Jellyfin 客戶端。",
"features_title": "功能",
"features_description": "Streamyfin 擁有許多功能,並與多種軟體整合,您可以在設置菜單中找到這些功能,包括:",
"jellyseerr_feature_description": "連接到您的 Jellyseerr 實例並直接在應用程序中請求電影。",
"downloads_feature_title": "下載",
"downloads_feature_description": "下載電影和電視節目以離線觀看。使用默認方法或安裝 Optimized Server 以在背景中下載文件。",
"chromecast_feature_description": "將電影和電視節目投射到您的 Chromecast 設備。",
"centralised_settings_plugin_title": "統一設置插件",
"centralised_settings_plugin_description": "從 Jellyfin 伺服器上的統一位置改變設置。所有用戶的所有客戶端設置將會自動同步。",
"done_button": "完成",
"go_to_settings_button": "前往設置",
"read_more": "閱讀更多"
},
"settings": {
"settings_title": "設置",
"log_out_button": "登出",
"user_info": {
"user_info_title": "用戶信息",
"user": "用戶",
"server": "伺服器",
"token": "令牌",
"app_version": "應用版本"
},
"quick_connect": {
"quick_connect_title": "快速連接",
"authorize_button": "授權快速連接",
"enter_the_quick_connect_code": "輸入快速連接代碼...",
"success": "成功",
"quick_connect_autorized": "快速連接已授權",
"error": "錯誤",
"invalid_code": "無效代碼",
"authorize": "授權"
},
"media_controls": {
"media_controls_title": "媒體控制",
"forward_skip_length": "前進跳過長度",
"rewind_length": "倒帶長度",
"seconds_unit": "秒"
},
"audio": {
"audio_title": "音頻",
"set_audio_track": "從上一個項目設置音軌",
"audio_language": "音頻語言",
"audio_hint": "選擇默認音頻語言。",
"none": "無",
"language": "語言"
},
"subtitles": {
"subtitle_title": "字幕",
"subtitle_language": "字幕語言",
"subtitle_mode": "字幕模式",
"set_subtitle_track": "從上一個項目設置字幕軌道",
"subtitle_size": "字幕大小",
"subtitle_hint": "配置字幕偏好。",
"none": "無",
"language": "語言",
"loading": "加載中",
"modes": {
"Default": "默認",
"Smart": "智能",
"Always": "總是",
"None": "無",
"OnlyForced": "僅強制"
}
},
"other": {
"other_title": "其他",
"auto_rotate": "自動旋轉",
"video_orientation": "影片方向",
"orientation": "方向",
"orientations": {
"DEFAULT": "默認",
"ALL": "全部",
"PORTRAIT": "縱向",
"PORTRAIT_UP": "縱向向上",
"PORTRAIT_DOWN": "縱向向下",
"LANDSCAPE": "橫向",
"LANDSCAPE_LEFT": "橫向左",
"LANDSCAPE_RIGHT": "橫向右",
"OTHER": "其他",
"UNKNOWN": "未知"
},
"safe_area_in_controls": "控制中的安全區域",
"show_custom_menu_links": "顯示自定義菜單鏈接",
"hide_libraries": "隱藏媒體庫",
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
"disable_haptic_feedback": "禁用觸覺回饋"
},
"downloads": {
"downloads_title": "下載",
"download_method": "下載方法",
"remux_max_download": "Remux 最大下載",
"auto_download": "自動下載",
"optimized_versions_server": "Optimized Version 伺服器",
"save_button": "保存",
"optimized_server": "Optimized Server",
"optimized": "優化",
"default": "默認",
"optimized_version_hint": "輸入 Optimized Server 的 URL。URL 應包括 http(s) 和端口 (可選)。",
"read_more_about_optimized_server": "閱讀更多關於 Optimized Server 的信息。",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "插件",
"jellyseerr": {
"jellyseerr_warning": "此集成處於早期階段。功能可能會有變化。",
"server_url": "伺服器 URL",
"server_url_hint": "示例http(s)://your-host.url\n如果需要添加端口",
"server_url_placeholder": "Jellyseerr URL...",
"password": "密碼",
"password_placeholder": "輸入 Jellyfin 用戶 {{username}} 的密碼",
"save_button": "保存",
"clear_button": "清除",
"login_button": "登入",
"total_media_requests": "總媒體請求",
"movie_quota_limit": "電影配額限制",
"movie_quota_days": "電影配額天數",
"tv_quota_limit": "電視配額限制",
"tv_quota_days": "電視配額天數",
"reset_jellyseerr_config_button": "重置 Jellyseerr 配置",
"unlimited": "無限制"
},
"marlin_search": {
"enable_marlin_search": "啟用 Marlin 搜索",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "輸入 Marlin 伺服器的 URL。URL 應包括 http(s) 和端口 (可選)。",
"read_more_about_marlin": "閱讀更多關於 Marlin 的信息。",
"save_button": "保存",
"toasts": {
"saved": "已保存"
}
}
},
"storage": {
"storage_title": "存儲",
"app_usage": "應用 {{usedSpace}}%",
"device_usage": "設備 {{availableSpace}}%",
"size_used": "已使用 {{used}} / {{total}}",
"delete_all_downloaded_files": "刪除所有已下載文件"
},
"intro": {
"show_intro": "顯示介紹",
"reset_intro": "重置介紹"
},
"logs": {
"logs_title": "日誌",
"no_logs_available": "無可用日誌",
"delete_all_logs": "刪除所有日誌"
},
"languages": {
"title": "語言",
"app_language": "應用語言",
"app_language_description": "選擇應用的語言。",
"system": "系統"
},
"toasts": {
"error_deleting_files": "刪除文件時出錯",
"background_downloads_enabled": "背景下載已啟用",
"background_downloads_disabled": "背景下載已禁用",
"connected": "已連接",
"could_not_connect": "無法連接",
"invalid_url": "無效的 URL"
}
},
"downloads": {
"downloads_title": "下載",
"tvseries": "電視劇",
"movies": "電影",
"queue": "隊列",
"queue_hint": "應用重啟後隊列和下載將會丟失",
"no_items_in_queue": "隊列中無項目",
"no_downloaded_items": "無已下載項目",
"delete_all_movies_button": "刪除所有電影",
"delete_all_tvseries_button": "刪除所有電視劇",
"delete_all_button": "刪除全部",
"active_download": "活動下載",
"no_active_downloads": "無活動下載",
"active_downloads": "活動下載",
"new_app_version_requires_re_download": "新應用版本需要重新下載",
"new_app_version_requires_re_download_description": "新更新需要重新下載內容。請刪除所有已下載內容後再重試。",
"back": "返回",
"delete": "刪除",
"something_went_wrong": "出了些問題",
"could_not_get_stream_url_from_jellyfin": "無法從 Jellyfin 獲取串流 URL",
"eta": "預計完成時間 {{eta}}",
"methods": "方法",
"toasts": {
"you_are_not_allowed_to_download_files": "您無權下載文件。",
"deleted_all_movies_successfully": "成功刪除所有電影!",
"failed_to_delete_all_movies": "刪除所有電影失敗",
"deleted_all_tvseries_successfully": "成功刪除所有電視劇!",
"failed_to_delete_all_tvseries": "刪除所有電視劇失敗",
"download_cancelled": "下載已取消",
"could_not_cancel_download": "無法取消下載",
"download_completed": "下載完成",
"download_started_for": "開始下載 {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} 準備好下載",
"download_stated_for_item": "開始下載 {{item}}",
"download_failed_for_item": "下載失敗 {{item}} - {{error}}",
"download_completed_for_item": "下載完成 {{item}}",
"queued_item_for_optimization": "已將 {{item}} 排隊進行優化",
"failed_to_start_download_for_item": "無法開始下載 {{item}}: {{message}}",
"server_responded_with_status_code": "伺服器響應狀態 {{statusCode}}",
"no_response_received_from_server": "未收到伺服器的響應",
"error_setting_up_the_request": "設置請求時出錯",
"failed_to_start_download_for_item_unexpected_error": "無法開始下載 {{item}}: 發生意外錯誤",
"all_files_folders_and_jobs_deleted_successfully": "所有文件、文件夾和任務成功刪除",
"an_error_occured_while_deleting_files_and_jobs": "刪除文件和任務時發生錯誤",
"go_to_downloads": "前往下載"
}
}
},
"search": {
"search_here": "在這裡搜索...",
"search": "搜索...",
"x_items": "{{count}} 項目",
"library": "媒體庫",
"discover": "發現",
"no_results": "沒有結果",
"no_results_found_for": "未找到結果",
"movies": "電影",
"series": "系列",
"episodes": "劇集",
"collections": "收藏",
"actors": "演員",
"request_movies": "請求電影",
"request_series": "請求系列",
"recently_added": "最近添加",
"recent_requests": "最近請求",
"plex_watchlist": "Plex 觀影清單",
"trending": "趨勢",
"popular_movies": "熱門電影",
"movie_genres": "電影類型",
"upcoming_movies": "即將上映的電影",
"studios": "工作室",
"popular_tv": "熱門電視",
"tv_genres": "電視類型",
"upcoming_tv": "即將上映的電視",
"networks": "網絡",
"tmdb_movie_keyword": "TMDB 電影關鍵詞",
"tmdb_movie_genre": "TMDB 電影類型",
"tmdb_tv_keyword": "TMDB 電視關鍵詞",
"tmdb_tv_genre": "TMDB 電視類型",
"tmdb_search": "TMDB 搜索",
"tmdb_studio": "TMDB 工作室",
"tmdb_network": "TMDB 網絡",
"tmdb_movie_streaming_services": "TMDB 電影流媒體服務",
"tmdb_tv_streaming_services": "TMDB 電視流媒體服務"
},
"library": {
"no_items_found": "未找到項目",
"no_results": "沒有結果",
"no_libraries_found": "未找到媒體庫",
"item_types": {
"movies": "電影",
"series": "系列",
"boxsets": "套裝",
"items": "項目"
},
"options": {
"display": "顯示",
"row": "行",
"list": "列表",
"image_style": "圖片樣式",
"poster": "海報",
"cover": "封面",
"show_titles": "顯示標題",
"show_stats": "顯示統計"
},
"filters": {
"genres": "類型",
"years": "年份",
"sort_by": "排序依據",
"sort_order": "排序順序",
"tags": "標籤"
}
},
"favorites": {
"series": "系列",
"movies": "電影",
"episodes": "劇集",
"videos": "影片",
"boxsets": "套裝",
"playlists": "播放列表"
},
"custom_links": {
"no_links": "無鏈接"
},
"player": {
"error": "錯誤",
"failed_to_get_stream_url": "無法獲取流 URL",
"an_error_occured_while_playing_the_video": "播放影片時發生錯誤。請檢查設置中的日誌。",
"client_error": "客戶端錯誤",
"could_not_create_stream_for_chromecast": "無法為 Chromecast 建立串流",
"message_from_server": "來自伺服器的消息:{{message}}",
"video_has_finished_playing": "影片播放完畢!",
"no_video_source": "無影片來源...",
"next_episode": "下一集",
"refresh_tracks": "刷新軌道",
"subtitle_tracks": "字幕軌道:",
"audio_tracks": "音頻軌道:",
"playback_state": "播放狀態:",
"no_data_available": "無可用數據",
"index": "索引:"
},
"item_card": {
"next_up": "下一個",
"no_items_to_display": "無項目顯示",
"cast_and_crew": "演員和工作人員",
"series": "系列",
"seasons": "季",
"season": "季",
"no_episodes_for_this_season": "本季無劇集",
"overview": "概覽",
"more_with": "更多 {{name}} 的作品",
"similar_items": "類似項目",
"no_similar_items_found": "未找到類似項目",
"video": "影片",
"more_details": "更多詳情",
"quality": "質量",
"audio": "音頻",
"subtitles": "字幕",
"show_more": "顯示更多",
"show_less": "顯示更少",
"appeared_in": "出現於",
"could_not_load_item": "無法加載項目",
"none": "無",
"download": {
"download_season": "下載季度",
"download_series": "下載系列",
"download_episode": "下載劇集",
"download_movie": "下載電影",
"download_x_item": "下載 {{item_count}} 項目",
"download_button": "下載",
"using_optimized_server": "使用 Optimized Server",
"using_default_method": "使用默認方法"
}
},
"live_tv": {
"next": "下一個",
"previous": "上一個",
"live_tv": "直播電視",
"coming_soon": "即將推出",
"on_now": "正在播放",
"shows": "節目",
"movies": "電影",
"sports": "體育",
"for_kids": "兒童",
"news": "新聞"
},
"jellyseerr": {
"confirm": "確認",
"cancel": "取消",
"yes": "是",
"whats_wrong": "出了什麼問題?",
"issue_type": "問題類型",
"select_an_issue": "選擇一個問題",
"types": "類型",
"describe_the_issue": "(可選)描述問題...",
"submit_button": "提交",
"report_issue_button": "報告問題",
"request_button": "請求",
"are_you_sure_you_want_to_request_all_seasons": "您確定要請求所有季度的節目嗎?",
"failed_to_login": "登入失敗",
"cast": "演員",
"details": "詳情",
"status": "狀態",
"original_title": "原標題",
"series_type": "系列類型",
"release_dates": "發行日期",
"first_air_date": "首次播出日期",
"next_air_date": "下次播出日期",
"revenue": "收入",
"budget": "預算",
"original_language": "原始語言",
"production_country": "製作國家",
"studios": "工作室",
"network": "網絡",
"currently_streaming_on": "目前在以下流媒體上播放",
"advanced": "高級",
"request_as": "請求為",
"tags": "標籤",
"quality_profile": "質量配置文件",
"root_folder": "根文件夾",
"season_x": "第 {{seasons}} 季",
"season_number": "第 {{season_number}} 季",
"number_episodes": "{{episode_number}} 集",
"born": "出生",
"appearances": "出場",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr 伺服器不符合最低版本要求!請更新至至少 2.0.0",
"jellyseerr_test_failed": "Jellyseerr 測試失敗。請再試一次。",
"failed_to_test_jellyseerr_server_url": "無法測試 Jellyseerr 伺服器 URL",
"issue_submitted": "問題已提交!",
"requested_item": "已請求 {{item}}",
"you_dont_have_permission_to_request": "您無權請求媒體!",
"something_went_wrong_requesting_media": "請求媒體時出了些問題!"
}
},
"tabs": {
"home": "主頁",
"search": "搜索",
"library": "庫",
"custom_links": "自定義鏈接",
"favorites": "收藏"
}
}

View File

@@ -3,15 +3,8 @@
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

View File

@@ -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;
};
}

View File

@@ -11,6 +11,7 @@ import {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
import { apiAtom } from "@/providers/JellyfinProvider";
import { writeInfoLog } from "@/utils/log";
@@ -122,6 +123,7 @@ export type Settings = {
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
defaultBitrate?: Bitrate;
libraryOptions: LibraryOptions;
defaultAudioLanguage: CultureDto | null;
playDefaultAudioTrack: boolean;
@@ -143,6 +145,7 @@ export type Settings = {
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
recentlyAddedNotifications: boolean;
};
export interface Lockable<T> {
@@ -168,6 +171,7 @@ const defaultValues: Settings = {
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
defaultBitrate: BITRATES[0],
libraryOptions: {
display: "list",
cardStyle: "detailed",
@@ -195,6 +199,7 @@ const defaultValues: Settings = {
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
recentlyAddedNotifications: true,
};
const loadSettings = (): Partial<Settings> => {
@@ -265,6 +270,8 @@ export const useSettings = () => {
const newSettings = { ..._settings, ...update };
setSettings(newSettings);
// @ts-expect-error
saveSettings(newSettings);
}
};

View File

@@ -8,7 +8,7 @@ export const BACKGROUND_FETCH_TASK = "background-fetch";
export async function registerBackgroundFetchAsync() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
minimumInterval: 60 * 1, // 1 minutes
minimumInterval: 10 * 60, // 10 minutes
stopOnTerminate: false, // android only,
startOnBoot: false, // android only
});
@@ -24,3 +24,26 @@ export async function unregisterBackgroundFetchAsync() {
console.log("Error unregistering background fetch task", error);
}
}
export const BACKGROUND_FETCH_TASK_RECENTLY_ADDED =
"background-fetch-recently-added";
export async function registerBackgroundFetchAsyncRecentlyAdded() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, {
minimumInterval: 10 * 60, // 10 minutes
stopOnTerminate: false, // android only,
startOnBoot: true, // android only
});
} catch (error) {
console.log("Error registering background fetch task", error);
}
}
export async function unregisterBackgroundFetchAsyncRecentlyAdded() {
try {
BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK_RECENTLY_ADDED);
} catch (error) {
console.log("Error unregistering background fetch task", error);
}
}

View File

@@ -92,10 +92,8 @@ export function getDefaultPlaySettings(
}
}
// 4. Get default bitrate
const bitrate = BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity)
)[0];
// 4. Get default bitrate from settings or fallback to max
const bitrate = settings.defaultBitrate ?? BITRATES[0];
return {
item,

View File

@@ -36,6 +36,7 @@ export const getStreamUrl = async ({
mediaSource: MediaSourceInfo | undefined;
} | null> => {
if (!api || !userId || !item?.Id) {
console.warn("Missing required parameters for getStreamUrl");
return null;
}
@@ -111,15 +112,6 @@ export const getStreamUrl = async ({
if (mediaSource?.TranscodingUrl) {
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
const transcodeUrl = urlObj.toString();
@@ -129,9 +121,7 @@ export const getStreamUrl = async ({
sessionId: sessionId,
mediaSource,
};
}
if (mediaSource?.SupportsDirectPlay) {
} else {
const searchParams = new URLSearchParams({
playSessionId: sessionData?.PlaySessionId || "",
mediaSourceId: mediaSource?.Id || "",
@@ -158,39 +148,4 @@ export const getStreamUrl = async ({
};
}
}
if (item.MediaType === "Audio") {
if (mediaSource?.TranscodingUrl) {
return {
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
sessionId,
mediaSource,
};
}
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData?.PlaySessionId || "",
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return {
url: `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`,
sessionId,
mediaSource,
};
}
throw new Error("Unsupported media type");
};

View File

@@ -15,6 +15,7 @@ export const chromecastProfile: DeviceProfile = {
Codec: "aac,mp3,flac,opus,vorbis",
},
],
ContainerProfiles: [],
DirectPlayProfiles: [
{
Container: "mp4",

View File

@@ -42,11 +42,9 @@ export default {
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
Container: "fmp4",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
AudioCodec: "aac,mp3,ac3,dts",
},
{
Type: MediaTypes.Audio,
@@ -58,131 +56,81 @@ export default {
},
],
SubtitleProfiles: [
// Official foramts
// Official formats
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "Hls" },
{ Format: "vtt", Method: "External" },
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "Hls" },
{ Format: "webvtt", Method: "External" },
{ Format: "webvtt", Method: "Encode" },
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "Hls" },
{ Format: "srt", Method: "External" },
{ Format: "srt", Method: "Encode" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "Hls" },
{ Format: "subrip", Method: "External" },
{ Format: "subrip", Method: "Encode" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "Hls" },
{ Format: "ttml", Method: "External" },
{ Format: "ttml", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvbsub", Method: "Hls" },
{ Format: "dvbsub", Method: "External" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "Hls" },
{ Format: "ass", Method: "External" },
{ Format: "ass", Method: "Encode" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Hls" },
{ Format: "idx", Method: "External" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Hls" },
{ Format: "pgs", Method: "External" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Hls" },
{ Format: "pgssub", Method: "External" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "Hls" },
{ Format: "ssa", Method: "External" },
{ Format: "ssa", Method: "Encode" },
// Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "Hls" },
{ Format: "microdvd", Method: "External" },
{ Format: "microdvd", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "Hls" },
{ Format: "mov_text", Method: "External" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "Hls" },
{ Format: "mpl2", Method: "External" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "Hls" },
{ Format: "pjs", Method: "External" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "Hls" },
{ Format: "realtext", Method: "External" },
{ Format: "realtext", Method: "Encode" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "Hls" },
{ Format: "scc", Method: "External" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "Hls" },
{ Format: "smi", Method: "External" },
{ Format: "smi", Method: "Encode" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "Hls" },
{ Format: "stl", Method: "External" },
{ Format: "stl", Method: "Encode" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "Hls" },
{ Format: "sub", Method: "External" },
{ Format: "sub", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "Hls" },
{ Format: "subviewer", Method: "External" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Hls" },
{ Format: "teletext", Method: "External" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "Hls" },
{ Format: "text", Method: "External" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "Hls" },
{ Format: "vplayer", Method: "External" },
{ Format: "vplayer", Method: "Encode" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "Hls" },
{ Format: "xsub", Method: "External" },
{ Format: "xsub", Method: "Encode" },
],
};

View File

@@ -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" },
],
};

View File

@@ -0,0 +1,187 @@
import { storage } from "@/utils/mmkv";
import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import { getOrSetDeviceId } from "./device";
export const RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY =
"notification_send_for_item_ids";
const acceptedIemTypes = ["Movie", "Episode", "Series"];
async function sendNewItemNotification(item: BaseItemDto) {
if (Platform.isTV) return;
if (!item.Type) return;
if (!acceptedIemTypes.includes(item.Type)) return;
if (item.Type === "Movie")
await Notifications.scheduleNotificationAsync({
content: {
title: `New Movie Added`,
body: `${item.Name} (${item.ProductionYear})`,
},
trigger: null,
});
else if (item.Type === "Episode")
await Notifications.scheduleNotificationAsync({
content: {
title: `New Episode Added`,
body: `${item.SeriesName} - ${item.Name}`,
},
trigger: null,
});
else if (item.Type === "Series")
await Notifications.scheduleNotificationAsync({
content: {
title: `New Series Added`,
body: `${item.Name} (${item.ProductionYear})`,
},
trigger: null,
});
}
/**
* Fetches recently added items from Jellyfin and sends notifications for new content.
*
* This function performs the following operations:
* 1. Retrieves previously notified item IDs from storage
* 2. Connects to Jellyfin server using provided credentials
* 3. Fetches 5 most recent episodes and 5 most recent movies
* 4. Checks for new items that haven't been notified before
* 5. Sends notifications for new items
* 6. Updates storage with new item IDs
*
* Note: On first run (when no previous notifications exist), it will store all
* current items without sending notifications to avoid mass-notifications.
*
* @param userId - The Jellyfin user ID to fetch items for
* @param basePath - The base URL of the Jellyfin server
* @param token - The authentication token for the Jellyfin server
*/
export async function fetchAndStoreRecentlyAdded(
userId: string,
basePath: string,
token: string
): Promise<number> {
try {
const deviceName = await getDeviceName();
const id = getOrSetDeviceId();
// Get stored items
const _alreadySentItemIds = storage.getString(
RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY
);
const alreadySentItemIds: string[] = _alreadySentItemIds
? JSON.parse(_alreadySentItemIds)
: [];
console.log(
"fetchAndStoreRecentlyAdded ~ notifications stored:",
alreadySentItemIds.length
);
const jellyfin = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.27.0" },
deviceInfo: {
name: deviceName,
id,
},
});
const api = jellyfin?.createApi(basePath, token);
const response1 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Episode"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const response2 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Movie"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const response3 = await getItemsApi(api).getItems({
userId: userId,
limit: 10,
recursive: true,
includeItemTypes: ["Series"],
sortOrder: ["Descending"],
sortBy: ["DateCreated"],
});
const newEpisodes =
response1.data.Items?.map((item) => ({
Id: item.Id,
Name: item.Name,
DateCreated: item.DateCreated,
Type: item.Type,
})) ?? [];
const newMovies =
response2.data.Items?.map((item) => ({
Id: item.Id,
Name: item.Name,
DateCreated: item.DateCreated,
Type: item.Type,
})) ?? [];
const newSeries =
response3.data.Items?.map((item) => ({
Id: item.Id,
Name: item.Name,
DateCreated: item.DateCreated,
Type: item.Type,
})) ?? [];
const newIds: string[] = [];
const items = [...newEpisodes, ...newMovies, ...newSeries];
// Don't send initial mass-notifications if there are no previously sent notifications
if (alreadySentItemIds.length === 0) {
// Store all items as sent (since these items are already in the users library)
storage.set(
RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY,
JSON.stringify(items.map((item) => item.Id))
);
return items.length;
} else {
// Only send notifications for items that haven't been sent yet
for (const newItem of items) {
const alreadySentNotificationFor = alreadySentItemIds.some(
(id) => id === newItem.Id
);
if (!alreadySentNotificationFor) {
const fullItem = await getUserLibraryApi(api).getItem({
itemId: newItem.Id!,
userId: userId,
});
await sendNewItemNotification(fullItem.data);
newIds.push(newItem.Id!);
}
}
// Store all new items as sent, so that we don't send notifications for them again
storage.set(
RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY,
JSON.stringify([...new Set([...alreadySentItemIds, ...newIds])])
);
return newIds.length;
}
} catch (error) {
console.error("Error fetching recently added items:", error);
}
return 0;
}

9412
yarn.lock

File diff suppressed because it is too large Load Diff