This commit is contained in:
Fredrik Burmester
2024-10-06 16:33:29 +02:00
parent 862e783de1
commit d6f02bd970
9 changed files with 394 additions and 147 deletions

View File

@@ -1,5 +1,6 @@
import { Controls } from "@/components/video-player/Controls";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
@@ -7,14 +8,13 @@ import {
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
@@ -22,27 +22,23 @@ import React, {
useRef,
useState,
} from "react";
import { Dimensions, Pressable, StatusBar, View } from "react-native";
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import * as NavigationBar from "expo-navigation-bar";
export default function page() {
const { playSettings, setPlaySettings, playUrl, reportStopPlayback } =
usePlaySettings();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { playSettings, playUrl } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const router = useRouter();
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [orientation, setOrientation] = useState(
@@ -57,59 +53,127 @@ export default function page() {
return null;
const togglePlay = useCallback(
(ticks: number) => {
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
console.log("togglePlay", ticks);
if (isPlaying) {
setIsPlaying(false);
videoRef.current?.pause();
reportPlaybackProgress({
api,
itemId: playSettings?.item?.Id,
positionTicks: ticks,
sessionId: undefined,
IsPaused: true,
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
});
} else {
setIsPlaying(true);
videoRef.current?.resume();
reportPlaybackProgress({
api,
itemId: playSettings?.item?.Id,
positionTicks: ticks,
sessionId: undefined,
IsPaused: false,
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef]
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
const play = useCallback(() => {
setIsPlaying(true);
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
setIsPlaying(false);
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaying(false);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: progress.value,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
});
};
const firstTime = useRef(true);
useEffect(() => {
play();
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
stop();
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, []);
const onProgress = useCallback(
(data: OnProgressData) => {
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
const ticks = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
if (!playSettings?.item?.Id || data.currentTime === 0) return;
const ticks = data.currentTime * 10000000;
reportPlaybackProgress({
api,
itemId: playSettings?.item.Id,
positionTicks: ticks,
sessionId: undefined,
IsPaused: !isPlaying,
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item.Id,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
});
},
[playSettings?.item.Id, isPlaying, api]
@@ -139,6 +203,13 @@ export default function page() {
: false;
}, [orientation]);
const { isConnected } = useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
return (
<View
style={{
@@ -163,6 +234,12 @@ export default function page() {
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}

View File

@@ -319,66 +319,64 @@ function Layout() {
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/play"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-music"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
closeButton
/>
</ThemeProvider>
</PlaybackProvider>
<Stack.Screen
name="(auth)/play"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-music"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</BottomSheetModalProvider>

View File

@@ -21,7 +21,14 @@ import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated from "react-native-reanimated";
@@ -34,17 +41,10 @@ import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
const { setPlaySettings, playUrl } = usePlaySettings();
const castDevice = useCastDevice();
const navigation = useNavigation();
const [settings] = useSettings();
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const [loadingLogo, setLoadingLogo] = useState(true);
@@ -89,7 +89,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
});
setPlaySettings((prev) => ({
...prev,
audioIndex: undefined,
subtitleIndex: undefined,
mediaSourceId: undefined,
bitrate: undefined,
mediaSource: undefined,
item,
}));
}, [item]);

View File

@@ -1,5 +1,4 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -40,7 +39,6 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom);
@@ -64,7 +62,11 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const onPress = async () => {
if (!url || !item) {
console.warn("No URL or item provided to PlayButton");
console.warn(
"No URL or item provided to PlayButton",
url?.slice(0, 100),
item?.Id
);
return;
}
if (!client) {

View File

@@ -19,7 +19,13 @@ import React, {
useRef,
useState,
} from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import {
Dimensions,
Platform,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, {
runOnJS,
@@ -34,6 +40,7 @@ import { VideoRef } from "react-native-video";
import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { TAB_HEIGHT } from "@/constants/Values";
interface Props {
item: BaseItemDto;
@@ -67,12 +74,12 @@ export const Controls: React.FC<Props> = ({
setIgnoreSafeAreas,
}) => {
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const screenDimensions = Dimensions.get("screen");
const windowDimensions = Dimensions.get("window");
const op = useSharedValue<number>(1);
const tr = useSharedValue<number>(10);
@@ -243,8 +250,8 @@ export const Controls: React.FC<Props> = ({
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
width: windowDimensions.width,
height: windowDimensions.height,
},
]}
>
@@ -299,8 +306,8 @@ export const Controls: React.FC<Props> = ({
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
width: windowDimensions.width + 100,
height: windowDimensions.height + 100,
},
animatedStyles,
]}
@@ -313,8 +320,8 @@ export const Controls: React.FC<Props> = ({
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
width: windowDimensions.width,
height: windowDimensions.height,
}}
pointerEvents="none"
className={`flex flex-col items-center justify-center
@@ -338,7 +345,7 @@ export const Controls: React.FC<Props> = ({
>
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
@@ -350,7 +357,7 @@ export const Controls: React.FC<Props> = ({
onPress={() => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
@@ -360,10 +367,10 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
width: screenDimensions.width - insets.left - insets.right,
maxHeight: screenDimensions.height,
width: windowDimensions.width - insets.left - insets.right,
maxHeight: windowDimensions.height,
left: insets.left,
bottom: insets.bottom,
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
},
animatedBottomStyles,
]}
@@ -388,7 +395,7 @@ export const Controls: React.FC<Props> = ({
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
items-center bg-neutral-800/90`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity

112
hooks/useWebsockets.ts Normal file
View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { Alert } from "react-native";
import { Router, useRouter } from "expo-router";
import { Api } from "@jellyfin/sdk";
import { useAtomValue } from "jotai";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
interface UseWebSocketProps {
isPlaying: boolean;
pauseVideo: () => void;
playVideo: () => void;
stopPlayback: () => void;
}
export const useWebSocket = ({
isPlaying,
pauseVideo,
playVideo,
stopPlayback,
}: UseWebSocketProps) => {
const router = useRouter();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: deviceId } = useQuery({
queryKey: ["deviceId"],
queryFn: async () => {
return await getOrSetDeviceId();
},
staleTime: Infinity,
});
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
useEffect(() => {
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
console.log("[WS] ~ ", json);
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
} 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(title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
return { isConnected };
};

View File

@@ -18,6 +18,8 @@ import old from "@/utils/profiles/old";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { Bitrate } from "@/components/BitrateSelector";
import ios from "@/utils/profiles/ios";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
export type PlaybackType = {
item?: BaseItemDto | null;
@@ -30,7 +32,7 @@ export type PlaybackType = {
type PlaySettingsContextType = {
playSettings: PlaybackType | null;
setPlaySettings: React.Dispatch<React.SetStateAction<PlaybackType | null>>;
playUrl: string | null;
playUrl?: string | null;
reportStopPlayback: (ticks: number) => Promise<void>;
};
@@ -65,14 +67,16 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => {
const fetchPlayUrl = async () => {
console.log("something changed, fetching url", playSettings?.item?.Id);
if (!api || !user || !settings) {
if (!api || !user || !settings || !playSettings) {
console.log("fetchPlayUrl ~ missing params");
setPlayUrl(null);
return;
}
console.log("fetchPlayUrl ~ fetching url", playSettings?.item?.Id);
// Determine the device profile
let deviceProfile: any = iosFmp4;
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") deviceProfile = native;
if (settings?.deviceProfile === "Old") deviceProfile = old;
@@ -100,6 +104,30 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
fetchPlayUrl();
}, [api, settings, user, playSettings]);
useEffect(() => {
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") deviceProfile = native;
if (settings?.deviceProfile === "Old") deviceProfile = old;
const postCaps = async () => {
if (!api) return;
await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
DeviceProfile: deviceProfile,
IconUrl:
"https://github.com/fredrikburmester/streamyfin/blob/master/assets/images/adaptive_icon.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},
});
};
postCaps();
}, [settings]);
return (
<PlaySettingsContext.Provider
value={{ playSettings, setPlaySettings, playUrl, reportStopPlayback }}

View File

@@ -35,6 +35,11 @@ export const getStreamUrl = async ({
mediaSourceId?: string | null;
}) => {
if (!api || !userId || !item?.Id) {
console.log("getStreamUrl: missing params", {
api: api?.basePath,
userId,
item: item?.Id,
});
return null;
}
@@ -122,6 +127,12 @@ export const getStreamUrl = async ({
}
if (!url) {
console.log("getStreamUrl: no url found", {
api: api.basePath,
userId,
item: item.Id,
mediaSourceId,
});
return null;
}

View File

@@ -2,6 +2,16 @@ import { Api } from "@jellyfin/sdk";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
import { Settings } from "@/utils/atoms/settings";
import {
getMediaInfoApi,
getPlaystateApi,
getSessionApi,
} from "@jellyfin/sdk/lib/utils/api";
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
interface ReportPlaybackProgressParams {
api?: Api | null;
@@ -33,31 +43,29 @@ export const reportPlaybackProgress = async ({
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
try {
await postCapabilities({
api,
await getPlaystateApi(api).onPlaybackProgress({
itemId,
sessionId,
deviceProfile,
audioStreamIndex: 0,
subtitleStreamIndex: 0,
mediaSourceId: itemId,
positionTicks: Math.round(positionTicks),
isPaused: IsPaused,
isMuted: false,
playMethod: "Transcode",
});
} catch (error) {
console.error("Failed to post capabilities.", error);
throw new Error("Failed to post capabilities.");
}
try {
await api.axiosInstance.post(
`${api.basePath}/Sessions/Playing/Progress`,
{
ItemId: itemId,
PlaySessionId: sessionId,
IsPaused,
PositionTicks: Math.round(positionTicks),
CanSeek: true,
MediaSourceId: itemId,
EventName: "timeupdate",
},
{ headers: getAuthHeaders(api) }
);
// await api.axiosInstance.post(
// `${api.basePath}/Sessions/Playing/Progress`,
// {
// ItemId: itemId,
// PlaySessionId: sessionId,
// IsPaused,
// PositionTicks: Math.round(positionTicks),
// CanSeek: true,
// MediaSourceId: itemId,
// EventName: "timeupdate",
// },
// { headers: getAuthHeaders(api) }
// );
} catch (error) {
console.error(error);
}