forked from Ninjalama/streamyfin_mirror
feat: remotecontrol (#705)
This commit is contained in:
@@ -18,12 +18,18 @@ import {
|
||||
HardwareAccelerationType,
|
||||
type SessionInfoDto,
|
||||
} 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 { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { get } from "lodash";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -181,6 +258,107 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function page() {
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||
@@ -67,6 +68,10 @@ export default function page() {
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
const VolumeManager = Platform.isTV
|
||||
? null
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
let getDownloadedItem = null;
|
||||
if (!Platform.isTV) {
|
||||
getDownloadedItem = downloadProvider.useDownload();
|
||||
@@ -219,7 +224,7 @@ export default function page() {
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
reportPlaybackProgress();
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
@@ -239,7 +244,15 @@ export default function page() {
|
||||
});
|
||||
|
||||
revalidateProgressCache();
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
progress,
|
||||
offline,
|
||||
revalidateProgressCache,
|
||||
]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
@@ -265,7 +278,7 @@ export default function page() {
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
isMuted: false,
|
||||
isMuted: isMuted,
|
||||
canSeek: true,
|
||||
repeatMode: RepeatMode.RepeatNone,
|
||||
playbackOrder: PlaybackOrder.Default,
|
||||
@@ -329,13 +342,84 @@ export default function page() {
|
||||
return item?.UserData?.PlaybackPositionTicks
|
||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||
: 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({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline,
|
||||
toggleMute: toggleMuteCb,
|
||||
volumeUp: volumeUpCb,
|
||||
volumeDown: volumeDownCb,
|
||||
setVolume: setVolumeCb,
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import type {
|
||||
@@ -34,6 +35,7 @@ import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
export type SelectedOptions = {
|
||||
@@ -50,6 +52,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
useImageColors({ item });
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
@@ -97,6 +101,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</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 };
|
||||
};
|
||||
|
||||
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;
|
||||
stopPlayback: () => void;
|
||||
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 = ({
|
||||
@@ -16,38 +48,270 @@ export const useWebSocket = ({
|
||||
togglePlay,
|
||||
stopPlayback,
|
||||
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) => {
|
||||
const router = useRouter();
|
||||
const { ws } = useWebSocketContext();
|
||||
const { lastMessage } = useWebSocketContext();
|
||||
const { t } = useTranslation();
|
||||
const { clearLastMessage } = useWebSocketContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
if (!lastMessage) return;
|
||||
if (offline) return;
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const json = JSON.parse(e.data);
|
||||
const command = json?.Data?.Command;
|
||||
const messageType = lastMessage.MessageType;
|
||||
const command: string | undefined =
|
||||
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("Command ~ PlayPause");
|
||||
console.log("[WS] ~ ", lastMessage);
|
||||
|
||||
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();
|
||||
} 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 () => {
|
||||
ws.onmessage = null;
|
||||
};
|
||||
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
|
||||
toggleMute?.();
|
||||
} else if (command === "ToggleOsd") {
|
||||
console.log("Command ~ ToggleOsd");
|
||||
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 { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -12,6 +13,12 @@ import React, {
|
||||
} from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
|
||||
interface WebSocketMessage {
|
||||
MessageType: string;
|
||||
Data: any;
|
||||
// Add other fields as needed
|
||||
}
|
||||
|
||||
interface WebSocketProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -19,6 +26,9 @@ interface WebSocketProviderProps {
|
||||
interface WebSocketContextType {
|
||||
ws: WebSocket | null;
|
||||
isConnected: boolean;
|
||||
lastMessage: WebSocketMessage | null;
|
||||
sendMessage: (message: any) => void;
|
||||
clearLastMessage: () => void;
|
||||
}
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
@@ -27,7 +37,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
const router = useRouter();
|
||||
const deviceId = useMemo(() => {
|
||||
return getOrSetDeviceId();
|
||||
}, []);
|
||||
@@ -48,6 +59,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
let keepAliveInterval: number | null = null;
|
||||
|
||||
newWebSocket.onopen = () => {
|
||||
console.log("WebSocket connection opened");
|
||||
setIsConnected(true);
|
||||
keepAliveInterval = setInterval(() => {
|
||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||
@@ -56,9 +68,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 5;
|
||||
const reconnectDelay = 10000;
|
||||
|
||||
newWebSocket.onerror = (e) => {
|
||||
console.error("WebSocket error:", e);
|
||||
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 = () => {
|
||||
@@ -67,7 +93,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}
|
||||
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);
|
||||
|
||||
return () => {
|
||||
@@ -78,6 +112,41 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
};
|
||||
}, [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(() => {
|
||||
const cleanup = connectWebSocket();
|
||||
return cleanup;
|
||||
@@ -126,9 +195,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
ws?.close();
|
||||
};
|
||||
}, [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 (
|
||||
<WebSocketContext.Provider value={{ ws, isConnected }}>
|
||||
<WebSocketContext.Provider
|
||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||
>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user