mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 08:43:44 +08:00
feat: Introduce tag input/list, video player empty/error states, and URL copy utility.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface TagInputProps {
|
||||
newTagInput: string;
|
||||
onNewTagInputChange: (value: string) => void;
|
||||
onAddTag: () => void;
|
||||
}
|
||||
|
||||
export function TagInput({
|
||||
newTagInput,
|
||||
onNewTagInputChange,
|
||||
onAddTag,
|
||||
}: TagInputProps) {
|
||||
return (
|
||||
<div className="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagInput}
|
||||
onChange={(e) => onNewTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onAddTag()}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors rounded-[var(--radius-2xl)]"
|
||||
/>
|
||||
<button
|
||||
onClick={onAddTag}
|
||||
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity rounded-[var(--radius-2xl)] cursor-pointer"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { SortableTag, Tag } from './SortableTag';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface TagListProps {
|
||||
tags: Tag[];
|
||||
selectedTag: string;
|
||||
showTagManager: boolean;
|
||||
justAddedTag: boolean;
|
||||
onTagSelect: (tagId: string) => void;
|
||||
onTagDelete: (tagId: string) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onJustAddedTagHandled: () => void;
|
||||
}
|
||||
|
||||
export function TagList({
|
||||
tags,
|
||||
selectedTag,
|
||||
showTagManager,
|
||||
justAddedTag,
|
||||
onTagSelect,
|
||||
onTagDelete,
|
||||
onDragEnd,
|
||||
onJustAddedTagHandled,
|
||||
}: TagListProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Auto-scroll to end when new tag is added
|
||||
useEffect(() => {
|
||||
if (justAddedTag && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
onJustAddedTagHandled();
|
||||
}
|
||||
}, [justAddedTag, onJustAddedTagHandled]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
onDragEnd(event);
|
||||
};
|
||||
|
||||
const activeTag = tags.find((t) => t.id === activeId);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
|
||||
>
|
||||
<SortableContext
|
||||
items={tags.map((t) => t.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<SortableTag
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
selectedTag={selectedTag}
|
||||
showTagManager={showTagManager}
|
||||
onTagSelect={onTagSelect}
|
||||
onTagDelete={onTagDelete}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId && activeTag ? (
|
||||
<div className="relative flex-shrink-0 animate-jiggle">
|
||||
<button className="px-6 py-2.5 text-sm font-semibold whitespace-nowrap rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white shadow-xl scale-110 cursor-grabbing border border-transparent">
|
||||
{activeTag.label}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
+19
-109
@@ -1,24 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { SortableTag, Tag } from './SortableTag';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { TagInput } from './TagInput';
|
||||
import { TagList } from './TagList';
|
||||
import { Tag } from './SortableTag';
|
||||
|
||||
interface TagManagerProps {
|
||||
tags: Tag[];
|
||||
@@ -51,42 +35,6 @@ export function TagManager({
|
||||
onDragEnd,
|
||||
onJustAddedTagHandled,
|
||||
}: TagManagerProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Auto-scroll to end when new tag is added
|
||||
useEffect(() => {
|
||||
if (justAddedTag && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
onJustAddedTagHandled();
|
||||
}
|
||||
}, [justAddedTag, onJustAddedTagHandled]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
onDragEnd(event);
|
||||
};
|
||||
|
||||
const activeTag = tags.find((t) => t.id === activeId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Management Controls */}
|
||||
@@ -111,62 +59,24 @@ export function TagManager({
|
||||
|
||||
{/* Add Custom Tag */}
|
||||
{showTagManager && (
|
||||
<div className="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagInput}
|
||||
onChange={(e) => onNewTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onAddTag()}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors rounded-[var(--radius-2xl)]"
|
||||
/>
|
||||
<button
|
||||
onClick={onAddTag}
|
||||
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity rounded-[var(--radius-2xl)] cursor-pointer"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<TagInput
|
||||
newTagInput={newTagInput}
|
||||
onNewTagInputChange={onNewTagInputChange}
|
||||
onAddTag={onAddTag}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tag Filter */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
|
||||
>
|
||||
<SortableContext
|
||||
items={tags.map(t => t.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<SortableTag
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
selectedTag={selectedTag}
|
||||
showTagManager={showTagManager}
|
||||
onTagSelect={onTagSelect}
|
||||
onTagDelete={onTagDelete}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId && activeTag ? (
|
||||
<div className="relative flex-shrink-0 animate-jiggle">
|
||||
<button className="px-6 py-2.5 text-sm font-semibold whitespace-nowrap rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white shadow-xl scale-110 cursor-grabbing border border-transparent">
|
||||
{activeTag.label}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<TagList
|
||||
tags={tags}
|
||||
selectedTag={selectedTag}
|
||||
showTagManager={showTagManager}
|
||||
justAddedTag={justAddedTag}
|
||||
onTagSelect={onTagSelect}
|
||||
onTagDelete={onTagDelete}
|
||||
onDragEnd={onDragEnd}
|
||||
onJustAddedTagHandled={onJustAddedTagHandled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
import { VideoPlayerError } from './VideoPlayerError';
|
||||
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
playUrl: string;
|
||||
@@ -111,53 +111,19 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
|
||||
: playUrl;
|
||||
|
||||
if (!playUrl) {
|
||||
return (
|
||||
<Card hover={false} className="p-0 overflow-hidden">
|
||||
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
|
||||
<div className="text-center text-[var(--text-secondary)]">
|
||||
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
|
||||
<p>暂无播放源</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
return <VideoPlayerEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card hover={false} className="p-0 overflow-hidden">
|
||||
{videoError ? (
|
||||
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
|
||||
<div
|
||||
className="text-center text-white max-w-md px-4"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
{retryCount < MAX_MANUAL_RETRIES && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
<span>重试 ({retryCount}/{MAX_MANUAL_RETRIES})</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VideoPlayerError
|
||||
error={videoError}
|
||||
onBack={onBack}
|
||||
onRetry={handleRetry}
|
||||
retryCount={retryCount}
|
||||
maxRetries={MAX_MANUAL_RETRIES}
|
||||
/>
|
||||
) : (
|
||||
<CustomVideoPlayer
|
||||
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}`} // Force remount when switching modes or retrying
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
export function VideoPlayerEmpty() {
|
||||
return (
|
||||
<Card hover={false} className="p-0 overflow-hidden">
|
||||
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
|
||||
<div className="text-center text-[var(--text-secondary)]">
|
||||
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
|
||||
<p>暂无播放源</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface VideoPlayerErrorProps {
|
||||
error: string;
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
export function VideoPlayerError({
|
||||
error,
|
||||
onBack,
|
||||
onRetry,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
}: VideoPlayerErrorProps) {
|
||||
return (
|
||||
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
|
||||
<div
|
||||
className="text-center text-white max-w-md px-4"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{error}</p>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
{retryCount < maxRetries && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
<span>重试 ({retryCount}/{maxRetries})</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useControlsVisibility } from './desktop/useControlsVisibility';
|
||||
import { useUtilities } from './desktop/useUtilities';
|
||||
import { useDesktopShortcuts } from './desktop/useDesktopShortcuts';
|
||||
import { useDesktopPlayerState } from './useDesktopPlayerState';
|
||||
import { getCopyUrl } from '../utils/urlUtils';
|
||||
|
||||
type DesktopPlayerState = ReturnType<typeof useDesktopPlayerState>;
|
||||
|
||||
@@ -132,27 +133,7 @@ export function useDesktopPlayerLogic({
|
||||
skipBackward: skipControls.skipBackward,
|
||||
changePlaybackSpeed: playbackControls.changePlaybackSpeed,
|
||||
handleCopyLink: (type: 'original' | 'proxy' = 'original') => {
|
||||
let urlToCopy = src;
|
||||
|
||||
// If user wants original link, strip proxy prefix if present
|
||||
if (type === 'original') {
|
||||
if (urlToCopy.includes('/api/proxy?url=')) {
|
||||
const match = urlToCopy.match(/url=([^&]*)/);
|
||||
if (match && match[1]) {
|
||||
urlToCopy = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If user wants proxy link, ensure it has proxy prefix
|
||||
else if (type === 'proxy') {
|
||||
if (!urlToCopy.includes('/api/proxy?url=')) {
|
||||
urlToCopy = `${window.location.origin}/api/proxy?url=${encodeURIComponent(urlToCopy)}`;
|
||||
} else if (urlToCopy.startsWith('/')) {
|
||||
// Ensure absolute URL for copy
|
||||
urlToCopy = `${window.location.origin}${urlToCopy}`;
|
||||
}
|
||||
}
|
||||
|
||||
const urlToCopy = getCopyUrl(src, type);
|
||||
utilities.handleCopyLink(urlToCopy);
|
||||
},
|
||||
startSpeedMenuTimeout: controlsVisibility.startSpeedMenuTimeout,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function getCopyUrl(src: string, type: 'original' | 'proxy' = 'original'): string {
|
||||
let urlToCopy = src;
|
||||
|
||||
// If user wants original link, strip proxy prefix if present
|
||||
if (type === 'original') {
|
||||
if (urlToCopy.includes('/api/proxy?url=')) {
|
||||
const match = urlToCopy.match(/url=([^&]*)/);
|
||||
if (match && match[1]) {
|
||||
urlToCopy = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If user wants proxy link, ensure it has proxy prefix
|
||||
else if (type === 'proxy') {
|
||||
if (!urlToCopy.includes('/api/proxy?url=')) {
|
||||
urlToCopy = `${window.location.origin}/api/proxy?url=${encodeURIComponent(urlToCopy)}`;
|
||||
} else if (urlToCopy.startsWith('/')) {
|
||||
// Ensure absolute URL for copy
|
||||
urlToCopy = `${window.location.origin}${urlToCopy}`;
|
||||
}
|
||||
}
|
||||
|
||||
return urlToCopy;
|
||||
}
|
||||
Reference in New Issue
Block a user