Fix: Improve casting and segment skipping

Fixes several issues and improves the casting player experience.

- Adds the ability to disable segment skipping options based on plugin settings.
- Improves Chromecast integration by:
  - Adding PlaySessionId for better tracking.
  - Improves audio track selection
  - Uses mediaInfo builder for loading media.
  - Adds support for loading next/previous episodes
  - Translation support
- Updates progress reporting to Jellyfin to be more accurate and reliable.
- Fixes an error message in the direct player.
This commit is contained in:
Uruk
2026-02-08 15:01:02 +01:00
parent 761b464fb6
commit c243fbc0ba
24 changed files with 463 additions and 724 deletions

View File

@@ -41,6 +41,8 @@ export function Chromecast({
const isConnected = castState === CastState.CONNECTED;
const lastReportedProgressRef = useRef(0);
const playSessionIdRef = useRef<string | null>(null);
const lastContentIdRef = useRef<string | null>(null);
const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3;
const hasLoggedDevices = useRef(false);
@@ -121,6 +123,13 @@ export function Chromecast({
}
const contentId = mediaStatus.mediaInfo.contentId;
// Generate a new PlaySessionId when the content changes
if (contentId !== lastContentIdRef.current) {
playSessionIdRef.current = `${contentId}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
lastContentIdRef.current = contentId;
}
const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
@@ -131,7 +140,7 @@ export function Chromecast({
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: contentId,
PlaySessionId: playSessionIdRef.current || contentId,
};
getPlaystateApi(api)

View File

@@ -47,6 +47,7 @@ interface PlatformDropdownProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
disabled?: boolean;
expoUIConfig?: {
hostStyle?: any;
};
@@ -197,6 +198,7 @@ const PlatformDropdownComponent = ({
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
disabled,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
@@ -231,6 +233,13 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") {
if (disabled) {
return (
<View style={{ opacity: 0.5 }} pointerEvents='none'>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
);
}
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
@@ -353,8 +362,14 @@ const PlatformDropdownComponent = ({
};
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.7}
disabled={disabled}
>
<View style={disabled ? { opacity: 0.5 } : undefined}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
</TouchableOpacity>
);
};

View File

@@ -9,7 +9,6 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
MediaPlayerState,
MediaStreamType,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -33,8 +32,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
@@ -112,7 +110,11 @@ export const PlayButton: React.FC<Props> = ({
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const options = [
t("casting_player.chromecast"),
t("casting_player.device"),
t("casting_player.cancel"),
];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
@@ -181,17 +183,6 @@ export const PlayButton: React.FC<Props> = ({
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
console.log("[PlayButton] Item before casting:", {
Type: item.Type,
Id: item.Id,
Name: item.Name,
ParentIndexNumber: item.ParentIndexNumber,
IndexNumber: item.IndexNumber,
SeasonId: item.SeasonId,
SeriesId: item.SeriesId,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
@@ -201,80 +192,16 @@ export const PlayButton: React.FC<Props> = ({
return;
}
// Calculate start time in seconds from playback position
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
console.log("[PlayButton] Loading media with customData:", {
hasCustomData: !!item,
customDataType: item.Type,
});
client
.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl: data?.url,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
customData: item,
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
mediaInfo: buildCastMediaInfo({
item,
streamUrl: data.url,
api,
}),
startTime: startTimeSeconds,
})
.then(() => {
@@ -285,7 +212,7 @@ export const PlayButton: React.FC<Props> = ({
router.push("/casting-player");
});
} catch (e) {
console.log(e);
console.error("[PlayButton] Cast error:", e);
}
}
});

View File

@@ -110,9 +110,10 @@ export const CastingMiniPlayer: React.FC = () => {
if (
currentItem.Type === "Episode" &&
currentItem.SeriesId &&
currentItem.ParentIndexNumber
currentItem.ParentIndexNumber !== undefined &&
currentItem.SeasonId
) {
// Build season poster URL using SeriesId and season number
// Build season poster URL using SeriesId and SeasonId as tag
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`;
}
@@ -146,12 +147,15 @@ export const CastingMiniPlayer: React.FC = () => {
router.push("/casting-player");
};
const handleTogglePlayPause = (e: any) => {
e.stopPropagation();
const handleTogglePlayPause = () => {
if (isPlaying) {
remoteMediaClient?.pause();
remoteMediaClient?.pause()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Pause error:", error);
});
} else {
remoteMediaClient?.play();
remoteMediaClient?.play()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Play error:", error);
});
}
};

View File

