Compare commits

..

17 Commits

Author SHA1 Message Date
sarendsen
343a2cb3b5 wip 2025-03-01 18:40:43 +01:00
sarendsen
49d1be6bdf wip 2025-03-01 16:07:24 +01:00
sarendsen
32687332b3 wip 2025-03-01 14:24:53 +01:00
sarendsen
7c96443532 wip 2025-03-01 14:23:13 +01:00
sarendsen
bf8728eeab wip 2025-03-01 14:20:52 +01:00
sarendsen
d7ae41907e feat: Add session count to app badge 2025-03-01 14:07:18 +01:00
lostb1t
446439c2e0 Update package.json 2025-02-28 00:11:03 +01:00
lostb1t
a5463d783d fix: use correct url on save for optimized 2025-02-26 19:25:20 +01:00
Little709
640db35456 fix: Update nl.json (#565) 2025-02-26 14:21:42 +01:00
Simon Eklundh
caa4b765c1 fix: makes the icon adaptive for android (#569) 2025-02-26 08:23:27 +01:00
sarendsen
9c6aebe66a small cleanup 2025-02-24 18:41:07 +01:00
sarendsen
ef42510383 small cleanup 2025-02-24 14:56:39 +01:00
sarendsen
5273dfd22b small cleanup 2025-02-24 14:23:48 +01:00
Fredrik Burmester
00bc4232fb fix: xcode warnings 2025-02-24 11:51:48 +01:00
sarendsen
35c9258062 fix: playback pause/play reporting 2025-02-24 10:30:01 +01:00
sarendsen
89bf51c3cc fix: playback reporting 2025-02-24 09:30:14 +01:00
sarendsen
f64c5a02db fix: add hw/sw badge to session 2025-02-23 19:15:10 +01:00
27 changed files with 267 additions and 457 deletions

View File

@@ -33,7 +33,9 @@
"jsEngine": "hermes",
"versionCode": 53,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
"foregroundImage": "./assets/images/adaptive_icon.png",
"backgroundColor": "#464646"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [

View File

@@ -4,7 +4,7 @@ import { FlashList } from "@shopify/flash-list";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Loader } from "@/components/Loader";
import { SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
import { HardwareAccelerationType, SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import Poster from "@/components/posters/Poster";
@@ -186,6 +186,7 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
resolution: <Ionicons name="film-outline" size={12} color="white" />,
language: <Ionicons name="language-outline" size={12} color="white" />,
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
hwType: <Ionicons name="hardware-chip-outline" size={12} color="white" />,
} as const;
const icon = (val: string) => {
@@ -200,6 +201,8 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
switch (key) {
case "bitrate":
return formatBitrate(val);
case "hwType":
return val === HardwareAccelerationType.None ? "sw" : "hw";
default:
return val;
}
@@ -219,6 +222,7 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
};
interface StreamProps {
hwType?: HardwareAccelerationType | null | undefined;
resolution?: string | null | undefined;
language?: string | null | undefined;
codec?: string | null | undefined;
@@ -296,8 +300,8 @@ const TranscodingView = ({ session }: SessionCardProps) => {
}, [session.PlayState?.SubtitleStreamIndex]);
const isTranscoding = useMemo(() => {
return session.PlayState?.PlayMethod == "Transcode";
}, [session.PlayState?.PlayMethod]);
return session.PlayState?.PlayMethod == "Transcode" && session.TranscodingInfo;
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
const videoStreamTitle = () => {
return videoStream?.DisplayTitle?.split(" ")[0];
@@ -313,6 +317,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
codec: videoStream?.Codec,
}}
transcodeProperties={{
hwType: session.TranscodingInfo?.HardwareAccelerationType,
bitrate: session.TranscodingInfo?.Bitrate,
codec: session.TranscodingInfo?.VideoCodec,
}}
@@ -332,7 +337,6 @@ const TranscodingView = ({ session }: SessionCardProps) => {
audioChannels: audioStream?.ChannelLayout,
}}
transcodeProperties={{
bitrate: session.TranscodingInfo?.Bitrate,
codec: session.TranscodingInfo?.AudioCodec,
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
}}

View File

@@ -38,7 +38,7 @@ export default function page() {
});
return await getStatistics({
url: settings?.optimizedVersionsServerUrl,
url: updatedUrl,
authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(),
});

