From acf8cff63a17b2cbfa8d2cfe5e71228493480c2c Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 17 Feb 2026 12:42:41 +0800 Subject: [PATCH] feat: Persist player volume and mute settings and integrate source selection directly into the episode list. --- app/player/page.tsx | 63 +++--- components/player/EpisodeList.tsx | 203 +++++++++++++++++- components/player/VideoMetadata.tsx | 25 ++- .../hooks/desktop/useDesktopShortcuts.ts | 4 + .../player/hooks/desktop/useVolumeControls.ts | 6 + .../player/hooks/useDesktopPlayerState.ts | 15 +- components/ui/icons/utility-icons.tsx | 8 + package-lock.json | 4 +- package.json | 2 +- 9 files changed, 273 insertions(+), 57 deletions(-) diff --git a/app/player/page.tsx b/app/player/page.tsx index 6a30e4f..aadd173 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -7,7 +7,7 @@ import { VideoPlayer } from '@/components/player/VideoPlayer'; import { VideoMetadata } from '@/components/player/VideoMetadata'; import { EpisodeList } from '@/components/player/EpisodeList'; import { PlayerError } from '@/components/player/PlayerError'; -import { SourceSelector, SourceInfo } from '@/components/player/SourceSelector'; +import { SourceInfo } from '@/components/player/EpisodeList'; import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer'; import { useHistory } from '@/lib/store/history-store'; import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; @@ -16,7 +16,6 @@ import { PlayerNavbar } from '@/components/player/PlayerNavbar'; import { settingsStore } from '@/lib/store/settings-store'; import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings'; import { SegmentedControl } from '@/components/ui/SegmentedControl'; -import Image from 'next/image'; function PlayerContent() { const searchParams = useSearchParams(); @@ -37,7 +36,7 @@ function PlayerContent() { ); // Mobile tab state - const [activeTab, setActiveTab] = useState<'episodes' | 'info' | 'sources'>('episodes'); + const [activeTab, setActiveTab] = useState<'episodes' | 'info'>('episodes'); // Sync with store changes if any (though usually it's one-way from UI to store) useEffect(() => { @@ -218,18 +217,15 @@ function PlayerContent() {
{/* Mobile Tabs */} - {groupedSources.length > 0 && ( - 1 ? [{ label: '来源', value: 'sources' as const }] : []), - ]} - value={activeTab} - onChange={setActiveTab} - className="lg:hidden mb-4" - /> - )} + {/* Info Tab Content - Mobile Only */}
@@ -240,7 +236,7 @@ function PlayerContent() { />
- {/* Episode List - Visible if desktop OR active mobile tab */} + {/* Episode List with integrated source selector - Visible if desktop OR active mobile tab */}
0 ? groupedSources : undefined} + currentSource={currentSourceId || source || ''} + onSourceChange={(newSource) => { + const params = new URLSearchParams(); + params.set('id', String(newSource.id)); + params.set('source', newSource.source); + params.set('title', title || ''); + if (groupedSourcesParam) { + params.set('groupedSources', groupedSourcesParam); + } + setCurrentSourceId(newSource.source); + router.replace(`/player?${params.toString()}`, { scroll: false }); + }} />
- - {/* Source Selector - Visible if (desktop AND grouped sources) OR (active mobile tab AND grouped sources) */} - {groupedSources.length > 0 && ( -
- { - // Navigate to same video with different source - const params = new URLSearchParams(); - params.set('id', String(newSource.id)); - params.set('source', newSource.source); - params.set('title', title || ''); - if (groupedSourcesParam) { - params.set('groupedSources', groupedSourcesParam); - } - setCurrentSourceId(newSource.source); - router.replace(`/player?${params.toString()}`, { scroll: false }); - }} - /> -
- )}
diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index 991ff16..7cd8ef8 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -1,23 +1,37 @@ 'use client'; -import { useRef, useCallback, useState, useMemo } from 'react'; +import { useRef, useCallback, useState, useMemo, useEffect } from 'react'; +import Image from 'next/image'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; +import { LatencyBadge } from '@/components/ui/LatencyBadge'; +import { Button } from '@/components/ui/Button'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; -import { settingsStore } from '@/lib/store/settings-store'; interface Episode { name?: string; url: string; } +export interface SourceInfo { + id: string | number; + source: string; + sourceName?: string; + latency?: number; + pic?: string; +} + interface EpisodeListProps { episodes: Episode[] | null; currentEpisode: number; isReversed?: boolean; onEpisodeClick: (episode: Episode, index: number) => void; onToggleReverse?: (reversed: boolean) => void; + // Optional source integration props + sources?: SourceInfo[]; + currentSource?: string; + onSourceChange?: (source: SourceInfo) => void; } export function EpisodeList({ @@ -25,10 +39,82 @@ export function EpisodeList({ currentEpisode, isReversed = false, onEpisodeClick, - onToggleReverse + onToggleReverse, + sources, + currentSource, + onSourceChange, }: EpisodeListProps) { const listRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + const [sourceExpanded, setSourceExpanded] = useState(false); + + // Source latency state + const [latencies, setLatencies] = useState>({}); + const [isLoadingLatency, setIsLoadingLatency] = useState(false); + + const showSourceSelector = sources && sources.length > 1 && onSourceChange; + + // Current source info + const currentSourceInfo = useMemo(() => { + if (!sources || !currentSource) return null; + return sources.find(s => s.source === currentSource) || null; + }, [sources, currentSource]); + + // Sort sources by latency + const sortedSources = useMemo(() => { + if (!sources) return []; + return [...sources].sort((a, b) => { + const latA = latencies[a.source] ?? a.latency ?? Infinity; + const latB = latencies[b.source] ?? b.latency ?? Infinity; + return latA - latB; + }); + }, [sources, latencies]); + + // Initialize latencies from sources + useEffect(() => { + if (!sources) return; + const initial: Record = {}; + sources.forEach(s => { + if (s.latency !== undefined) { + initial[s.source] = s.latency; + } + }); + setLatencies(initial); + }, [sources]); + + // Refresh latencies + const refreshLatencies = useCallback(async () => { + if (!sources) return; + setIsLoadingLatency(true); + + const results = await Promise.all( + sources.map(async (source) => { + try { + const response = await fetch('/api/ping', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: source.source }), + }); + if (response.ok) { + const data = await response.json(); + return { source: source.source, latency: data.latency }; + } + } catch { + // Ignore errors + } + return { source: source.source, latency: undefined }; + }) + ); + + const newLatencies: Record = {}; + results.forEach(({ source, latency }) => { + if (latency !== undefined) { + newLatencies[source] = latency; + } + }); + setLatencies(newLatencies); + setIsLoadingLatency(false); + }, [sources]); // Memoized display episodes - reversed if toggle is on const displayEpisodes = useMemo(() => { @@ -76,6 +162,117 @@ export function EpisodeList({ return ( + {/* Integrated Source Selector Header */} + {showSourceSelector && ( +
+ + + {/* Expanded source list */} + {sourceExpanded && ( +
+
+ +
+
+ {sortedSources.map((source, index) => { + const isCurrent = source.source === currentSource; + const latency = latencies[source.source] ?? source.latency; + + return ( + + ); + })} +
+
+ )} +
+ )} + + {/* Episode List Header */}

