fix: separate item and show bar state

This commit is contained in:
Fredrik Burmester
2024-08-19 21:43:48 +02:00
parent 66ce6b2cfa
commit caeedfbc52
8 changed files with 227 additions and 56 deletions

View File

@@ -1,10 +1,5 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { DownloadItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
@@ -42,6 +37,12 @@ import CastContext, {
useRemoteMediaClient,
} from "react-native-google-cast";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -55,6 +56,7 @@ const page: React.FC = () => {
const castDevice = useCastDevice();
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
const [, setShowCurrentlyPlayingBar] = useAtom(showCurrentlyPlayingBarAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
@@ -168,6 +170,8 @@ const page: React.FC = () => {
playbackUrl,
});
setPlaying(true);
setShowCurrentlyPlayingBar(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}

View File

@@ -1,4 +1,10 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
@@ -6,12 +12,11 @@ import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlayback
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
@@ -23,17 +28,9 @@ import Video, { OnProgressData, VideoRef } from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);
export const playingAtom = atom(false);
export const fullScreenAtom = atom(false);
export const CurrentlyPlayingBar: React.FC = () => {
const queryClient = useQueryClient();
const segments = useSegments();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -42,6 +39,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
currentlyPlayingItemAtom
);
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
const [show, setShow] = useAtom(showCurrentlyPlayingBarAtom);
const videoRef = useRef<VideoRef | null>(null);
const [progress, setProgress] = useState(0);
@@ -120,21 +118,26 @@ export const CurrentlyPlayingBar: React.FC = () => {
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!currentTime ||
!sessionData?.PlaySessionId ||
!playing ||
!api ||
!currentlyPlaying?.item.Id
)
if (!sessionData?.PlaySessionId || !api || !currentlyPlaying?.item.Id)
return;
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
IsPaused: !playing,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
},
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
@@ -147,13 +150,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
videoRef.current?.resume();
} else {
videoRef.current?.pause();
if (progress > 0 && sessionData?.PlaySessionId)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
@@ -174,6 +170,17 @@ export const CurrentlyPlayingBar: React.FC = () => {
}
}, [fullScreen]);
useEffect(() => {
if (!show && currentlyPlaying && item && sessionData && api) {
reportPlaybackStopped({
api,
itemId: item?.Id,
sessionId: sessionData?.PlaySessionId,
positionTicks: progress,
});
}
}, [show]);
const startPosition = useMemo(
() =>
item?.UserData?.PlaybackPositionTicks
@@ -193,7 +200,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
[item]
);
if (!currentlyPlaying || !api) return null;
if (show === false || !api) return null;
return (
<Animated.View
@@ -223,7 +230,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
`}
>
{currentlyPlaying.playbackUrl && (
{currentlyPlaying?.playbackUrl && (
<Video
ref={videoRef}
allowsExternalPlayback
@@ -272,7 +279,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
setPlaying(false);
}
}}
progressUpdateInterval={1000}
progressUpdateInterval={2000}
onError={(e) => {
console.log(e);
writeToLog(
@@ -347,7 +354,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setCurrentlyPlaying(null);
setShow(false);
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>

View File

@@ -1,8 +1,12 @@
import {
currentlyPlayingItemAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isLoaded } from "expo-font";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -21,6 +25,7 @@ interface Server {
export const apiAtom = atom<Api | null>(null);
export const userAtom = atom<UserDto | null>(null);
export const wsAtom = atom<WebSocket | null>(null);
interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>;
@@ -31,7 +36,7 @@ interface JellyfinContextValue {
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined,
undefined
);
const getOrSetDeviceId = async () => {
@@ -49,6 +54,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [playing, setPlaying] = useAtom(playingAtom);
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
currentlyPlayingItemAtom
);
const [showCurrentlyPlayingBar, setShowCurrentlyPlayingBar] = useAtom(
showCurrentlyPlayingBarAtom
);
useEffect(() => {
(async () => {
@@ -56,19 +70,85 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.6.1" },
clientInfo: { name: "Streamyfin", version: "0.6.2" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}),
})
);
setDeviceId(id);
})();
}, []);
const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom);
const [ws, setWs] = useAtom(wsAtom);
useEffect(() => {
if (!deviceId || !api) return;
const url = `wss://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
console.log("WS", url);
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
// Start sending "KeepAlive" message every 30 seconds
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
console.log("KeepAlive message sent");
}
}, 30000);
};
newWebSocket.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
// On PlayPause
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
setPlaying((state) => !state);
} else if (command === "Stop") {
console.log("Command ~ Stop");
setPlaying(false);
setShowCurrentlyPlayingBar(false);
}
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
console.log("WebSocket connection closed:", e.reason);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId]);
const discoverServers = async (url: string): Promise<Server[]> => {
const servers =
await jellyfin?.discovery.getRecommendedServerCandidates(url);
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
url
);
return servers?.map((server) => ({ address: server.address })) || [];
};
@@ -144,7 +224,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string,
(await AsyncStorage.getItem("user")) as string
) as UserDto;
if (serverUrl && token && user.Id && jellyfin) {

10
utils/atoms/playState.ts Normal file
View File

@@ -0,0 +1,10 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom } from "jotai";
export const playingAtom = atom(false);
export const fullScreenAtom = atom(false);
export const showCurrentlyPlayingBarAtom = atom(false);
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);

