mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: Introduce distinct mobile and desktop video player UI, controls, and state management, along with new styling and icon components.
This commit is contained in:
+4
-799
@@ -1,801 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "./scroll-optimization.css";
|
||||
|
||||
:root {
|
||||
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
||||
|
||||
/* Light Mode Palette - Optimized for Performance */
|
||||
--bg-color-light: #f2f4f7;
|
||||
/* Removed complex gradient for solid color or very simple gradient */
|
||||
--bg-image-light: none;
|
||||
--text-color-light: #1d1d1f;
|
||||
--text-color-secondary-light: #6e6e73;
|
||||
--accent-color-light: #0056b3;
|
||||
/* Increased opacity to replace blur effect */
|
||||
--glass-bg-light: rgba(255, 255, 255, 0.95);
|
||||
--glass-border-light: rgba(0, 0, 0, 0.05);
|
||||
--shadow-color-light: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Dark Mode Palette */
|
||||
--bg-color-dark: #121212;
|
||||
--bg-image-dark: linear-gradient(120deg, #1a1a1a 0%, #121212 100%);
|
||||
--text-color-dark: #f5f5f7;
|
||||
--text-color-secondary-dark: #8e8e93;
|
||||
--accent-color-dark: #1A6DBF;
|
||||
--glass-bg-dark: rgba(30, 30, 30, 0.9);
|
||||
--glass-border-dark: rgba(255, 255, 255, 0.1);
|
||||
--shadow-color-dark: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Universal Variables */
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
--shadow-sm: 0 1px 2px var(--shadow-color);
|
||||
--shadow-md: 0 4px 6px var(--shadow-color);
|
||||
--transition-fluid: 0.3s ease;
|
||||
|
||||
/* Active Theme Variables */
|
||||
--bg-color: var(--bg-color-light);
|
||||
--bg-image: var(--bg-image-light);
|
||||
--text-color: var(--text-color-light);
|
||||
--text-color-secondary: var(--text-color-secondary-light);
|
||||
--accent-color: var(--accent-color-light);
|
||||
--glass-bg: var(--glass-bg-light);
|
||||
--glass-border: var(--glass-border-light);
|
||||
--shadow-color: var(--shadow-color-light);
|
||||
|
||||
--background: #f2f4f7;
|
||||
--foreground: #1d1d1f;
|
||||
}
|
||||
|
||||
body {
|
||||
--bg-color: var(--bg-color-light);
|
||||
--bg-image: var(--bg-image-light);
|
||||
--text-color: var(--text-color-light);
|
||||
--text-color-secondary: var(--text-color-secondary-light);
|
||||
--accent-color: var(--accent-color-light);
|
||||
--glass-bg: var(--glass-bg-light);
|
||||
--glass-border: var(--glass-border-light);
|
||||
--shadow-color: var(--shadow-color-light);
|
||||
|
||||
background-color: var(--bg-color);
|
||||
background-image: var(--bg-image);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-family-system);
|
||||
|
||||
/* Optimize scrolling performance */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body.dark,
|
||||
.dark body {
|
||||
--bg-color: var(--bg-color-dark);
|
||||
--bg-image: var(--bg-image-dark);
|
||||
--text-color: var(--text-color-dark);
|
||||
--text-color-secondary: var(--text-color-secondary-dark);
|
||||
--accent-color: var(--accent-color-dark);
|
||||
--glass-bg: var(--glass-bg-dark);
|
||||
--glass-border: var(--glass-border-dark);
|
||||
--shadow-color: var(--shadow-color-dark);
|
||||
|
||||
--background: #121212;
|
||||
--foreground: #f5f5f7;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
/* Optimize font rendering */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
/* Disable smooth scrolling for better performance - users expect instant scroll */
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* Global focus-visible styles for keyboard navigation accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
/* Screen reader only utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Special handling for pill-shaped elements */
|
||||
[class*="rounded-full"]:focus-visible {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--accent-color) 60%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-subtle {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-20px) translateX(10px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-x {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 2s linear infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-scale-out {
|
||||
animation: scale-out 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient-x {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-x 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Liquid Glass Components - OPTIMIZED */
|
||||
.glass-card {
|
||||
background: var(--bg-color);
|
||||
opacity: 1;
|
||||
/* Removed backdrop-filter */
|
||||
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
|
||||
/* Hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Optimize fixed/sticky elements for scrolling */
|
||||
nav:where(.sticky, .fixed),
|
||||
.sticky,
|
||||
.fixed {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: var(--glass-bg);
|
||||
/* Removed backdrop-filter */
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2xl);
|
||||
color: var(--text-color);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.glass-button:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.glass-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Animations for History Components */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Video Player Styles */
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
/* Removed backdrop-filter */
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-2xl);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-icon:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.slider-track.h-1 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: var(--accent-color);
|
||||
border-radius: var(--radius-full);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: var(--radius-full);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-track.h-1 .slider-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.slider-track:hover .slider-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.slider-thumb:active,
|
||||
.slider-track:active .slider-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.3);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
/* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* Toast slide-up animation */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Performance optimizations for scrolling */
|
||||
/* Apply CSS containment to grid items for better scroll performance */
|
||||
[class*="grid"]>* {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 400px;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
/* Smooth video grid transitions */
|
||||
[class*="grid"]>a {
|
||||
animation: fadeInUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduce paint complexity on scroll */
|
||||
img {
|
||||
content-visibility: auto;
|
||||
/* Hardware acceleration for images */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Disable pointer events during scroll for better performance */
|
||||
body.scrolling * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Force hardware acceleration for specific animated elements */
|
||||
.group:hover img,
|
||||
.glass-card,
|
||||
nav {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
/* Optimize badge rendering */
|
||||
[class*="badge"],
|
||||
[class*="Badge"] {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
SEARCH HISTORY DROPDOWN - OPTIMIZED
|
||||
=========================================== */
|
||||
|
||||
/* Search History Dropdown - Absolute positioning under search bar */
|
||||
.search-history-dropdown {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Optimized: High opacity background instead of blur */
|
||||
background: var(--bg-color);
|
||||
opacity: 0.98;
|
||||
/* Removed backdrop-filter */
|
||||
|
||||
/* Rounded-2xl corners - Universal softness */
|
||||
border-radius: var(--radius-2xl);
|
||||
|
||||
/* Depth and definition */
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--glass-border);
|
||||
|
||||
/* Content padding */
|
||||
padding: 0.75rem;
|
||||
|
||||
/* Smooth appearance animation */
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
animation: search-dropdown-appear 0.2s ease-out forwards;
|
||||
|
||||
/* Transform origin for natural scaling */
|
||||
transform-origin: top center;
|
||||
|
||||
/* Prevent text selection during interaction */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
/* Hardware acceleration */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes search-dropdown-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header section */
|
||||
.search-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Divider with glass effect subtlety */
|
||||
.search-history-divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--glass-border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* List container */
|
||||
.search-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Individual history item */
|
||||
.search-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
|
||||
/* Universal rounded-2xl for container elements */
|
||||
border-radius: var(--radius-2xl);
|
||||
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
/* Fluid animation - Physics-based transitions */
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
/* Subtle background for hover/highlight */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Hover state - Light interaction */
|
||||
.search-history-item:hover {
|
||||
background: color-mix(in srgb, var(--accent-color) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Highlighted state - Keyboard navigation */
|
||||
.search-history-item.highlighted {
|
||||
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
|
||||
|
||||
/* Subtle inner-glow effect - Lensing principle */
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Remove button */
|
||||
.search-history-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* Rounded-full for small circular elements */
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color-secondary);
|
||||
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* Fluid animation */
|
||||
transition: all 0.2s ease;
|
||||
|
||||
/* Prevent triggering parent hover */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-history-remove:hover {
|
||||
background: color-mix(in srgb, var(--text-color-secondary) 20%, transparent);
|
||||
color: var(--text-color);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.search-history-remove:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Optimize scrolling performance */
|
||||
.search-history-dropdown {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dropdown */
|
||||
.search-history-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.search-history-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-history-dropdown::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
@import "./styles/variables.css";
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/animations.css";
|
||||
@import "./styles/components.css";
|
||||
@@ -0,0 +1,240 @@
|
||||
/* Keyframe Animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-subtle {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-20px) translateX(10px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-x {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes search-dropdown-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation Utility Classes */
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 2s linear infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-scale-out {
|
||||
animation: scale-out 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient-x {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-x 3s ease infinite;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/* Base Styles */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* Focus Styles */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[class*="rounded-full"]:focus-visible {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--accent-color) 60%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.search-history-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.search-history-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-history-dropdown::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Performance Optimizations */
|
||||
nav:where(.sticky, .fixed),
|
||||
.sticky,
|
||||
.fixed {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
[class*="grid"]>* {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 400px;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
[class*="grid"]>a {
|
||||
animation: fadeInUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
img {
|
||||
content-visibility: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
body.scrolling * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.group:hover img,
|
||||
.glass-card,
|
||||
nav {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
[class*="badge"],
|
||||
[class*="Badge"] {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.search-history-dropdown {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/* Glass Components */
|
||||
.glass-card {
|
||||
background: var(--bg-color);
|
||||
opacity: 1;
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2xl);
|
||||
color: var(--text-color);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.glass-button:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.glass-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Video Player Components */
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-2xl);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-icon:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.slider-track.h-1 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: var(--accent-color);
|
||||
border-radius: var(--radius-full);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: var(--radius-full);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-track.h-1 .slider-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.slider-track:hover .slider-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.slider-thumb:active,
|
||||
.slider-track:active .slider-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.3);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Search History Dropdown */
|
||||
.search-history-dropdown {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-color);
|
||||
opacity: 0.98;
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0.75rem;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
animation: search-dropdown-appear 0.2s ease-out forwards;
|
||||
transform-origin: top center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.search-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.search-history-divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--glass-border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.search-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.search-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-history-item:hover {
|
||||
background: color-mix(in srgb, var(--accent-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.search-history-item.highlighted {
|
||||
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.search-history-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-history-remove:hover {
|
||||
background: color-mix(in srgb, var(--text-color-secondary) 20%, transparent);
|
||||
color: var(--text-color);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.search-history-remove:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/* CSS Custom Properties */
|
||||
:root {
|
||||
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
||||
|
||||
/* Light Mode Palette */
|
||||
--bg-color-light: #f2f4f7;
|
||||
--bg-image-light: none;
|
||||
--text-color-light: #1d1d1f;
|
||||
--text-color-secondary-light: #6e6e73;
|
||||
--accent-color-light: #0056b3;
|
||||
--glass-bg-light: rgba(255, 255, 255, 0.95);
|
||||
--glass-border-light: rgba(0, 0, 0, 0.05);
|
||||
--shadow-color-light: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Dark Mode Palette */
|
||||
--bg-color-dark: #121212;
|
||||
--bg-image-dark: linear-gradient(120deg, #1a1a1a 0%, #121212 100%);
|
||||
--text-color-dark: #f5f5f7;
|
||||
--text-color-secondary-dark: #8e8e93;
|
||||
--accent-color-dark: #1A6DBF;
|
||||
--glass-bg-dark: rgba(30, 30, 30, 0.9);
|
||||
--glass-border-dark: rgba(255, 255, 255, 0.1);
|
||||
--shadow-color-dark: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Universal Variables */
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
--shadow-sm: 0 1px 2px var(--shadow-color);
|
||||
--shadow-md: 0 4px 6px var(--shadow-color);
|
||||
--transition-fluid: 0.3s ease;
|
||||
|
||||
/* Active Theme Variables */
|
||||
--bg-color: var(--bg-color-light);
|
||||
--bg-image: var(--bg-image-light);
|
||||
--text-color: var(--text-color-light);
|
||||
--text-color-secondary: var(--text-color-secondary-light);
|
||||
--accent-color: var(--accent-color-light);
|
||||
--glass-bg: var(--glass-bg-light);
|
||||
--glass-border: var(--glass-border-light);
|
||||
--shadow-color: var(--shadow-color-light);
|
||||
|
||||
--background: #f2f4f7;
|
||||
--foreground: #1d1d1f;
|
||||
}
|
||||
|
||||
body {
|
||||
--bg-color: var(--bg-color-light);
|
||||
--bg-image: var(--bg-image-light);
|
||||
--text-color: var(--text-color-light);
|
||||
--text-color-secondary: var(--text-color-secondary-light);
|
||||
--accent-color: var(--accent-color-light);
|
||||
--glass-bg: var(--glass-bg-light);
|
||||
--glass-border: var(--glass-border-light);
|
||||
--shadow-color: var(--shadow-color-light);
|
||||
|
||||
background-color: var(--bg-color);
|
||||
background-image: var(--bg-image);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-family-system);
|
||||
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body.dark,
|
||||
.dark body {
|
||||
--bg-color: var(--bg-color-dark);
|
||||
--bg-image: var(--bg-image-dark);
|
||||
--text-color: var(--text-color-dark);
|
||||
--text-color-secondary: var(--text-color-secondary-dark);
|
||||
--accent-color: var(--accent-color-dark);
|
||||
--glass-bg: var(--glass-bg-dark);
|
||||
--glass-border: var(--glass-border-dark);
|
||||
--shadow-color: var(--shadow-color-dark);
|
||||
|
||||
--background: #121212;
|
||||
--foreground: #f5f5f7;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { DesktopProgressBar } from './DesktopProgressBar';
|
||||
import { DesktopVolumeControl } from './DesktopVolumeControl';
|
||||
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
|
||||
import { DesktopMoreMenu } from './DesktopMoreMenu';
|
||||
|
||||
interface DesktopControlsProps {
|
||||
showControls: boolean;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
playbackRate: number;
|
||||
showSpeedMenu: boolean;
|
||||
showMoreMenu: boolean;
|
||||
showVolumeBar: boolean;
|
||||
isPiPSupported: boolean;
|
||||
isAirPlaySupported: boolean;
|
||||
progressBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
volumeBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
onTogglePlay: () => void;
|
||||
onSkipForward: () => void;
|
||||
onSkipBackward: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onTogglePictureInPicture: () => void;
|
||||
onShowAirPlayMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onToggleMoreMenu: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onCopyLink: () => void;
|
||||
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onSpeedMenuMouseEnter: () => void;
|
||||
onSpeedMenuMouseLeave: () => void;
|
||||
onMoreMenuMouseEnter: () => void;
|
||||
onMoreMenuMouseLeave: () => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
speeds: number[];
|
||||
}
|
||||
|
||||
export function DesktopControls({
|
||||
showControls,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
playbackRate,
|
||||
showSpeedMenu,
|
||||
showMoreMenu,
|
||||
showVolumeBar,
|
||||
isPiPSupported,
|
||||
isAirPlaySupported,
|
||||
progressBarRef,
|
||||
volumeBarRef,
|
||||
onTogglePlay,
|
||||
onSkipForward,
|
||||
onSkipBackward,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onVolumeMouseDown,
|
||||
onToggleFullscreen,
|
||||
onTogglePictureInPicture,
|
||||
onShowAirPlayMenu,
|
||||
onToggleSpeedMenu,
|
||||
onToggleMoreMenu,
|
||||
onSpeedChange,
|
||||
onCopyLink,
|
||||
onProgressClick,
|
||||
onProgressMouseDown,
|
||||
onSpeedMenuMouseEnter,
|
||||
onSpeedMenuMouseLeave,
|
||||
onMoreMenuMouseEnter,
|
||||
onMoreMenuMouseLeave,
|
||||
formatTime,
|
||||
speeds
|
||||
}: DesktopControlsProps) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 transition-all duration-300 ${showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||
}`}
|
||||
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<DesktopProgressBar
|
||||
progressBarRef={progressBarRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onProgressClick={onProgressClick}
|
||||
onProgressMouseDown={onProgressMouseDown}
|
||||
/>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="btn-icon"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Skip Backward 10s */}
|
||||
<button
|
||||
onClick={onSkipBackward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip backward 10 seconds"
|
||||
title="后退 10 秒"
|
||||
>
|
||||
<Icons.SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* Skip Forward 10s */}
|
||||
<button
|
||||
onClick={onSkipForward}
|
||||
className="btn-icon"
|
||||
aria-label="Skip forward 10 seconds"
|
||||
title="快进 10 秒"
|
||||
>
|
||||
<Icons.SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<DesktopVolumeControl
|
||||
volumeBarRef={volumeBarRef}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
showVolumeBar={showVolumeBar}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeMouseDown={onVolumeMouseDown}
|
||||
/>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-white text-sm font-medium tabular-nums">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Playback Speed */}
|
||||
<DesktopSpeedMenu
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
playbackRate={playbackRate}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onToggleSpeedMenu={onToggleSpeedMenu}
|
||||
onMouseEnter={onSpeedMenuMouseEnter}
|
||||
onMouseLeave={onSpeedMenuMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Picture-in-Picture */}
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={onTogglePictureInPicture}
|
||||
className="btn-icon"
|
||||
aria-label="Picture-in-Picture"
|
||||
title="画中画"
|
||||
>
|
||||
<Icons.PictureInPicture size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AirPlay */}
|
||||
{isAirPlaySupported && (
|
||||
<button
|
||||
onClick={onShowAirPlayMenu}
|
||||
className="btn-icon"
|
||||
aria-label="AirPlay"
|
||||
title="AirPlay"
|
||||
>
|
||||
<Icons.Airplay size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* More Menu */}
|
||||
<DesktopMoreMenu
|
||||
showMoreMenu={showMoreMenu}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onMouseEnter={onMoreMenuMouseEnter}
|
||||
onMouseLeave={onMoreMenuMouseLeave}
|
||||
onCopyLink={onCopyLink}
|
||||
/>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
className="btn-icon"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface DesktopMoreMenuProps {
|
||||
showMoreMenu: boolean;
|
||||
onToggleMoreMenu: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onCopyLink: () => void;
|
||||
}
|
||||
|
||||
export function DesktopMoreMenu({
|
||||
showMoreMenu,
|
||||
onToggleMoreMenu,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onCopyLink
|
||||
}: DesktopMoreMenuProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggleMoreMenu}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="btn-icon"
|
||||
aria-label="More options"
|
||||
title="更多选项"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* More Menu Dropdown */}
|
||||
{showMoreMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[180px]"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<button
|
||||
onClick={onCopyLink}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3"
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制链接</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface DesktopOverlayProps {
|
||||
isLoading: boolean;
|
||||
isPlaying: boolean;
|
||||
showSkipForwardIndicator: boolean;
|
||||
showSkipBackwardIndicator: boolean;
|
||||
skipForwardAmount: number;
|
||||
skipBackwardAmount: number;
|
||||
isSkipForwardAnimatingOut: boolean;
|
||||
isSkipBackwardAnimatingOut: boolean;
|
||||
showToast: boolean;
|
||||
toastMessage: string | null;
|
||||
onTogglePlay: () => void;
|
||||
}
|
||||
|
||||
export function DesktopOverlay({
|
||||
isLoading,
|
||||
isPlaying,
|
||||
showSkipForwardIndicator,
|
||||
showSkipBackwardIndicator,
|
||||
skipForwardAmount,
|
||||
skipBackwardAmount,
|
||||
isSkipForwardAnimatingOut,
|
||||
isSkipBackwardAnimatingOut,
|
||||
showToast,
|
||||
toastMessage,
|
||||
onTogglePlay
|
||||
}: DesktopOverlayProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Loading Spinner */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Forward Indicator */}
|
||||
{showSkipForwardIndicator && (
|
||||
<div className="absolute top-1/2 right-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipForwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
}`}>
|
||||
+{skipForwardAmount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Backward Indicator */}
|
||||
{showSkipBackwardIndicator && (
|
||||
<div className="absolute top-1/2 left-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipBackwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
}`}>
|
||||
-{skipBackwardAmount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center Play Button (when paused) */}
|
||||
{!isPlaying && !isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="pointer-events-auto w-20 h-20 rounded-full bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] flex items-center justify-center transition-all duration-300 hover:scale-110 hover:bg-[var(--accent-color)] shadow-[var(--shadow-md)] will-change-transform"
|
||||
aria-label="Play"
|
||||
>
|
||||
<Icons.Play size={32} className="text-white ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast Notification */}
|
||||
{showToast && toastMessage && (
|
||||
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-[200] animate-slide-up">
|
||||
<div className="bg-[rgba(28,28,30,0.95)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.6)] px-6 py-3 flex items-center gap-3 min-w-[200px]">
|
||||
<Icons.Check size={18} className="text-[#34c759] flex-shrink-0" />
|
||||
<span className="text-white text-sm font-medium">{toastMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { RefObject } from 'react';
|
||||
|
||||
interface DesktopProgressBarProps {
|
||||
progressBarRef: RefObject<HTMLDivElement | null>;
|
||||
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function DesktopProgressBar({
|
||||
progressBarRef,
|
||||
currentTime,
|
||||
duration,
|
||||
onProgressClick,
|
||||
onProgressMouseDown
|
||||
}: DesktopProgressBarProps) {
|
||||
return (
|
||||
<div className="px-4 pb-1">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="slider-track cursor-pointer"
|
||||
onClick={onProgressClick}
|
||||
onMouseDown={onProgressMouseDown}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div
|
||||
className="slider-range"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="slider-thumb"
|
||||
style={{ left: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DesktopSpeedMenuProps {
|
||||
showSpeedMenu: boolean;
|
||||
playbackRate: number;
|
||||
speeds: number[];
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
export function DesktopSpeedMenu({
|
||||
showSpeedMenu,
|
||||
playbackRate,
|
||||
speeds,
|
||||
onSpeedChange,
|
||||
onToggleSpeedMenu,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}: DesktopSpeedMenuProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggleSpeedMenu}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="btn-icon text-xs font-semibold min-w-[2.5rem]"
|
||||
aria-label="Playback speed"
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
|
||||
{/* Speed Menu */}
|
||||
{showSpeedMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[5rem]"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => onSpeedChange(speed)}
|
||||
className={`w-full px-3 py-2 rounded-[var(--radius-2xl)] text-sm font-medium transition-colors ${playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { RefObject } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface DesktopVolumeControlProps {
|
||||
volumeBarRef: RefObject<HTMLDivElement | null>;
|
||||
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
showVolumeBar: boolean;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function DesktopVolumeControl({
|
||||
volumeBarRef,
|
||||
volume,
|
||||
isMuted,
|
||||
showVolumeBar,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onVolumeMouseDown
|
||||
}: DesktopVolumeControlProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 group/volume">
|
||||
<button
|
||||
onClick={onToggleMute}
|
||||
className="btn-icon"
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<Icons.VolumeX size={20} />
|
||||
) : volume < 0.5 ? (
|
||||
<Icons.Volume1 size={20} />
|
||||
) : (
|
||||
<Icons.Volume2 size={20} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Volume Bar */}
|
||||
<div className={`flex items-center gap-2 overflow-hidden transition-all duration-300 ${showVolumeBar
|
||||
? 'opacity-100 w-32'
|
||||
: 'opacity-0 w-0 group-hover/volume:opacity-100 group-hover/volume:w-32'
|
||||
}`}>
|
||||
<div
|
||||
ref={volumeBarRef}
|
||||
className="slider-track h-1 cursor-pointer flex-1"
|
||||
onClick={onVolumeChange}
|
||||
onMouseDown={onVolumeMouseDown}
|
||||
>
|
||||
<div
|
||||
className="slider-range h-full"
|
||||
style={{ width: `${isMuted ? 0 : volume * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="slider-thumb"
|
||||
style={{ left: `${isMuted ? 0 : volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
|
||||
{Math.round((isMuted ? 0 : volume) * 100)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
interface UseDesktopPlayerLogicProps {
|
||||
src: string;
|
||||
initialTime: number;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
refs: any;
|
||||
state: any;
|
||||
}
|
||||
|
||||
export function useDesktopPlayerLogic({
|
||||
src,
|
||||
initialTime,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
refs,
|
||||
state
|
||||
}: UseDesktopPlayerLogicProps) {
|
||||
const {
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
volumeBarRef,
|
||||
controlsTimeoutRef,
|
||||
speedMenuTimeoutRef,
|
||||
skipForwardTimeoutRef,
|
||||
skipBackwardTimeoutRef,
|
||||
volumeBarTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
isDraggingVolumeRef,
|
||||
mouseMoveThrottleRef,
|
||||
toastTimeoutRef,
|
||||
moreMenuTimeoutRef
|
||||
} = refs;
|
||||
|
||||
const {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
showControls, setShowControls,
|
||||
setIsLoading,
|
||||
playbackRate, setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
isAirPlaySupported, setIsAirPlaySupported,
|
||||
skipForwardAmount, setSkipForwardAmount,
|
||||
skipBackwardAmount, setSkipBackwardAmount,
|
||||
showSkipForwardIndicator, setShowSkipForwardIndicator,
|
||||
showSkipBackwardIndicator, setShowSkipBackwardIndicator,
|
||||
setIsSkipForwardAnimatingOut,
|
||||
setIsSkipBackwardAnimatingOut,
|
||||
setShowVolumeBar,
|
||||
setToastMessage,
|
||||
setShowToast,
|
||||
showMoreMenu, setShowMoreMenu
|
||||
} = state;
|
||||
|
||||
// Check for PiP and AirPlay support
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
setIsPiPSupported('pictureInPictureEnabled' in document);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window);
|
||||
}
|
||||
}, [setIsPiPSupported, setIsAirPlaySupported]);
|
||||
|
||||
// Auto-hide controls
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const hideControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying && !showSpeedMenu) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
hideControls();
|
||||
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, showSpeedMenu, setShowControls, controlsTimeoutRef]);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (mouseMoveThrottleRef.current) return;
|
||||
|
||||
mouseMoveThrottleRef.current = setTimeout(() => {
|
||||
mouseMoveThrottleRef.current = null;
|
||||
}, 200);
|
||||
|
||||
if (!showControls) {
|
||||
setShowControls(true);
|
||||
}
|
||||
if (isPlaying && controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
|
||||
}
|
||||
}, [showControls, isPlaying, setShowControls, controlsTimeoutRef, mouseMoveThrottleRef]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
}, [isPlaying, videoRef]);
|
||||
|
||||
const handlePlay = useCallback(() => setIsPlaying(true), [setIsPlaying]);
|
||||
const handlePause = useCallback(() => setIsPlaying(false), [setIsPlaying]);
|
||||
|
||||
const handleTimeUpdateEvent = useCallback(() => {
|
||||
if (!videoRef.current || isDraggingProgressRef.current) return;
|
||||
|
||||
const current = videoRef.current.currentTime;
|
||||
const total = videoRef.current.duration;
|
||||
|
||||
setCurrentTime(current);
|
||||
setDuration(total);
|
||||
|
||||
if (onTimeUpdate) {
|
||||
onTimeUpdate(current, total);
|
||||
}
|
||||
}, [videoRef, isDraggingProgressRef, setCurrentTime, setDuration, onTimeUpdate]);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
setDuration(videoRef.current.duration);
|
||||
setIsLoading(false);
|
||||
|
||||
if (initialTime > 0) {
|
||||
videoRef.current.currentTime = initialTime;
|
||||
}
|
||||
|
||||
videoRef.current.play().catch((err: Error) => {
|
||||
console.warn('Autoplay was prevented:', err);
|
||||
});
|
||||
}, [videoRef, setDuration, setIsLoading, initialTime]);
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
if (onError) {
|
||||
onError('Video failed to load');
|
||||
}
|
||||
}, [setIsLoading, onError]);
|
||||
|
||||
const handleProgressClick = useCallback((e: any) => {
|
||||
if (!videoRef.current || !progressBarRef.current) return;
|
||||
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
const newTime = pos * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}, [videoRef, progressBarRef, duration, setCurrentTime]);
|
||||
|
||||
const handleProgressMouseDown = useCallback((e: any) => {
|
||||
e.preventDefault();
|
||||
isDraggingProgressRef.current = true;
|
||||
handleProgressClick(e);
|
||||
}, [isDraggingProgressRef, handleProgressClick]);
|
||||
|
||||
// Mouse move/up listeners for progress bar
|
||||
useEffect(() => {
|
||||
const handleProgressMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingProgressRef.current || !progressBarRef.current || !videoRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const newTime = pos * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
isDraggingProgressRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleProgressMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleProgressMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [duration, isDraggingProgressRef, progressBarRef, videoRef, setCurrentTime]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (isMuted) {
|
||||
videoRef.current.volume = volume;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
videoRef.current.volume = 0;
|
||||
setIsMuted(true);
|
||||
}
|
||||
}, [videoRef, isMuted, volume, setIsMuted]);
|
||||
|
||||
const showVolumeBarTemporarily = useCallback(() => {
|
||||
setShowVolumeBar(true);
|
||||
|
||||
if (volumeBarTimeoutRef.current) {
|
||||
clearTimeout(volumeBarTimeoutRef.current);
|
||||
}
|
||||
|
||||
volumeBarTimeoutRef.current = setTimeout(() => {
|
||||
setShowVolumeBar(false);
|
||||
}, 1000);
|
||||
}, [setShowVolumeBar, volumeBarTimeoutRef]);
|
||||
|
||||
const handleVolumeChange = useCallback((e: any) => {
|
||||
if (!videoRef.current || !volumeBarRef.current) return;
|
||||
|
||||
const rect = volumeBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
setIsMuted(pos === 0);
|
||||
}, [videoRef, volumeBarRef, setVolume, setIsMuted]);
|
||||
|
||||
const handleVolumeMouseDown = useCallback((e: any) => {
|
||||
e.preventDefault();
|
||||
isDraggingVolumeRef.current = true;
|
||||
handleVolumeChange(e);
|
||||
}, [isDraggingVolumeRef, handleVolumeChange]);
|
||||
|
||||
// Mouse move/up listeners for volume bar
|
||||
useEffect(() => {
|
||||
const handleVolumeMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingVolumeRef.current || !volumeBarRef.current || !videoRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const rect = volumeBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
setIsMuted(pos === 0);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDraggingVolumeRef.current) {
|
||||
isDraggingVolumeRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleVolumeMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleVolumeMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDraggingVolumeRef, volumeBarRef, videoRef, setVolume, setIsMuted]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (containerRef.current.requestFullscreen) {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}, [containerRef, isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, [setIsFullscreen]);
|
||||
|
||||
const togglePictureInPicture = useCallback(async () => {
|
||||
if (!videoRef.current || !isPiPSupported) return;
|
||||
|
||||
try {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture();
|
||||
} else {
|
||||
await videoRef.current.requestPictureInPicture();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle Picture-in-Picture:', error);
|
||||
}
|
||||
}, [videoRef, isPiPSupported]);
|
||||
|
||||
const showAirPlayMenu = useCallback(() => {
|
||||
if (!videoRef.current || !isAirPlaySupported) return;
|
||||
|
||||
const video = videoRef.current as any;
|
||||
if (video.webkitShowPlaybackTargetPicker) {
|
||||
video.webkitShowPlaybackTargetPicker();
|
||||
}
|
||||
}, [videoRef, isAirPlaySupported]);
|
||||
|
||||
const skipForward = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
setShowSkipBackwardIndicator(false);
|
||||
setSkipBackwardAmount(0);
|
||||
setIsSkipBackwardAnimatingOut(false);
|
||||
if (skipBackwardTimeoutRef.current) {
|
||||
clearTimeout(skipBackwardTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (skipForwardTimeoutRef.current) {
|
||||
clearTimeout(skipForwardTimeoutRef.current);
|
||||
}
|
||||
|
||||
const newSkipAmount = showSkipForwardIndicator ? skipForwardAmount + 10 : 10;
|
||||
setSkipForwardAmount(newSkipAmount);
|
||||
setShowSkipForwardIndicator(true);
|
||||
setIsSkipForwardAnimatingOut(false);
|
||||
|
||||
const targetTime = Math.min(videoRef.current.currentTime + 10, duration);
|
||||
videoRef.current.currentTime = targetTime;
|
||||
setCurrentTime(targetTime);
|
||||
|
||||
skipForwardTimeoutRef.current = setTimeout(() => {
|
||||
setIsSkipForwardAnimatingOut(true);
|
||||
setTimeout(() => {
|
||||
setShowSkipForwardIndicator(false);
|
||||
setSkipForwardAmount(0);
|
||||
setIsSkipForwardAnimatingOut(false);
|
||||
}, 200);
|
||||
}, 800);
|
||||
}, [videoRef, duration, showSkipForwardIndicator, skipForwardAmount, skipBackwardTimeoutRef, skipForwardTimeoutRef, setShowSkipBackwardIndicator, setSkipBackwardAmount, setIsSkipBackwardAnimatingOut, setSkipForwardAmount, setShowSkipForwardIndicator, setIsSkipForwardAnimatingOut, setCurrentTime]);
|
||||
|
||||
const skipBackward = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
setShowSkipForwardIndicator(false);
|
||||
setSkipForwardAmount(0);
|
||||
setIsSkipForwardAnimatingOut(false);
|
||||
if (skipForwardTimeoutRef.current) {
|
||||
clearTimeout(skipForwardTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (skipBackwardTimeoutRef.current) {
|
||||
clearTimeout(skipBackwardTimeoutRef.current);
|
||||
}
|
||||
|
||||
const newSkipAmount = showSkipBackwardIndicator ? skipBackwardAmount + 10 : 10;
|
||||
setSkipBackwardAmount(newSkipAmount);
|
||||
setShowSkipBackwardIndicator(true);
|
||||
setIsSkipBackwardAnimatingOut(false);
|
||||
|
||||
const targetTime = Math.max(videoRef.current.currentTime - 10, 0);
|
||||
videoRef.current.currentTime = targetTime;
|
||||
setCurrentTime(targetTime);
|
||||
|
||||
skipBackwardTimeoutRef.current = setTimeout(() => {
|
||||
setIsSkipBackwardAnimatingOut(true);
|
||||
setTimeout(() => {
|
||||
setShowSkipBackwardIndicator(false);
|
||||
setSkipBackwardAmount(0);
|
||||
setIsSkipBackwardAnimatingOut(false);
|
||||
}, 200);
|
||||
}, 800);
|
||||
}, [videoRef, showSkipBackwardIndicator, skipBackwardAmount, skipForwardTimeoutRef, skipBackwardTimeoutRef, setShowSkipForwardIndicator, setSkipForwardAmount, setIsSkipForwardAnimatingOut, setSkipBackwardAmount, setShowSkipBackwardIndicator, setIsSkipBackwardAnimatingOut, setCurrentTime]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcuts = [' ', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'f', 'F', 'm', 'M', 'i', 'I', '<', '>', ',', '.'];
|
||||
if (shortcuts.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
if (isPlaying) {
|
||||
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
togglePlay();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case '<':
|
||||
case ',':
|
||||
skipBackward();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case '>':
|
||||
case '.':
|
||||
skipForward();
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
toggleMute();
|
||||
showVolumeBarTemporarily();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (videoRef.current) {
|
||||
const newVolume = Math.min(1, volume + 0.05);
|
||||
setVolume(newVolume);
|
||||
videoRef.current.volume = newVolume;
|
||||
setIsMuted(newVolume === 0);
|
||||
showVolumeBarTemporarily();
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (videoRef.current) {
|
||||
const newVolume = Math.max(0, volume - 0.05);
|
||||
setVolume(newVolume);
|
||||
videoRef.current.volume = newVolume;
|
||||
setIsMuted(newVolume === 0);
|
||||
showVolumeBarTemporarily();
|
||||
}
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case 'i':
|
||||
case 'I':
|
||||
if (isPiPSupported) {
|
||||
togglePictureInPicture();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isPlaying, volume, isMuted, isPiPSupported, togglePlay, toggleMute, toggleFullscreen, togglePictureInPicture, skipForward, skipBackward, showVolumeBarTemporarily, setShowControls, controlsTimeoutRef, videoRef, setVolume, setIsMuted]);
|
||||
|
||||
const changePlaybackSpeed = useCallback((speed: number) => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.playbackRate = speed;
|
||||
setPlaybackRate(speed);
|
||||
setShowSpeedMenu(false);
|
||||
if (speedMenuTimeoutRef.current) {
|
||||
clearTimeout(speedMenuTimeoutRef.current);
|
||||
}
|
||||
}, [videoRef, setPlaybackRate, setShowSpeedMenu, speedMenuTimeoutRef]);
|
||||
|
||||
const showToastNotification = useCallback((message: string) => {
|
||||
setToastMessage(message);
|
||||
setShowToast(true);
|
||||
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
}
|
||||
|
||||
toastTimeoutRef.current = setTimeout(() => {
|
||||
setShowToast(false);
|
||||
setTimeout(() => setToastMessage(null), 300);
|
||||
}, 3000);
|
||||
}, [setToastMessage, setShowToast, toastTimeoutRef]);
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(src);
|
||||
showToastNotification('链接已复制到剪贴板');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showToastNotification('复制失败,请重试');
|
||||
}
|
||||
}, [src, showToastNotification]);
|
||||
|
||||
const startSpeedMenuTimeout = useCallback(() => {
|
||||
if (speedMenuTimeoutRef.current) {
|
||||
clearTimeout(speedMenuTimeoutRef.current);
|
||||
}
|
||||
speedMenuTimeoutRef.current = setTimeout(() => {
|
||||
setShowSpeedMenu(false);
|
||||
}, 1500);
|
||||
}, [speedMenuTimeoutRef, setShowSpeedMenu]);
|
||||
|
||||
const clearSpeedMenuTimeout = useCallback(() => {
|
||||
if (speedMenuTimeoutRef.current) {
|
||||
clearTimeout(speedMenuTimeoutRef.current);
|
||||
}
|
||||
}, [speedMenuTimeoutRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSpeedMenu) {
|
||||
startSpeedMenuTimeout();
|
||||
} else {
|
||||
clearSpeedMenuTimeout();
|
||||
}
|
||||
return () => clearSpeedMenuTimeout();
|
||||
}, [showSpeedMenu, startSpeedMenuTimeout, clearSpeedMenuTimeout]);
|
||||
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
if (isNaN(seconds)) return '0:00:00';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleMouseMove,
|
||||
togglePlay,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleTimeUpdateEvent,
|
||||
handleLoadedMetadata,
|
||||
handleVideoError,
|
||||
handleProgressClick,
|
||||
handleProgressMouseDown,
|
||||
toggleMute,
|
||||
showVolumeBarTemporarily,
|
||||
handleVolumeChange,
|
||||
handleVolumeMouseDown,
|
||||
toggleFullscreen,
|
||||
togglePictureInPicture,
|
||||
showAirPlayMenu,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
changePlaybackSpeed,
|
||||
handleCopyLink,
|
||||
startSpeedMenuTimeout,
|
||||
clearSpeedMenuTimeout,
|
||||
formatTime
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
export function useDesktopPlayerState() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const volumeBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs for timeouts and tracking
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const speedMenuTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const skipForwardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const skipBackwardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const volumeBarTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const isDraggingVolumeRef = useRef(false);
|
||||
const mouseMoveThrottleRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const moreMenuTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// State
|
||||
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 [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
const [isPiPSupported, setIsPiPSupported] = useState(false);
|
||||
const [isAirPlaySupported, setIsAirPlaySupported] = useState(false);
|
||||
const [skipForwardAmount, setSkipForwardAmount] = useState(0);
|
||||
const [skipBackwardAmount, setSkipBackwardAmount] = useState(0);
|
||||
const [showSkipForwardIndicator, setShowSkipForwardIndicator] = useState(false);
|
||||
const [showSkipBackwardIndicator, setShowSkipBackwardIndicator] = useState(false);
|
||||
const [isSkipForwardAnimatingOut, setIsSkipForwardAnimatingOut] = useState(false);
|
||||
const [isSkipBackwardAnimatingOut, setIsSkipBackwardAnimatingOut] = useState(false);
|
||||
const [showVolumeBar, setShowVolumeBar] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
|
||||
return {
|
||||
refs: {
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
volumeBarRef,
|
||||
controlsTimeoutRef,
|
||||
speedMenuTimeoutRef,
|
||||
skipForwardTimeoutRef,
|
||||
skipBackwardTimeoutRef,
|
||||
volumeBarTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
isDraggingVolumeRef,
|
||||
mouseMoveThrottleRef,
|
||||
toastTimeoutRef,
|
||||
moreMenuTimeoutRef
|
||||
},
|
||||
state: {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
showControls, setShowControls,
|
||||
isLoading, setIsLoading,
|
||||
playbackRate, setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
isAirPlaySupported, setIsAirPlaySupported,
|
||||
skipForwardAmount, setSkipForwardAmount,
|
||||
skipBackwardAmount, setSkipBackwardAmount,
|
||||
showSkipForwardIndicator, setShowSkipForwardIndicator,
|
||||
showSkipBackwardIndicator, setShowSkipBackwardIndicator,
|
||||
isSkipForwardAnimatingOut, setIsSkipForwardAnimatingOut,
|
||||
isSkipBackwardAnimatingOut, setIsSkipBackwardAnimatingOut,
|
||||
showVolumeBar, setShowVolumeBar,
|
||||
toastMessage, setToastMessage,
|
||||
showToast, setShowToast,
|
||||
showMoreMenu, setShowMoreMenu
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useIsIOS } from '@/lib/hooks/useMobilePlayer';
|
||||
|
||||
interface UseMobilePlayerLogicProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
initialTime: number;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
refs: any;
|
||||
state: any;
|
||||
}
|
||||
|
||||
export function useMobilePlayerLogic({
|
||||
src,
|
||||
initialTime,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
refs,
|
||||
state
|
||||
}: UseMobilePlayerLogicProps) {
|
||||
const {
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
controlsTimeoutRef,
|
||||
skipTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
menuIdleTimeoutRef,
|
||||
isTogglingRef,
|
||||
toastTimeoutRef
|
||||
} = refs;
|
||||
|
||||
const {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
setShowControls,
|
||||
setIsLoading,
|
||||
setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
showVolumeMenu, setShowVolumeMenu,
|
||||
showMoreMenu, setShowMoreMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
skipAmount, setSkipAmount,
|
||||
skipSide, setSkipSide,
|
||||
setShowSkipIndicator,
|
||||
wasPlayingBeforeMenu, setWasPlayingBeforeMenu,
|
||||
setToastMessage,
|
||||
setShowToast,
|
||||
setViewportWidth
|
||||
} = state;
|
||||
|
||||
const isIOS = useIsIOS();
|
||||
|
||||
// Screen orientation management
|
||||
// Note: This was originally a hook call in the component.
|
||||
// We can't call hooks conditionally or inside callbacks, so we assume the component calls useScreenOrientation separately if needed,
|
||||
// or we move it here if it's a top-level hook.
|
||||
// Since useScreenOrientation is a hook, we should call it in the component, not here if this is just a logic function.
|
||||
// But this IS a hook (useMobilePlayerLogic), so we can call other hooks.
|
||||
// However, useScreenOrientation takes isFullscreen as an argument.
|
||||
|
||||
// Check for PiP support
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
setIsPiPSupported('pictureInPictureEnabled' in document);
|
||||
}
|
||||
}, [setIsPiPSupported]);
|
||||
|
||||
// Track viewport width
|
||||
useEffect(() => {
|
||||
const updateViewportWidth = () => {
|
||||
setViewportWidth(window.innerWidth);
|
||||
};
|
||||
updateViewportWidth();
|
||||
window.addEventListener('resize', updateViewportWidth);
|
||||
return () => window.removeEventListener('resize', updateViewportWidth);
|
||||
}, [setViewportWidth]);
|
||||
|
||||
// Skip forward/backward
|
||||
const skipVideo = useCallback((seconds: number, side: 'left' | 'right') => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
|
||||
const newSkipAmount = skipSide === side ? skipAmount + Math.abs(seconds) : Math.abs(seconds);
|
||||
setSkipAmount(newSkipAmount);
|
||||
setSkipSide(side);
|
||||
setShowSkipIndicator(true);
|
||||
|
||||
const targetTime = side === 'left'
|
||||
? Math.max(videoRef.current.currentTime - Math.abs(seconds), 0)
|
||||
: Math.min(videoRef.current.currentTime + Math.abs(seconds), duration);
|
||||
|
||||
videoRef.current.currentTime = targetTime;
|
||||
setCurrentTime(targetTime);
|
||||
|
||||
skipTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipIndicator(false);
|
||||
setSkipAmount(0);
|
||||
setSkipSide(null);
|
||||
}, 1500);
|
||||
}, [duration, skipAmount, skipSide, videoRef, skipTimeoutRef, setSkipAmount, setSkipSide, setShowSkipIndicator, setCurrentTime]);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!videoRef.current || isTogglingRef.current) return;
|
||||
isTogglingRef.current = true;
|
||||
|
||||
try {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
setShowMoreMenu(false);
|
||||
setShowVolumeMenu(false);
|
||||
setShowSpeedMenu(false);
|
||||
await videoRef.current.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Play/pause error:', error);
|
||||
} finally {
|
||||
isTogglingRef.current = false;
|
||||
}
|
||||
}, [isPlaying, videoRef, isTogglingRef, setShowMoreMenu, setShowVolumeMenu, setShowSpeedMenu]);
|
||||
|
||||
const handlePlay = useCallback(() => setIsPlaying(true), [setIsPlaying]);
|
||||
const handlePause = useCallback(() => setIsPlaying(false), [setIsPlaying]);
|
||||
|
||||
const handleTimeUpdateEvent = useCallback(() => {
|
||||
if (!videoRef.current || isDraggingProgressRef.current) return;
|
||||
const current = videoRef.current.currentTime;
|
||||
const total = videoRef.current.duration;
|
||||
setCurrentTime(current);
|
||||
setDuration(total);
|
||||
if (onTimeUpdate) {
|
||||
onTimeUpdate(current, total);
|
||||
}
|
||||
}, [videoRef, isDraggingProgressRef, setCurrentTime, setDuration, onTimeUpdate]);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
setDuration(videoRef.current.duration);
|
||||
setIsLoading(false);
|
||||
if (initialTime > 0) {
|
||||
videoRef.current.currentTime = initialTime;
|
||||
}
|
||||
videoRef.current.play().catch((err: Error) => {
|
||||
console.warn('Autoplay was prevented:', err);
|
||||
});
|
||||
}, [videoRef, setDuration, setIsLoading, initialTime]);
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
if (onError) {
|
||||
onError('Video failed to load');
|
||||
}
|
||||
}, [setIsLoading, onError]);
|
||||
|
||||
const updateProgressFromEvent = useCallback((e: any) => {
|
||||
if (!videoRef.current || !progressBarRef.current) return;
|
||||
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
let clientX: number;
|
||||
|
||||
if ('touches' in e) {
|
||||
const touch = e.touches[0] || e.changedTouches?.[0];
|
||||
if (!touch) return;
|
||||
clientX = touch.clientX;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
}
|
||||
|
||||
const pos = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return pos * duration;
|
||||
}, [videoRef, progressBarRef, duration]);
|
||||
|
||||
const handleProgressTouchStart = useCallback((e: any) => {
|
||||
isDraggingProgressRef.current = true;
|
||||
const newTime = updateProgressFromEvent(e);
|
||||
if (newTime !== undefined) {
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
}, [isDraggingProgressRef, updateProgressFromEvent, setCurrentTime]);
|
||||
|
||||
const handleProgressTouchMove = useCallback((e: any) => {
|
||||
if (!isDraggingProgressRef.current) return;
|
||||
e.preventDefault();
|
||||
const newTime = updateProgressFromEvent(e);
|
||||
if (newTime !== undefined) {
|
||||
setCurrentTime(newTime);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = newTime;
|
||||
}
|
||||
}
|
||||
}, [isDraggingProgressRef, updateProgressFromEvent, setCurrentTime, videoRef]);
|
||||
|
||||
const handleProgressTouchEnd = useCallback((e: any) => {
|
||||
if (!isDraggingProgressRef.current) return;
|
||||
isDraggingProgressRef.current = false;
|
||||
const newTime = updateProgressFromEvent(e);
|
||||
if (newTime !== undefined && videoRef.current) {
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
}, [isDraggingProgressRef, updateProgressFromEvent, videoRef, setCurrentTime]);
|
||||
|
||||
const handleProgressClick = useCallback((e: any) => {
|
||||
const newTime = updateProgressFromEvent(e);
|
||||
if (newTime !== undefined && videoRef.current) {
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
}, [updateProgressFromEvent, videoRef, setCurrentTime]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
if (isMuted) {
|
||||
videoRef.current.volume = volume;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
videoRef.current.volume = 0;
|
||||
setIsMuted(true);
|
||||
}
|
||||
}, [videoRef, isMuted, volume, setIsMuted]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (isIOS && videoRef.current && (videoRef.current as any).webkitEnterFullscreen) {
|
||||
(videoRef.current as any).webkitEnterFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerRef.current.requestFullscreen) {
|
||||
containerRef.current.requestFullscreen().catch((err: Error) => console.warn('Fullscreen request failed:', err));
|
||||
} else if ((containerRef.current as any).webkitRequestFullscreen) {
|
||||
(containerRef.current as any).webkitRequestFullscreen();
|
||||
} else if ((containerRef.current as any).webkitRequestFullScreen) {
|
||||
(containerRef.current as any).webkitRequestFullScreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen().catch((err: Error) => console.warn('Exit fullscreen failed:', err));
|
||||
} else if ((document as any).webkitExitFullscreen) {
|
||||
(document as any).webkitExitFullscreen();
|
||||
} else if ((document as any).webkitCancelFullScreen) {
|
||||
(document as any).webkitCancelFullScreen();
|
||||
}
|
||||
}
|
||||
}, [containerRef, isFullscreen, isIOS, videoRef]);
|
||||
|
||||
// Fullscreen change listener
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const isInFullscreen = !!(
|
||||
document.fullscreenElement ||
|
||||
(document as any).webkitFullscreenElement ||
|
||||
(document as any).webkitCurrentFullScreenElement
|
||||
);
|
||||
setIsFullscreen(isInFullscreen);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||
};
|
||||
}, [setIsFullscreen]);
|
||||
|
||||
const togglePictureInPicture = useCallback(async () => {
|
||||
if (!videoRef.current || !isPiPSupported) return;
|
||||
|
||||
try {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture();
|
||||
} else {
|
||||
await videoRef.current.requestPictureInPicture();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle Picture-in-Picture:', error);
|
||||
}
|
||||
}, [videoRef, isPiPSupported]);
|
||||
|
||||
const changePlaybackSpeed = useCallback((speed: number) => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.playbackRate = speed;
|
||||
setPlaybackRate(speed);
|
||||
setShowSpeedMenu(false);
|
||||
}, [videoRef, setPlaybackRate, setShowSpeedMenu]);
|
||||
|
||||
const showToastNotification = useCallback((message: string) => {
|
||||
setToastMessage(message);
|
||||
setShowToast(true);
|
||||
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
}
|
||||
|
||||
toastTimeoutRef.current = setTimeout(() => {
|
||||
setShowToast(false);
|
||||
setTimeout(() => setToastMessage(null), 300);
|
||||
}, 3000);
|
||||
}, [setToastMessage, setShowToast, toastTimeoutRef]);
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(src);
|
||||
showToastNotification('链接已复制到剪贴板');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showToastNotification('复制失败,请重试');
|
||||
}
|
||||
}, [src, showToastNotification]);
|
||||
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
if (isNaN(seconds)) return '0:00:00';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}, []);
|
||||
|
||||
// Auto-hide controls
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const hideControls = () => {
|
||||
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false);
|
||||
setShowSpeedMenu(false);
|
||||
setShowVolumeMenu(false);
|
||||
setShowMoreMenu(false);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
hideControls();
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
|
||||
};
|
||||
}, [isPlaying, setShowControls, setShowSpeedMenu, setShowVolumeMenu, setShowMoreMenu, controlsTimeoutRef]);
|
||||
|
||||
// Auto-close menus
|
||||
useEffect(() => {
|
||||
if (showMoreMenu) {
|
||||
if (videoRef.current && isPlaying) {
|
||||
setWasPlayingBeforeMenu(true);
|
||||
videoRef.current.pause();
|
||||
}
|
||||
if (menuIdleTimeoutRef.current) clearTimeout(menuIdleTimeoutRef.current);
|
||||
menuIdleTimeoutRef.current = setTimeout(() => {
|
||||
setShowMoreMenu(false);
|
||||
if (wasPlayingBeforeMenu && videoRef.current) {
|
||||
videoRef.current.play().catch((err: Error) => console.warn('Resume play error:', err));
|
||||
setWasPlayingBeforeMenu(false);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => {
|
||||
if (menuIdleTimeoutRef.current) clearTimeout(menuIdleTimeoutRef.current);
|
||||
};
|
||||
}, [showMoreMenu, isPlaying, wasPlayingBeforeMenu, videoRef, menuIdleTimeoutRef, setShowMoreMenu, setWasPlayingBeforeMenu]);
|
||||
|
||||
// Pause on submenu open
|
||||
useEffect(() => {
|
||||
if (showVolumeMenu || showSpeedMenu) {
|
||||
if (videoRef.current && isPlaying) {
|
||||
setWasPlayingBeforeMenu(true);
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}
|
||||
}, [showVolumeMenu, showSpeedMenu, isPlaying, videoRef, setWasPlayingBeforeMenu]);
|
||||
|
||||
// Click outside listener
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: any) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isMenuClick = target.closest('.menu-container') || target.closest('[aria-label="更多"]');
|
||||
|
||||
if (!isMenuClick && (showMoreMenu || showVolumeMenu || showSpeedMenu)) {
|
||||
setShowMoreMenu(false);
|
||||
setShowVolumeMenu(false);
|
||||
setShowSpeedMenu(false);
|
||||
|
||||
if (wasPlayingBeforeMenu && videoRef.current) {
|
||||
videoRef.current.play().catch((err: Error) => console.warn('Resume play error:', err));
|
||||
setWasPlayingBeforeMenu(false);
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
if (showMoreMenu || showVolumeMenu || showSpeedMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchstart', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchstart', handleClickOutside);
|
||||
};
|
||||
}, [showMoreMenu, showVolumeMenu, showSpeedMenu, wasPlayingBeforeMenu, videoRef, setShowMoreMenu, setShowVolumeMenu, setShowSpeedMenu, setWasPlayingBeforeMenu]);
|
||||
|
||||
return {
|
||||
skipVideo,
|
||||
togglePlay,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleTimeUpdateEvent,
|
||||
handleLoadedMetadata,
|
||||
handleVideoError,
|
||||
handleProgressTouchStart,
|
||||
handleProgressTouchMove,
|
||||
handleProgressTouchEnd,
|
||||
handleProgressClick,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
togglePictureInPicture,
|
||||
changePlaybackSpeed,
|
||||
showToastNotification,
|
||||
handleCopyLink,
|
||||
formatTime
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
export function useMobilePlayerState() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs for timeouts and tracking
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const menuIdleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const submenuIdleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isTogglingRef = useRef(false);
|
||||
const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// State
|
||||
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 [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
const [showVolumeMenu, setShowVolumeMenu] = useState(false);
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
const [isPiPSupported, setIsPiPSupported] = useState(false);
|
||||
const [skipAmount, setSkipAmount] = useState(0);
|
||||
const [skipSide, setSkipSide] = useState<'left' | 'right' | null>(null);
|
||||
const [showSkipIndicator, setShowSkipIndicator] = useState(false);
|
||||
const [wasPlayingBeforeMenu, setWasPlayingBeforeMenu] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
|
||||
return {
|
||||
refs: {
|
||||
videoRef,
|
||||
containerRef,
|
||||
progressBarRef,
|
||||
controlsTimeoutRef,
|
||||
skipTimeoutRef,
|
||||
isDraggingProgressRef,
|
||||
menuIdleTimeoutRef,
|
||||
submenuIdleTimeoutRef,
|
||||
isTogglingRef,
|
||||
toastTimeoutRef
|
||||
},
|
||||
state: {
|
||||
isPlaying, setIsPlaying,
|
||||
currentTime, setCurrentTime,
|
||||
duration, setDuration,
|
||||
volume, setVolume,
|
||||
isMuted, setIsMuted,
|
||||
isFullscreen, setIsFullscreen,
|
||||
showControls, setShowControls,
|
||||
isLoading, setIsLoading,
|
||||
playbackRate, setPlaybackRate,
|
||||
showSpeedMenu, setShowSpeedMenu,
|
||||
showVolumeMenu, setShowVolumeMenu,
|
||||
showMoreMenu, setShowMoreMenu,
|
||||
isPiPSupported, setIsPiPSupported,
|
||||
skipAmount, setSkipAmount,
|
||||
skipSide, setSkipSide,
|
||||
showSkipIndicator, setShowSkipIndicator,
|
||||
wasPlayingBeforeMenu, setWasPlayingBeforeMenu,
|
||||
toastMessage, setToastMessage,
|
||||
showToast, setShowToast,
|
||||
viewportWidth, setViewportWidth
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { MobileProgressBar } from './MobileProgressBar';
|
||||
import { MobileVolumeMenu } from './MobileVolumeMenu';
|
||||
import { MobileSpeedMenu } from './MobileSpeedMenu';
|
||||
import { MobileMoreMenu } from './MobileMoreMenu';
|
||||
|
||||
interface MobileControlsProps {
|
||||
showControls: boolean;
|
||||
isCompactLayout: boolean;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
playbackRate: number;
|
||||
showMoreMenu: boolean;
|
||||
showVolumeMenu: boolean;
|
||||
showSpeedMenu: boolean;
|
||||
isPiPSupported: boolean;
|
||||
progressBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
|
||||
onTogglePlay: () => void;
|
||||
onSkipVideo: (seconds: number, side: 'left' | 'right') => void;
|
||||
onToggleMute: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onToggleMoreMenu: () => void;
|
||||
onToggleVolumeMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onTogglePiP: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onCopyLink: () => void;
|
||||
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchMove: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchEnd: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
speeds: number[];
|
||||
}
|
||||
|
||||
export function MobileControls({
|
||||
showControls,
|
||||
isCompactLayout,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isFullscreen,
|
||||
playbackRate,
|
||||
showMoreMenu,
|
||||
showVolumeMenu,
|
||||
showSpeedMenu,
|
||||
isPiPSupported,
|
||||
progressBarRef,
|
||||
onTogglePlay,
|
||||
onSkipVideo,
|
||||
onToggleMute,
|
||||
onToggleFullscreen,
|
||||
onToggleMoreMenu,
|
||||
onToggleVolumeMenu,
|
||||
onToggleSpeedMenu,
|
||||
onTogglePiP,
|
||||
onVolumeChange,
|
||||
onSpeedChange,
|
||||
onCopyLink,
|
||||
onProgressClick,
|
||||
onProgressTouchStart,
|
||||
onProgressTouchMove,
|
||||
onProgressTouchEnd,
|
||||
formatTime,
|
||||
speeds
|
||||
}: MobileControlsProps) {
|
||||
const iconSize = isCompactLayout ? 20 : 22;
|
||||
const buttonPadding = isCompactLayout ? 'p-2' : 'p-2.5';
|
||||
const controlsGap = isCompactLayout ? 'gap-2' : 'gap-3';
|
||||
const textSize = isCompactLayout ? 'text-xs' : 'text-sm';
|
||||
const controlsPadding = isCompactLayout ? 'px-3 pb-3' : 'px-4 pb-4';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 z-50 transition-all duration-300 ${showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||
}`}
|
||||
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<MobileProgressBar
|
||||
progressBarRef={progressBarRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onProgressClick={onProgressClick}
|
||||
onProgressTouchStart={onProgressTouchStart}
|
||||
onProgressTouchMove={onProgressTouchMove}
|
||||
onProgressTouchEnd={onProgressTouchEnd}
|
||||
/>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className={`bg-gradient-to-t from-black/90 via-black/70 to-transparent ${controlsPadding} pt-2`}>
|
||||
{isCompactLayout ? (
|
||||
// Compact Layout
|
||||
<div className={`flex items-center justify-between ${controlsGap}`}>
|
||||
<div className={`flex items-center ${controlsGap} min-w-0`}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePlay();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
|
||||
</button>
|
||||
|
||||
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
|
||||
<div className="relative z-[60]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleMoreMenu();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
|
||||
aria-label="更多"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<MobileMoreMenu
|
||||
showMoreMenu={showMoreMenu}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
playbackRate={playbackRate}
|
||||
isPiPSupported={isPiPSupported}
|
||||
onCopyLink={() => {
|
||||
onToggleMoreMenu();
|
||||
onCopyLink();
|
||||
}}
|
||||
onToggleVolumeMenu={() => {
|
||||
onToggleMoreMenu();
|
||||
onToggleVolumeMenu();
|
||||
}}
|
||||
onToggleSpeedMenu={() => {
|
||||
onToggleMoreMenu();
|
||||
onToggleSpeedMenu();
|
||||
}}
|
||||
onTogglePiP={() => {
|
||||
onToggleMoreMenu();
|
||||
onTogglePiP();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFullscreen();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
|
||||
aria-label={isFullscreen ? '退出全屏' : '全屏'}
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Full Layout
|
||||
<div className={`flex items-center ${controlsGap}`}>
|
||||
<div className={`flex items-center ${controlsGap}`}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePlay();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSkipVideo(10, 'left');
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
|
||||
aria-label="后退 10 秒"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.SkipBack size={iconSize} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSkipVideo(10, 'right');
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
|
||||
aria-label="前进 10 秒"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.SkipForward size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVolumeMenu();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
|
||||
aria-label="音量"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={iconSize} /> : <Icons.Volume2 size={iconSize} />}
|
||||
</button>
|
||||
|
||||
<MobileVolumeMenu
|
||||
showVolumeMenu={showVolumeMenu}
|
||||
isCompactLayout={false}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onClose={onToggleVolumeMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSpeedMenu();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
|
||||
aria-label="播放速度"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<span className={`text-white ${textSize} font-medium`}>{playbackRate}x</span>
|
||||
</button>
|
||||
|
||||
<MobileSpeedMenu
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
isCompactLayout={false}
|
||||
playbackRate={playbackRate}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onClose={onToggleSpeedMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePiP();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
|
||||
aria-label="画中画"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.PictureInPicture size={iconSize} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleMoreMenu();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
|
||||
aria-label="更多"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFullscreen();
|
||||
}}
|
||||
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
|
||||
aria-label={isFullscreen ? '退出全屏' : '全屏'}
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact Layout Submenus */}
|
||||
<MobileVolumeMenu
|
||||
showVolumeMenu={showVolumeMenu}
|
||||
isCompactLayout={true}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onClose={onToggleVolumeMenu}
|
||||
/>
|
||||
|
||||
<MobileSpeedMenu
|
||||
showSpeedMenu={showSpeedMenu}
|
||||
isCompactLayout={true}
|
||||
playbackRate={playbackRate}
|
||||
speeds={speeds}
|
||||
onSpeedChange={onSpeedChange}
|
||||
onClose={onToggleSpeedMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface MobileMoreMenuProps {
|
||||
showMoreMenu: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
isPiPSupported: boolean;
|
||||
onCopyLink: () => void;
|
||||
onToggleVolumeMenu: () => void;
|
||||
onToggleSpeedMenu: () => void;
|
||||
onTogglePiP: () => void;
|
||||
}
|
||||
|
||||
export function MobileMoreMenu({
|
||||
showMoreMenu,
|
||||
isMuted,
|
||||
volume,
|
||||
playbackRate,
|
||||
isPiPSupported,
|
||||
onCopyLink,
|
||||
onToggleVolumeMenu,
|
||||
onToggleSpeedMenu,
|
||||
onTogglePiP
|
||||
}: MobileMoreMenuProps) {
|
||||
if (!showMoreMenu) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full right-0 mb-2 min-w-[160px] z-[100] menu-container">
|
||||
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] overflow-hidden">
|
||||
{/* Copy Link Option */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyLink();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.Link size={18} />
|
||||
<span>复制链接</span>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-white/10 my-1" />
|
||||
|
||||
{/* Volume Option */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVolumeMenu();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
<span>音量</span>
|
||||
</button>
|
||||
|
||||
{/* Speed Option */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSpeedMenu();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span>速度 {playbackRate}x</span>
|
||||
</button>
|
||||
|
||||
{/* PiP Option */}
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePiP();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all touch-manipulation"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
<Icons.PictureInPicture size={18} />
|
||||
<span>画中画</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface MobileOverlayProps {
|
||||
isLoading: boolean;
|
||||
showToast: boolean;
|
||||
toastMessage: string | null;
|
||||
}
|
||||
|
||||
export function MobileOverlay({
|
||||
isLoading,
|
||||
showToast,
|
||||
toastMessage
|
||||
}: MobileOverlayProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Loading Spinner */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast Notification */}
|
||||
{showToast && toastMessage && (
|
||||
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-[200] animate-slide-up" style={{ transform: 'translate(-50%, 0) translateZ(0)' }}>
|
||||
<div className="bg-[rgba(28,28,30,0.95)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.6)] px-6 py-3 flex items-center gap-3 min-w-[200px] max-w-[90vw]">
|
||||
<Icons.Check size={18} className="text-[#34c759] flex-shrink-0" />
|
||||
<span className="text-white text-sm font-medium">{toastMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { RefObject } from 'react';
|
||||
|
||||
interface MobileProgressBarProps {
|
||||
progressBarRef: RefObject<HTMLDivElement | null>;
|
||||
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchMove: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onProgressTouchEnd: (e: React.TouchEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function MobileProgressBar({
|
||||
progressBarRef,
|
||||
currentTime,
|
||||
duration,
|
||||
onProgressClick,
|
||||
onProgressTouchStart,
|
||||
onProgressTouchMove,
|
||||
onProgressTouchEnd
|
||||
}: MobileProgressBarProps) {
|
||||
return (
|
||||
<div className="px-4 pb-2">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="h-1 bg-white/30 rounded-full cursor-pointer"
|
||||
onClick={onProgressClick}
|
||||
onTouchStart={onProgressTouchStart}
|
||||
onTouchMove={onProgressTouchMove}
|
||||
onTouchEnd={onProgressTouchEnd}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)] rounded-full relative"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MobileSkipIndicatorProps {
|
||||
showSkipIndicator: boolean;
|
||||
skipSide: 'left' | 'right' | null;
|
||||
skipAmount: number;
|
||||
}
|
||||
|
||||
export function MobileSkipIndicator({
|
||||
showSkipIndicator,
|
||||
skipSide,
|
||||
skipAmount
|
||||
}: MobileSkipIndicatorProps) {
|
||||
if (!showSkipIndicator || !skipSide) return null;
|
||||
|
||||
return (
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 pointer-events-none ${skipSide === 'left' ? 'left-8' : 'right-8'}`}>
|
||||
<div className="text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] animate-scale-in">
|
||||
{skipSide === 'left' ? '-' : '+'}{skipAmount}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MobileSpeedMenuProps {
|
||||
showSpeedMenu: boolean;
|
||||
isCompactLayout: boolean;
|
||||
playbackRate: number;
|
||||
speeds: number[];
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileSpeedMenu({
|
||||
showSpeedMenu,
|
||||
isCompactLayout,
|
||||
playbackRate,
|
||||
speeds,
|
||||
onSpeedChange,
|
||||
onClose
|
||||
}: MobileSpeedMenuProps) {
|
||||
if (!showSpeedMenu) return null;
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-white/20 menu-container">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpeedChange(speed);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-xs font-medium transition-colors ${playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-white/20 text-white hover:bg-white/30'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white text-xs mt-2"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full right-0 mb-2 z-[100] menu-container">
|
||||
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] p-2 flex gap-2">
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpeedChange(speed);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-xs font-medium transition-colors whitespace-nowrap ${playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-white/20 text-white hover:bg-white/30'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface MobileVolumeMenuProps {
|
||||
showVolumeMenu: boolean;
|
||||
isCompactLayout: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileVolumeMenu({
|
||||
showVolumeMenu,
|
||||
isCompactLayout,
|
||||
isMuted,
|
||||
volume,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onClose
|
||||
}: MobileVolumeMenuProps) {
|
||||
if (!showVolumeMenu) return null;
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-white/20 menu-container">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onToggleMute} className="btn-icon p-2">
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={isMuted ? 0 : volume * 100}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value) / 100)}
|
||||
className="w-full h-1 bg-white/30 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
|
||||
{Math.round((isMuted ? 0 : volume) * 100)}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white text-xs"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-2 z-[100] menu-container">
|
||||
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] p-3 flex flex-col items-center gap-2 min-w-[48px]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleMute();
|
||||
}}
|
||||
className="btn-icon p-1.5"
|
||||
>
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onVolumeChange(parseFloat(e.target.value));
|
||||
}}
|
||||
className="h-24 w-1 bg-white/30 rounded-full appearance-none cursor-pointer [writing-mode:vertical-lr] [direction:rtl] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
style={{ WebkitAppearance: 'slider-vertical' }}
|
||||
/>
|
||||
|
||||
<span className="text-white text-xs font-medium tabular-nums">
|
||||
{Math.round((isMuted ? 0 : volume) * 100)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-593
@@ -1,596 +1,17 @@
|
||||
export interface IconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
export type { IconProps } from './icons/types.tsx';
|
||||
|
||||
|
||||
export { MediaIcons } from './icons/media-icons';
|
||||
export { NavigationIcons } from './icons/navigation-icons';
|
||||
export { UtilityIcons } from './icons/utility-icons';
|
||||
|
||||
// For backward compatibility, export a combined Icons object
|
||||
import { MediaIcons } from './icons/media-icons';
|
||||
import { NavigationIcons } from './icons/navigation-icons';
|
||||
import { UtilityIcons } from './icons/utility-icons';
|
||||
|
||||
export const Icons = {
|
||||
// Video & Media
|
||||
Film: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||
<line x1="7" y1="2" x2="7" y2="22"/>
|
||||
<line x1="17" y1="2" x2="17" y2="22"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="2" y1="7" x2="7" y2="7"/>
|
||||
<line x1="2" y1="17" x2="7" y2="17"/>
|
||||
<line x1="17" y1="17" x2="22" y2="17"/>
|
||||
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Play: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Pause: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="6" y="4" width="4" height="16"/>
|
||||
<rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
TV: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"/>
|
||||
<polyline points="17 2 12 7 7 2"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
// Search & Navigation
|
||||
Search: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
ChevronLeft: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
ChevronDown: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
// List & Organization
|
||||
List: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
// Features
|
||||
Zap: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Target: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="6"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Sparkles: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M12 3v18M5.2 8.2l13.6 7.6M18.8 8.2L5.2 15.8"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
// Info & Details
|
||||
Calendar: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Globe: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
// Empty States
|
||||
Inbox: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/>
|
||||
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
// Alert & Status
|
||||
AlertTriangle: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
RefreshCw: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Volume2: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Volume1: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
VolumeX: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<line x1="23" y1="9" x2="17" y2="15"/>
|
||||
<line x1="17" y1="9" x2="23" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Maximize: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Minimize: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
SkipForward: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="5 4 15 12 5 20 5 4"/>
|
||||
<line x1="19" y1="5" x2="19" y2="19"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
SkipBack: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="19 20 9 12 19 4 19 20"/>
|
||||
<line x1="5" y1="19" x2="5" y2="5"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
PictureInPicture: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<rect x="13" y="10" width="7" height="7" rx="1" ry="1"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Airplay: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"/>
|
||||
<polygon points="12 15 17 21 7 21 12 15"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Check: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Tag: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
X: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Star: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Clock: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
History: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 -960 960 960"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Trash: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Download: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Link: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
),
|
||||
...MediaIcons,
|
||||
...NavigationIcons,
|
||||
...UtilityIcons,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const MediaIcons = {
|
||||
Film: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
|
||||
<line x1="7" y1="2" x2="7" y2="22" />
|
||||
<line x1="17" y1="2" x2="17" y2="22" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<line x1="2" y1="7" x2="7" y2="7" />
|
||||
<line x1="2" y1="17" x2="7" y2="17" />
|
||||
<line x1="17" y1="17" x2="22" y2="17" />
|
||||
<line x1="17" y1="7" x2="22" y2="7" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Play: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Pause: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
TV: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="2" y="7" width="20" height="15" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Volume2: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Volume1: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
VolumeX: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Maximize: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Minimize: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
SkipForward: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="5 4 15 12 5 20 5 4" />
|
||||
<line x1="19" y1="5" x2="19" y2="19" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
SkipBack: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="19 20 9 12 19 4 19 20" />
|
||||
<line x1="5" y1="19" x2="5" y2="5" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
PictureInPicture: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<rect x="13" y="10" width="7" height="7" rx="1" ry="1" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Airplay: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
|
||||
<polygon points="12 15 17 21 7 21 12 15" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const NavigationIcons = {
|
||||
Search: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
ChevronLeft: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
ChevronDown: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
List: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const UtilityIcons = {
|
||||
Zap: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Target: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Sparkles: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M12 3v18M5.2 8.2l13.6 7.6M18.8 8.2L5.2 15.8" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Calendar: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Globe: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Inbox: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
AlertTriangle: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
RefreshCw: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Check: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Tag: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
X: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Star: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Clock: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
History: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 -960 960 960" fill="currentColor" className={className}>
|
||||
<path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Trash: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Download: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Link: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
// Default predefined video sources - Real Chinese video APIs
|
||||
export const DEFAULT_SOURCES: VideoSource[] = [
|
||||
{ id: 'feifan', name: '非凡资源', baseUrl: 'http://ffzy5.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 1 },
|
||||
{ id: 'wolong', name: '卧龙资源', baseUrl: 'https://wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 2 },
|
||||
{ id: 'zuida', name: '最大资源', baseUrl: 'https://api.zuidapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 3 },
|
||||
{ id: 'baiduyun', name: '百度云资源', baseUrl: 'https://api.apibdzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 4 },
|
||||
{ id: 'baofeng', name: '暴风资源', baseUrl: 'https://bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 5 },
|
||||
{ id: 'jisu', name: '极速资源', baseUrl: 'https://jszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 6 },
|
||||
{ id: 'tianya', name: '天涯资源', baseUrl: 'https://tyyszy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 7 },
|
||||
{ id: 'wujin', name: '无尽资源', baseUrl: 'https://api.wujinapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 8 },
|
||||
{ id: 'modu', name: '魔都资源', baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 9 },
|
||||
{ id: 'sanliuling', name: '360资源', baseUrl: 'https://360zy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 10 },
|
||||
{ id: 'dytt', name: '电影天堂', baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 11 },
|
||||
{ id: 'ruyi', name: '如意资源', baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 12 },
|
||||
{ id: 'wangwang', name: '旺旺资源', baseUrl: 'https://wwzy.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 13 },
|
||||
{ id: 'hongniu', name: '红牛资源', baseUrl: 'https://www.hongniuzy2.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 14 },
|
||||
{ id: 'guangsu', name: '光速资源', baseUrl: 'https://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 15 },
|
||||
{ id: 'ikun', name: 'iKun资源', baseUrl: 'https://ikunzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 16 },
|
||||
{ id: 'youku', name: '优酷资源', baseUrl: 'https://api.ukuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 17 },
|
||||
{ id: 'huya', name: '虎牙资源', baseUrl: 'https://www.huyaapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 18 },
|
||||
{ id: 'xinlang', name: '新浪资源', baseUrl: 'http://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 19 },
|
||||
{ id: 'lezi', name: '乐子资源', baseUrl: 'https://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 20 },
|
||||
{ id: 'haihua', name: '海豚资源', baseUrl: 'https://hhzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 21 },
|
||||
{ id: 'jiangyu', name: '鲸鱼资源', baseUrl: 'https://jyzyapi.com/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 22 },
|
||||
{ id: 'yilingba', name: '1080资源', baseUrl: 'https://api.1080zyku.com/inc/api_mac10.php', searchPath: '', detailPath: '', enabled: true, priority: 23 },
|
||||
{ id: 'aidan', name: '爱蛋资源', baseUrl: 'https://lovedan.net/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 24 },
|
||||
{ id: 'leba', name: '乐播资源', baseUrl: 'https://lbapi9.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 25 },
|
||||
{ id: 'moduzy', name: '魔都影视', baseUrl: 'https://www.moduzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 26 },
|
||||
{ id: 'feifanapi', name: '非凡API', baseUrl: 'https://api.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 27 },
|
||||
{ id: 'feifancj', name: '非凡采集', baseUrl: 'http://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 28 },
|
||||
{ id: 'feifancj2', name: '非凡采集HTTPS', baseUrl: 'https://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 29 },
|
||||
{ id: 'feifan1', name: '非凡线路1', baseUrl: 'http://ffzy1.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 30 },
|
||||
{ id: 'wolong2', name: '卧龙采集', baseUrl: 'https://collect.wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 31 },
|
||||
{ id: 'baofeng2', name: '暴风APP', baseUrl: 'https://app.bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 32 },
|
||||
{ id: 'wujin2', name: '无尽ME', baseUrl: 'https://api.wujinapi.me/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 33 },
|
||||
{ id: 'tianyazy', name: '天涯海角', baseUrl: 'https://tyyszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 34 },
|
||||
{ id: 'guangsu2', name: '光速HTTP', baseUrl: 'http://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 35 },
|
||||
{ id: 'xinlang2', name: '新浪HTTPS', baseUrl: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 36 },
|
||||
{ id: 'yilingba2', name: '1080JSON', baseUrl: 'https://api.1080zyku.com/inc/apijson.php', searchPath: '', detailPath: '', enabled: true, priority: 37 },
|
||||
{ id: 'lezi2', name: '乐子HTTP', baseUrl: 'http://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 38 },
|
||||
];
|
||||
+3
-346
@@ -4,352 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
// Default predefined video sources - Real Chinese video APIs
|
||||
const DEFAULT_SOURCES: VideoSource[] = [
|
||||
{
|
||||
id: 'feifan',
|
||||
name: '非凡资源',
|
||||
baseUrl: 'http://ffzy5.tv/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
id: 'wolong',
|
||||
name: '卧龙资源',
|
||||
baseUrl: 'https://wolongzyw.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
id: 'zuida',
|
||||
name: '最大资源',
|
||||
baseUrl: 'https://api.zuidapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 3,
|
||||
},
|
||||
{
|
||||
id: 'baiduyun',
|
||||
name: '百度云资源',
|
||||
baseUrl: 'https://api.apibdzy.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 4,
|
||||
},
|
||||
{
|
||||
id: 'baofeng',
|
||||
name: '暴风资源',
|
||||
baseUrl: 'https://bfzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 5,
|
||||
},
|
||||
{
|
||||
id: 'jisu',
|
||||
name: '极速资源',
|
||||
baseUrl: 'https://jszyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 6,
|
||||
},
|
||||
{
|
||||
id: 'tianya',
|
||||
name: '天涯资源',
|
||||
baseUrl: 'https://tyyszy.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 7,
|
||||
},
|
||||
{
|
||||
id: 'wujin',
|
||||
name: '无尽资源',
|
||||
baseUrl: 'https://api.wujinapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 8,
|
||||
},
|
||||
{
|
||||
id: 'modu',
|
||||
name: '魔都资源',
|
||||
baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 9,
|
||||
},
|
||||
{
|
||||
id: 'sanliuling',
|
||||
name: '360资源',
|
||||
baseUrl: 'https://360zy.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
id: 'dytt',
|
||||
name: '电影天堂',
|
||||
baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 11,
|
||||
},
|
||||
{
|
||||
id: 'ruyi',
|
||||
name: '如意资源',
|
||||
baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 12,
|
||||
},
|
||||
{
|
||||
id: 'wangwang',
|
||||
name: '旺旺资源',
|
||||
baseUrl: 'https://wwzy.tv/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 13,
|
||||
},
|
||||
{
|
||||
id: 'hongniu',
|
||||
name: '红牛资源',
|
||||
baseUrl: 'https://www.hongniuzy2.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 14,
|
||||
},
|
||||
{
|
||||
id: 'guangsu',
|
||||
name: '光速资源',
|
||||
baseUrl: 'https://api.guangsuapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 15,
|
||||
},
|
||||
{
|
||||
id: 'ikun',
|
||||
name: 'iKun资源',
|
||||
baseUrl: 'https://ikunzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 16,
|
||||
},
|
||||
{
|
||||
id: 'youku',
|
||||
name: '优酷资源',
|
||||
baseUrl: 'https://api.ukuapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 17,
|
||||
},
|
||||
{
|
||||
id: 'huya',
|
||||
name: '虎牙资源',
|
||||
baseUrl: 'https://www.huyaapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 18,
|
||||
},
|
||||
{
|
||||
id: 'xinlang',
|
||||
name: '新浪资源',
|
||||
baseUrl: 'http://api.xinlangapi.com/xinlangapi.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 19,
|
||||
},
|
||||
{
|
||||
id: 'lezi',
|
||||
name: '乐子资源',
|
||||
baseUrl: 'https://cj.lziapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 20,
|
||||
},
|
||||
{
|
||||
id: 'haihua',
|
||||
name: '海豚资源',
|
||||
baseUrl: 'https://hhzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 21,
|
||||
},
|
||||
{
|
||||
id: 'jiangyu',
|
||||
name: '鲸鱼资源',
|
||||
baseUrl: 'https://jyzyapi.com/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 22,
|
||||
},
|
||||
{
|
||||
id: 'yilingba',
|
||||
name: '1080资源',
|
||||
baseUrl: 'https://api.1080zyku.com/inc/api_mac10.php',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 23,
|
||||
},
|
||||
{
|
||||
id: 'aidan',
|
||||
name: '爱蛋资源',
|
||||
baseUrl: 'https://lovedan.net/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 24,
|
||||
},
|
||||
{
|
||||
id: 'leba',
|
||||
name: '乐播资源',
|
||||
baseUrl: 'https://lbapi9.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 25,
|
||||
},
|
||||
{
|
||||
id: 'moduzy',
|
||||
name: '魔都影视',
|
||||
baseUrl: 'https://www.moduzy.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 26,
|
||||
},
|
||||
{
|
||||
id: 'feifanapi',
|
||||
name: '非凡API',
|
||||
baseUrl: 'https://api.ffzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 27,
|
||||
},
|
||||
{
|
||||
id: 'feifancj',
|
||||
name: '非凡采集',
|
||||
baseUrl: 'http://cj.ffzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 28,
|
||||
},
|
||||
{
|
||||
id: 'feifancj2',
|
||||
name: '非凡采集HTTPS',
|
||||
baseUrl: 'https://cj.ffzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 29,
|
||||
},
|
||||
{
|
||||
id: 'feifan1',
|
||||
name: '非凡线路1',
|
||||
baseUrl: 'http://ffzy1.tv/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
id: 'wolong2',
|
||||
name: '卧龙采集',
|
||||
baseUrl: 'https://collect.wolongzyw.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 31,
|
||||
},
|
||||
{
|
||||
id: 'baofeng2',
|
||||
name: '暴风APP',
|
||||
baseUrl: 'https://app.bfzyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 32,
|
||||
},
|
||||
{
|
||||
id: 'wujin2',
|
||||
name: '无尽ME',
|
||||
baseUrl: 'https://api.wujinapi.me/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 33,
|
||||
},
|
||||
{
|
||||
id: 'tianyazy',
|
||||
name: '天涯海角',
|
||||
baseUrl: 'https://tyyszyapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 34,
|
||||
},
|
||||
{
|
||||
id: 'guangsu2',
|
||||
name: '光速HTTP',
|
||||
baseUrl: 'http://api.guangsuapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 35,
|
||||
},
|
||||
{
|
||||
id: 'xinlang2',
|
||||
name: '新浪HTTPS',
|
||||
baseUrl: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 36,
|
||||
},
|
||||
{
|
||||
id: 'yilingba2',
|
||||
name: '1080JSON',
|
||||
baseUrl: 'https://api.1080zyku.com/inc/apijson.php',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 37,
|
||||
},
|
||||
{
|
||||
id: 'lezi2',
|
||||
name: '乐子HTTP',
|
||||
baseUrl: 'http://cj.lziapi.com/api.php/provide/vod',
|
||||
searchPath: '',
|
||||
detailPath: '',
|
||||
enabled: true,
|
||||
priority: 38,
|
||||
},
|
||||
];
|
||||
import { DEFAULT_SOURCES } from './default-sources';
|
||||
|
||||
/**
|
||||
* Get source by ID
|
||||
@@ -358,3 +13,5 @@ export function getSourceById(id: string): VideoSource | undefined {
|
||||
return DEFAULT_SOURCES.find(source => source.id === id);
|
||||
}
|
||||
|
||||
// Re-export DEFAULT_SOURCES for backward compatibility
|
||||
export { DEFAULT_SOURCES };
|
||||
|
||||
Reference in New Issue
Block a user