mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-21 14:26:35 +01:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user