View File

@@ -10,6 +10,8 @@ type Settings = {
deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
mediaListCollectionIds?: string[];
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
};
/**
@@ -33,6 +35,8 @@ const loadSettings = async (): Promise<Settings> => {
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
searchEngine: "Jellyfin",
marlinServerUrl: "",
};
};

View File

@@ -1,12 +1,13 @@
import { Api } from "@jellyfin/sdk";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
interface ReportPlaybackProgressParams {
api: Api;
sessionId: string;
itemId: string;
positionTicks: number;
api?: Api | null;
sessionId?: string | null;
itemId?: string | null;
positionTicks?: number | null;
IsPaused?: boolean;
}
/**
@@ -20,25 +21,39 @@ export const reportPlaybackProgress = async ({
sessionId,
itemId,
positionTicks,
IsPaused = false,
}: ReportPlaybackProgressParams): Promise<void> => {
console.info(
"Reporting playback progress:",
sessionId,
if (!api || !sessionId || !itemId || !positionTicks) {
console.error("Missing required parameter");
return;
}
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
try {
await postCapabilities({
api,
itemId,
positionTicks,
);
sessionId,
});
} 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: false,
IsPaused,
PositionTicks: Math.round(positionTicks),
CanSeek: true,
MediaSourceId: itemId,
EventName: "timeupdate",
},
{ headers: getAuthHeaders(api) },
{ headers: getAuthHeaders(api) }
);
} catch (error) {
console.error(error);

View File

@@ -41,12 +41,15 @@ export const reportPlaybackStopped = async ({
return;
}
console.log("reportPlaybackStopped ~", { sessionId, itemId });
try {
const url = `${api.basePath}/PlayingItems/${itemId}`;
const params = {
playSessionId: sessionId,
positionTicks: Math.round(positionTicks),
mediaSourceId: itemId,
MediaSourceId: itemId,
IsPaused: true,
};
const headers = getAuthHeaders(api);
@@ -58,7 +61,7 @@ export const reportPlaybackStopped = async ({
console.error(
"Failed to report playback progress",
error.message,
error.response?.data,
error.response?.data
);
} else {
console.error("Failed to report playback progress", error);

View File

@@ -0,0 +1,48 @@
import { Api } from "@jellyfin/sdk";
import {
SessionApi,
SessionApiPostCapabilitiesRequest,
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
}
/**
* Marks a media item as not played for a specific user.
*
* @param params - The parameters for marking an item as not played
* @returns A promise that resolves to true if the operation was successful, false otherwise
*/
export const postCapabilities = async ({
api,
itemId,
sessionId,
}: PostCapabilitiesParams): Promise<void> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing required parameters");
}
try {
const r = await api.axiosInstance.post(
api.basePath + "/Sessions/Capabilities/Full",
{
playableMediaTypes: ["Audio", "Video", "Audio"],
supportedCommands: ["PlayState", "Play"],
supportsMediaControl: true,
id: sessionId,
},
{
headers: getAuthHeaders(api),
}
);
} catch (error: any | AxiosError) {
console.log("Failed to mark as not played", error);
throw new Error("Failed to mark as not played");
}
};