@@ -6,6 +6,7 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -24,6 +25,7 @@ export const ChromecastConnectionMenu: React.FC<
ChromecastConnectionMenuProps
> = ({ visible, onClose, onDisconnect }) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const castDevice = useCastDevice();
const castSession = useCastSession();
@@ -191,10 +193,10 @@ export const ChromecastConnectionMenu: React.FC<
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{castDevice?.friendlyName || "Chromecast"}
{castDevice?.friendlyName || t("casting_player.chromecast")}
</Text>
<Text style={{ color: protocolColor, fontSize: 12 }}>
Connected
{t("casting_player.connected")}
</Text>
</View>
</View>
@@ -213,9 +215,11 @@ export const ChromecastConnectionMenu: React.FC<
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? "Muted" : `${displayVolume}%`}
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
@@ -255,7 +259,15 @@ export const ChromecastConnectionMenu: React.FC<
handleVolumeChange(value);
if (isMuted) {
setIsMuted(false);
castSession?.setMute(false);
try {
castSession?.setMute(false);
} catch (error: unknown) {
console.error(
"[ChromecastConnectionMenu] Failed to unmute:",
error,
);
setIsMuted(true); // Rollback on failure
}
}
}}
onSlidingComplete={handleVolumeComplete}
@@ -288,7 +300,7 @@ export const ChromecastConnectionMenu: React.FC<
<Text
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
>
Disconnect
{t("casting_player.disconnect")}
</Text>
</Pressable>
</View>

View File

@@ -5,10 +5,11 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { type Device, useCastSession } from "react-native-google-cast";
import { useCastSession } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -16,7 +17,7 @@ import { Text } from "@/components/common/Text";
interface ChromecastDeviceSheetProps {
visible: boolean;
onClose: () => void;
device: Device | null;
device: { friendlyName?: string } | null;
onDisconnect: () => Promise<void>;
volume?: number;
onVolumeChange?: (volume: number) => Promise<void>;
@@ -31,6 +32,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onVolumeChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
const volumeValue = useSharedValue(volume * 100);
@@ -76,16 +78,14 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
// Check mute state
const muteState = await castSession.isMute();
if (muteState !== isMuted) {
setIsMuted(muteState);
}
setIsMuted(muteState);
} catch {
// Ignore errors - device might be disconnected
}
}, 1000);
return () => clearInterval(interval);
}, [visible, castSession, displayVolume, volumeValue, isMuted]);
}, [visible, castSession, volumeValue]);
const handleDisconnect = async () => {
setIsDisconnecting(true);
@@ -107,7 +107,6 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
// This works even when no media is playing, unlike setStreamVolume
if (castSession) {
await castSession.setVolume(newVolume);
console.log("[Volume] Set device volume via CastSession:", newVolume);
} else if (onVolumeChange) {
// Fallback to prop method if session not available
await onVolumeChange(newVolume);
@@ -153,6 +152,15 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
}
}, [castSession, isMuted]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
};
}, []);
return (
<Modal
visible={visible}
@@ -196,7 +204,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Text
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
>
Chromecast
{t("casting_player.chromecast")}
</Text>
</View>
<Pressable onPress={onClose} style={{ padding: 8 }}>
@@ -208,12 +216,12 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<View style={{ padding: 16 }}>
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
Device Name
{t("casting_player.device_name")}
</Text>
<Text
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
>
{device?.friendlyName || "Unknown Device"}
{device?.friendlyName || t("casting_player.unknown_device")}
</Text>
</View>
{/* Volume control */}
@@ -226,9 +234,11 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
marginBottom: 12,
}}
>
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
<Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? "Muted" : `${displayVolume}%`}
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
@@ -317,7 +327,9 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
{isDisconnecting
? t("casting_player.disconnecting")
: t("casting_player.stop_casting")}
</Text>
</Pressable>
</View>

View File

