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

View File

@@ -1,4 +1,10 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; 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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { atom, useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
@@ -23,17 +28,9 @@ import Video, { OnProgressData, VideoRef } from "react-native-video";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { Loader } from "./Loader"; 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 = () => { export const CurrentlyPlayingBar: React.FC = () => {
const queryClient = useQueryClient();
const segments = useSegments(); const segments = useSegments();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -42,6 +39,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
currentlyPlayingItemAtom currentlyPlayingItemAtom
); );
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom); const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
const [show, setShow] = useAtom(showCurrentlyPlayingBarAtom);
const videoRef = useRef<VideoRef | null>(null); const videoRef = useRef<VideoRef | null>(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@@ -120,21 +118,26 @@ export const CurrentlyPlayingBar: React.FC = () => {
const onProgress = useCallback( const onProgress = useCallback(
({ currentTime }: OnProgressData) => { ({ currentTime }: OnProgressData) => {
if ( if (!sessionData?.PlaySessionId || !api || !currentlyPlaying?.item.Id)
!currentTime ||
!sessionData?.PlaySessionId ||
!playing ||
!api ||
!currentlyPlaying?.item.Id
)
return; return;
const newProgress = currentTime * 10000000; const newProgress = currentTime * 10000000;
setProgress(newProgress); setProgress(newProgress);
reportPlaybackProgress({ reportPlaybackProgress({
api, api,
itemId: currentlyPlaying?.item.Id, itemId: currentlyPlaying?.item.Id,
positionTicks: newProgress, positionTicks: newProgress,
sessionId: sessionData.PlaySessionId, 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] [sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
@@ -147,13 +150,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
videoRef.current?.resume(); videoRef.current?.resume();
} else { } else {
videoRef.current?.pause(); videoRef.current?.pause();
if (progress > 0 && sessionData?.PlaySessionId)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId], queryKey: ["nextUp", item?.SeriesId],
@@ -174,6 +170,17 @@ export const CurrentlyPlayingBar: React.FC = () => {
} }
}, [fullScreen]); }, [fullScreen]);
useEffect(() => {
if (!show && currentlyPlaying && item && sessionData && api) {
reportPlaybackStopped({
api,
itemId: item?.Id,
sessionId: sessionData?.PlaySessionId,
positionTicks: progress,
});
}
}, [show]);
const startPosition = useMemo( const startPosition = useMemo(
() => () =>
item?.UserData?.PlaybackPositionTicks item?.UserData?.PlaybackPositionTicks
@@ -193,7 +200,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
[item] [item]
); );
if (!currentlyPlaying || !api) return null; if (show === false || !api) return null;
return ( return (
<Animated.View <Animated.View
@@ -223,7 +230,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"} ${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
`} `}
> >
{currentlyPlaying.playbackUrl && ( {currentlyPlaying?.playbackUrl && (
<Video <Video
ref={videoRef} ref={videoRef}
allowsExternalPlayback allowsExternalPlayback
@@ -272,7 +279,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
setPlaying(false); setPlaying(false);
} }
}} }}
progressUpdateInterval={1000} progressUpdateInterval={2000}
onError={(e) => { onError={(e) => {
console.log(e); console.log(e);
writeToLog( writeToLog(
@@ -347,7 +354,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
setCurrentlyPlaying(null); setShow(false);
}} }}
className="aspect-square rounded flex flex-col items-center justify-center p-2" 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 { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { isLoaded } from "expo-font";
import { router, useSegments } from "expo-router"; import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { import React, {
@@ -21,6 +25,7 @@ interface Server {
export const apiAtom = atom<Api | null>(null); export const apiAtom = atom<Api | null>(null);
export const userAtom = atom<UserDto | null>(null); export const userAtom = atom<UserDto | null>(null);
export const wsAtom = atom<WebSocket | null>(null);
interface JellyfinContextValue { interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>; discoverServers: (url: string) => Promise<Server[]>;
@@ -31,7 +36,7 @@ interface JellyfinContextValue {
} }
const JellyfinContext = createContext<JellyfinContextValue | undefined>( const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined, undefined
); );
const getOrSetDeviceId = async () => { const getOrSetDeviceId = async () => {
@@ -49,6 +54,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children, children,
}) => { }) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined); 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(() => { useEffect(() => {
(async () => { (async () => {
@@ -56,19 +70,85 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.6.1" }, clientInfo: { name: "Streamyfin", version: "0.6.2" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}), })
); );
setDeviceId(id);
})(); })();
}, []); }, []);
const [api, setApi] = useAtom(apiAtom); const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom); 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 discoverServers = async (url: string): Promise<Server[]> => {
const servers = const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
await jellyfin?.discovery.getRecommendedServerCandidates(url); url
);
return servers?.map((server) => ({ address: server.address })) || []; 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 token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl"); const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse( const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string, (await AsyncStorage.getItem("user")) as string
) as UserDto; ) as UserDto;
if (serverUrl && token && user.Id && jellyfin) { 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"; deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean; forceDirectPlay?: boolean;
mediaListCollectionIds?: string[]; mediaListCollectionIds?: string[];
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
}; };
/** /**
@@ -33,6 +35,8 @@ const loadSettings = async (): Promise<Settings> => {
deviceProfile: "Expo", deviceProfile: "Expo",
forceDirectPlay: false, forceDirectPlay: false,
mediaListCollectionIds: [], mediaListCollectionIds: [],
searchEngine: "Jellyfin",
marlinServerUrl: "",
}; };
}; };

View File

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

View File

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