From dcc897f13994b664cc2e8711885044157d1d2625 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 25 Jan 2026 16:01:28 +0800 Subject: [PATCH] feat: Enhance player page mobile experience with a new SegmentedControl component for tab-based content switching. --- app/player/page.tsx | 136 +++++++++++++++++++---------- components/ui/SegmentedControl.tsx | 80 +++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 4 files changed, 174 insertions(+), 48 deletions(-) create mode 100644 components/ui/SegmentedControl.tsx diff --git a/app/player/page.tsx b/app/player/page.tsx index 25973d7..f1ff869 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -14,6 +14,7 @@ import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; import { FavoriteButton } from '@/components/favorites/FavoriteButton'; import { PlayerNavbar } from '@/components/player/PlayerNavbar'; import { settingsStore } from '@/lib/store/settings-store'; +import { SegmentedControl } from '@/components/ui/SegmentedControl'; import Image from 'next/image'; function PlayerContent() { @@ -28,24 +29,14 @@ function PlayerContent() { const episodeParam = searchParams.get('episode'); const groupedSourcesParam = searchParams.get('groupedSources'); - // Parse grouped sources if available - const groupedSources = useMemo(() => { - if (!groupedSourcesParam) return []; - try { - return JSON.parse(groupedSourcesParam); - } catch { - return []; - } - }, [groupedSourcesParam]); - - // Track current source for switching - const [currentSourceId, setCurrentSourceId] = useState(source); - // Track settings const [isReversed, setIsReversed] = useState(() => typeof window !== 'undefined' ? settingsStore.getSettings().episodeReverseOrder : false ); + // Mobile tab state + const [activeTab, setActiveTab] = useState<'episodes' | 'info' | 'sources'>('episodes'); + // Sync with store changes if any (though usually it's one-way from UI to store) useEffect(() => { setIsReversed(settingsStore.getSettings().episodeReverseOrder); @@ -69,6 +60,32 @@ function PlayerContent() { fetchVideoDetails, } = useVideoPlayer(videoId, source, episodeParam, isReversed); + // Parse grouped sources if available + const groupedSources = useMemo(() => { + let sources: SourceInfo[] = []; + if (groupedSourcesParam) { + try { + sources = JSON.parse(groupedSourcesParam); + } catch { + sources = []; + } + } + + // Always ensure the current source is in the list + if (source && !sources.find(s => s.source === source)) { + sources.unshift({ + id: videoId || '', + source: source, + sourceName: source, + pic: videoData?.vod_pic + }); + } + return sources; + }, [groupedSourcesParam, source, videoId, videoData?.vod_pic]); + + // Track current source for switching + const [currentSourceId, setCurrentSourceId] = useState(source); + // Add initial history entry when video data is loaded useEffect(() => { if (videoData && playUrl && videoId) { @@ -159,16 +176,18 @@ function PlayerContent() { videoId={videoId || undefined} currentEpisode={currentEpisode} onBack={() => router.back()} - totalEpisodes={videoData?.episodes?.length || 1} + totalEpisodes={videoData?.episodes?.length || 0} onNextEpisode={handleNextEpisode} isReversed={isReversed} isPremium={isPremium} /> - +
+ +
{/* Favorite Button for current video */} {videoData && videoId && ( @@ -193,34 +212,61 @@ function PlayerContent() { {/* Sidebar with sticky wrapper */}
- - - {/* Source Selector - only show when grouped sources available */} - {groupedSources.length > 1 && ( - { - // 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 }); - // Data will be refetched automatically via useEffect in useVideoPlayer hook - }} + {/* 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 */} +
+ +
+ + {/* Episode List - Visible if desktop OR active mobile tab */} +
+ +
+ + {/* 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/ui/SegmentedControl.tsx b/components/ui/SegmentedControl.tsx new file mode 100644 index 0000000..456132a --- /dev/null +++ b/components/ui/SegmentedControl.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useRef, useEffect, useState } from 'react'; + +/** + * SegmentedControl - A switch-style tab component following Liquid Glass design + */ + +interface SegmentedControlProps { + options: { label: string; value: T }[]; + value: T; + onChange: (value: T) => void; + className?: string; +} + +export function SegmentedControl({ + options, + value, + onChange, + className = '', +}: SegmentedControlProps) { + const containerRef = useRef(null); + const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }); + + useEffect(() => { + const updateIndicator = () => { + if (!containerRef.current) return; + const activeElement = containerRef.current.querySelector( + `[data-value="${value}"]` + ) as HTMLElement; + + if (activeElement) { + setIndicatorStyle({ + left: activeElement.offsetLeft, + width: activeElement.offsetWidth, + }); + } + }; + + updateIndicator(); + // Update on window resize as well + window.addEventListener('resize', updateIndicator); + return () => window.removeEventListener('resize', updateIndicator); + }, [value, options]); + + return ( +
+ {/* Sliding Indicator */} +
+ + {/* Segment Buttons */} + {options.map((option) => ( + + ))} +
+ ); +} diff --git a/package-lock.json b/package-lock.json index b77d712..78878c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.0.5", + "version": "4.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.0.5", + "version": "4.0.6", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index fd6cfa8..2c3dbd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.0.5", + "version": "4.0.6", "private": true, "scripts": { "dev": "next dev",