mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-19 18:53:42 +08:00
feat: Enhance player page mobile experience with a new SegmentedControl component for tab-based content switching.
This commit is contained in:
+91
-45
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.0.5",
|
||||
"version": "4.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user