选集 diff --git a/components/player/VideoMetadata.tsx b/components/player/VideoMetadata.tsx index 9cba8dc..96bf832 100644 --- a/components/player/VideoMetadata.tsx +++ b/components/player/VideoMetadata.tsx @@ -1,6 +1,5 @@ 'use client'; -import Link from 'next/link'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; @@ -75,14 +74,16 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) 主演: {splitPersonNames(videoData.vod_actor).map((name) => ( - {name} - + + ))} @@ -92,14 +93,16 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) 导演: {splitPersonNames(videoData.vod_director).map((name) => ( - {name} - + + ))} diff --git a/components/player/hooks/desktop/useDesktopShortcuts.ts b/components/player/hooks/desktop/useDesktopShortcuts.ts index f9a5f2b..c509128 100644 --- a/components/player/hooks/desktop/useDesktopShortcuts.ts +++ b/components/player/hooks/desktop/useDesktopShortcuts.ts @@ -89,6 +89,8 @@ export function useDesktopShortcuts({ setVolume(newVolUp); if (videoRef.current) videoRef.current.volume = newVolUp; setIsMuted(newVolUp === 0); + localStorage.setItem('kvideo-volume', String(newVolUp)); + localStorage.setItem('kvideo-muted', String(newVolUp === 0)); showVolumeBarTemporarily(); break; case 'arrowdown': @@ -97,6 +99,8 @@ export function useDesktopShortcuts({ setVolume(newVolDown); if (videoRef.current) videoRef.current.volume = newVolDown; setIsMuted(newVolDown === 0); + localStorage.setItem('kvideo-volume', String(newVolDown)); + localStorage.setItem('kvideo-muted', String(newVolDown === 0)); showVolumeBarTemporarily(); break; } diff --git a/components/player/hooks/desktop/useVolumeControls.ts b/components/player/hooks/desktop/useVolumeControls.ts index 4996819..b1ad0a7 100644 --- a/components/player/hooks/desktop/useVolumeControls.ts +++ b/components/player/hooks/desktop/useVolumeControls.ts @@ -28,9 +28,11 @@ export function useVolumeControls({ if (isMuted) { videoRef.current.volume = volume; setIsMuted(false); + localStorage.setItem('kvideo-muted', 'false'); } else { videoRef.current.volume = 0; setIsMuted(true); + localStorage.setItem('kvideo-muted', 'true'); } }, [videoRef, isMuted, volume, setIsMuted]); @@ -51,6 +53,8 @@ export function useVolumeControls({ setVolume(pos); videoRef.current.volume = pos; setIsMuted(pos === 0); + localStorage.setItem('kvideo-volume', String(pos)); + localStorage.setItem('kvideo-muted', String(pos === 0)); }, [videoRef, volumeBarRef, setVolume, setIsMuted]); const handleVolumeMouseDown = useCallback((e: any) => { @@ -68,6 +72,8 @@ export function useVolumeControls({ setVolume(pos); videoRef.current.volume = pos; setIsMuted(pos === 0); + localStorage.setItem('kvideo-volume', String(pos)); + localStorage.setItem('kvideo-muted', String(pos === 0)); }; const handleMouseUp = () => { diff --git a/components/player/hooks/useDesktopPlayerState.ts b/components/player/hooks/useDesktopPlayerState.ts index 23d3a3c..0d6050e 100644 --- a/components/player/hooks/useDesktopPlayerState.ts +++ b/components/player/hooks/useDesktopPlayerState.ts @@ -22,8 +22,19 @@ export function useDesktopPlayerState() { const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - const [volume, setVolume] = useState(1); - const [isMuted, setIsMuted] = useState(false); + const [volume, setVolume] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('kvideo-volume'); + return saved ? parseFloat(saved) : 1; + } + return 1; + }); + const [isMuted, setIsMuted] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem('kvideo-muted') === 'true'; + } + return false; + }); const [isFullscreen, setIsFullscreen] = useState(false); const [showControls, setShowControls] = useState(true); const [isLoading, setIsLoading] = useState(true); diff --git a/components/ui/icons/utility-icons.tsx b/components/ui/icons/utility-icons.tsx index 82779a1..0ce50bc 100644 --- a/components/ui/icons/utility-icons.tsx +++ b/components/ui/icons/utility-icons.tsx @@ -189,4 +189,12 @@ export const UtilityIcons = { ), + + ExternalLink: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), }; diff --git a/package-lock.json b/package-lock.json index badb381..c955001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.3.3", + "version": "4.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.3.3", + "version": "4.3.4", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index f688c47..9a30347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.3.3", + "version": "4.3.4", "private": true, "scripts": { "dev": "next dev",