feat: add configurable seek step setting

This commit is contained in:
kuekhaoyang
2026-04-02 19:57:08 +08:00
parent 97049137fb
commit a6bfcf4b36
15 changed files with 205 additions and 23 deletions
+14 -2
View File
@@ -12,6 +12,7 @@ import Hls from 'hls.js';
import { Icons } from '@/components/ui/Icon';
import type { M3UChannel } from '@/lib/utils/m3u-parser';
import type { IPTVSource } from '@/lib/store/iptv-store';
import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store';
const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
enableWorker: true,
@@ -96,6 +97,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
const [currentRouteIndex, setCurrentRouteIndex] = useState(0);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showAllRoutes, setShowAllRoutes] = useState(false);
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
// Multi-level sidebar state
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set());
@@ -119,6 +121,16 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
}
}, [showSidebar, channel.url]);
useEffect(() => {
const syncSeekStep = () => {
setSeekStepSeconds(settingsStore.getSettings().seekStepSeconds ?? DEFAULT_SEEK_STEP_SECONDS);
};
syncSeekStep();
const unsubscribe = settingsStore.subscribe(syncSeekStep);
return () => unsubscribe();
}, []);
// Auto-expand the source/group containing the active channel
useEffect(() => {
if (channel.sourceId) {
@@ -506,14 +518,14 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
case 'l':
e.preventDefault();
if (!isLive && isFinite(video.duration)) {
video.currentTime = Math.min(video.duration, video.currentTime + 10);
video.currentTime = Math.min(video.duration, video.currentTime + seekStepSeconds);
}
break;
case 'arrowleft':
case 'j':
e.preventDefault();
if (!isLive && isFinite(video.duration)) {
video.currentTime = Math.max(0, video.currentTime - 10);
video.currentTime = Math.max(0, video.currentTime - seekStepSeconds);
}
break;
case 'arrowup':
+1
View File
@@ -18,6 +18,7 @@ interface CustomVideoPlayerProps {
// Danmaku props
videoTitle?: string;
episodeName?: string;
isPremium?: boolean;
// Resolution callback
onResolutionDetected?: (info: import('./hooks/useVideoResolution').VideoResolutionInfo) => void;
}
+20 -1
View File
@@ -14,6 +14,8 @@ import { usePlayerSettings } from './hooks/usePlayerSettings';
import { useDanmaku } from './hooks/useDanmaku';
import { useIsIOS, useIsMobile } from '@/lib/hooks/mobile/useDeviceDetection';
import { useDoubleTap } from '@/lib/hooks/mobile/useDoubleTap';
import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store';
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
import './web-fullscreen.css';
type WebFullscreenSize = 'full' | 'large' | 'focused';
@@ -36,6 +38,7 @@ interface DesktopVideoPlayerProps {
// Danmaku props
videoTitle?: string;
episodeName?: string;
isPremium?: boolean;
// Resolution callback
onResolutionDetected?: (info: import('./hooks/useVideoResolution').VideoResolutionInfo) => void;
}
@@ -53,12 +56,14 @@ export function DesktopVideoPlayer({
isReversed = false,
videoTitle = '',
episodeName = '',
isPremium = false,
onResolutionDetected,
}: DesktopVideoPlayerProps) {
const { refs, data, actions } = useDesktopPlayerState();
const { fullscreenType: settingsFullscreenType } = usePlayerSettings();
const isIOS = useIsIOS();
const isMobile = useIsMobile();
const [seekStepSeconds, setSeekStepSeconds] = React.useState(DEFAULT_SEEK_STEP_SECONDS);
const [webFullscreenSize, setWebFullscreenSize] = React.useState<WebFullscreenSize>(() => {
if (typeof window === 'undefined') return 'full';
const saved = localStorage.getItem(WEB_FULLSCREEN_SIZE_KEY);
@@ -118,6 +123,18 @@ export function DesktopVideoPlayer({
localStorage.setItem(WEB_FULLSCREEN_SIZE_KEY, webFullscreenSize);
}, [webFullscreenSize]);
React.useEffect(() => {
const store = isPremium ? premiumModeSettingsStore : settingsStore;
const syncSeekStep = () => {
setSeekStepSeconds(store.getSettings().seekStepSeconds ?? DEFAULT_SEEK_STEP_SECONDS);
};
syncSeekStep();
const unsubscribe = store.subscribe(syncSeekStep);
return () => unsubscribe();
}, [isPremium]);
React.useEffect(() => {
if (!data.isFullscreen) {
setFullscreenClock('');
@@ -179,7 +196,8 @@ export function DesktopVideoPlayer({
data,
actions,
fullscreenType,
isForceLandscape: shouldForceLandscape
isForceLandscape: shouldForceLandscape,
seekStepSeconds,
});
// Auto-skip intro/outro and auto-next episode
@@ -335,6 +353,7 @@ export function DesktopVideoPlayer({
}, 800); // Increased timeout for better stability
}}
onCopyLink={logic.handleCopyLink}
seekStepSeconds={seekStepSeconds}
// Speed Menu Props
playbackRate={data.playbackRate}
showSpeedMenu={data.showSpeedMenu}
+1
View File
@@ -247,6 +247,7 @@ export function VideoPlayer({
isReversed={isReversed}
videoTitle={videoTitle}
episodeName={episodeName}
isPremium={isPremium}
onResolutionDetected={onResolutionDetected}
/>
)}
+4 -2
View File
@@ -28,6 +28,7 @@ interface DesktopOverlayProps {
onMoreMenuMouseEnter: () => void;
onMoreMenuMouseLeave: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
seekStepSeconds: number;
// Speed Menu Props
playbackRate: number;
showSpeedMenu: boolean;
@@ -66,6 +67,7 @@ export function DesktopOverlay({
onMoreMenuMouseEnter,
onMoreMenuMouseLeave,
onCopyLink,
seekStepSeconds,
playbackRate,
showSpeedMenu,
speeds,
@@ -176,7 +178,7 @@ export function DesktopOverlay({
onSkipBackward();
}}
className="group flex items-center justify-center w-10 h-10 md:w-16 md:h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95 cursor-pointer"
aria-label="后退 10 秒"
aria-label={`后退 ${seekStepSeconds}`}
>
<Icons.SkipBack className="w-5 h-5 md:w-8 md:h-8 text-white/80 group-hover:text-white" />
</button>
@@ -194,7 +196,7 @@ export function DesktopOverlay({
onSkipForward();
}}
className="group flex items-center justify-center w-10 h-10 md:w-16 md:h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95 cursor-pointer"
aria-label="前进 10 秒"
aria-label={`前进 ${seekStepSeconds}`}
>
<Icons.FastForward className="w-5 h-5 md:w-8 md:h-8 text-white/80 group-hover:text-white" />
</button>
@@ -18,6 +18,7 @@ interface DesktopOverlayWrapperProps {
onMoreMenuMouseEnter: () => void;
onMoreMenuMouseLeave: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
seekStepSeconds: number;
// Speed Menu Props
playbackRate: number;
showSpeedMenu: boolean;
@@ -47,6 +48,7 @@ export function DesktopOverlayWrapper({
onMoreMenuMouseEnter,
onMoreMenuMouseLeave,
onCopyLink,
seekStepSeconds,
playbackRate,
showSpeedMenu,
speeds,
@@ -96,6 +98,7 @@ export function DesktopOverlayWrapper({
onMoreMenuMouseEnter={onMoreMenuMouseEnter}
onMoreMenuMouseLeave={onMoreMenuMouseLeave}
onCopyLink={onCopyLink}
seekStepSeconds={seekStepSeconds}
playbackRate={playbackRate}
showSpeedMenu={showSpeedMenu}
speeds={speeds}
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react';
interface UseSkipControlsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
duration: number;
seekStepSeconds: number;
setCurrentTime: (time: number) => void;
showSkipForwardIndicator: boolean;
showSkipBackwardIndicator: boolean;
@@ -21,6 +22,7 @@ interface UseSkipControlsProps {
export function useSkipControls({
videoRef,
duration,
seekStepSeconds,
setCurrentTime,
showSkipForwardIndicator,
showSkipBackwardIndicator,
@@ -49,12 +51,12 @@ export function useSkipControls({
clearTimeout(skipForwardTimeoutRef.current);
}
const newSkipAmount = showSkipForwardIndicator ? skipForwardAmount + 10 : 10;
const newSkipAmount = showSkipForwardIndicator ? skipForwardAmount + seekStepSeconds : seekStepSeconds;
setSkipForwardAmount(newSkipAmount);
setShowSkipForwardIndicator(true);
setIsSkipForwardAnimatingOut(false);
const targetTime = Math.min(videoRef.current.currentTime + 10, duration);
const targetTime = Math.min(videoRef.current.currentTime + seekStepSeconds, duration);
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
@@ -66,7 +68,7 @@ export function useSkipControls({
setIsSkipForwardAnimatingOut(false);
}, 200);
}, 800);
}, [videoRef, duration, showSkipForwardIndicator, skipForwardAmount, skipBackwardTimeoutRef, skipForwardTimeoutRef, setShowSkipBackwardIndicator, setSkipBackwardAmount, setIsSkipBackwardAnimatingOut, setSkipForwardAmount, setShowSkipForwardIndicator, setIsSkipForwardAnimatingOut, setCurrentTime]);
}, [videoRef, duration, seekStepSeconds, showSkipForwardIndicator, skipForwardAmount, skipBackwardTimeoutRef, skipForwardTimeoutRef, setShowSkipBackwardIndicator, setSkipBackwardAmount, setIsSkipBackwardAnimatingOut, setSkipForwardAmount, setShowSkipForwardIndicator, setIsSkipForwardAnimatingOut, setCurrentTime]);
const skipBackward = useCallback(() => {
if (!videoRef.current) return;
@@ -82,12 +84,12 @@ export function useSkipControls({
clearTimeout(skipBackwardTimeoutRef.current);
}
const newSkipAmount = showSkipBackwardIndicator ? skipBackwardAmount + 10 : 10;
const newSkipAmount = showSkipBackwardIndicator ? skipBackwardAmount + seekStepSeconds : seekStepSeconds;
setSkipBackwardAmount(newSkipAmount);
setShowSkipBackwardIndicator(true);
setIsSkipBackwardAnimatingOut(false);
const targetTime = Math.max(videoRef.current.currentTime - 10, 0);
const targetTime = Math.max(videoRef.current.currentTime - seekStepSeconds, 0);
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
@@ -99,7 +101,7 @@ export function useSkipControls({
setIsSkipBackwardAnimatingOut(false);
}, 200);
}, 800);
}, [videoRef, showSkipBackwardIndicator, skipBackwardAmount, skipForwardTimeoutRef, skipBackwardTimeoutRef, setShowSkipForwardIndicator, setSkipForwardAmount, setIsSkipForwardAnimatingOut, setSkipBackwardAmount, setShowSkipBackwardIndicator, setIsSkipBackwardAnimatingOut, setCurrentTime]);
}, [videoRef, seekStepSeconds, showSkipBackwardIndicator, skipBackwardAmount, skipForwardTimeoutRef, skipBackwardTimeoutRef, setShowSkipForwardIndicator, setSkipForwardAmount, setIsSkipForwardAnimatingOut, setSkipBackwardAmount, setShowSkipBackwardIndicator, setIsSkipBackwardAnimatingOut, setCurrentTime]);
const skipActions = useMemo(() => ({
skipForward,
@@ -10,6 +10,7 @@ import { useDesktopShortcuts } from './desktop/useDesktopShortcuts';
import { useDesktopPlayerState } from './useDesktopPlayerState';
import { getCopyUrl } from '../utils/urlUtils';
import { useCastControls } from './desktop/useCastControls';
import { DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store';
type DesktopPlayerState = ReturnType<typeof useDesktopPlayerState>;
@@ -24,6 +25,7 @@ interface UseDesktopPlayerLogicProps {
actions: DesktopPlayerState['actions'];
fullscreenType?: 'native' | 'window';
isForceLandscape?: boolean;
seekStepSeconds?: number;
}
export function useDesktopPlayerLogic({
@@ -36,7 +38,8 @@ export function useDesktopPlayerLogic({
data,
actions,
fullscreenType = 'native',
isForceLandscape = false
isForceLandscape = false,
seekStepSeconds = DEFAULT_SEEK_STEP_SECONDS
}: UseDesktopPlayerLogicProps) {
const {
videoRef, containerRef, progressBarRef, volumeBarRef,
@@ -112,7 +115,7 @@ export function useDesktopPlayerLogic({
});
const skipControls = useSkipControls({
videoRef, duration, setCurrentTime,
videoRef, duration, seekStepSeconds, setCurrentTime,
showSkipForwardIndicator, showSkipBackwardIndicator,
skipForwardAmount, skipBackwardAmount,
setShowSkipForwardIndicator, setShowSkipBackwardIndicator,
+65 -1
View File
@@ -6,13 +6,20 @@
*/
import { Icons } from '@/components/ui/Icon';
import type { ProxyMode } from '@/lib/store/settings-store';
import {
type ProxyMode,
DEFAULT_SEEK_STEP_SECONDS,
MIN_SEEK_STEP_SECONDS,
MAX_SEEK_STEP_SECONDS,
} from '@/lib/store/settings-store';
interface PlayerSettingsProps {
fullscreenType: 'auto' | 'native' | 'window';
onFullscreenTypeChange: (type: 'auto' | 'native' | 'window') => void;
proxyMode: ProxyMode;
onProxyModeChange: (mode: ProxyMode) => void;
seekStepSeconds: number;
onSeekStepSecondsChange: (value: number) => void;
danmakuApiUrl: string;
onDanmakuApiUrlChange: (url: string) => void;
danmakuOpacity: number;
@@ -31,12 +38,15 @@ const DANMAKU_DISPLAY_AREAS = [
{ value: 0.75, label: '3/4屏' },
{ value: 1.0, label: '全屏' },
];
const SEEK_STEP_PRESETS = [5, 10, 15, 30, 60];
export function PlayerSettings({
fullscreenType,
onFullscreenTypeChange,
proxyMode,
onProxyModeChange,
seekStepSeconds,
onSeekStepSecondsChange,
danmakuApiUrl,
onDanmakuApiUrlChange,
danmakuOpacity,
@@ -87,6 +97,60 @@ export function PlayerSettings({
<div className="border-t border-[var(--glass-border)]" />
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.FastForward size={18} className="text-[var(--accent-color)]" />
/ 退
</h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
J / L
</p>
<div className="flex gap-2 flex-wrap mb-4">
{SEEK_STEP_PRESETS.map((seconds) => (
<button
key={seconds}
onClick={() => onSeekStepSecondsChange(seconds)}
className={`px-3 py-1.5 rounded-[var(--radius-2xl)] border text-sm font-medium transition-all duration-200 cursor-pointer ${seekStepSeconds === seconds
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
{seconds}
</button>
))}
</div>
<div className="max-w-xs">
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
</label>
<div className="flex items-center gap-2">
<input
type="number"
min={MIN_SEEK_STEP_SECONDS}
max={MAX_SEEK_STEP_SECONDS}
step="1"
inputMode="numeric"
value={seekStepSeconds}
onChange={(e) => {
if (e.target.value === '') return;
const nextValue = Number(e.target.value);
if (Number.isNaN(nextValue)) return;
onSeekStepSecondsChange(nextValue);
}}
className="w-full px-4 py-2.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] transition-colors text-sm"
/>
<span className="text-sm text-[var(--text-color-secondary)]"></span>
</div>
<p className="text-xs text-[var(--text-color-secondary)] mt-1.5">
{MIN_SEEK_STEP_SECONDS}-{MAX_SEEK_STEP_SECONDS} {DEFAULT_SEEK_STEP_SECONDS}
</p>
</div>
</div>
<div className="border-t border-[var(--glass-border)]" />
{/* Proxy Mode Selection */}
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">