forked from Ninjalama/streamyfin_mirror
feat: remotecontrol (#705)
This commit is contained in:
@@ -18,12 +18,18 @@ import {
|
|||||||
HardwareAccelerationType,
|
HardwareAccelerationType,
|
||||||
type SessionInfoDto,
|
type SessionInfoDto,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
GeneralCommandType,
|
||||||
|
PlaystateCommand,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import { get } from "lodash";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
@@ -110,6 +116,77 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle session controls
|
||||||
|
const [isControlLoading, setIsControlLoading] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const handleSystemCommand = async (command: GeneralCommandType) => {
|
||||||
|
if (!api || !session.Id) return false;
|
||||||
|
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSessionApi(api).sendSystemCommand({
|
||||||
|
sessionId: session.Id,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending ${command} command:`, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaystateCommand = async (command: PlaystateCommand) => {
|
||||||
|
if (!api || !session.Id) return false;
|
||||||
|
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSessionApi(api).sendPlaystateCommand({
|
||||||
|
sessionId: session.Id,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending playstate ${command} command:`, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayPause = async () => {
|
||||||
|
console.log("handlePlayPause");
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.PlayPause);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.Stop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.NextTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMute = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.ToggleMute);
|
||||||
|
};
|
||||||
|
const handleVolumeUp = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.VolumeUp);
|
||||||
|
};
|
||||||
|
const handleVolumeDown = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.VolumeDown);
|
||||||
|
};
|
||||||
|
|
||||||
useInterval(tick, 1000);
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -181,6 +258,107 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Session controls */}
|
||||||
|
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePrevious}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='skip-previous'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayPause}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.PlayPause]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.PlayPause]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.PlayState?.IsPaused ? (
|
||||||
|
<Ionicons name='play' size={24} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='pause' size={24} color='white' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleStop}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.Stop]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='stop' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleNext}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.NextTrack]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.NextTrack]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='skip-next'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleVolumeDown}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.VolumeDown]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='volume-low' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.ToggleMute]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='volume-mute'
|
||||||
|
size={24}
|
||||||
|
color={session.PlayState?.IsMuted ? "red" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleVolumeUp}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.VolumeUp]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='volume-high' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function page() {
|
|||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||||
@@ -67,6 +68,10 @@ export default function page() {
|
|||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
const VolumeManager = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
let getDownloadedItem = null;
|
let getDownloadedItem = null;
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
getDownloadedItem = downloadProvider.useDownload();
|
getDownloadedItem = downloadProvider.useDownload();
|
||||||
@@ -219,7 +224,7 @@ export default function page() {
|
|||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
reportPlaybackStopped();
|
reportPlaybackProgress();
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
@@ -239,7 +244,15 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [api, item, mediaSourceId, stream]);
|
}, [
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
mediaSourceId,
|
||||||
|
stream,
|
||||||
|
progress,
|
||||||
|
offline,
|
||||||
|
revalidateProgressCache,
|
||||||
|
]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
@@ -265,7 +278,7 @@ export default function page() {
|
|||||||
isPaused: !isPlaying,
|
isPaused: !isPlaying,
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
isMuted: false,
|
isMuted: isMuted,
|
||||||
canSeek: true,
|
canSeek: true,
|
||||||
repeatMode: RepeatMode.RepeatNone,
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
playbackOrder: PlaybackOrder.Default,
|
||||||
@@ -329,13 +342,84 @@ export default function page() {
|
|||||||
return item?.UserData?.PlaybackPositionTicks
|
return item?.UserData?.PlaybackPositionTicks
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||||
: 0;
|
: 0;
|
||||||
}, [item]);
|
}, [item, offline]);
|
||||||
|
|
||||||
|
const volumeUpCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||||
|
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
||||||
|
|
||||||
|
await VolumeManager.setVolume(newVolume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adjusting volume:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const toggleMuteCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||||
|
const currentVolumePercent = currentVolume * 100;
|
||||||
|
|
||||||
|
if (currentVolumePercent > 0) {
|
||||||
|
// Currently not muted, so mute
|
||||||
|
setPreviousVolume(currentVolumePercent);
|
||||||
|
await VolumeManager.setVolume(0);
|
||||||
|
setIsMuted(true);
|
||||||
|
} else {
|
||||||
|
// Currently muted, so restore previous volume
|
||||||
|
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
|
||||||
|
await VolumeManager.setVolume(volumeToRestore / 100);
|
||||||
|
setPreviousVolume(null);
|
||||||
|
setIsMuted(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error toggling mute:", error);
|
||||||
|
}
|
||||||
|
}, [previousVolume]);
|
||||||
|
const volumeDownCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||||
|
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
||||||
|
console.log(
|
||||||
|
"Volume Down",
|
||||||
|
Math.round(currentVolume * 100),
|
||||||
|
"→",
|
||||||
|
Math.round(newVolume * 100),
|
||||||
|
);
|
||||||
|
await VolumeManager.setVolume(newVolume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adjusting volume:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVolumeCb = useCallback(async (newVolume: number) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
||||||
|
console.log("Setting volume to", clampedVolume);
|
||||||
|
await VolumeManager.setVolume(clampedVolume / 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting volume:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay: togglePlay,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
offline,
|
||||||
|
toggleMute: toggleMuteCb,
|
||||||
|
volumeUp: volumeUpCb,
|
||||||
|
volumeDown: volumeDownCb,
|
||||||
|
setVolume: setVolumeCb,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useImageColors } from "@/hooks/useImageColors";
|
|||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import type {
|
import type {
|
||||||
@@ -34,6 +35,7 @@ import { ItemHeader } from "./ItemHeader";
|
|||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
@@ -50,6 +52,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
@@ -97,6 +101,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={item} size='large' />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
194
components/PlayInRemoteSession.tsx
Normal file
194
components/PlayInRemoteSession.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
type BaseItemDto,
|
||||||
|
PlayCommand,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
size?: "default" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Playing ${item.Name} in session ${sessionId}`);
|
||||||
|
getSessionApi(api).play({
|
||||||
|
sessionId,
|
||||||
|
itemIds: [item.Id],
|
||||||
|
playCommand: PlayCommand.PlayNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error playing in remote session:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
icon='play-circle-outline'
|
||||||
|
onPress={() => setModalVisible(true)}
|
||||||
|
size={props.size}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
animationType='slide'
|
||||||
|
transparent={true}
|
||||||
|
visible={modalVisible}
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.centeredView}>
|
||||||
|
<View style={styles.modalView}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Select Session</Text>
|
||||||
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : !sessions || sessions.length === 0 ? (
|
||||||
|
<Text style={styles.noSessionsText}>
|
||||||
|
No active sessions found
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={sessions}
|
||||||
|
keyExtractor={(session) => session.Id || "unknown"}
|
||||||
|
renderItem={({ item: session }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.sessionItem}
|
||||||
|
onPress={() => handlePlayInSession(session.Id || "")}
|
||||||
|
>
|
||||||
|
<View style={styles.sessionInfo}>
|
||||||
|
<Text style={styles.sessionName}>
|
||||||
|
{session.DeviceName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.sessionDetails}>
|
||||||
|
{session.UserName} • {session.Client}
|
||||||
|
</Text>
|
||||||
|
{session.NowPlayingItem && (
|
||||||
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
|
Now playing:{" "}
|
||||||
|
{session.NowPlayingItem.SeriesName
|
||||||
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
|
: ""}
|
||||||
|
{session.NowPlayingItem.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Ionicons name='play-sharp' size={20} color='#888' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
centeredView: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
},
|
||||||
|
modalView: {
|
||||||
|
width: "90%",
|
||||||
|
maxHeight: "80%",
|
||||||
|
backgroundColor: "#1c1c1c",
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
noSessionsText: {
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#888",
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
sessionItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
},
|
||||||
|
sessionInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
sessionName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sessionDetails: {
|
||||||
|
fontSize: 13,
|
||||||
|
opacity: 0.7,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
nowPlaying: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.5,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -44,3 +44,27 @@ export const useSessions = ({
|
|||||||
|
|
||||||
return { sessions: data, isLoading };
|
return { sessions: data, isLoading };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAllSessions = ({
|
||||||
|
refetchInterval = 5 * 1000,
|
||||||
|
activeWithinSeconds = 360,
|
||||||
|
}: useSessionsProps) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["allSessions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const response = await getSessionApi(api).getSessions({
|
||||||
|
activeWithinSeconds: activeWithinSeconds,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sessions: data, isLoading };
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,38 @@ interface UseWebSocketProps {
|
|||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
stopPlayback: () => void;
|
stopPlayback: () => void;
|
||||||
offline: boolean;
|
offline: boolean;
|
||||||
|
|
||||||
|
nextTrack?: () => void;
|
||||||
|
previousTrack?: () => void;
|
||||||
|
rewindPlayback?: () => void;
|
||||||
|
fastForwardPlayback?: () => void;
|
||||||
|
seekPlayback?: (positionTicks: number) => void;
|
||||||
|
volumeUp?: () => void;
|
||||||
|
volumeDown?: () => void;
|
||||||
|
toggleMute?: () => void;
|
||||||
|
toggleOsd?: () => void;
|
||||||
|
toggleFullscreen?: () => void;
|
||||||
|
goHome?: () => void;
|
||||||
|
goToSettings?: () => void;
|
||||||
|
setAudioStreamIndex?: (index: number) => void;
|
||||||
|
setSubtitleStreamIndex?: (index: number) => void;
|
||||||
|
|
||||||
|
moveUp?: () => void;
|
||||||
|
moveDown?: () => void;
|
||||||
|
moveLeft?: () => void;
|
||||||
|
moveRight?: () => void;
|
||||||
|
select?: () => void;
|
||||||
|
pageUp?: () => void;
|
||||||
|
pageDown?: () => void;
|
||||||
|
setVolume?: (volume: number) => void;
|
||||||
|
setRepeatMode?: (mode: string) => void;
|
||||||
|
setShuffleMode?: (mode: string) => void;
|
||||||
|
togglePictureInPicture?: () => void;
|
||||||
|
takeScreenshot?: () => void;
|
||||||
|
sendString?: (text: string) => void;
|
||||||
|
sendKey?: (key: string) => void;
|
||||||
|
playMediaSource?: (itemIds: string[], startPositionTicks?: number) => void;
|
||||||
|
playTrailers?: (itemId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWebSocket = ({
|
export const useWebSocket = ({
|
||||||
@@ -16,38 +48,270 @@ export const useWebSocket = ({
|
|||||||
togglePlay,
|
togglePlay,
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
offline,
|
offline,
|
||||||
|
nextTrack,
|
||||||
|
previousTrack,
|
||||||
|
rewindPlayback,
|
||||||
|
fastForwardPlayback,
|
||||||
|
seekPlayback,
|
||||||
|
volumeUp,
|
||||||
|
volumeDown,
|
||||||
|
toggleMute,
|
||||||
|
toggleOsd,
|
||||||
|
toggleFullscreen,
|
||||||
|
goHome,
|
||||||
|
goToSettings,
|
||||||
|
setAudioStreamIndex,
|
||||||
|
setSubtitleStreamIndex,
|
||||||
|
moveUp,
|
||||||
|
moveDown,
|
||||||
|
moveLeft,
|
||||||
|
moveRight,
|
||||||
|
select,
|
||||||
|
pageUp,
|
||||||
|
pageDown,
|
||||||
|
setVolume,
|
||||||
|
setRepeatMode,
|
||||||
|
setShuffleMode,
|
||||||
|
togglePictureInPicture,
|
||||||
|
takeScreenshot,
|
||||||
|
sendString,
|
||||||
|
sendKey,
|
||||||
|
playMediaSource,
|
||||||
|
playTrailers,
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { ws } = useWebSocketContext();
|
const { lastMessage } = useWebSocketContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { clearLastMessage } = useWebSocketContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (!lastMessage) return;
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
const messageType = lastMessage.MessageType;
|
||||||
const json = JSON.parse(e.data);
|
const command: string | undefined =
|
||||||
const command = json?.Data?.Command;
|
lastMessage?.Data?.Command || lastMessage?.Data?.Name;
|
||||||
|
|
||||||
console.log("[WS] ~ ", json);
|
const args = lastMessage?.Data?.Arguments as
|
||||||
|
| Record<string, string>
|
||||||
|
| undefined; // Arguments are Dictionary<string, string>
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
console.log("[WS] ~ ", lastMessage);
|
||||||
console.log("Command ~ PlayPause");
|
|
||||||
|
if (command === "PlayPause") {
|
||||||
|
console.log("Command ~ PlayPause");
|
||||||
|
togglePlay();
|
||||||
|
} else if (command === "Stop") {
|
||||||
|
console.log("Command ~ Stop");
|
||||||
|
stopPlayback();
|
||||||
|
router.canGoBack() && router.back();
|
||||||
|
} else if (command === "Pause") {
|
||||||
|
console.log("Command ~ Pause");
|
||||||
|
if (isPlaying) {
|
||||||
togglePlay();
|
togglePlay();
|
||||||
} else if (command === "Stop") {
|
|
||||||
console.log("Command ~ Stop");
|
|
||||||
stopPlayback();
|
|
||||||
router.canGoBack() && router.back();
|
|
||||||
} else if (json?.Data?.Name === "DisplayMessage") {
|
|
||||||
console.log("Command ~ DisplayMessage");
|
|
||||||
const title = json?.Data?.Arguments?.Header;
|
|
||||||
const body = json?.Data?.Arguments?.Text;
|
|
||||||
Alert.alert(t("player.message_from_server", { message: title }), body);
|
|
||||||
}
|
}
|
||||||
};
|
} else if (command === "Unpause") {
|
||||||
|
console.log("Command ~ Unpause");
|
||||||
|
if (!isPlaying) {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
} else if (command === "NextTrack") {
|
||||||
|
console.log("Command ~ NextTrack");
|
||||||
|
nextTrack?.();
|
||||||
|
} else if (command === "PreviousTrack") {
|
||||||
|
console.log("Command ~ PreviousTrack");
|
||||||
|
previousTrack?.();
|
||||||
|
} else if (command === "Rewind") {
|
||||||
|
console.log("Command ~ Rewind");
|
||||||
|
rewindPlayback?.();
|
||||||
|
} else if (command === "FastForward") {
|
||||||
|
console.log("Command ~ FastForward");
|
||||||
|
fastForwardPlayback?.();
|
||||||
|
} else if (command === "Seek") {
|
||||||
|
const positionStr = args?.SeekPositionTicks;
|
||||||
|
console.log("Command ~ Seek", { positionStr });
|
||||||
|
if (positionStr) {
|
||||||
|
const position = Number.parseInt(positionStr, 10);
|
||||||
|
if (!Number.isNaN(position)) {
|
||||||
|
seekPlayback?.(position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (command === "Back") {
|
||||||
|
console.log("Command ~ Back");
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
} else if (command === "GoHome") {
|
||||||
|
console.log("Command ~ GoHome");
|
||||||
|
goHome ? goHome() : router.push("/");
|
||||||
|
} else if (command === "GoToSettings") {
|
||||||
|
console.log("Command ~ GoToSettings");
|
||||||
|
goToSettings ? goToSettings() : router.push("/settings");
|
||||||
|
} else if (command === "VolumeUp") {
|
||||||
|
console.log("Command ~ VolumeUp");
|
||||||
|
volumeUp?.();
|
||||||
|
} else if (command === "VolumeDown") {
|
||||||
|
console.log("Command ~ VolumeDown");
|
||||||
|
volumeDown?.();
|
||||||
|
} else if (command === "ToggleMute") {
|
||||||
|
console.log("Command ~ ToggleMute");
|
||||||
|
|
||||||
return () => {
|
toggleMute?.();
|
||||||
ws.onmessage = null;
|
} else if (command === "ToggleOsd") {
|
||||||
};
|
console.log("Command ~ ToggleOsd");
|
||||||
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
|
toggleOsd?.();
|
||||||
|
} else if (command === "ToggleFullscreen") {
|
||||||
|
console.log("Command ~ ToggleFullscreen");
|
||||||
|
toggleFullscreen?.();
|
||||||
|
} else if (command === "SetAudioStreamIndex") {
|
||||||
|
const indexStr = args?.Index;
|
||||||
|
console.log("Command ~ SetAudioStreamIndex", { indexStr });
|
||||||
|
if (indexStr) {
|
||||||
|
const index = Number.parseInt(indexStr, 10);
|
||||||
|
if (!Number.isNaN(index)) {
|
||||||
|
setAudioStreamIndex?.(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (command === "SetSubtitleStreamIndex") {
|
||||||
|
const indexStr = args?.Index;
|
||||||
|
console.log("Command ~ SetSubtitleStreamIndex", { indexStr });
|
||||||
|
if (indexStr) {
|
||||||
|
const index = Number.parseInt(indexStr, 10);
|
||||||
|
if (!Number.isNaN(index)) {
|
||||||
|
setSubtitleStreamIndex?.(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Neue Befehle hier implementieren
|
||||||
|
else if (command === "MoveUp") {
|
||||||
|
console.log("Command ~ MoveUp");
|
||||||
|
moveUp?.();
|
||||||
|
} else if (command === "MoveDown") {
|
||||||
|
console.log("Command ~ MoveDown");
|
||||||
|
moveDown?.();
|
||||||
|
} else if (command === "MoveLeft") {
|
||||||
|
console.log("Command ~ MoveLeft");
|
||||||
|
moveLeft?.();
|
||||||
|
} else if (command === "MoveRight") {
|
||||||
|
console.log("Command ~ MoveRight");
|
||||||
|
moveRight?.();
|
||||||
|
} else if (command === "Select") {
|
||||||
|
console.log("Command ~ Select");
|
||||||
|
select?.();
|
||||||
|
} else if (command === "PageUp") {
|
||||||
|
console.log("Command ~ PageUp");
|
||||||
|
pageUp?.();
|
||||||
|
} else if (command === "PageDown") {
|
||||||
|
console.log("Command ~ PageDown");
|
||||||
|
pageDown?.();
|
||||||
|
} else if (command === "SetVolume") {
|
||||||
|
const volumeStr = args?.Volume;
|
||||||
|
console.log("Command ~ SetVolume", { volumeStr });
|
||||||
|
if (volumeStr) {
|
||||||
|
const volumeValue = Number.parseInt(volumeStr, 10);
|
||||||
|
if (!Number.isNaN(volumeValue)) {
|
||||||
|
setVolume?.(volumeValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (command === "SetRepeatMode") {
|
||||||
|
const mode = args?.Mode;
|
||||||
|
console.log("Command ~ SetRepeatMode", { mode });
|
||||||
|
if (mode) {
|
||||||
|
setRepeatMode?.(mode);
|
||||||
|
}
|
||||||
|
} else if (command === "SetShuffleMode") {
|
||||||
|
const mode = args?.Mode;
|
||||||
|
console.log("Command ~ SetShuffleMode", { mode });
|
||||||
|
if (mode) {
|
||||||
|
setShuffleMode?.(mode);
|
||||||
|
}
|
||||||
|
} else if (command === "TogglePictureInPicture") {
|
||||||
|
console.log("Command ~ TogglePictureInPicture");
|
||||||
|
togglePictureInPicture?.();
|
||||||
|
} else if (command === "TakeScreenshot") {
|
||||||
|
console.log("Command ~ TakeScreenshot");
|
||||||
|
takeScreenshot?.();
|
||||||
|
} else if (command === "SendString") {
|
||||||
|
const text = args?.Text;
|
||||||
|
console.log("Command ~ SendString", { text });
|
||||||
|
if (text) {
|
||||||
|
sendString?.(text);
|
||||||
|
}
|
||||||
|
} else if (command === "SendKey") {
|
||||||
|
const key = args?.Key;
|
||||||
|
console.log("Command ~ SendKey", { key });
|
||||||
|
if (key) {
|
||||||
|
sendKey?.(key);
|
||||||
|
}
|
||||||
|
} else if (command === "PlayMediaSource") {
|
||||||
|
const itemIdsStr = args?.ItemIds;
|
||||||
|
const startPositionTicksStr = args?.StartPositionTicks;
|
||||||
|
console.log("Command ~ PlayMediaSource", {
|
||||||
|
itemIdsStr,
|
||||||
|
startPositionTicksStr,
|
||||||
|
});
|
||||||
|
if (itemIdsStr) {
|
||||||
|
const itemIds = itemIdsStr.split(",");
|
||||||
|
let startPositionTicks: number | undefined = undefined;
|
||||||
|
if (startPositionTicksStr) {
|
||||||
|
const parsedTicks = Number.parseInt(startPositionTicksStr, 10);
|
||||||
|
if (!Number.isNaN(parsedTicks)) {
|
||||||
|
startPositionTicks = parsedTicks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playMediaSource?.(itemIds, startPositionTicks);
|
||||||
|
}
|
||||||
|
} else if (command === "PlayTrailers") {
|
||||||
|
const itemId = args?.ItemId;
|
||||||
|
console.log("Command ~ PlayTrailers", { itemId });
|
||||||
|
if (itemId) {
|
||||||
|
playTrailers?.(itemId);
|
||||||
|
}
|
||||||
|
} else if (command === "DisplayMessage") {
|
||||||
|
console.log("Command ~ DisplayMessage");
|
||||||
|
const title = args?.Header;
|
||||||
|
const body = args?.Text;
|
||||||
|
Alert.alert(t("player.message_from_server", { message: title }), body);
|
||||||
|
}
|
||||||
|
clearLastMessage();
|
||||||
|
}, [
|
||||||
|
lastMessage,
|
||||||
|
offline,
|
||||||
|
isPlaying,
|
||||||
|
togglePlay,
|
||||||
|
stopPlayback,
|
||||||
|
router,
|
||||||
|
nextTrack,
|
||||||
|
previousTrack,
|
||||||
|
rewindPlayback,
|
||||||
|
fastForwardPlayback,
|
||||||
|
seekPlayback,
|
||||||
|
volumeUp,
|
||||||
|
volumeDown,
|
||||||
|
toggleMute,
|
||||||
|
toggleOsd,
|
||||||
|
toggleFullscreen,
|
||||||
|
goHome,
|
||||||
|
goToSettings,
|
||||||
|
setAudioStreamIndex,
|
||||||
|
setSubtitleStreamIndex,
|
||||||
|
moveUp,
|
||||||
|
moveDown,
|
||||||
|
moveLeft,
|
||||||
|
moveRight,
|
||||||
|
select,
|
||||||
|
pageUp,
|
||||||
|
pageDown,
|
||||||
|
setVolume,
|
||||||
|
setRepeatMode,
|
||||||
|
setShuffleMode,
|
||||||
|
togglePictureInPicture,
|
||||||
|
takeScreenshot,
|
||||||
|
sendString,
|
||||||
|
sendKey,
|
||||||
|
playMediaSource,
|
||||||
|
playTrailers,
|
||||||
|
t,
|
||||||
|
clearLastMessage,
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -12,6 +13,12 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
|
||||||
|
interface WebSocketMessage {
|
||||||
|
MessageType: string;
|
||||||
|
Data: any;
|
||||||
|
// Add other fields as needed
|
||||||
|
}
|
||||||
|
|
||||||
interface WebSocketProviderProps {
|
interface WebSocketProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -19,6 +26,9 @@ interface WebSocketProviderProps {
|
|||||||
interface WebSocketContextType {
|
interface WebSocketContextType {
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
lastMessage: WebSocketMessage | null;
|
||||||
|
sendMessage: (message: any) => void;
|
||||||
|
clearLastMessage: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||||
@@ -27,7 +37,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
const deviceId = useMemo(() => {
|
const deviceId = useMemo(() => {
|
||||||
return getOrSetDeviceId();
|
return getOrSetDeviceId();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -48,6 +59,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
let keepAliveInterval: number | null = null;
|
let keepAliveInterval: number | null = null;
|
||||||
|
|
||||||
newWebSocket.onopen = () => {
|
newWebSocket.onopen = () => {
|
||||||
|
console.log("WebSocket connection opened");
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
keepAliveInterval = setInterval(() => {
|
keepAliveInterval = setInterval(() => {
|
||||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
@@ -56,9 +68,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
const reconnectDelay = 10000;
|
||||||
|
|
||||||
newWebSocket.onerror = (e) => {
|
newWebSocket.onerror = (e) => {
|
||||||
console.error("WebSocket error:", e);
|
console.error("WebSocket error:", e);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
|
||||||
|
if (reconnectAttempts < maxReconnectAttempts) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(`WebSocket reconnect attempt ${reconnectAttempts}`);
|
||||||
|
connectWebSocket();
|
||||||
|
}, reconnectDelay);
|
||||||
|
} else {
|
||||||
|
console.warn("Max WebSocket reconnect attempts reached.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
newWebSocket.onclose = () => {
|
newWebSocket.onclose = () => {
|
||||||
@@ -67,7 +93,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}
|
}
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
};
|
};
|
||||||
|
newWebSocket.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(e.data);
|
||||||
|
console.log("[WS] Received message:", message);
|
||||||
|
setLastMessage(message); // Store the last message in context
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing WebSocket message:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
setWs(newWebSocket);
|
setWs(newWebSocket);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -78,6 +112,41 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
};
|
};
|
||||||
}, [api, deviceId]);
|
}, [api, deviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastMessage.MessageType === "Play") {
|
||||||
|
handlePlayCommand(lastMessage.Data);
|
||||||
|
}
|
||||||
|
}, [lastMessage, router]);
|
||||||
|
|
||||||
|
const handlePlayCommand = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
||||||
|
console.warn("[WS] Received Play command with no items");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = data.ItemIds[0];
|
||||||
|
console.log(`[WS] Handling Play command for item: ${itemId}`);
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/player/direct-player",
|
||||||
|
params: {
|
||||||
|
itemId: itemId,
|
||||||
|
playCommand: data.PlayCommand || "PlayNow",
|
||||||
|
audioIndex: data.AudioStreamIndex?.toString(),
|
||||||
|
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
||||||
|
mediaSourceId: data.MediaSourceId || "",
|
||||||
|
bitrateValue: "",
|
||||||
|
offline: "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = connectWebSocket();
|
const cleanup = connectWebSocket();
|
||||||
return cleanup;
|
return cleanup;
|
||||||
@@ -126,9 +195,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
ws?.close();
|
ws?.close();
|
||||||
};
|
};
|
||||||
}, [ws, connectWebSocket]);
|
}, [ws, connectWebSocket]);
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(message: any) => {
|
||||||
|
if (ws && isConnected) {
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
} else {
|
||||||
|
console.warn("Cannot send message: WebSocket is not connected");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ws, isConnected],
|
||||||
|
);
|
||||||
|
const clearLastMessage = useCallback(() => {
|
||||||
|
setLastMessage(null);
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider value={{ ws, isConnected }}>
|
<WebSocketContext.Provider
|
||||||
|
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user