feat: implement proxy mode setting for video playback with retry, none, and always options

This commit is contained in:
kuekhaoyang
2026-01-25 14:40:38 +08:00
parent f4b16bac2d
commit 2aa90a4d4f
8 changed files with 141 additions and 39 deletions
+14 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { settingsStore, getDefaultSources, type SortOption, type SearchDisplayMode } from '@/lib/store/settings-store';
import { settingsStore, getDefaultSources, type SortOption, type SearchDisplayMode, type ProxyMode } from '@/lib/store/settings-store';
import type { VideoSource, SourceSubscription } from '@/lib/types';
import {
type ImportResult,
@@ -27,6 +27,7 @@ export function useSettingsPage() {
const [realtimeLatency, setRealtimeLatency] = useState(false);
const [searchDisplayMode, setSearchDisplayMode] = useState<SearchDisplayMode>('normal');
const [fullscreenType, setFullscreenType] = useState<'native' | 'window'>('native');
const [proxyMode, setProxyMode] = useState<ProxyMode>('retry');
useEffect(() => {
const settings = settingsStore.getSettings();
@@ -38,6 +39,7 @@ export function useSettingsPage() {
setRealtimeLatency(settings.realtimeLatency);
setSearchDisplayMode(settings.searchDisplayMode);
setFullscreenType(settings.fullscreenType);
setProxyMode(settings.proxyMode);
// Fetch env password status
fetch('/api/config')
@@ -290,6 +292,15 @@ export function useSettingsPage() {
});
};
const handleProxyModeChange = (mode: ProxyMode) => {
setProxyMode(mode);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
proxyMode: mode,
});
};
const handleRestoreDefaults = () => {
const defaults = getDefaultSources();
handleSourcesChange(defaults);
@@ -342,5 +353,7 @@ export function useSettingsPage() {
handleSearchDisplayModeChange,
fullscreenType,
handleFullscreenTypeChange,
proxyMode,
handleProxyModeChange,
};
}
+4
View File
@@ -55,6 +55,8 @@ export default function SettingsPage() {
handleRealtimeLatencyChange,
handleSearchDisplayModeChange,
handleFullscreenTypeChange,
proxyMode,
handleProxyModeChange,
} = useSettingsPage();
return (
@@ -67,6 +69,8 @@ export default function SettingsPage() {
<PlayerSettings
fullscreenType={fullscreenType}
onFullscreenTypeChange={handleFullscreenTypeChange}
proxyMode={proxyMode}
onProxyModeChange={handleProxyModeChange}
/>
{/* Password Settings */}
+27 -5
View File
@@ -42,20 +42,37 @@ export function VideoPlayer({
const SAVE_INTERVAL = 5000; // 5 seconds throttle
// Get showModeIndicator setting
// Get showModeIndicator and proxyMode settings
const [showModeIndicator, setShowModeIndicator] = useState(false);
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
useEffect(() => {
// Initial value
setShowModeIndicator(settingsStore.getSettings().showModeIndicator);
const settings = settingsStore.getSettings();
setShowModeIndicator(settings.showModeIndicator);
setProxyMode(settings.proxyMode);
// Subscribe to changes
const unsubscribe = settingsStore.subscribe(() => {
setShowModeIndicator(settingsStore.getSettings().showModeIndicator);
const newSettings = settingsStore.getSettings();
setShowModeIndicator(newSettings.showModeIndicator);
setProxyMode(newSettings.proxyMode);
});
return () => unsubscribe();
}, []);
// Initialize useProxy based on proxyMode when the component mounts or proxyMode changes
// We use a separate effect for this to react to setting changes
useEffect(() => {
if (proxyMode === 'always') {
setUseProxy(true);
} else if (proxyMode === 'none') {
setUseProxy(false);
}
// For 'retry', we assume it starts as false (direct), which is the default state of useProxy
}, [proxyMode]);
// Use reactive hook to subscribe to history updates
// This ensures the component re-renders when history is hydrated from localStorage
@@ -134,8 +151,13 @@ export function VideoPlayer({
const handleVideoError = (error: string) => {
console.error('Video playback error:', error);
// Auto-retry with proxy if not already using it
if (!useProxy) {
// Auto-retry with proxy if:
// 1. Not already using proxy
// 2. Proxy mode is NOT 'none' (so 'retry' or potentially 'always' if it somehow failed locally)
// 3. Proxy mode is 'retry' (specifically for the auto-switch logic)
// Note: If mode is 'always', we are already using proxy. If it fails, we show error.
if (!useProxy && proxyMode === 'retry') {
setUseProxy(true);
setShouldAutoPlay(true); // Force autoplay after proxy retry
setVideoError('');
@@ -164,7 +186,7 @@ export function VideoPlayer({
setUseProxy(prev => !prev);
};
const finalPlayUrl = useProxy
const finalPlayUrl = useProxy || proxyMode === 'always'
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
: playUrl;
@@ -21,6 +21,7 @@ export function usePlayerSettings() {
adFilterMode: stored.adFilterMode,
adKeywords: stored.adKeywords,
fullscreenType: stored.fullscreenType,
proxyMode: stored.proxyMode,
};
});
@@ -39,6 +40,7 @@ export function usePlayerSettings() {
adFilterMode: stored.adFilterMode,
adKeywords: stored.adKeywords,
fullscreenType: stored.fullscreenType,
proxyMode: stored.proxyMode,
});
});
return unsubscribe;
@@ -95,6 +97,10 @@ export function usePlayerSettings() {
updateSetting('fullscreenType', value);
}, [updateSetting]);
const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => {
updateSetting('proxyMode', value);
}, [updateSetting]);
return {
...settings,
setAutoNextEpisode,
@@ -107,5 +113,6 @@ export function usePlayerSettings() {
setAdFilterMode,
setAdKeywords,
setFullscreenType,
setProxyMode,
};
}
+82 -30
View File
@@ -6,50 +6,102 @@
*/
import { Icons } from '@/components/ui/Icon';
import type { ProxyMode } from '@/lib/store/settings-store';
interface PlayerSettingsProps {
fullscreenType: 'native' | 'window';
onFullscreenTypeChange: (type: 'native' | 'window') => void;
proxyMode: ProxyMode;
onProxyModeChange: (mode: ProxyMode) => void;
}
export function PlayerSettings({
fullscreenType,
onFullscreenTypeChange,
proxyMode,
onProxyModeChange,
}: PlayerSettingsProps) {
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
{/* Fullscreen Mode Selection */}
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Maximize size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
onClick={() => onFullscreenTypeChange('native')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${fullscreenType === 'native'
? '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)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onFullscreenTypeChange('window')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${fullscreenType === 'window'
? '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)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<div className="space-y-6">
{/* Fullscreen Mode Selection */}
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Maximize size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
onClick={() => onFullscreenTypeChange('native')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${fullscreenType === 'native'
? '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)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onFullscreenTypeChange('window')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${fullscreenType === 'window'
? '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)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</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">
<Icons.Globe size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => onProxyModeChange('retry')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry'
? '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)]'
}`}
>
<div className="font-semibold"> ()</div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onProxyModeChange('none')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none'
? '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)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button
onClick={() => onProxyModeChange('always')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always'
? '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)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
</div>
</div>
</div>
+4
View File
@@ -19,6 +19,7 @@ export type SortOption =
export type SearchDisplayMode = 'normal' | 'grouped';
export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive';
export type ProxyMode = 'retry' | 'none' | 'always';
export interface AppSettings {
sources: VideoSource[];
@@ -44,6 +45,7 @@ export interface AppSettings {
searchDisplayMode: SearchDisplayMode; // 'normal' = individual cards, 'grouped' = group same-name videos
episodeReverseOrder: boolean; // Persist episode list reverse state
fullscreenType: 'native' | 'window'; // Fullscreen mode preference
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
}
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
@@ -116,6 +118,7 @@ function getDefaultAppSettings(): AppSettings {
searchDisplayMode: 'normal',
episodeReverseOrder: false,
fullscreenType: 'native',
proxyMode: 'retry',
};
}
@@ -192,6 +195,7 @@ export const settingsStore = {
searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal',
episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false,
fullscreenType: parsed.fullscreenType === 'window' ? 'window' : 'native',
proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry',
};
} catch {
// Even if localStorage fails, we should return defaults + ENV subscriptions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.0.3",
"version": "4.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.0.3",
"version": "4.0.4",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.0.3",
"version": "4.0.4",
"private": true,
"scripts": {
"dev": "next dev",