View File

@@ -7,11 +7,10 @@ import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybac
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import {
OnDiscoveryStateChangedPayload,
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef, VLCRendererItem,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -30,12 +29,14 @@ import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BaseItemDto, MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {ListGroup} from "@/components/list/ListGroup";
import {ListItem} from "@/components/list/ListItem";
import {storage} from "@/utils/mmkv";
import {t} from "i18next";
import {
BaseItemDto,
MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -51,8 +52,6 @@ export default function page() {
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const [rendererItems, setRendererItems] = useState<VLCRendererItem[]>([]);
const discoveryModal = useRef<BottomSheetModal>(null);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -183,16 +182,15 @@ export default function page() {
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
const togglePlay = useCallback(async () => {
if (!api) return;
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
} else {
videoRef.current?.play();
}
}, [isPlaying, api, item, stream, videoRef, audioIndex, subtitleIndex, mediaSourceId, offline, progress]);
};
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
@@ -220,6 +218,23 @@ export default function page() {
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
return {
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,
isMuted: false,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
@@ -233,67 +248,30 @@ export default function page() {
if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
reportPlaybackProgress();
},
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
);
const changePlaybackState = useCallback(
async (isPlaying: boolean) => {
if (!api || offline || !stream) return;
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,
});
},
[api, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]
);
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [api, isPlaying, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
}, [item]);
const reportPlaybackStart = useCallback(async () => {
if (offline || !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,
});
hasReportedRef.current = true;
}, [api, item, stream]);
const hasReportedRef = useRef(false);
useEffect(() => {
if (stream && !hasReportedRef.current) {
reportPlaybackStart();
hasReportedRef.current = true; // Mark as reported
}
}, [stream]);
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
@@ -301,36 +279,19 @@ export default function page() {
offline,
});
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const onDiscoveryStateChanged = useCallback((e: OnDiscoveryStateChangedPayload) => {
const {renderers} = e.nativeEvent;
setRendererItems(renderers);
}, []);
const startDiscovery = useCallback(async () => {
videoRef?.current?.pause?.()
videoRef?.current?.stopDiscovery?.()
videoRef?.current?.startDiscovery?.()
discoveryModal?.current?.present?.()
}, [rendererItems, videoRef])
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
await changePlaybackState(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") {
setIsPlaying(false);
await changePlaybackState(false);
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
@@ -342,7 +303,7 @@ export default function page() {
setIsBuffering(true);
}
},
[changePlaybackState]
[reportPlaybackProgress]
);
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
@@ -428,7 +389,6 @@ export default function page() {
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onDiscoveryStateChanged={onDiscoveryStateChanged}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
@@ -440,76 +400,34 @@ export default function page() {
/>
</View>
{videoRef.current && !isPipStarted && isMounted === true ? (
<>
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
startDiscovery={startDiscovery}
isVlc
/>
<BottomSheetModal
ref={discoveryModal}
enableDynamicSizing
enableDismissOnClose
snapPoints={["100%"]}
onDismiss={() => {
videoRef.current?.stopDiscovery?.()
videoRef.current?.play?.()
}}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
}
>
<BottomSheetView>
<ListGroup title={t("player.device_discovery")} className="mt-4 h-1/3">
{rendererItems.map((renderItem, index) => (
<ListItem
onPress={() => {
// todo: set renderer item on player to change to device
}}
icon="cast"
title={renderItem.name}
key={index}
/>
))}
</ListGroup>
</BottomSheetView>
</BottomSheetModal>
</>
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
isVlc
/>
) : null}
</View>
);

View File

