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:
kuekhaoyang
2025-11-21 12:34:45 +08:00
parent d199c7aa6f
commit 36488e545c
31 changed files with 3634 additions and 3532 deletions
+4 -799
View File
@@ -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";
+240
View File
@@ -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;
}
+135
View File
@@ -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;
}
+245
View File
@@ -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);
}
+78
View File
@@ -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
}
};
}
+340
View File
@@ -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
View File
@@ -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,
};
+99
View File
@@ -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>
),
};
+33
View File
@@ -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>
),
};
+4
View File
@@ -0,0 +1,4 @@
export interface IconProps {
className?: string;
size?: number;
}
+124
View File
@@ -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>
),
};
+43
View File
@@ -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
View File
@@ -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 };