fix(playback): dispatch each remote command once; stabilise controllers

This commit is contained in:
Uruk
2026-05-22 02:30:29 +02:00
parent e9f61a2f7c
commit 8b94f491e4
3 changed files with 68 additions and 32 deletions

View File

@@ -5,7 +5,7 @@
import { router, Stack } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
@@ -189,23 +189,37 @@ export default function CastingPlayerScreen() {
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
// Expose this player to the app-wide remote-control surface while a cast
// session is connected. `castingControls` is the live useCasting result.
// session is connected. The individual useCasting methods are each
// useCallback-wrapped and stable, so depend on them directly rather than on
// the whole `castingControls` object literal (rebuilt every render).
const {
togglePlayPause: castTogglePlayPause,
pause: castPause,
play: castPlay,
stop: castStop,
seek: castSeek,
setVolume: castSetVolume,
} = castingControls;
// toggleMute reads the latest volume without making `volume` a useMemo dep.
const volumeRef = useRef(volume);
volumeRef.current = volume;
const castController = useMemo<PlaybackController>(
() => ({
playPause: () => {
castingControls.togglePlayPause();
castTogglePlayPause();
},
pause: () => {
castingControls.pause();
castPause();
},
unpause: () => {
castingControls.play();
castPlay();
},
stop: () => {
castingControls.stop();
castStop();
},
seek: (positionMs) => {
castingControls.seek(positionMs);
castSeek(positionMs);
},
next: () => {
if (nextEpisode) loadEpisode(nextEpisode);
@@ -215,13 +229,24 @@ export default function CastingPlayerScreen() {
if (idx > 0) loadEpisode(episodes[idx - 1]);
},
setVolume: (level) => {
castingControls.setVolume(level);
castSetVolume(level);
},
toggleMute: () => {
castingControls.setVolume(castingControls.volume > 0 ? 0 : 1);
castSetVolume(volumeRef.current > 0 ? 0 : 1);
},
}),
[castingControls, episodes, nextEpisode, loadEpisode, currentItem?.Id],
[
castTogglePlayPause,
castPause,
castPlay,
castStop,
castSeek,
castSetVolume,
episodes,
nextEpisode,
loadEpisode,
currentItem?.Id,
],
);
useRegisterPlaybackController(

View File

@@ -343,26 +343,6 @@ export default function page() {
reportPlaybackStart();
}, [stream, api, offline]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
};
const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId || offline || !api) return;
@@ -431,6 +411,35 @@ export default function page() {
isMuted,
]);
// Declared after currentPlayStateInfo so the dependency array can reference
// it without hitting the temporal dead zone.
const togglePlay = useCallback(async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
}, [
lightHapticFeedback,
isPlaying,
currentPlayStateInfo,
playbackManager,
offline,
api,
]);
const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second

View File

@@ -5,7 +5,7 @@
*/
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { toast } from "sonner-native";
import { activePlaybackControllerAtom } from "@/utils/playback/playbackController";
import {
@@ -16,9 +16,11 @@ import {
/** Handle one remote-control message (call it whenever a new WS message arrives). */
export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => {
const controller = useAtomValue(activePlaybackControllerAtom);
const handledRef = useRef<RemoteWsMessage | null>(null);
useEffect(() => {
if (!lastMessage) return;
if (!lastMessage || lastMessage === handledRef.current) return;
handledRef.current = lastMessage;
const action = mapRemoteCommand(lastMessage);
if (!action) return;