mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-23 14:20:25 +01:00
Compare commits
1 Commits
feat/syncp
...
v0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f34f2e6a6 |
@@ -1,7 +1,7 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
@@ -32,16 +32,6 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/syncplay");
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="people" color={"white"} size={22} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Chromecast />
|
<Chromecast />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -68,13 +58,6 @@ export default function IndexLayout() {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="syncplay"
|
|
||||||
options={{
|
|
||||||
title: "Syncplay",
|
|
||||||
presentation: "modal",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { SettingToggles } from "@/components/settings/SettingToggles";
|
|||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, readFromLog } from "@/utils/log";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import { red } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
@@ -26,6 +28,36 @@ export default function settings() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
|
Alert.prompt(
|
||||||
|
"Quick connect",
|
||||||
|
"Enter the code from the Jellyfin app",
|
||||||
|
async (text) => {
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
|
code: text,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
console.log(res.status, res.statusText, res.data);
|
||||||
|
if (res.status === 200) {
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
Alert.alert("Success", "Quick connect authorized");
|
||||||
|
} else {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -38,12 +70,19 @@ export default function settings() {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem title="User" subTitle={user?.Name} />
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
|
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||||
|
Authorize
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { List } from "@/components/List";
|
|
||||||
import { ListItem } from "@/components/ListItem";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { ActivityIndicator, Alert, ScrollView, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const name = useMemo(() => user?.Name || "", [user]);
|
|
||||||
|
|
||||||
const { data: activeGroups } = useQuery({
|
|
||||||
queryKey: ["syncplay", "activeGroups"],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
const res = await getSyncPlayApi(api).syncPlayGetGroups();
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
refetchInterval: 1000,
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnMount: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createGroupMutation = useMutation({
|
|
||||||
mutationFn: async (GroupName: string) => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getSyncPlayApi(api).syncPlayCreateGroup({
|
|
||||||
newGroupRequestDto: {
|
|
||||||
GroupName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.status !== 204) {
|
|
||||||
Alert.alert("Error", "Failed to create group");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createGroup = () => {
|
|
||||||
Alert.prompt("Create Group", "Enter a name for the group", (text) => {
|
|
||||||
if (text) {
|
|
||||||
createGroupMutation.mutate(text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const joinGroupMutation = useMutation({
|
|
||||||
mutationFn: async (groupId: string) => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getSyncPlayApi(api).syncPlayJoinGroup({
|
|
||||||
joinGroupRequestDto: {
|
|
||||||
GroupId: groupId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.status !== 204) {
|
|
||||||
Alert.alert("Error", "Failed to join group");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const leaveGroupMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getSyncPlayApi(api).syncPlayLeaveGroup();
|
|
||||||
if (res.status !== 204) {
|
|
||||||
Alert.alert("Error", "Failed to exit group");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView>
|
|
||||||
<View className="px-4 py-4">
|
|
||||||
<View>
|
|
||||||
<Text className="text-lg font-bold mb-4">Join group</Text>
|
|
||||||
{!activeGroups?.length && (
|
|
||||||
<Text className="text-neutral-500 mb-4">No active groups</Text>
|
|
||||||
)}
|
|
||||||
<List>
|
|
||||||
{activeGroups?.map((group) => (
|
|
||||||
<ListItem
|
|
||||||
key={group.GroupId}
|
|
||||||
title={group.GroupName}
|
|
||||||
onPress={async () => {
|
|
||||||
if (!group.GroupId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (group.Participants?.includes(name)) {
|
|
||||||
leaveGroupMutation.mutate();
|
|
||||||
} else {
|
|
||||||
joinGroupMutation.mutate(group.GroupId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
iconAfter={
|
|
||||||
group.Participants?.includes(name) ? (
|
|
||||||
<Ionicons name="exit-outline" size={20} color="white" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="add" size={20} color="white" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
subTitle={group.Participants?.join(", ")}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<ListItem
|
|
||||||
onPress={() => createGroup()}
|
|
||||||
key={"create"}
|
|
||||||
title={"Create group"}
|
|
||||||
iconAfter={
|
|
||||||
createGroupMutation.isPending ? (
|
|
||||||
<ActivityIndicator size={20} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="add" size={20} color="white" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
videoRef,
|
videoRef,
|
||||||
presentFullscreenPlayer,
|
presentFullscreenPlayer,
|
||||||
onProgress,
|
onProgress,
|
||||||
onBuffer,
|
|
||||||
} = usePlayback();
|
} = usePlayback();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -183,24 +182,18 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
enable: true,
|
enable: true,
|
||||||
thread: true,
|
thread: true,
|
||||||
}}
|
}}
|
||||||
onIdle={() => {
|
|
||||||
console.log("IDLE");
|
|
||||||
}}
|
|
||||||
fullscreenAutorotate={true}
|
|
||||||
onReadyForDisplay={() => {
|
|
||||||
console.log("READY FOR DISPLAY");
|
|
||||||
}}
|
|
||||||
onProgress={(e) => onProgress(e)}
|
onProgress={(e) => onProgress(e)}
|
||||||
subtitleStyle={{
|
subtitleStyle={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
}}
|
}}
|
||||||
onBuffer={(e) => onBuffer(e.isBuffering)}
|
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
presentFullscreenPlayer();
|
presentFullscreenPlayer();
|
||||||
}, 300);
|
}, 300);
|
||||||
}}
|
}}
|
||||||
|
onFullscreenPlayerDidDismiss={() => {}}
|
||||||
|
onFullscreenPlayerDidPresent={() => {}}
|
||||||
onPlaybackStateChanged={(e) => {
|
onPlaybackStateChanged={(e) => {
|
||||||
if (e.isPlaying === true) {
|
if (e.isPlaying === true) {
|
||||||
playVideo(false);
|
playVideo(false);
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const List: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
import {
|
import { View, ViewProps } from "react-native";
|
||||||
TouchableOpacity,
|
|
||||||
TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null | undefined;
|
title?: string | null | undefined;
|
||||||
subTitle?: string | null | undefined;
|
subTitle?: string | null | undefined;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -22,7 +17,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -31,6 +26,6 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{iconAfter}
|
{iconAfter}
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
@@ -16,7 +22,9 @@ import { useState } from "react";
|
|||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
|
|
||||||
export const SettingToggles: React.FC = () => {
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -49,7 +57,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
{/* <View>
|
{/* <View>
|
||||||
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
||||||
|
|||||||
@@ -18,21 +18,13 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
PlaybackInfoResponse,
|
PlaybackInfoResponse,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi, getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce, isBuffer } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { OnProgressData, type VideoRef } from "react-native-video";
|
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
import {
|
|
||||||
GroupData,
|
|
||||||
GroupJoinedData,
|
|
||||||
PlayQueueData,
|
|
||||||
StateUpdateData,
|
|
||||||
} from "@/types/syncplay";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
type CurrentlyPlayingState = {
|
type CurrentlyPlayingState = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -43,8 +35,6 @@ interface PlaybackContextType {
|
|||||||
sessionData: PlaybackInfoResponse | null | undefined;
|
sessionData: PlaybackInfoResponse | null | undefined;
|
||||||
currentlyPlaying: CurrentlyPlayingState | null;
|
currentlyPlaying: CurrentlyPlayingState | null;
|
||||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||||
onBuffer: (isBuffering: boolean) => void;
|
|
||||||
onReady: () => void;
|
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
progressTicks: number | null;
|
progressTicks: number | null;
|
||||||
@@ -84,7 +74,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||||
const [volume, _setVolume] = useState<number | null>(null);
|
const [volume, _setVolume] = useState<number | null>(null);
|
||||||
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
||||||
const [syncplayGroup, setSyncplayGroup] = useState<GroupData | null>(null);
|
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] =
|
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||||
useState<CurrentlyPlayingState | null>(null);
|
useState<CurrentlyPlayingState | null>(null);
|
||||||
|
|
||||||
@@ -126,7 +115,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setCurrentlyPlayingState = useCallback(
|
const setCurrentlyPlayingState = useCallback(
|
||||||
async (state: CurrentlyPlayingState | null, paused = false) => {
|
async (state: CurrentlyPlayingState | null) => {
|
||||||
try {
|
try {
|
||||||
if (state?.item.Id && user?.Id) {
|
if (state?.item.Id && user?.Id) {
|
||||||
const vlcLink = "vlc://" + state?.url;
|
const vlcLink = "vlc://" + state?.url;
|
||||||
@@ -148,12 +137,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
setSession(res.data);
|
setSession(res.data);
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
|
setIsPlaying(true);
|
||||||
if (paused === true) {
|
|
||||||
pauseVideo();
|
|
||||||
} else {
|
|
||||||
playVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -267,53 +251,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
|
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onBuffer = useCallback(
|
|
||||||
(isBuffering: boolean) => {
|
|
||||||
console.log("Buffering...", "Playing:", isPlaying);
|
|
||||||
if (
|
|
||||||
isBuffering &&
|
|
||||||
syncplayGroup?.GroupId &&
|
|
||||||
isPlaying === false &&
|
|
||||||
currentlyPlaying?.item.PlaylistItemId
|
|
||||||
) {
|
|
||||||
console.log("Sending syncplay buffering...");
|
|
||||||
getSyncPlayApi(api!).syncPlayBuffering({
|
|
||||||
bufferRequestDto: {
|
|
||||||
IsPlaying: isPlaying,
|
|
||||||
When: new Date().toISOString(),
|
|
||||||
PositionTicks: progressTicks ? progressTicks : 0,
|
|
||||||
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
syncplayGroup?.GroupId,
|
|
||||||
currentlyPlaying?.item.PlaylistItemId,
|
|
||||||
api,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onReady = useCallback(() => {
|
|
||||||
if (syncplayGroup?.GroupId && currentlyPlaying?.item.PlaylistItemId) {
|
|
||||||
getSyncPlayApi(api!).syncPlayReady({
|
|
||||||
readyRequestDto: {
|
|
||||||
When: new Date().toISOString(),
|
|
||||||
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
|
|
||||||
IsPlaying: isPlaying,
|
|
||||||
PositionTicks: progressTicks ? progressTicks : 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
syncplayGroup?.GroupId,
|
|
||||||
currentlyPlaying?.item.PlaylistItemId,
|
|
||||||
progressTicks,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
debounce((e: OnProgressData) => {
|
debounce((e: OnProgressData) => {
|
||||||
_onProgress(e);
|
_onProgress(e);
|
||||||
@@ -331,185 +268,63 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setIsFullscreen(false);
|
setIsFullscreen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((ticks: number) => {
|
|
||||||
const time = ticks / 10000000;
|
|
||||||
videoRef.current?.seek(time);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deviceId || !api?.accessToken || !user?.Id) {
|
if (!deviceId || !api?.accessToken) return;
|
||||||
console.info("[WS] Waiting for deviceId, accessToken and userId");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
||||||
|
|
||||||
const url = `${protocol}://${api?.basePath
|
const url = `${protocol}://${api?.basePath
|
||||||
.replace("https://", "")
|
.replace("https://", "")
|
||||||
.replace("http://", "")}/socket?api_key=${
|
.replace("http://", "")}/socket?api_key=${
|
||||||
api?.accessToken
|
api?.accessToken
|
||||||
}&deviceId=${deviceId}`;
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
let ws: WebSocket | null = null;
|
const newWebSocket = new WebSocket(url);
|
||||||
|
|
||||||
let keepAliveInterval: NodeJS.Timeout | null = null;
|
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
const connect = () => {
|
newWebSocket.onopen = () => {
|
||||||
ws = new WebSocket(url);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
// Start sending "KeepAlive" message every 30 seconds
|
||||||
keepAliveInterval = setInterval(() => {
|
keepAliveInterval = setInterval(() => {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
console.log("⬆︎ KeepAlive...");
|
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
ws.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (e) => {
|
newWebSocket.onerror = (e) => {
|
||||||
console.error("WebSocket error:", e);
|
console.error("WebSocket error:", e);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
newWebSocket.onclose = (e) => {
|
||||||
setIsConnected(false);
|
|
||||||
if (keepAliveInterval) {
|
if (keepAliveInterval) {
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
setTimeout(connect, 5000); // Attempt to reconnect after 5 seconds
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setWs(ws);
|
setWs(newWebSocket);
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
if (keepAliveInterval) {
|
if (keepAliveInterval) {
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
|
newWebSocket.close();
|
||||||
};
|
};
|
||||||
}, [api?.accessToken, deviceId, user]);
|
}, [api, deviceId, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws || !api) return;
|
if (!ws) return;
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
const command = json?.Data?.Command;
|
const command = json?.Data?.Command;
|
||||||
|
|
||||||
if (json.MessageType === "KeepAlive") {
|
|
||||||
console.log("⬇︎ KeepAlive...");
|
|
||||||
} else if (json.MessageType === "ForceKeepAlive") {
|
|
||||||
console.log("⬇︎ ForceKeepAlive...");
|
|
||||||
} else if (json.MessageType === "SyncPlayCommand") {
|
|
||||||
console.log("SyncPlayCommand ~", command, json.Data);
|
|
||||||
switch (command) {
|
|
||||||
case "Stop":
|
|
||||||
console.log("STOP");
|
|
||||||
stopPlayback();
|
|
||||||
break;
|
|
||||||
case "Pause":
|
|
||||||
console.log("PAUSE");
|
|
||||||
pauseVideo();
|
|
||||||
break;
|
|
||||||
case "Play":
|
|
||||||
case "Unpause":
|
|
||||||
console.log("PLAY");
|
|
||||||
playVideo();
|
|
||||||
break;
|
|
||||||
case "Seek":
|
|
||||||
console.log("SEEK", json.Data.PositionTicks);
|
|
||||||
seek(json.Data.PositionTicks);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (json.MessageType === "SyncPlayGroupUpdate") {
|
|
||||||
const type = json.Data.Type;
|
|
||||||
|
|
||||||
if (type === "StateUpdate") {
|
|
||||||
const data = json.Data.Data as StateUpdateData;
|
|
||||||
console.log("StateUpdate ~", data);
|
|
||||||
} else if (type === "GroupJoined") {
|
|
||||||
const data = json.Data.Data as GroupData;
|
|
||||||
setSyncplayGroup(data);
|
|
||||||
console.log("GroupJoined ~", data);
|
|
||||||
} else if (type === "GroupLeft") {
|
|
||||||
console.log("GroupLeft");
|
|
||||||
setSyncplayGroup(null);
|
|
||||||
} else if (type === "PlayQueue") {
|
|
||||||
const data = json.Data.Data as PlayQueueData;
|
|
||||||
console.log("PlayQueue ~", {
|
|
||||||
IsPlaying: data.IsPlaying,
|
|
||||||
Reason: data.Reason,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.Reason === "SetCurrentItem") {
|
|
||||||
console.log("SetCurrentItem ~ ", json);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.Reason === "NewPlaylist") {
|
|
||||||
const itemId = data.Playlist?.[data.PlayingItemIndex].ItemId;
|
|
||||||
if (!itemId) {
|
|
||||||
console.error("No itemId found in PlayQueue");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set playback item
|
|
||||||
getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId,
|
|
||||||
}).then(async (item) => {
|
|
||||||
if (!item) {
|
|
||||||
Alert.alert("Error", "Could not find item for syncplay");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: data.StartPositionTicks,
|
|
||||||
userId: user?.Id,
|
|
||||||
mediaSourceId: item?.MediaSources?.[0].Id!,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
Alert.alert("Error", "Could not find stream url for syncplay");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await setCurrentlyPlayingState(
|
|
||||||
{
|
|
||||||
item,
|
|
||||||
url,
|
|
||||||
},
|
|
||||||
!data.IsPlaying
|
|
||||||
);
|
|
||||||
|
|
||||||
await getSyncPlayApi(api).syncPlayReady({
|
|
||||||
readyRequestDto: {
|
|
||||||
IsPlaying: data.IsPlaying,
|
|
||||||
PositionTicks: data.StartPositionTicks,
|
|
||||||
PlaylistItemId: data.Playlist[0].PlaylistItemId,
|
|
||||||
When: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("[WS] ~ ", json);
|
console.log("[WS] ~ ", json);
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
console.log("[WS] ~ ", json);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
|
||||||
// On PlayPause
|
// On PlayPause
|
||||||
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
if (isPlaying) pauseVideo();
|
if (isPlaying) pauseVideo();
|
||||||
else playVideo();
|
else playVideo();
|
||||||
@@ -531,18 +346,16 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
Alert.alert(title, body);
|
Alert.alert(title, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ws, stopPlayback, playVideo, pauseVideo, setVolume, api, seek]);
|
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaybackContext.Provider
|
<PlaybackContext.Provider
|
||||||
value={{
|
value={{
|
||||||
onProgress,
|
onProgress,
|
||||||
onReady,
|
|
||||||
progressTicks,
|
progressTicks,
|
||||||
setVolume,
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setIsFullscreen,
|
setIsFullscreen,
|
||||||
onBuffer,
|
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentlyPlaying,
|
currentlyPlaying,
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
export type PlaylistItem = {
|
|
||||||
ItemId: string;
|
|
||||||
PlaylistItemId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlayQueueData = {
|
|
||||||
IsPlaying: boolean;
|
|
||||||
LastUpdate: string;
|
|
||||||
PlayingItemIndex: number;
|
|
||||||
Playlist: PlaylistItem[];
|
|
||||||
Reason: "NewPlaylist" | "SetCurrentItem"; // or use string if more values are expected
|
|
||||||
RepeatMode: "RepeatNone"; // or use string if more values are expected
|
|
||||||
ShuffleMode: "Sorted"; // or use string if more values are expected
|
|
||||||
StartPositionTicks: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GroupData = {
|
|
||||||
GroupId: string;
|
|
||||||
GroupName: string;
|
|
||||||
LastUpdatedAt: string;
|
|
||||||
Participants: Participant[];
|
|
||||||
State: string; // You can use an enum or union type if there are known possible states
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SyncPlayCommandData = {
|
|
||||||
Command: string;
|
|
||||||
EmittedAt: string;
|
|
||||||
GroupId: string;
|
|
||||||
PlaylistItemId: string;
|
|
||||||
PositionTicks: number;
|
|
||||||
When: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StateUpdateData = {
|
|
||||||
State: "Waiting" | "Playing" | "Paused";
|
|
||||||
Reason: "Pause" | "Unpause";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GroupJoinedData = {
|
|
||||||
GroupId: string;
|
|
||||||
GroupName: string;
|
|
||||||
LastUpdatedAt: string;
|
|
||||||
Participants: string[];
|
|
||||||
State: "Idle";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Participant = string[];
|
|
||||||
@@ -25,8 +25,8 @@ export const getStreamUrl = async ({
|
|||||||
userId: string | null | undefined;
|
userId: string | null | undefined;
|
||||||
startTimeTicks: number;
|
startTimeTicks: number;
|
||||||
maxStreamingBitrate?: number;
|
maxStreamingBitrate?: number;
|
||||||
sessionData?: PlaybackInfoResponse;
|
sessionData: PlaybackInfoResponse;
|
||||||
deviceProfile?: any;
|
deviceProfile: any;
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
@@ -72,12 +72,16 @@ export const getStreamUrl = async ({
|
|||||||
throw new Error("No media source");
|
throw new Error("No media source");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sessionData.PlaySessionId) {
|
||||||
|
throw new Error("no PlaySessionId");
|
||||||
|
}
|
||||||
|
|
||||||
let url: string | null | undefined;
|
let url: string | null | undefined;
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
console.log("Using direct stream for video!");
|
console.log("Using direct stream for video!");
|
||||||
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
console.log("Using direct stream for audio!");
|
console.log("Using direct stream for audio!");
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
@@ -90,9 +94,7 @@ export const getStreamUrl = async ({
|
|||||||
TranscodingProtocol: "hls",
|
TranscodingProtocol: "hls",
|
||||||
AudioCodec: "aac",
|
AudioCodec: "aac",
|
||||||
api_key: api.accessToken,
|
api_key: api.accessToken,
|
||||||
PlaySessionId: sessionData?.PlaySessionId
|
PlaySessionId: sessionData.PlaySessionId,
|
||||||
? sessionData.PlaySessionId
|
|
||||||
: "",
|
|
||||||
StartTimeTicks: "0",
|
StartTimeTicks: "0",
|
||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
interface PlaybackStoppedParams {
|
interface PlaybackStoppedParams {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
@@ -28,23 +27,17 @@ export const reportPlaybackStopped = async ({
|
|||||||
if (!positionTicks || positionTicks === 0) return;
|
if (!positionTicks || positionTicks === 0) return;
|
||||||
|
|
||||||
if (!api) {
|
if (!api) {
|
||||||
writeToLog("WARN", "Could not report playback stopped due to missing api");
|
console.error("Missing api");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
writeToLog(
|
console.error("Missing sessionId", sessionId);
|
||||||
"WARN",
|
|
||||||
"Could not report playback stopped due to missing session id"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
writeToLog(
|
console.error("Missing itemId");
|
||||||
"WARN",
|
|
||||||
"Could not report playback progress due to missing item id"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user