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
@@ -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<SearchDisplayMode>('normal');
const [fullscreenType, setFullscreenType] = useState<'auto' | 'native' | 'window'>('auto');
const [proxyMode, setProxyMode] = useState<ProxyMode>('retry');
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
const [rememberScrollPosition, setRememberScrollPosition] = useState(true);
const [locale, setLocale] = useState<LocaleOption>('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<string, any>) => {
const savePremiumModeSetting = (partial: Partial<ModeSettings>) => {
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,
+4
View File
@@ -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}
+28 -5
View File
@@ -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<SearchDisplayMode>('normal');
const [fullscreenType, setFullscreenType] = useState<'auto' | 'native' | 'window'>('auto');
const [proxyMode, setProxyMode] = useState<ProxyMode>('retry');
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
const [rememberScrollPosition, setRememberScrollPosition] = useState(true);
const [locale, setLocale] = useState<LocaleOption>('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,
+4
View File
@@ -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}
+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">
+12 -1
View File
@@ -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,
+15
View File
@@ -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',