@@ -8,6 +8,7 @@ import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -32,6 +33,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
api,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
@@ -76,13 +78,14 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
);
if (currentIndex !== -1 && flatListRef.current) {
// Delay to ensure FlatList is rendered
setTimeout(() => {
const timeoutId = setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: currentIndex,
animated: true,
viewPosition: 0.5, // Center the item
});
}, 300);
return () => clearTimeout(timeoutId);
}
}
}, [visible, currentItem, filteredEpisodes]);
@@ -147,7 +150,8 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}}
numberOfLines={1}
>
{item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)}
{item.IndexNumber}.{" "}
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
</Text>
{item.Overview && (
<Text
@@ -178,7 +182,8 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
)}
{item.RunTimeTicks && (
<Text style={{ color: "#666", fontSize: 11 }}>
{Math.round(item.RunTimeTicks / 600000000)} min
{Math.round(item.RunTimeTicks / 600000000)}{" "}
{t("casting_player.minutes_short")}
</Text>
)}
</View>
@@ -237,7 +242,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Episodes
{t("casting_player.episodes")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
@@ -270,7 +275,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
fontWeight: selectedSeason === season ? "600" : "400",
}}
>
Season {season}
{t("casting_player.season", { number: season })}
</Text>
</Pressable>
))}
@@ -283,7 +288,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
ref={flatListRef}
data={filteredEpisodes}
renderItem={renderEpisode}
keyExtractor={(item) => item.Id || ""}
keyExtractor={(item, index) => item.Id || `episode-${index}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,

View File

@@ -6,6 +6,7 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -51,6 +52,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
onPlaybackSpeedChange,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [expandedSection, setExpandedSection] = useState<string | null>(null);
const toggleSection = (section: string) => {
@@ -124,7 +126,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Playback Settings
{t("casting_player.playback_settings")}
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
@@ -132,9 +134,14 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
</View>
<ScrollView>
{/* Quality/Media Source */}
{renderSectionHeader("Quality", "film-outline", "quality")}
{expandedSection === "quality" && (
{/* Quality/Media Source - only show when sources available */}
{mediaSources.length > 0 &&
renderSectionHeader(
t("casting_player.quality"),
"film-outline",
"quality",
)}
{mediaSources.length > 0 && expandedSection === "quality" && (
<View style={{ paddingVertical: 8 }}>
{mediaSources.map((source) => (
<Pressable
@@ -176,7 +183,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
{/* Audio Tracks - only show if more than one track */}
{audioTracks.length > 1 &&
renderSectionHeader("Audio", "musical-notes", "audio")}
renderSectionHeader(
t("casting_player.audio"),
"musical-notes",
"audio",
)}
{audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) => (
@@ -199,7 +210,9 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle || track.language || "Unknown"}
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{track.codec && (
<Text
@@ -219,7 +232,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
{/* Subtitle Tracks - only show if subtitles available */}
{subtitleTracks.length > 0 &&
renderSectionHeader("Subtitles", "text", "subtitles")}
renderSectionHeader(
t("casting_player.subtitles"),
"text",
"subtitles",
)}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}>
<Pressable
@@ -238,7 +255,9 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>None</Text>
<Text style={{ color: "white", fontSize: 15 }}>
{t("casting_player.none")}
</Text>
{selectedSubtitleTrack === null && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
@@ -263,14 +282,16 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle || track.language || "Unknown"}
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{track.codec && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec.toUpperCase()}
{track.isForced && " • Forced"}
{track.isForced && `${t("casting_player.forced")}`}
</Text>
)}
</View>
@@ -283,7 +304,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)}
{/* Playback Speed */}
{renderSectionHeader("Playback Speed", "speedometer", "speed")}
{renderSectionHeader(
t("casting_player.playback_speed"),
"speedometer",
"speed",
)}
{expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) => (
@@ -299,13 +324,15 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
alignItems: "center",
padding: 16,
backgroundColor:
playbackSpeed === speed ? "#2a2a2a" : "transparent",
Math.abs(playbackSpeed - speed) < 0.01
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{speed === 1 ? "Normal" : `${speed}x`}
{speed === 1 ? t("casting_player.normal") : `${speed}x`}
</Text>
{playbackSpeed === speed && (
{Math.abs(playbackSpeed - speed) < 0.01 && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>

View File

@@ -346,12 +346,15 @@ export const Controls: FC<Props> = ({
seek(timeInSeconds * 1000);
// Brief delay ensures the seek operation completes before resuming playback
// Without this, playback may resume from the old position
playTimeoutRef.current = setTimeout(() => {
play();
playTimeoutRef.current = null;
}, 200);
// Only resume if currently playing to avoid overriding user pause
if (isPlaying) {
playTimeoutRef.current = setTimeout(() => {
play();
playTimeoutRef.current = null;
}, 200);
}
},
[seek, play],
[seek, play, isPlaying],
);
// Use unified segment skipper for all segment types
@@ -427,7 +430,7 @@ export const Controls: FC<Props> = ({
);
const skipIntro = activeSegment?.skipSegment || noop;
const showSkipCreditButton = activeSegment?.type === "Outro";
const skipCredit = outroSkipper.skipSegment;
const skipCredit = outroSkipper.skipSegment || noop;
const hasContentAfterCredits =
outroSkipper.currentSegment && maxSeconds
? outroSkipper.currentSegment.endTime < maxSeconds