fix: resolve 13 review issues across casting components

- casting-player: remove redundant self-navigation useEffect
- casting-player: derive Type from metadata instead of hardcoding 'Movie'
- casting-player: pass null to useTrickplay instead of empty BaseItemDto
- casting-player: use != null for skip time labels (allow 0 to render)
- Chromecast: case-insensitive m3u8 detection via regex
- Chromecast: fix UUID hyphen indices to 4,6,8,10 for proper v4 format
- CastingMiniPlayer: use SeriesPrimaryImageTag for series poster URL
- ChromecastConnectionMenu: send rounded volume to castSession.setVolume
- ChromecastConnectionMenu: use isMutedRef in onValueChange to avoid stale closure
- ChromecastDeviceSheet: skip volume sync during active sliding
- ChromecastDeviceSheet: move unmute logic from onValueChange to onSlidingStart
- useCasting: detect playback start via isPlaying/playerState, not just progress>0
- useCasting: derive isChromecastAvailable from castState instead of hardcoding true
- useTrickplay: accept BaseItemDto|null with null guards on Id access
This commit is contained in:
Uruk
2026-02-09 22:31:07 +01:00
committed by Gauvain
parent a841619d78
commit fcd7e46599
7 changed files with 55 additions and 34 deletions

View File

@@ -168,10 +168,18 @@ export default function CastingPlayerScreen() {
// Priority 3: Create minimal fallback while loading // Priority 3: Create minimal fallback while loading
if (mediaStatus?.mediaInfo) { if (mediaStatus?.mediaInfo) {
const { contentId, metadata } = mediaStatus.mediaInfo; const { contentId, metadata } = mediaStatus.mediaInfo;
// Derive type from metadata if available, otherwise omit to avoid
// misrepresenting episodes as movies
let metadataType: string | undefined;
if (metadata?.type === "movie") {
metadataType = "Movie";
} else if (metadata?.type === "tvShow") {
metadataType = "Episode";
}
return { return {
Id: contentId, Id: contentId,
Name: metadata?.title || "Unknown", Name: metadata?.title || "Unknown",
Type: "Movie", // Temporary until API fetch completes ...(metadataType ? { Type: metadataType } : {}),
ServerId: "", ServerId: "",
} as BaseItemDto; } as BaseItemDto;
} }
@@ -188,7 +196,7 @@ export default function CastingPlayerScreen() {
// Trickplay for seeking preview - use fetched item with full data // Trickplay for seeking preview - use fetched item with full data
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
fetchedItem ?? ({} as BaseItemDto), fetchedItem ?? null,
); );
// Update slider max when duration changes // Update slider max when duration changes
@@ -494,13 +502,10 @@ export default function CastingPlayerScreen() {
api, api,
]); ]);
// Auto-navigate to player when casting starts (if not already on player screen) // NOTE: Auto-navigation to casting-player is handled by higher-level
useEffect(() => { // components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
if (mediaStatus?.currentItemId && !currentItem) { // do NOT call router.replace("/casting-player") here because this component
// New media started casting while we're not on the player // IS the casting-player screen — doing so would cause redundant navigation loops.
router.replace("/casting-player" as const);
}
}, [mediaStatus?.currentItemId, currentItem, router]);
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection // Segment detection (skip intro/credits) - use progress in seconds for accurate detection
const { currentSegment, skipIntro, skipCredits, skipSegment } = const { currentSegment, skipIntro, skipCredits, skipSegment } =
@@ -1275,7 +1280,7 @@ export default function CastingPlayerScreen() {
color='white' color='white'
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }} style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
/> />
{!!settings?.rewindSkipTime && ( {settings?.rewindSkipTime != null && (
<Text <Text
style={{ style={{
position: "absolute", position: "absolute",
@@ -1320,7 +1325,7 @@ export default function CastingPlayerScreen() {
}} }}
> >
<Ionicons name='refresh-outline' size={48} color='white' /> <Ionicons name='refresh-outline' size={48} color='white' />
{!!settings?.forwardSkipTime && ( {settings?.forwardSkipTime != null && (
<Text <Text
style={{ style={{
position: "absolute", position: "absolute",

View File

@@ -121,7 +121,7 @@ export function Chromecast({
randomBytes[8] = (randomBytes[8] & 0x3f) | 0x80; // Variant 10 randomBytes[8] = (randomBytes[8] & 0x3f) | 0x80; // Variant 10
const uuid = Array.from(randomBytes, (b, i) => { const uuid = Array.from(randomBytes, (b, i) => {
const hex = b.toString(16).padStart(2, "0"); const hex = b.toString(16).padStart(2, "0");
return [3, 5, 7, 9].includes(i) ? `-${hex}` : hex; return [4, 6, 8, 10].includes(i) ? `-${hex}` : hex;
}).join(""); }).join("");
playSessionIdRef.current = uuid; playSessionIdRef.current = uuid;
lastContentIdRef.current = contentId; lastContentIdRef.current = contentId;
@@ -130,7 +130,7 @@ export function Chromecast({
const positionTicks = Math.floor(streamPosition * 10000000); const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused"; const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || ""; const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
const isTranscoding = streamUrl.includes("m3u8"); const isTranscoding = /m3u8/i.test(streamUrl);
const progressInfo: PlaybackProgressInfo = { const progressInfo: PlaybackProgressInfo = {
ItemId: contentId, ItemId: contentId,

View File

@@ -45,9 +45,9 @@ export const CastingMiniPlayer: React.FC = () => {
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined; return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
}, [mediaStatus?.mediaInfo?.customData]); }, [mediaStatus?.mediaInfo?.customData]);
// Trickplay support - pass currentItem as BaseItemDto or empty object // Trickplay support - pass currentItem as BaseItemDto or null
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
currentItem || ({} as BaseItemDto), currentItem || null,
); );
const [trickplayTime, setTrickplayTime] = useState({ const [trickplayTime, setTrickplayTime] = useState({
hours: 0, hours: 0,
@@ -121,7 +121,7 @@ export const CastingMiniPlayer: React.FC = () => {
} }
}, [progress, sliderProgress]); }, [progress, sliderProgress]);
// For episodes, use season poster; for other content, use item poster // For episodes, use series poster; for other content, use item poster
const posterUrl = useMemo(() => { const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem) return null; if (!api?.basePath || !currentItem) return null;
@@ -131,8 +131,8 @@ export const CastingMiniPlayer: React.FC = () => {
currentItem.ParentIndexNumber !== undefined && currentItem.ParentIndexNumber !== undefined &&
currentItem.SeasonId currentItem.SeasonId
) { ) {
// Build season poster URL using SeriesId and image tag for cache validation // Build series poster URL using SeriesId and series-level image tag
const imageTag = currentItem.ImageTags?.Primary || ""; const imageTag = currentItem.SeriesPrimaryImageTag || "";
const tagParam = imageTag ? `&tag=${imageTag}` : ""; const tagParam = imageTag ? `&tag=${imageTag}` : "";
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`; return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
} }

View File

@@ -108,7 +108,7 @@ export const ChromecastConnectionMenu: React.FC<
try { try {
if (castSession) { if (castSession) {
await castSession.setVolume(value / 100); await castSession.setVolume(rounded / 100);
} }
} catch (error) { } catch (error) {
console.error("[Connection Menu] Volume error:", error); console.error("[Connection Menu] Volume error:", error);
@@ -262,7 +262,9 @@ export const ChromecastConnectionMenu: React.FC<
onValueChange={async (value) => { onValueChange={async (value) => {
volumeValue.value = value; volumeValue.value = value;
handleVolumeChange(value); handleVolumeChange(value);
if (isMuted) { // Unmute when adjusting volume - use ref to avoid
// stale closure and prevent repeated async calls
if (isMutedRef.current) {
isMutedRef.current = false; isMutedRef.current = false;
setIsMuted(false); setIsMuted(false);
try { try {

View File

@@ -45,7 +45,9 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
const lastSetVolume = useRef(Math.round(volume * 100)); const lastSetVolume = useRef(Math.round(volume * 100));
// Sync volume slider with prop changes (updates from physical buttons) // Sync volume slider with prop changes (updates from physical buttons)
// Skip updates while user is actively sliding to avoid overwriting drag
useEffect(() => { useEffect(() => {
if (isSliding.current) return;
volumeValue.value = volume * 100; volumeValue.value = volume * 100;
setDisplayVolume(Math.round(volume * 100)); setDisplayVolume(Math.round(volume * 100));
}, [volume, volumeValue]); }, [volume, volumeValue]);
@@ -275,13 +277,9 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
minimumTrackTintColor: isMuted ? "#666" : "#a855f7", minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
bubbleBackgroundColor: "#a855f7", bubbleBackgroundColor: "#a855f7",
}} }}
onSlidingStart={() => { onSlidingStart={async () => {
isSliding.current = true; isSliding.current = true;
}} // Auto-unmute when user starts adjusting volume
onValueChange={async (value) => {
volumeValue.value = value;
handleVolumeChange(value);
// Unmute when adjusting volume
if (isMuted && castSession) { if (isMuted && castSession) {
setIsMuted(false); setIsMuted(false);
try { try {
@@ -292,6 +290,10 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
} }
} }
}} }}
onValueChange={(value) => {
volumeValue.value = value;
handleVolumeChange(value);
}}
onSlidingComplete={(value) => { onSlidingComplete={(value) => {
isSliding.current = false; isSliding.current = false;
lastSetVolume.current = Math.round(value); lastSetVolume.current = Math.round(value);

View File

@@ -120,8 +120,13 @@ export const useCasting = (item: BaseItemDto | null) => {
const playStateApi = getPlaystateApi(api); const playStateApi = getPlaystateApi(api);
// Report playback start when media begins (only once per item) // Report playback start when media begins (only once per item)
// Don't require progress > 0 — playback can legitimately start at position 0
const currentState = stateRef.current; const currentState = stateRef.current;
if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) { const isPlaybackActive =
currentState.isPlaying ||
mediaStatus?.playerState === "playing" ||
currentState.progress > 0;
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
// Set synchronously before async call to prevent race condition duplicates // Set synchronously before async call to prevent race condition duplicates
hasReportedStartRef.current = item.Id || null; hasReportedStartRef.current = item.Id || null;
@@ -366,8 +371,11 @@ export const useCasting = (item: BaseItemDto | null) => {
duration: state.duration, duration: state.duration,
volume: state.volume, volume: state.volume,
// Availability // Availability - derived from actual cast state
isChromecastAvailable: true, // Always available via react-native-google-cast isChromecastAvailable:
castState === CastState.CONNECTED ||
castState === CastState.CONNECTING ||
castState === CastState.NOT_CONNECTED,
// Raw clients (for advanced operations) // Raw clients (for advanced operations)
remoteMediaClient: client, remoteMediaClient: client,

View File

@@ -17,20 +17,24 @@ interface TrickplayUrl {
} }
/** Hook to handle trickplay logic for a given item. */ /** Hook to handle trickplay logic for a given item. */
export const useTrickplay = (item: BaseItemDto) => { export const useTrickplay = (item: BaseItemDto | null) => {
const { getDownloadedItemById } = useDownload(); const { getDownloadedItemById } = useDownload();
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null); const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0); const lastCalculationTime = useRef(0);
const throttleDelay = 200; const throttleDelay = 200;
const isOffline = useGlobalSearchParams().offline === "true"; const isOffline = useGlobalSearchParams().offline === "true";
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]); const trickplayInfo = useMemo(
() => (item ? getTrickplayInfo(item) : null),
[item],
);
/** Generates the trickplay URL for the given item and sheet index. /** Generates the trickplay URL for the given item and sheet index.
* We change between offline and online trickplay URLs depending on the state of the app. */ * We change between offline and online trickplay URLs depending on the state of the app. */
const getTrickplayUrl = useCallback( const getTrickplayUrl = useCallback(
(item: BaseItemDto, sheetIndex: number) => { (item: BaseItemDto, sheetIndex: number) => {
if (!item.Id) return null;
// If we are offline, we can use the downloaded item's trickplay data path // If we are offline, we can use the downloaded item's trickplay data path
const downloadedItem = getDownloadedItemById(item.Id!); const downloadedItem = getDownloadedItemById(item.Id);
if (isOffline && downloadedItem?.trickPlayData?.path) { if (isOffline && downloadedItem?.trickPlayData?.path) {
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`; return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
} }
@@ -45,7 +49,7 @@ export const useTrickplay = (item: BaseItemDto) => {
const now = Date.now(); const now = Date.now();
if ( if (
!trickplayInfo || !trickplayInfo ||
!item.Id || !item?.Id ||
now - lastCalculationTime.current < throttleDelay now - lastCalculationTime.current < throttleDelay
) )
return; return;
@@ -62,7 +66,7 @@ export const useTrickplay = (item: BaseItemDto) => {
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */ /** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
const prefetchAllTrickplayImages = useCallback(async () => { const prefetchAllTrickplayImages = useCallback(async () => {
if (!trickplayInfo || !item.Id) return; if (!trickplayInfo || !item?.Id) return;
const maxConcurrent = 4; const maxConcurrent = 4;
const total = trickplayInfo.totalImageSheets; const total = trickplayInfo.totalImageSheets;
const urls: string[] = []; const urls: string[] = [];