From a6bfcf4b361b298558af98ceb6ec939f9299b444 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 2 Apr 2026 19:57:08 +0800 Subject: [PATCH] feat: add configurable seek step setting --- .../settings/hooks/usePremiumSettingsPage.ts | 24 ++++++- app/premium/settings/page.tsx | 4 ++ app/settings/hooks/useSettingsPage.ts | 33 ++++++++-- app/settings/page.tsx | 4 ++ components/iptv/IPTVPlayer.tsx | 16 ++++- components/player/CustomVideoPlayer.tsx | 1 + components/player/DesktopVideoPlayer.tsx | 21 +++++- components/player/VideoPlayer.tsx | 1 + components/player/desktop/DesktopOverlay.tsx | 6 +- .../player/desktop/DesktopOverlayWrapper.tsx | 3 + .../player/hooks/desktop/useSkipControls.ts | 14 ++-- .../player/hooks/useDesktopPlayerLogic.ts | 7 +- components/settings/PlayerSettings.tsx | 66 ++++++++++++++++++- lib/store/premium-mode-settings.ts | 13 +++- lib/store/settings-store.ts | 15 +++++ 15 files changed, 205 insertions(+), 23 deletions(-) diff --git a/app/premium/settings/hooks/usePremiumSettingsPage.ts b/app/premium/settings/hooks/usePremiumSettingsPage.ts index f0498fb..294f039 100644 --- a/app/premium/settings/hooks/usePremiumSettingsPage.ts +++ b/app/premium/settings/hooks/usePremiumSettingsPage.ts @@ -1,6 +1,14 @@ import { useState, useEffect } from 'react'; -import { settingsStore, getDefaultPremiumSources, type SortOption, type SearchDisplayMode, type ProxyMode, type LocaleOption } from '@/lib/store/settings-store'; -import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings'; +import { + settingsStore, + getDefaultPremiumSources, + type SearchDisplayMode, + type ProxyMode, + type LocaleOption, + DEFAULT_SEEK_STEP_SECONDS, + normalizeSeekStepSeconds, +} from '@/lib/store/settings-store'; +import { premiumModeSettingsStore, type ModeSettings } from '@/lib/store/premium-mode-settings'; import type { VideoSource } from '@/lib/types'; export function usePremiumSettingsPage() { @@ -14,6 +22,7 @@ export function usePremiumSettingsPage() { const [searchDisplayMode, setSearchDisplayMode] = useState('normal'); const [fullscreenType, setFullscreenType] = useState<'auto' | 'native' | 'window'>('auto'); const [proxyMode, setProxyMode] = useState('retry'); + const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS); const [rememberScrollPosition, setRememberScrollPosition] = useState(true); const [locale, setLocale] = useState('zh-CN'); @@ -38,6 +47,7 @@ export function usePremiumSettingsPage() { setSearchDisplayMode(modeSettings.searchDisplayMode); setFullscreenType(modeSettings.fullscreenType); setProxyMode(modeSettings.proxyMode); + setSeekStepSeconds(modeSettings.seekStepSeconds); setRememberScrollPosition(modeSettings.rememberScrollPosition); setDanmakuApiUrl(modeSettings.danmakuApiUrl); setDanmakuOpacity(modeSettings.danmakuOpacity); @@ -81,7 +91,7 @@ export function usePremiumSettingsPage() { // --- Premium mode settings helpers --- - const savePremiumModeSetting = (partial: Record) => { + const savePremiumModeSetting = (partial: Partial) => { const current = premiumModeSettingsStore.getSettings(); premiumModeSettingsStore.saveSettings({ ...current, ...partial }); }; @@ -108,6 +118,12 @@ export function usePremiumSettingsPage() { savePremiumModeSetting({ proxyMode: mode }); }; + const handleSeekStepSecondsChange = (value: number) => { + const normalized = normalizeSeekStepSeconds(value); + setSeekStepSeconds(normalized); + savePremiumModeSetting({ seekStepSeconds: normalized }); + }; + const handleRememberScrollPositionChange = (enabled: boolean) => { setRememberScrollPosition(enabled); savePremiumModeSetting({ rememberScrollPosition: enabled }); @@ -166,11 +182,13 @@ export function usePremiumSettingsPage() { searchDisplayMode, fullscreenType, proxyMode, + seekStepSeconds, rememberScrollPosition, handleRealtimeLatencyChange, handleSearchDisplayModeChange, handleFullscreenTypeChange, handleProxyModeChange, + handleSeekStepSecondsChange, handleRememberScrollPositionChange, locale, handleLocaleChange, diff --git a/app/premium/settings/page.tsx b/app/premium/settings/page.tsx index 52af4c7..00e455b 100644 --- a/app/premium/settings/page.tsx +++ b/app/premium/settings/page.tsx @@ -27,11 +27,13 @@ export default function PremiumSettingsPage() { searchDisplayMode, fullscreenType, proxyMode, + seekStepSeconds, rememberScrollPosition, handleRealtimeLatencyChange, handleSearchDisplayModeChange, handleFullscreenTypeChange, handleProxyModeChange, + handleSeekStepSecondsChange, handleRememberScrollPositionChange, locale, handleLocaleChange, @@ -79,6 +81,8 @@ export default function PremiumSettingsPage() { onFullscreenTypeChange={handleFullscreenTypeChange} proxyMode={proxyMode} onProxyModeChange={handleProxyModeChange} + seekStepSeconds={seekStepSeconds} + onSeekStepSecondsChange={handleSeekStepSecondsChange} danmakuApiUrl={danmakuApiUrl} onDanmakuApiUrlChange={handleDanmakuApiUrlChange} danmakuOpacity={danmakuOpacity} diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index c9aa066..3634fc2 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -1,5 +1,14 @@ import { useState, useEffect } from 'react'; -import { settingsStore, getDefaultSources, type SortOption, type SearchDisplayMode, type ProxyMode, type LocaleOption } from '@/lib/store/settings-store'; +import { + settingsStore, + getDefaultSources, + type SortOption, + type SearchDisplayMode, + type ProxyMode, + type LocaleOption, + DEFAULT_SEEK_STEP_SECONDS, + normalizeSeekStepSeconds, +} from '@/lib/store/settings-store'; import type { VideoSource, SourceSubscription } from '@/lib/types'; import { type ImportResult, @@ -24,6 +33,7 @@ export function useSettingsPage() { const [searchDisplayMode, setSearchDisplayMode] = useState('normal'); const [fullscreenType, setFullscreenType] = useState<'auto' | 'native' | 'window'>('auto'); const [proxyMode, setProxyMode] = useState('retry'); + const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS); const [rememberScrollPosition, setRememberScrollPosition] = useState(true); const [locale, setLocale] = useState('zh-CN'); @@ -45,6 +55,7 @@ export function useSettingsPage() { setSearchDisplayMode(settings.searchDisplayMode); setFullscreenType(settings.fullscreenType); setProxyMode(settings.proxyMode); + setSeekStepSeconds(settings.seekStepSeconds); setRememberScrollPosition(settings.rememberScrollPosition); setLocale(settings.locale); setDanmakuApiUrl(settings.danmakuApiUrl); @@ -131,11 +142,11 @@ export function useSettingsPage() { const handleImportLink = (result: ImportResult, isSync: boolean = false): boolean => { try { // Merge normal sources - let updatedSources = mergeSources(sources, result.normalSources); + const updatedSources = mergeSources(sources, result.normalSources); // Merge premium sources if needed const currentSettings = settingsStore.getSettings(); - let updatedPremiumSources = mergeSources(currentSettings.premiumSources, result.premiumSources); + const updatedPremiumSources = mergeSources(currentSettings.premiumSources, result.premiumSources); // Save everything settingsStore.saveSettings({ @@ -253,6 +264,16 @@ export function useSettingsPage() { }); }; + const handleSeekStepSecondsChange = (value: number) => { + const normalized = normalizeSeekStepSeconds(value); + setSeekStepSeconds(normalized); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + seekStepSeconds: normalized, + }); + }; + const handleRememberScrollPositionChange = (enabled: boolean) => { setRememberScrollPosition(enabled); const currentSettings = settingsStore.getSettings(); @@ -335,6 +356,9 @@ export function useSettingsPage() { sortBy, realtimeLatency, searchDisplayMode, + fullscreenType, + proxyMode, + seekStepSeconds, isAddModalOpen, isExportModalOpen, isImportModalOpen, @@ -361,10 +385,9 @@ export function useSettingsPage() { handleEditSource, handleRealtimeLatencyChange, handleSearchDisplayModeChange, - fullscreenType, handleFullscreenTypeChange, - proxyMode, handleProxyModeChange, + handleSeekStepSecondsChange, rememberScrollPosition, handleRememberScrollPositionChange, locale, diff --git a/app/settings/page.tsx b/app/settings/page.tsx index b969357..baa7dcb 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -24,6 +24,7 @@ export default function SettingsPage() { realtimeLatency, searchDisplayMode, fullscreenType, + seekStepSeconds, isAddModalOpen, isExportModalOpen, isImportModalOpen, @@ -54,6 +55,7 @@ export default function SettingsPage() { handleFullscreenTypeChange, proxyMode, handleProxyModeChange, + handleSeekStepSecondsChange, rememberScrollPosition, handleRememberScrollPositionChange, locale, @@ -86,6 +88,8 @@ export default function SettingsPage() { onFullscreenTypeChange={handleFullscreenTypeChange} proxyMode={proxyMode} onProxyModeChange={handleProxyModeChange} + seekStepSeconds={seekStepSeconds} + onSeekStepSecondsChange={handleSeekStepSecondsChange} danmakuApiUrl={danmakuApiUrl} onDanmakuApiUrlChange={handleDanmakuApiUrlChange} danmakuOpacity={danmakuOpacity} diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index c4f0c98..6895810 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -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 = { 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>(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': diff --git a/components/player/CustomVideoPlayer.tsx b/components/player/CustomVideoPlayer.tsx index 55aab10..4cc9b47 100644 --- a/components/player/CustomVideoPlayer.tsx +++ b/components/player/CustomVideoPlayer.tsx @@ -18,6 +18,7 @@ interface CustomVideoPlayerProps { // Danmaku props videoTitle?: string; episodeName?: string; + isPremium?: boolean; // Resolution callback onResolutionDetected?: (info: import('./hooks/useVideoResolution').VideoResolutionInfo) => void; } diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 73cf84c..031b48a 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -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(() => { 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} diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx index de75752..bd5e10a 100644 --- a/components/player/VideoPlayer.tsx +++ b/components/player/VideoPlayer.tsx @@ -247,6 +247,7 @@ export function VideoPlayer({ isReversed={isReversed} videoTitle={videoTitle} episodeName={episodeName} + isPremium={isPremium} onResolutionDetected={onResolutionDetected} /> )} diff --git a/components/player/desktop/DesktopOverlay.tsx b/components/player/desktop/DesktopOverlay.tsx index f16621c..bea1c39 100644 --- a/components/player/desktop/DesktopOverlay.tsx +++ b/components/player/desktop/DesktopOverlay.tsx @@ -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} 秒`} > @@ -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} 秒`} > diff --git a/components/player/desktop/DesktopOverlayWrapper.tsx b/components/player/desktop/DesktopOverlayWrapper.tsx index 47ff55b..d4ba7f0 100644 --- a/components/player/desktop/DesktopOverlayWrapper.tsx +++ b/components/player/desktop/DesktopOverlayWrapper.tsx @@ -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} diff --git a/components/player/hooks/desktop/useSkipControls.ts b/components/player/hooks/desktop/useSkipControls.ts index 514a24b..734febc 100644 --- a/components/player/hooks/desktop/useSkipControls.ts +++ b/components/player/hooks/desktop/useSkipControls.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'; interface UseSkipControlsProps { videoRef: React.RefObject; 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, diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index 7995d21..bb15455 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -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; @@ -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, diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx index 9dae988..41395cc 100644 --- a/components/settings/PlayerSettings.tsx +++ b/components/settings/PlayerSettings.tsx @@ -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({
+
+

+ + 快进 / 快退间隔 +

+

+ 控制键盘 J / L、方向键和双击手势每次跳转的秒数 +

+ +
+ {SEEK_STEP_PRESETS.map((seconds) => ( + + ))} +
+ +
+ +
+ { + 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" + /> + +
+

+ 范围 {MIN_SEEK_STEP_SECONDS}-{MAX_SEEK_STEP_SECONDS} 秒,默认 {DEFAULT_SEEK_STEP_SECONDS} 秒 +

+
+
+ +
+ {/* Proxy Mode Selection */}

diff --git a/lib/store/premium-mode-settings.ts b/lib/store/premium-mode-settings.ts index a64f09d..e0e529c 100644 --- a/lib/store/premium-mode-settings.ts +++ b/lib/store/premium-mode-settings.ts @@ -4,7 +4,14 @@ * Mirrors the relevant subset of AppSettings but uses its own localStorage key. */ -import type { SortOption, SearchDisplayMode, ProxyMode, AdFilterMode } from './settings-store'; +import { + type SortOption, + type SearchDisplayMode, + type ProxyMode, + type AdFilterMode, + DEFAULT_SEEK_STEP_SECONDS, + normalizeSeekStepSeconds, +} from './settings-store'; const PREMIUM_MODE_SETTINGS_KEY = 'kvideo-premium-mode-settings'; @@ -15,6 +22,7 @@ export interface ModeSettings { skipIntroSeconds: number; autoSkipOutro: boolean; skipOutroSeconds: number; + seekStepSeconds: number; showModeIndicator: boolean; adFilterMode: AdFilterMode; fullscreenType: 'auto' | 'native' | 'window'; @@ -39,6 +47,7 @@ function getDefaultModeSettings(): ModeSettings { skipIntroSeconds: 0, autoSkipOutro: false, skipOutroSeconds: 0, + seekStepSeconds: DEFAULT_SEEK_STEP_SECONDS, showModeIndicator: false, adFilterMode: 'heuristic', fullscreenType: 'auto', @@ -76,6 +85,7 @@ export const premiumModeSettingsStore = { skipIntroSeconds: typeof parsed.skipIntroSeconds === 'number' ? parsed.skipIntroSeconds : 0, autoSkipOutro: parsed.autoSkipOutro !== undefined ? parsed.autoSkipOutro : false, skipOutroSeconds: typeof parsed.skipOutroSeconds === 'number' ? parsed.skipOutroSeconds : 0, + seekStepSeconds: normalizeSeekStepSeconds(parsed.seekStepSeconds), showModeIndicator: parsed.showModeIndicator !== undefined ? parsed.showModeIndicator : false, adFilterMode: parsed.adFilterMode || 'heuristic', fullscreenType: (parsed.fullscreenType === 'window' || parsed.fullscreenType === 'native' || parsed.fullscreenType === 'auto') ? parsed.fullscreenType : 'auto', @@ -136,6 +146,7 @@ export function getModeSettings(isPremium: boolean): ModeSettings { skipIntroSeconds: s.skipIntroSeconds, autoSkipOutro: s.autoSkipOutro, skipOutroSeconds: s.skipOutroSeconds, + seekStepSeconds: s.seekStepSeconds, showModeIndicator: s.showModeIndicator, adFilterMode: s.adFilterMode, fullscreenType: s.fullscreenType, diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index c6f24c7..a0a9c5b 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -23,6 +23,18 @@ export type SearchDisplayMode = 'normal' | 'grouped'; export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive'; export type ProxyMode = 'retry' | 'none' | 'always'; +export const DEFAULT_SEEK_STEP_SECONDS = 10; +export const MIN_SEEK_STEP_SECONDS = 1; +export const MAX_SEEK_STEP_SECONDS = 120; + +export function normalizeSeekStepSeconds(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_SEEK_STEP_SECONDS; + } + + return Math.min(MAX_SEEK_STEP_SECONDS, Math.max(MIN_SEEK_STEP_SECONDS, Math.round(value))); +} + export interface AppSettings { sources: VideoSource[]; premiumSources: VideoSource[]; @@ -36,6 +48,7 @@ export interface AppSettings { skipIntroSeconds: number; autoSkipOutro: boolean; skipOutroSeconds: number; + seekStepSeconds: number; showModeIndicator: boolean; // Show '直连模式'/'代理模式' badge on player adFilter: boolean; // Filter ad tags from m3u8 (legacy, kept for compatibility) adFilterMode: AdFilterMode; // 'off' | 'keyword' | 'heuristic' | 'aggressive' @@ -118,6 +131,7 @@ function getDefaultAppSettings(): AppSettings { skipIntroSeconds: 0, autoSkipOutro: false, skipOutroSeconds: 0, + seekStepSeconds: DEFAULT_SEEK_STEP_SECONDS, showModeIndicator: false, adFilter: false, adFilterMode: 'heuristic', @@ -220,6 +234,7 @@ export const settingsStore = { skipIntroSeconds: typeof parsed.skipIntroSeconds === 'number' ? parsed.skipIntroSeconds : 0, autoSkipOutro: parsed.autoSkipOutro !== undefined ? parsed.autoSkipOutro : false, skipOutroSeconds: typeof parsed.skipOutroSeconds === 'number' ? parsed.skipOutroSeconds : 0, + seekStepSeconds: normalizeSeekStepSeconds(parsed.seekStepSeconds), showModeIndicator: parsed.showModeIndicator !== undefined ? parsed.showModeIndicator : false, adFilter: parsed.adFilter !== undefined ? parsed.adFilter : false, adFilterMode: parsed.adFilterMode || 'heuristic',