feat: Enhance player page mobile experience with a new SegmentedControl component for tab-based content switching.

This commit is contained in:
kuekhaoyang
2026-01-25 16:01:28 +08:00
parent 30600e4d95
commit dcc897f139
4 changed files with 174 additions and 48 deletions
+91 -45
View File
@@ -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<SourceInfo[]>(() => {
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<SourceInfo[]>(() => {
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}
/>
<VideoMetadata
videoData={videoData}
source={source}
title={title}
/>
<div className="hidden lg:block">
<VideoMetadata
videoData={videoData}
source={source}
title={title}
/>
</div>
{/* Favorite Button for current video */}
{videoData && videoId && (
@@ -193,34 +212,61 @@ function PlayerContent() {
{/* Sidebar with sticky wrapper */}
<div className="lg:col-span-1">
<div className="lg:sticky lg:top-32 space-y-6">
<EpisodeList
episodes={videoData?.episodes || null}
currentEpisode={currentEpisode}
isReversed={isReversed}
onEpisodeClick={handleEpisodeClick}
onToggleReverse={handleToggleReverse}
/>
{/* Source Selector - only show when grouped sources available */}
{groupedSources.length > 1 && (
<SourceSelector
sources={groupedSources}
currentSource={currentSourceId || source || ''}
onSourceChange={(newSource) => {
// 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 && (
<SegmentedControl
options={[
{ label: '选集', value: 'episodes' },
{ label: '简介', value: 'info' },
...(groupedSources.length > 1 ? [{ label: '来源', value: 'sources' as const }] : []),
]}
value={activeTab}
onChange={setActiveTab}
className="lg:hidden mb-4"
/>
)}
{/* Info Tab Content - Mobile Only */}
<div className={activeTab !== 'info' ? 'hidden' : 'block lg:hidden'}>
<VideoMetadata
videoData={videoData}
source={source}
title={title}
/>
</div>
{/* Episode List - Visible if desktop OR active mobile tab */}
<div className={activeTab !== 'episodes' ? 'hidden lg:block' : 'block'}>
<EpisodeList
episodes={videoData?.episodes || null}
currentEpisode={currentEpisode}
isReversed={isReversed}
onEpisodeClick={handleEpisodeClick}
onToggleReverse={handleToggleReverse}
/>
</div>
{/* Source Selector - Visible if (desktop AND grouped sources) OR (active mobile tab AND grouped sources) */}
{groupedSources.length > 0 && (
<div className={activeTab !== 'sources' ? 'hidden lg:block' : 'block'}>
<SourceSelector
sources={groupedSources}
currentSource={currentSourceId || source || ''}
onSourceChange={(newSource) => {
// 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 });
}}
/>
</div>
)}
</div>
</div>
</div>
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { useRef, useEffect, useState } from 'react';
/**
* SegmentedControl - A switch-style tab component following Liquid Glass design
*/
interface SegmentedControlProps<T extends string> {
options: { label: string; value: T }[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onChange,
className = '',
}: SegmentedControlProps<T>) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={`
relative flex p-1 bg-[var(--glass-bg)] backdrop-blur-xl
border border-[var(--glass-border)] rounded-[var(--radius-2xl)]
shadow-[var(--shadow-sm)] ${className}
`}
>
{/* Sliding Indicator */}
<div
className="absolute top-1 bottom-1 bg-[var(--accent-color)] rounded-[calc(var(--radius-2xl)-4px)] shadow-[0_2px_8px_rgba(0,122,255,0.3)] transition-all duration-300 cubic-bezier(0.2, 0.8, 0.2, 1)"
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
{/* Segment Buttons */}
{options.map((option) => (
<button
key={option.value}
data-value={option.value}
onClick={() => onChange(option.value)}
className={`
relative z-10 flex-1 py-2 px-4 text-sm font-semibold transition-colors duration-200
${value === option.value ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'}
`}
>
{option.label}
</button>
))}
</div>
);
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.0.5",
"version": "4.0.6",
"private": true,
"scripts": {
"dev": "next dev",