mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Merge pull request #136 from KuekHaoYang/codex-issue-135-seek-step-settings
[codex] add configurable seek step setting
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -18,6 +18,7 @@ interface CustomVideoPlayerProps {
|
||||
// Danmaku props
|
||||
videoTitle?: string;
|
||||
episodeName?: string;
|
||||
isPremium?: boolean;
|
||||
// Resolution callback
|
||||
onResolutionDetected?: (info: import('./hooks/useVideoResolution').VideoResolutionInfo) => void;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -247,6 +247,7 @@ export function VideoPlayer({
|
||||
isReversed={isReversed}
|
||||
videoTitle={videoTitle}
|
||||
episodeName={episodeName}
|
||||
isPremium={isPremium}
|
||||
onResolutionDetected={onResolutionDetected}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user