@@ -2,30 +2,26 @@ import "@/augmentations";
import { Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { getOrSetDeviceId, getTokenFromStorage, JellyfinProvider, apiAtom } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
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_SESSIONS,
registerBackgroundFetchAsyncSessions,
} from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
const BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") : null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
const BackgroundFetch = !Platform.isTV ? require("expo-background-fetch") : null;
import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router";
@@ -41,6 +37,10 @@ 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 { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { store } from "@/utils/store";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -74,20 +74,16 @@ function useNotificationObserver() {
}
}
Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => {
if (!isMounted || !response?.notification) {
return;
}
redirect(response?.notification);
Notifications.getLastNotificationResponseAsync().then((response: { notification: any }) => {
if (!isMounted || !response?.notification) {
return;
}
);
redirect(response?.notification);
});
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
}
);
const subscription = Notifications.addNotificationResponseReceivedListener((response: { notification: any }) => {
redirect(response.notification);
});
return () => {
isMounted = false;
@@ -97,6 +93,22 @@ function useNotificationObserver() {
}
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
console.log("TaskManager ~ sessions trigger");
const api = store.get(apiAtom);
if (api === null || api === undefined) return;
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: 360,
});
const result = response.data.filter((s) => s.NowPlayingItem);
Notifications.setBadgeCountAsync(result.length);
return BackgroundFetch.BackgroundFetchResult.NewData;
});
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
@@ -109,15 +121,13 @@ if (!Platform.isTV) {
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
if (!settings?.autoDownload || !url) return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
if (!token || !deviceId || !baseDirectory) return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
@@ -194,9 +204,7 @@ if (!Platform.isTV) {
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission"
);
const hasAskedBefore = storage.getString("hasAskedForNotificationPermission");
if (hasAskedBefore !== "true") {
const { status } = await Notifications.requestPermissionsAsync();
@@ -214,11 +222,7 @@ const checkAndRequestPermissions = async () => {
console.log("Already asked for notification permissions before.");
}
} catch (error) {
writeToLog(
"ERROR",
"Error checking/requesting notification permissions:",
error
);
writeToLog("ERROR", "Error checking/requesting notification permissions:", error);
console.error("Error checking/requesting notification permissions:", error);
}
};
@@ -253,12 +257,11 @@ const queryClient = new QueryClient({
function Layout() {
const [settings] = useSettings();
const [user] = useAtom(userAtom);
const appState = useRef(AppState.currentState);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
);
i18n.changeLanguage(settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en");
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
@@ -266,6 +269,11 @@ function Layout() {
useEffect(() => {
checkAndRequestPermissions();
(async () => {
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
registerBackgroundFetchAsyncSessions();
}
})();
}, []);
useEffect(() => {
@@ -275,24 +283,16 @@ function Layout() {
ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
}
}, [settings]);
useEffect(() => {
const subscription = AppState.addEventListener(
"change",
(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === "active") {
BackGroundDownloader.checkForExistingDownloads();
}
);
});
BackGroundDownloader.checkForExistingDownloads();
@@ -369,9 +369,7 @@ function Layout() {
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
let items: BaseItemDto[] = downloadedItems ? JSON.parse(downloadedItems) : [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -1,4 +1,4 @@
import {Ionicons, MaterialCommunityIcons} from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import { PropsWithChildren, ReactNode } from "react";
import {
TouchableOpacity,
@@ -13,7 +13,7 @@ interface Props extends TouchableOpacityProps, ViewProps {
value?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
icon?: keyof typeof Ionicons.glyphMap | keyof typeof MaterialCommunityIcons.glyphMap;
icon?: keyof typeof Ionicons.glyphMap;
showArrow?: boolean;
textColor?: "default" | "blue" | "red";
onPress?: () => void;
@@ -89,19 +89,7 @@ const ListItemContent = ({
<View className="flex flex-row items-center w-full">
{icon && (
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
{icon in Ionicons.glyphMap ?
<Ionicons
name={icon as keyof typeof Ionicons.glyphMap}
size={18}
color="white"
/>
:
<MaterialCommunityIcons
name={icon as keyof typeof MaterialCommunityIcons.glyphMap}
size={18}
color="white"
/>
}
<Ionicons name="person-circle-outline" size={18} color="white" />
</View>
)}
<Text

View File

@@ -80,7 +80,6 @@ interface Props {
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
startDiscovery: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -94,33 +93,32 @@ interface Props {
const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
item,
seek,
startDiscovery,
startPictureInPicture,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
offline = false,
enableTrickplay = true,
isVlc = false,
}) => {
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
offline = false,
enableTrickplay = true,
isVlc = false,
}) => {
const [settings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
@@ -496,17 +494,6 @@ export const Controls: React.FC<Props> = ({
)}
<View className="flex flex-row items-center space-x-2 ">
<TouchableOpacity
onPress={startDiscovery}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<MaterialIcons
name="cast"
size={24}
color="white"
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
{!Platform.isTV && (
<TouchableOpacity
onPress={startPictureInPicture}

View File

@@ -3,6 +3,8 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { userAtom } from "@/providers/JellyfinProvider";
import { Platform } from "react-native";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
export interface useSessionsProps {
refetchInterval: number;
@@ -22,9 +24,13 @@ export const useSessions = ({ refetchInterval = 5 * 1000, activeWithinSeconds =
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: activeWithinSeconds,
});
return response.data
const result = response.data
.filter((s) => s.NowPlayingItem)
.sort((a, b) => (b.NowPlayingItem?.Name ?? "").localeCompare(a.NowPlayingItem?.Name ?? ""));
Notifications.setBadgeCountAsync(result.length);
return result
},
refetchInterval: refetchInterval,
});

View File

@@ -23,8 +23,7 @@ public class VlcPlayerModule: Module {
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted",
"onDiscoveryStateChanged"
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
@@ -43,14 +42,6 @@ public class VlcPlayerModule: Module {
view.stop()
}
AsyncFunction("startDiscovery") { (view: VlcPlayerView) in
view.startDiscovery()
}
AsyncFunction("stopDiscovery") { (view: VlcPlayerView) in
view.stopDiscovery()
}
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
view.seekTo(time)
}

View File

@@ -26,15 +26,11 @@ public class VLCPlayerView: UIView {
}
class VLCPlayerWrapper: NSObject {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VLCPlayerWrapper")
private var lastProgressCall = Date().timeIntervalSince1970
public var player: VLCMediaPlayer = VLCMediaPlayer()
private var updatePlayerState: (() -> Void)?
private var updateVideoProgress: (() -> Void)?
private var onDiscoveryStateChanged: ((_ renderers: [[String : Any]]) -> Void)?
private var playerView: VLCPlayerView = VLCPlayerView()
public var discoverer: VLCRendererDiscoverer?
public weak var pipController: VLCPictureInPictureWindowControlling?
override public init() {
@@ -42,22 +38,15 @@ class VLCPlayerWrapper: NSObject {
player.delegate = self
player.drawable = self
player.scaleFactor = 0
#if DEBUG
let consoleLogger = VLCConsoleLogger()
consoleLogger.level = VLCLogLevel.debug
player.libraryInstance.loggers = [consoleLogger]
#endif
}
public func setup(
parent: UIView,
updatePlayerState: (() -> Void)?,
updateVideoProgress: (() -> Void)?,
onDiscoveryStateChanged: ((_ renderers: [[String : Any]]) -> Void)?
updateVideoProgress: (() -> Void)?
) {
self.updatePlayerState = updatePlayerState
self.updateVideoProgress = updateVideoProgress
self.onDiscoveryStateChanged = onDiscoveryStateChanged
player.delegate = self
parent.addSubview(playerView)
@@ -67,50 +56,6 @@ class VLCPlayerWrapper: NSObject {
public func getPlayerView() -> UIView {
return playerView
}
public func startDiscovery() {
if self.discoverer != nil {
self.discoverer!.stop()
self.discoverer!.start()
return
}
let _discoverer = VLCRendererDiscoverer(name: "bonjour renderer")
_discoverer!.delegate = self
self.discoverer = _discoverer
self.discoverer?.start()
}
public func stopDiscovery() {
guard let discoverer = self.discoverer else { return }
discoverer.stop()
}
}
extension VLCPlayerWrapper: VLCRendererDiscovererDelegate {
func rendererDiscovererItemAdded(_ rendererDiscoverer: VLCRendererDiscoverer?, item: VLCRendererItem?) {
logger.debug("Renderer item added: \(item)")
self.onDiscoveryStateChanged?(getRenderersMap(rendererDiscoverer: rendererDiscoverer))
}
func rendererDiscovererItemDeleted(_ rendererDiscoverer: VLCRendererDiscoverer?, item: VLCRendererItem?) {
logger.debug("Renderer item removed: \(item)")
self.onDiscoveryStateChanged?(getRenderersMap(rendererDiscoverer: rendererDiscoverer))
}
private func getRenderersMap(rendererDiscoverer: VLCRendererDiscoverer?) -> [[String : Any]] {
let renderers = (rendererDiscoverer ?? discoverer)?.renderers.enumerated().map { (index, rendererItem) in
return [
"index": index,
"name": rendererItem.name,
"type": rendererItem.type,
"iconURI": rendererItem.iconURI,
"flags": rendererItem.flags
]
} ?? []
logger.debug("Renderers mapped to: \(renderers)")
return renderers
}
}
// MARK: - VLCPictureInPictureDrawable
@@ -211,16 +156,6 @@ class VlcPlayerView: ExpoView {
private var externalSubtitles: [[String: String]]?
var hasSource = false
// 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?
@objc var onDiscoveryStateChanged: RCTDirectEventBlock?
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
@@ -234,15 +169,10 @@ class VlcPlayerView: ExpoView {
vlc.setup(
parent: self,
updatePlayerState: updatePlayerState,
updateVideoProgress: updateVideoProgress,
onDiscoveryStateChanged: updateDiscoveryState
updateVideoProgress: updateVideoProgress
)
}
private func updateDiscoveryState(renderers: [[String: Any]]) {
self.onDiscoveryStateChanged?(["renderers": renderers])
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillResignActive),
@@ -260,19 +190,6 @@ class VlcPlayerView: ExpoView {
self.vlc.pipController?.startPictureInPicture()
}
func startDiscovery() {
logger.debug("Starting Discovery")
self.vlc.startDiscovery()
if self.vlc.discoverer != nil {
logger.debug("Discoverer description: \(self.vlc.discoverer!.description)")
logger.debug("Discoverer renderer: \(self.vlc.discoverer!.renderers)")
}
}
func stopDiscovery() {
self.vlc.stopDiscovery()
}
@objc func play() {
self.vlc.player.play()
self.isPaused = false
@@ -323,6 +240,12 @@ class VlcPlayerView: ExpoView {
self.startPosition = source["startPosition"] as? Int32 ?? 0
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 {
logger.error("Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
@@ -347,20 +270,6 @@ class VlcPlayerView: ExpoView {
}
}
for item in initOptions {
let option = item.components(separatedBy: "=")
var key = option[0].replacingOccurrences(of: "--", with: "")
if option.count > 1 {
mediaOptions.updateValue(
option[1],
forKey: key
)
}
else {
media.addOption(key)
}
}
logger.debug("Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
@@ -493,7 +402,7 @@ class VlcPlayerView: ExpoView {
}
private func updateVideoProgress() {
guard let media = self.vlc.player.media else { return }
guard self.vlc.player.media != nil else { return }
let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0
@@ -518,6 +427,15 @@ class VlcPlayerView: ExpoView {
])
}
// 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 {
@@ -541,7 +459,7 @@ extension VlcPlayerView: SimpleAppLifecycleListener {
}
// Current solution to fixing black screen when re-entering application
if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true },
if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }),
!self.vlc.isMediaPlaying()
{
videoTrack.isSelected = false
@@ -561,6 +479,7 @@ extension VLCMediaPlayerState {
case .paused: return "Paused"
case .stopped: return "Stopped"
case .error: return "Error"
case .stopping: return "Stopping"
@unknown default: return "Unknown"
}
}

View File

@@ -30,18 +30,6 @@ export type PipStartedPayload = {
};
};
export type VLCRendererItem = {
index: number,
name: string,
type: string,
iconURI: string,
flags: number
}
export type OnDiscoveryStateChangedPayload = {
nativeEvent: { renderers: VLCRendererItem[] }
}
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
@@ -83,12 +71,9 @@ export type VlcPlayerViewProps = {
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
onDiscoveryStateChanged?: (event: OnDiscoveryStateChangedPayload) => void;
};
export interface VlcPlayerViewRef {
startDiscovery: () => Promise<void>;
stopDiscovery: () => Promise<void>;
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;

View File

@@ -23,12 +23,6 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({
startDiscovery: async () => {
await nativeRef.current?.startDiscovery()
},
stopDiscovery: async () => {
await nativeRef.current?.stopDiscovery()
},
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture()
},
@@ -106,7 +100,6 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoLoadEnd,
onVideoError,
onPipStarted,
onDiscoveryStateChanged,
...otherProps
} = props;
@@ -134,7 +127,6 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
onDiscoveryStateChanged={onDiscoveryStateChanged}
/>
);
}

View File

@@ -12,7 +12,6 @@
"android:tv": "EXPO_TV=1 expo run:android",
"prebuild": "EXPO_TV=0 bun run clean",
"prebuild:tv": "EXPO_TV=1 bun run clean",
"prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; bun run prebuild:tv",
"test": "jest --watchAll",
"lint": "expo lint",
"postinstall": "patch-package"

View File

@@ -3,6 +3,7 @@ import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { store } from "@/utils/store";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
@@ -165,6 +166,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
await refreshStreamyfinPluginSettings();
})();
}, []);
useEffect(() => {
store.set(apiAtom, api);
}, [api]);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min

View File

@@ -352,8 +352,7 @@
"audio_tracks": "Audiospuren:",
"playback_state": "Wiedergabestatus:",
"no_data_available": "Keine Daten verfügbar",
"index": "Index:",
"device_discovery": "Device discovery"
"index": "Index:"
},
"item_card": {
"next_up": "Als Nächstes",

View File

@@ -356,8 +356,7 @@
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"no_data_available": "No data available",
"index": "Index:",
"device_discovery": "Device discovery"
"index": "Index:"
},
"item_card": {
"next_up": "Next up",

View File

@@ -352,8 +352,7 @@
"audio_tracks": "Pistas de audio:",
"playback_state": "Estado de la reproducción:",
"no_data_available": "No hay datos disponibles",
"index": "Índice:",
"device_discovery": "Device discovery"
"index": "Índice:"
},
"item_card": {
"next_up": "A continuación",

View File

@@ -353,8 +353,7 @@
"audio_tracks": "Pistes audio:",
"playback_state": "État de lecture:",
"no_data_available": "Aucune donnée disponible",
"index": "Index:",
"device_discovery": "Device discovery"
"index": "Index:"
},
"item_card": {
"next_up": "À suivre",

View File

@@ -352,8 +352,7 @@
"audio_tracks": "Tracce audio:",
"playback_state": "Stato della riproduzione:",
"no_data_available": "Nessun dato disponibile",
"index": "Indice:",
"device_discovery": "Device discovery"
"index": "Indice:"
},
"item_card": {
"next_up": "Il prossimo",

View File

@@ -351,8 +351,7 @@
"audio_tracks": "音声トラック:",
"playback_state": "再生状態:",
"no_data_available": "データなし",
"index": "インデックス:",
"device_discovery": "Device discovery"
"index": "インデックス:"
},
"item_card": {
"next_up": "次",

View File

@@ -9,13 +9,13 @@
"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",
"failed_to_initiate_quick_connect": "Mislukt om Snel Verbinden op te starten",
"got_it": "Begrepen",
"connection_failed": "Verbinding gefaald",
"connection_failed": "Verbinding mislukt",
"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",
"change_server": "Server wijzigen",
"invalid_username_or_password": "Onjuiste 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",
@@ -42,7 +42,7 @@
"continue_watching": "Verder Kijken",
"next_up": "Volgende",
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
"suggested_movies": "Voorgestelde Films",
"suggested_movies": "Voorgestelde films",
"suggested_episodes": "Voorgestelde Afleveringen",
"intro": {
"welcome_to_streamyfin": "Welkom bij Streamyfin",
@@ -56,7 +56,7 @@
"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",
"go_to_settings_button": "Ga naar instellingen",
"read_more": "Lees meer"
},
"settings": {
@@ -82,7 +82,7 @@
"media_controls": {
"media_controls_title": "Media Bedieningen",
"forward_skip_length": "Duur voorwaarts overslaan",
"rewind_length": "Duur terugspeolen",
"rewind_length": "Duur terugspoelen",
"seconds_unit": "s"
},
"audio": {
@@ -96,7 +96,7 @@
"subtitles": {
"subtitle_title": "Ondertitels",
"subtitle_language": "Ondertitel taal",
"subtitle_mode": "Ondertitle Modus",
"subtitle_mode": "Ondertitelmodus",
"set_subtitle_track": "Gebruik Ondertitel Track Van Vorig Item",
"subtitle_size": "Ondertitel Grootte",
"subtitle_hint": "Stel ondertitel voorkeuren in.",
@@ -108,7 +108,7 @@
"Smart": "Slim",
"Always": "Altijd",
"None": "Geen",
"OnlyForced": "Alleen Geforceeerd"
"OnlyForced": "Alleen Geforceerd"
}
},
"other": {
@@ -131,18 +131,18 @@
"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.",
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
"disable_haptic_feedback": "Haptische feedback uitschakelen",
"default_quality": "Standaard kwaliteit"
},
"downloads": {
"downloads_title": "Downloads",
"download_method": "Download methode",
"remux_max_download": "Remux max download",
"remux_max_download": "Maximale Remux-download",
"auto_download": "Auto download",
"optimized_versions_server": "Geoptimaliseerde server versies",
"save_button": "Opslaan",
"optimized_server": "Geoptimailseerde Server",
"optimized_server": "Geoptimaliseerde 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.",
@@ -161,7 +161,7 @@
"password_placeholder": "Voeg het wachtwoord in voor de Jellyfin gebruiker {{username}}",
"save_button": "Opslaan",
"clear_button": "Wissen",
"login_button": "Aannmelden",
"login_button": "Aanmelden",
"total_media_requests": "Totaal aantal mediaverzoeken",
"movie_quota_limit": "Limiet filmquota",
"movie_quota_days": "Filmquota dagen",
@@ -171,7 +171,7 @@
"unlimited": "Ongelimiteerd"
},
"marlin_search": {
"enable_marlin_search": "Marlin Search inschakeln ",
"enable_marlin_search": "Marlin Search inschakelen ",
"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.",
@@ -205,7 +205,7 @@
"system": "Systeem"
},
"toasts": {
"error_deleting_files": "Fout bij het verwijden van bestanden",
"error_deleting_files": "Fout bij het verwijderen van bestanden",
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld",
"connected": "Verbonden",
@@ -237,7 +237,7 @@
"methods": "Methoden",
"toasts": {
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
"deleted_all_movies_successfully": "Alle filns succesvol verwijderd!",
"deleted_all_movies_successfully": "Alle films 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",
@@ -280,18 +280,18 @@
"recent_requests": "Recent Aangevraagd",
"plex_watchlist": "Plex Kijklijst",
"trending": "Trending",
"popular_movies": "Populaire Films",
"popular_movies": "Populaire films",
"movie_genres": "Film Genres",
"upcoming_movies": "Aankomende Movies",
"upcoming_movies": "Aankomende films",
"studios": "Studios",
"popular_tv": "Populaire TV",
"tv_genres": "TV Genres",
"upcoming_tv": "Opkomend TV",
"upcoming_tv": "Aankomende TV",
"networks": "Netwerken",
"tmdb_movie_keyword": "TMDB Film Trefwoord",
"tmdb_movie_genre": "TMDB Film Genre",
"tmdb_movie_genre": "TMDB Filmgenres",
"tmdb_tv_keyword": "TMDB TV Trefwoord",
"tmdb_tv_genre": "TMDB TV Genre",
"tmdb_tv_genre": "TMDB TV-Genres",
"tmdb_search": "TMDB Zoeken",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Netwerk",
@@ -303,9 +303,9 @@
"no_results": "Geen resultaten",
"no_libraries_found": "Geen bibliotheken gevonden",
"item_types": {
"movies": "films",
"series": "series",
"boxsets": "box sets",
"movies": "Films",
"series": "Series",
"boxsets": "Boxsets",
"items": "items"
},
"options": {
@@ -345,15 +345,14 @@
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
"message_from_server": "Bericht van de server",
"video_has_finished_playing": "Video is gedaan met spelen!",
"no_video_source": "Geen video bron...",
"no_video_source": "Geen videobron...",
"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:",
"device_discovery": "Device discovery"
"index": "Index:"
},
"item_card": {
"next_up": "Volgende",
@@ -373,7 +372,7 @@
"audio": "Audio",
"subtitles": "Ondertitel",
"show_more": "Toon meer",
"show_less": "Toon minden",
"show_less": "Toon minder",
"appeared_in": "Verschenen in",
"could_not_load_item": "Kon item niet laden",
"none": "Geen",
@@ -418,7 +417,7 @@
"details": "Details",
"status": "Status",
"original_title": "Originele titel",
"series_type": "Serie Type",
"series_type": "Serietype",
"release_dates": "Verschijningsdatums",
"first_air_date": "Eerste uitzenddatum",
"next_air_date": "Volgende uitzenddatum",
@@ -441,12 +440,12 @@
"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.",
"jellyseerr_test_failed": "Jellyseerr test mislukt. 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!"
"something_went_wrong_requesting_media": "Er ging iets mis met het aanvragen van media!"
}
},
"tabs": {

View File

@@ -351,8 +351,7 @@
"audio_tracks": "Ses Parçaları:",
"playback_state": "Oynatma Durumu:",
"no_data_available": "Veri bulunamadı",
"index": "İndeks:",
"device_discovery": "Device discovery"
"index": "İndeks:"
},
"item_card": {
"next_up": "Sıradaki",

View File

@@ -351,8 +351,7 @@
"audio_tracks": "音频轨道:",
"playback_state": "播放状态:",
"no_data_available": "无可用数据",
"index": "索引:",
"device_discovery": "Device discovery"
"index": "索引:"
},
"item_card": {
"next_up": "下一个",

View File

@@ -351,8 +351,7 @@
"audio_tracks": "音頻軌道:",
"playback_state": "播放狀態:",
"no_data_available": "無可用數據",
"index": "索引:",
"device_discovery": "Device discovery"
"index": "索引:"
},
"item_card": {
"next_up": "下一個",

View File

@@ -24,3 +24,26 @@ export async function unregisterBackgroundFetchAsync() {
console.log("Error unregistering background fetch task", error);
}
}
export const BACKGROUND_FETCH_TASK_SESSIONS =
"background-fetch-sessions";
export async function registerBackgroundFetchAsyncSessions() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK_SESSIONS, {
minimumInterval: 1 * 60, // 1 minutes
stopOnTerminate: false, // android only,
startOnBoot: true, // android only
});
} catch (error) {
console.log("Error registering background fetch task", error);
}
}
export async function unregisterBackgroundFetchAsyncSessions() {
try {
BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK_SESSIONS);
} catch (error) {
console.log("Error unregistering background fetch task", error);
}
}

3
utils/store.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createStore } from 'jotai';
export const store = createStore();