From acbc650ccf9dc536d3e4161694bc333291b3bc0e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 8 Sep 2024 11:37:21 +0300 Subject: [PATCH] wip --- app/(auth)/(tabs)/(home)/_layout.tsx | 19 ++- app/(auth)/(tabs)/(home)/syncplay.tsx | 141 ++++++++++++++++++ components/List.tsx | 19 +++ components/ListItem.tsx | 13 +- providers/PlaybackProvider.tsx | 131 +++++++++++++++- types/syncplay.ts | 47 ++++++ utils/jellyfin/media/getStreamUrl.ts | 14 +- .../playstate/reportPlaybackStopped.ts | 13 +- 8 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/syncplay.tsx create mode 100644 components/List.tsx create mode 100644 types/syncplay.ts diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index d2c9988a..0c37d45d 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,7 +1,7 @@ import { Chromecast } from "@/components/Chromecast"; import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; -import { Feather } from "@expo/vector-icons"; +import { Feather, Ionicons } from "@expo/vector-icons"; import { Stack, useRouter } from "expo-router"; import { Platform, TouchableOpacity, View } from "react-native"; @@ -32,6 +32,16 @@ export default function IndexLayout() { ), headerRight: () => ( + { + router.push("/(auth)/syncplay"); + }} + style={{ + marginRight: 8, + }} + > + + { @@ -58,6 +68,13 @@ export default function IndexLayout() { title: "Settings", }} /> + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( ))} diff --git a/app/(auth)/(tabs)/(home)/syncplay.tsx b/app/(auth)/(tabs)/(home)/syncplay.tsx new file mode 100644 index 00000000..86bfa7be --- /dev/null +++ b/app/(auth)/(tabs)/(home)/syncplay.tsx @@ -0,0 +1,141 @@ +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: 5000, + }); + + 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 ( + + + + Join group + {!activeGroups?.length && ( + No active groups + )} + + {activeGroups?.map((group) => ( + { + if (!group.GroupId) { + return; + } + if (group.Participants?.includes(name)) { + leaveGroupMutation.mutate(); + } else { + joinGroupMutation.mutate(group.GroupId); + } + }} + iconAfter={ + group.Participants?.includes(name) ? ( + + ) : ( + + ) + } + subTitle={group.Participants?.join(", ")} + /> + ))} + createGroup()} + key={"create"} + title={"Create group"} + iconAfter={ + createGroupMutation.isPending ? ( + + ) : ( + + ) + } + /> + + + + + ); +} diff --git a/components/List.tsx b/components/List.tsx new file mode 100644 index 00000000..eb901a70 --- /dev/null +++ b/components/List.tsx @@ -0,0 +1,19 @@ +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> = ({ + children, + ...props +}) => { + return ( + + {children} + + ); +}; diff --git a/components/ListItem.tsx b/components/ListItem.tsx index b7b4dd9c..5c84e275 100644 --- a/components/ListItem.tsx +++ b/components/ListItem.tsx @@ -1,8 +1,13 @@ import { PropsWithChildren, ReactNode } from "react"; -import { View, ViewProps } from "react-native"; +import { + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; import { Text } from "./common/Text"; -interface Props extends ViewProps { +interface Props extends TouchableOpacityProps { title?: string | null | undefined; subTitle?: string | null | undefined; children?: ReactNode; @@ -17,7 +22,7 @@ export const ListItem: React.FC> = ({ ...props }) => { return ( - @@ -26,6 +31,6 @@ export const ListItem: React.FC> = ({ {subTitle && {subTitle}} {iconAfter} - + ); }; diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index d66b6c2d..c9765948 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -18,13 +18,21 @@ import { BaseItemDto, PlaybackInfoResponse, } from "@jellyfin/sdk/lib/generated-client/models"; -import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; +import { getMediaInfoApi, getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; import * as Linking from "expo-linking"; import { useAtom } from "jotai"; import { debounce } from "lodash"; import { Alert } from "react-native"; import { OnProgressData, type VideoRef } from "react-native-video"; 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 = { url: string; @@ -115,7 +123,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ ); const setCurrentlyPlayingState = useCallback( - async (state: CurrentlyPlayingState | null) => { + async (state: CurrentlyPlayingState | null, paused = false) => { try { if (state?.item.Id && user?.Id) { const vlcLink = "vlc://" + state?.url; @@ -137,7 +145,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ setSession(res.data); setCurrentlyPlaying(state); - setIsPlaying(true); + + if (paused === true) { + pauseVideo(); + } else { + playVideo(); + } if (settings?.openFullScreenVideoPlayerByDefault) { setTimeout(() => { @@ -268,6 +281,11 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ setIsFullscreen(false); }, []); + const seek = useCallback((ticks: number) => { + const time = ticks / 10000000; + videoRef.current?.seek(time); + }, []); + useEffect(() => { if (!deviceId || !api?.accessToken) return; @@ -321,10 +339,113 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const json = JSON.parse(e.data); const command = json?.Data?.Command; - console.log("[WS] ~ ", json); + if (json.MessageType === "KeepAlive") { + // TODO: ?? + } else if (json.MessageType === "ForceKeepAlive") { + // TODO: ?? + } else if (json.MessageType === "SyncPlayGroupUpdate") { + if (!api) return; + + 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 GroupJoinedData; + console.log("GroupJoined ~", data); + } else if (type === "PlayQueue") { + const data = json.Data.Data as PlayQueueData; + console.log("PlayQueue ~", { + IsPlaying: data.IsPlaying, + StartPositionTicks: data.StartPositionTicks, + PlaylistLength: data.Playlist?.length, + PlayingItemIndex: data.PlayingItemIndex, + Reason: data.Reason, + }); + + if (data.Reason === "SetCurrentItem") { + if ( + currentlyPlaying?.item.Id === + data.Playlist?.[data.PlayingItemIndex].ItemId + ) { + console.log("SetCurrentItem ~", json); + + seek(data.StartPositionTicks); + + if (data.IsPlaying) { + playVideo(); + } else { + pauseVideo(); + } + + // getSyncPlayApi(api).syncPlayReady({ + // readyRequestDto: { + // IsPlaying: data.IsPlaying, + // PositionTicks: data.StartPositionTicks, + // PlaylistItemId: currentlyPlaying?.item.Id, + // When: new Date().toISOString(), + // }, + // }); + + return; + } + } + + const itemId = data.Playlist?.[data.PlayingItemIndex].ItemId; + if (itemId) { + 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, + }, + true + ); + + await getSyncPlayApi(api).syncPlayReady({ + readyRequestDto: { + IsPlaying: data.IsPlaying, + PositionTicks: data.StartPositionTicks, + PlaylistItemId: itemId, + When: new Date().toISOString(), + }, + }); + }); + } + } else { + console.log("[WS] ~ ", json); + } + + return; + } else { + console.log("[WS] ~ ", json); + } - // On PlayPause if (command === "PlayPause") { + // On PlayPause console.log("Command ~ PlayPause"); if (isPlaying) pauseVideo(); else playVideo(); diff --git a/types/syncplay.ts b/types/syncplay.ts new file mode 100644 index 00000000..21227819 --- /dev/null +++ b/types/syncplay.ts @@ -0,0 +1,47 @@ +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 = { + Reason: "Pause" | "Unpause"; + State: "Waiting" | "Playing"; +}; + +export type GroupJoinedData = { + GroupId: string; + GroupName: string; + LastUpdatedAt: string; + Participants: string[]; + State: "Idle"; +}; + +export type Participant = string[]; diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 28b47f8e..842ab429 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -25,8 +25,8 @@ export const getStreamUrl = async ({ userId: string | null | undefined; startTimeTicks: number; maxStreamingBitrate?: number; - sessionData: PlaybackInfoResponse; - deviceProfile: any; + sessionData?: PlaybackInfoResponse; + deviceProfile?: any; audioStreamIndex?: number; subtitleStreamIndex?: number; forceDirectPlay?: boolean; @@ -72,16 +72,12 @@ export const getStreamUrl = async ({ throw new Error("No media source"); } - if (!sessionData.PlaySessionId) { - throw new Error("no PlaySessionId"); - } - let url: string | null | undefined; if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) { if (item.MediaType === "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") { console.log("Using direct stream for audio!"); const searchParams = new URLSearchParams({ @@ -94,7 +90,9 @@ export const getStreamUrl = async ({ TranscodingProtocol: "hls", AudioCodec: "aac", api_key: api.accessToken, - PlaySessionId: sessionData.PlaySessionId, + PlaySessionId: sessionData?.PlaySessionId + ? sessionData.PlaySessionId + : "", StartTimeTicks: "0", EnableRedirection: "true", EnableRemoteMedia: "false", diff --git a/utils/jellyfin/playstate/reportPlaybackStopped.ts b/utils/jellyfin/playstate/reportPlaybackStopped.ts index 665eb032..169adb37 100644 --- a/utils/jellyfin/playstate/reportPlaybackStopped.ts +++ b/utils/jellyfin/playstate/reportPlaybackStopped.ts @@ -1,6 +1,7 @@ import { Api } from "@jellyfin/sdk"; import { AxiosError } from "axios"; import { getAuthHeaders } from "../jellyfin"; +import { writeToLog } from "@/utils/log"; interface PlaybackStoppedParams { api: Api | null | undefined; @@ -27,17 +28,23 @@ export const reportPlaybackStopped = async ({ if (!positionTicks || positionTicks === 0) return; if (!api) { - console.error("Missing api"); + writeToLog("WARN", "Could not report playback stopped due to missing api"); return; } if (!sessionId) { - console.error("Missing sessionId", sessionId); + writeToLog( + "WARN", + "Could not report playback stopped due to missing session id" + ); return; } if (!itemId) { - console.error("Missing itemId"); + writeToLog( + "WARN", + "Could not report playback progress due to missing item id" + ); return; }