diff --git a/app/globals.css b/app/globals.css index ae43934..25d0e40 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); -} \ No newline at end of file +@import "./styles/variables.css"; +@import "./styles/base.css"; +@import "./styles/animations.css"; +@import "./styles/components.css"; \ No newline at end of file diff --git a/app/styles/animations.css b/app/styles/animations.css new file mode 100644 index 0000000..bce3f62 --- /dev/null +++ b/app/styles/animations.css @@ -0,0 +1,240 @@ +/* Keyframe Animations */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes spin-reverse { + from { + transform: rotate(360deg); + } + + to { + transform: rotate(0deg); + } +} + +@keyframes bounce-subtle { + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-5px); + } +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +@keyframes scale-in { + 0% { + opacity: 0; + transform: scale(0.9) translateY(10px); + } + + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes scale-out { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + } + + 100% { + opacity: 0; + transform: scale(0.9) translateY(-10px); + } +} + +@keyframes float { + + 0%, + 100% { + transform: translateY(0) translateX(0); + opacity: 0.3; + } + + 50% { + transform: translateY(-20px) translateX(10px); + opacity: 0.8; + } +} + +@keyframes gradient-x { + + 0%, + 100% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes search-dropdown-appear { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Animation Utility Classes */ +.animate-fade-in { + animation: fade-in 0.4s ease-out; +} + +.animate-slide-up { + animation: slide-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.animate-pulse { + animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +.animate-spin-slow { + animation: spin-slow 3s linear infinite; +} + +.animate-spin-reverse { + animation: spin-reverse 2s linear infinite; +} + +.animate-bounce-subtle { + animation: bounce-subtle 2s ease-in-out infinite; +} + +.animate-shimmer { + animation: shimmer 2s infinite; +} + +.animate-scale-in { + animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.animate-scale-out { + animation: scale-out 0.2s ease-out forwards; +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-gradient-x { + background-size: 200% 200%; + animation: gradient-x 3s ease infinite; +} \ No newline at end of file diff --git a/app/styles/base.css b/app/styles/base.css new file mode 100644 index 0000000..0ec7f9b --- /dev/null +++ b/app/styles/base.css @@ -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; +} \ No newline at end of file diff --git a/app/styles/components.css b/app/styles/components.css new file mode 100644 index 0000000..2af98b4 --- /dev/null +++ b/app/styles/components.css @@ -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); +} \ No newline at end of file diff --git a/app/styles/variables.css b/app/styles/variables.css new file mode 100644 index 0000000..57ea282 --- /dev/null +++ b/app/styles/variables.css @@ -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; +} \ No newline at end of file diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 8b8cb65..d699a19 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -1,7 +1,9 @@ 'use client'; -import { useRef, useState, useEffect } from 'react'; -import { Icons } from '@/components/ui/Icon'; +import { useDesktopPlayerState } from './hooks/useDesktopPlayerState'; +import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic'; +import { DesktopControls } from './desktop/DesktopControls'; +import { DesktopOverlay } from './desktop/DesktopOverlay'; interface DesktopVideoPlayerProps { src: string; @@ -11,588 +13,88 @@ interface DesktopVideoPlayerProps { initialTime?: number; } -export function DesktopVideoPlayer({ - src, +export function DesktopVideoPlayer({ + src, poster, - onError, + onError, onTimeUpdate, - initialTime = 0 + initialTime = 0 }: DesktopVideoPlayerProps) { - const videoRef = useRef(null); - const containerRef = useRef(null); - const progressBarRef = useRef(null); - const volumeBarRef = useRef(null); - - 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(null); - const [showToast, setShowToast] = useState(false); - const [showMoreMenu, setShowMoreMenu] = useState(false); - - const controlsTimeoutRef = useRef(null); - const speedMenuTimeoutRef = useRef(null); - const skipForwardTimeoutRef = useRef(null); - const skipBackwardTimeoutRef = useRef(null); - const volumeBarTimeoutRef = useRef(null); - const isDraggingProgressRef = useRef(false); - const isDraggingVolumeRef = useRef(false); - const mouseMoveThrottleRef = useRef(null); - const toastTimeoutRef = useRef(null); - const moreMenuTimeoutRef = useRef(null); + const { refs, state } = useDesktopPlayerState(); + const { + videoRef, + containerRef, + progressBarRef, + volumeBarRef, + moreMenuTimeoutRef + } = refs; - // Check for PiP and AirPlay support - useEffect(() => { - if (typeof document !== 'undefined') { - setIsPiPSupported('pictureInPictureEnabled' in document); - } - if (typeof window !== 'undefined') { - // Check for AirPlay support (Safari/WebKit) - setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window); - } - }, []); + const { + isPlaying, + currentTime, + duration, + volume, + isMuted, + isFullscreen, + showControls, + isLoading, + playbackRate, + showSpeedMenu, + isPiPSupported, + isAirPlaySupported, + skipForwardAmount, + skipBackwardAmount, + showSkipForwardIndicator, + showSkipBackwardIndicator, + isSkipForwardAnimatingOut, + isSkipBackwardAnimatingOut, + showVolumeBar, + toastMessage, + showToast, + showMoreMenu, + setShowControls, + setIsLoading, + setShowMoreMenu + } = state; - // Auto-hide controls - useEffect(() => { - if (!isPlaying) return; - - const hideControls = () => { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - controlsTimeoutRef.current = setTimeout(() => { - if (isPlaying && !showSpeedMenu) { - setShowControls(false); - } - }, 3000); - }; + const logic = useDesktopPlayerLogic({ + src, + initialTime, + onError, + onTimeUpdate, + refs, + state + }); - hideControls(); + const { + handleMouseMove, + togglePlay, + handlePlay, + handlePause, + handleTimeUpdateEvent, + handleLoadedMetadata, + handleVideoError, + handleProgressClick, + handleProgressMouseDown, + toggleMute, + handleVolumeChange, + handleVolumeMouseDown, + toggleFullscreen, + togglePictureInPicture, + showAirPlayMenu, + skipForward, + skipBackward, + changePlaybackSpeed, + handleCopyLink, + startSpeedMenuTimeout, + clearSpeedMenuTimeout, + formatTime + } = logic; - return () => { - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - }; - }, [isPlaying, showSpeedMenu]); - - // Handle mouse movement to show controls (throttled for performance) - const handleMouseMove = () => { - // Throttle mouse move events to improve performance (200ms instead of 100ms) - if (mouseMoveThrottleRef.current) return; - - mouseMoveThrottleRef.current = setTimeout(() => { - mouseMoveThrottleRef.current = null; - }, 200); // Increased from 100ms to 200ms - - if (!showControls) { - setShowControls(true); - } - if (isPlaying && controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000); - } - }; // Play/Pause toggle - const togglePlay = () => { - if (!videoRef.current) return; - - if (isPlaying) { - videoRef.current.pause(); - } else { - videoRef.current.play(); - } - }; - - // Handle video events - const handlePlay = () => setIsPlaying(true); - const handlePause = () => setIsPlaying(false); - - const handleTimeUpdateEvent = () => { - 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); - } - }; - - const handleLoadedMetadata = () => { - if (!videoRef.current) return; - - setDuration(videoRef.current.duration); - setIsLoading(false); - - // Set initial time if provided - if (initialTime > 0) { - videoRef.current.currentTime = initialTime; - } - - // Auto-play the video when loaded - videoRef.current.play().catch(err => { - console.warn('Autoplay was prevented:', err); - // Autoplay may be blocked by browser policy, which is fine - }); - }; - - const handleVideoError = () => { - setIsLoading(false); - if (onError) { - onError('Video failed to load'); - } - }; - - // Progress bar seeking - const handleProgressClick = (e: React.MouseEvent) => { - 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); - }; - - const handleProgressMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - isDraggingProgressRef.current = true; - handleProgressClick(e); - }; - - 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]); - - // Volume control - const toggleMute = () => { - if (!videoRef.current) return; - - if (isMuted) { - videoRef.current.volume = volume; - setIsMuted(false); - } else { - videoRef.current.volume = 0; - setIsMuted(true); - } - }; - - // Show volume bar temporarily - const showVolumeBarTemporarily = () => { - setShowVolumeBar(true); - - // Clear existing timeout - if (volumeBarTimeoutRef.current) { - clearTimeout(volumeBarTimeoutRef.current); - } - - // Hide after 1 second - volumeBarTimeoutRef.current = setTimeout(() => { - setShowVolumeBar(false); - }, 1000); - }; - - const handleVolumeChange = (e: React.MouseEvent) => { - 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); - }; - - const handleVolumeMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - isDraggingVolumeRef.current = true; - handleVolumeChange(e); - }; - - 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); - }; - }, []); - - // Fullscreen - const toggleFullscreen = () => { - if (!containerRef.current) return; - - if (!isFullscreen) { - if (containerRef.current.requestFullscreen) { - containerRef.current.requestFullscreen(); - } - } else { - if (document.exitFullscreen) { - document.exitFullscreen(); - } - } - }; - - useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); - }, []); - - // Picture-in-Picture - const togglePictureInPicture = 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); - } - }; - - // AirPlay - const showAirPlayMenu = () => { - if (!videoRef.current || !isAirPlaySupported) return; - - const video = videoRef.current as any; - if (video.webkitShowPlaybackTargetPicker) { - video.webkitShowPlaybackTargetPicker(); - } - }; - - // Skip forward/backward with visual feedback - const skipForward = () => { - if (!videoRef.current) return; - - // Clear backward indicator immediately - setShowSkipBackwardIndicator(false); - setSkipBackwardAmount(0); - setIsSkipBackwardAnimatingOut(false); - if (skipBackwardTimeoutRef.current) { - clearTimeout(skipBackwardTimeoutRef.current); - } - - // Clear existing timeout to reset the fade out timer - if (skipForwardTimeoutRef.current) { - clearTimeout(skipForwardTimeoutRef.current); - } - - // Calculate new skip amount (accumulate if already showing) - const newSkipAmount = showSkipForwardIndicator ? skipForwardAmount + 10 : 10; - setSkipForwardAmount(newSkipAmount); - setShowSkipForwardIndicator(true); - setIsSkipForwardAnimatingOut(false); - - // Apply the skip to video - const targetTime = Math.min(videoRef.current.currentTime + 10, duration); - videoRef.current.currentTime = targetTime; - setCurrentTime(targetTime); - - // Start fade out animation after 800ms of inactivity - skipForwardTimeoutRef.current = setTimeout(() => { - setIsSkipForwardAnimatingOut(true); - // Hide indicator after animation completes (200ms) - setTimeout(() => { - setShowSkipForwardIndicator(false); - setSkipForwardAmount(0); - setIsSkipForwardAnimatingOut(false); - }, 200); - }, 800); - }; - - const skipBackward = () => { - if (!videoRef.current) return; - - // Clear forward indicator immediately - setShowSkipForwardIndicator(false); - setSkipForwardAmount(0); - setIsSkipForwardAnimatingOut(false); - if (skipForwardTimeoutRef.current) { - clearTimeout(skipForwardTimeoutRef.current); - } - - // Clear existing timeout to reset the fade out timer - if (skipBackwardTimeoutRef.current) { - clearTimeout(skipBackwardTimeoutRef.current); - } - - // Calculate new skip amount (accumulate if already showing) - const newSkipAmount = showSkipBackwardIndicator ? skipBackwardAmount + 10 : 10; - setSkipBackwardAmount(newSkipAmount); - setShowSkipBackwardIndicator(true); - setIsSkipBackwardAnimatingOut(false); - - // Apply the skip to video - const targetTime = Math.max(videoRef.current.currentTime - 10, 0); - videoRef.current.currentTime = targetTime; - setCurrentTime(targetTime); - - // Start fade out animation after 800ms of inactivity - skipBackwardTimeoutRef.current = setTimeout(() => { - setIsSkipBackwardAnimatingOut(true); - // Hide indicator after animation completes (200ms) - setTimeout(() => { - setShowSkipBackwardIndicator(false); - setSkipBackwardAmount(0); - setIsSkipBackwardAnimatingOut(false); - }, 200); - }, 800); - }; - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (skipForwardTimeoutRef.current) { - clearTimeout(skipForwardTimeoutRef.current); - } - if (skipBackwardTimeoutRef.current) { - clearTimeout(skipBackwardTimeoutRef.current); - } - if (volumeBarTimeoutRef.current) { - clearTimeout(volumeBarTimeoutRef.current); - } - }; - }, []); - - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Ignore if user is typing in an input field - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return; - } - - // Prevent default for our shortcuts - const shortcuts = [' ', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'f', 'F', 'm', 'M', 'i', 'I', '<', '>', ',', '.']; - if (shortcuts.includes(e.key)) { - e.preventDefault(); - - // Show controls when any shortcut key is pressed - setShowControls(true); - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - // Hide controls after 3 seconds if video is playing - if (isPlaying) { - controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000); - } - } - - switch (e.key) { - // Playback control - case ' ': // Spacebar: Play/pause - togglePlay(); - break; - - case 'ArrowLeft': // Left arrow: Rewind 10 seconds - case '<': // <: Rewind 10 seconds - case ',': // Comma key - skipBackward(); - break; - - case 'ArrowRight': // Right arrow: Fast-forward 10 seconds - case '>': // >: Fast-forward 10 seconds - case '.': // Period key - skipForward(); - break; - - // Volume - case 'm': // M: Mute/unmute - case 'M': - toggleMute(); - showVolumeBarTemporarily(); - break; - - case 'ArrowUp': // Up arrow: Increase volume by 5% - if (videoRef.current) { - const newVolume = Math.min(1, volume + 0.05); - setVolume(newVolume); - videoRef.current.volume = newVolume; - setIsMuted(newVolume === 0); - showVolumeBarTemporarily(); - } - break; - - case 'ArrowDown': // Down arrow: Decrease volume by 5% - if (videoRef.current) { - const newVolume = Math.max(0, volume - 0.05); - setVolume(newVolume); - videoRef.current.volume = newVolume; - setIsMuted(newVolume === 0); - showVolumeBarTemporarily(); - } - break; - - // Display - case 'f': // F: Toggle full-screen mode - case 'F': - toggleFullscreen(); - break; - - case 'i': // I: Toggle miniplayer (Picture-in-Picture) - case 'I': - if (isPiPSupported) { - togglePictureInPicture(); - } - break; - } - }; - - // Add event listener - window.addEventListener('keydown', handleKeyDown); - - // Cleanup - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, [isPlaying, volume, isMuted, isPiPSupported, togglePlay, toggleMute, toggleFullscreen, togglePictureInPicture, skipForward, skipBackward, showVolumeBarTemporarily]); // Include dependencies - - // Playback speed const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; - - const changePlaybackSpeed = (speed: number) => { - if (!videoRef.current) return; - videoRef.current.playbackRate = speed; - setPlaybackRate(speed); - setShowSpeedMenu(false); - // Clear timeout when manually closing - if (speedMenuTimeoutRef.current) { - clearTimeout(speedMenuTimeoutRef.current); - } - }; - - // Show toast notification - const showToastNotification = (message: string) => { - setToastMessage(message); - setShowToast(true); - - if (toastTimeoutRef.current) { - clearTimeout(toastTimeoutRef.current); - } - - toastTimeoutRef.current = setTimeout(() => { - setShowToast(false); - setTimeout(() => setToastMessage(null), 300); - }, 3000); - }; - - // Copy video link - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(src); - showToastNotification('链接已复制到剪贴板'); - } catch (error) { - console.error('Copy failed:', error); - showToastNotification('复制失败,请重试'); - } - }; - - // Auto-hide speed menu after 1.5s of inactivity - const startSpeedMenuTimeout = () => { - if (speedMenuTimeoutRef.current) { - clearTimeout(speedMenuTimeoutRef.current); - } - speedMenuTimeoutRef.current = setTimeout(() => { - setShowSpeedMenu(false); - }, 1500); - }; - - const clearSpeedMenuTimeout = () => { - if (speedMenuTimeoutRef.current) { - clearTimeout(speedMenuTimeoutRef.current); - } - }; - - // Start timeout when menu opens - useEffect(() => { - if (showSpeedMenu) { - startSpeedMenuTimeout(); - } else { - clearSpeedMenuTimeout(); - } - return () => clearSpeedMenuTimeout(); - }, [showSpeedMenu]); - - // Format time helper - const formatTime = (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 ( -
- {/* Loading Spinner */} - {isLoading && ( -
-
-
- )} + - {/* Skip Forward Indicator */} - {showSkipForwardIndicator && ( -
-
- +{skipForwardAmount} -
-
- )} - - {/* Skip Backward Indicator */} - {showSkipBackwardIndicator && ( -
-
- -{skipBackwardAmount} -
-
- )} - - {/* Center Play Button (when paused) */} - {!isPlaying && !isLoading && ( -
- -
- )} - - {/* Custom Controls */} -
- {/* Progress Bar */} -
-
-
-
-
-
- - {/* Controls Bar */} -
-
- {/* Left Controls */} -
- {/* Play/Pause */} - - - {/* Skip Backward 10s */} - - - {/* Skip Forward 10s */} - - - {/* Volume */} -
- - - {/* Volume Bar */} -
-
-
-
-
- - {Math.round((isMuted ? 0 : volume) * 100)} - -
-
- - {/* Time */} - - {formatTime(currentTime)} / {formatTime(duration)} - -
- - {/* Right Controls */} -
- {/* Playback Speed */} -
- - - {/* Speed Menu */} - {showSpeedMenu && ( -
setShowSpeedMenu(false)} - > - {speeds.map((speed) => ( - - ))} -
- )} -
- - {/* Picture-in-Picture */} - {isPiPSupported && ( - - )} - - {/* AirPlay */} - {isAirPlaySupported && ( - - )} - - {/* More Menu */} -
- - - {/* More Menu Dropdown */} - {showMoreMenu && ( -
{ - if (moreMenuTimeoutRef.current) { - clearTimeout(moreMenuTimeoutRef.current); - } - }} - onMouseLeave={() => { - setShowMoreMenu(false); - }} - > - -
- )} -
- - {/* Fullscreen */} - -
-
-
-
- - {/* Toast Notification */} - {showToast && toastMessage && ( -
-
- - {toastMessage} -
-
- )} + state.setShowSpeedMenu(!showSpeedMenu)} + onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)} + onSpeedChange={changePlaybackSpeed} + onCopyLink={handleCopyLink} + onProgressClick={handleProgressClick} + onProgressMouseDown={handleProgressMouseDown} + onSpeedMenuMouseEnter={clearSpeedMenuTimeout} + onSpeedMenuMouseLeave={startSpeedMenuTimeout} + onMoreMenuMouseEnter={() => { + if (moreMenuTimeoutRef.current) { + clearTimeout(moreMenuTimeoutRef.current); + } + }} + onMoreMenuMouseLeave={() => { + moreMenuTimeoutRef.current = setTimeout(() => { + setShowMoreMenu(false); + }, 300); + }} + formatTime={formatTime} + speeds={speeds} + />
); } diff --git a/components/player/MobileVideoPlayer.tsx b/components/player/MobileVideoPlayer.tsx index e3730da..6afb461 100644 --- a/components/player/MobileVideoPlayer.tsx +++ b/components/player/MobileVideoPlayer.tsx @@ -1,8 +1,12 @@ 'use client'; -import { useRef, useState, useEffect } from 'react'; -import { Icons } from '@/components/ui/Icon'; -import { useDoubleTap, useScreenOrientation, useIsIOS } from '@/lib/hooks/useMobilePlayer'; +import { useEffect } from 'react'; +import { useDoubleTap, useScreenOrientation } from '@/lib/hooks/useMobilePlayer'; +import { useMobilePlayerState } from './hooks/useMobilePlayerState'; +import { useMobilePlayerLogic } from './hooks/useMobilePlayerLogic'; +import { MobileControls } from './mobile/MobileControls'; +import { MobileOverlay } from './mobile/MobileOverlay'; +import { MobileSkipIndicator } from './mobile/MobileSkipIndicator'; interface MobileVideoPlayerProps { src: string; @@ -19,95 +23,70 @@ export function MobileVideoPlayer({ onTimeUpdate, initialTime = 0 }: MobileVideoPlayerProps) { - const videoRef = useRef(null); - const containerRef = useRef(null); - const progressBarRef = useRef(null); + const { refs, state } = useMobilePlayerState(); + const { + videoRef, + containerRef, + progressBarRef, + controlsTimeoutRef + } = refs; - 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(null); - const [showToast, setShowToast] = useState(false); - const [viewportWidth, setViewportWidth] = useState(0); - const isIOS = useIsIOS(); + const { + isPlaying, + currentTime, + duration, + volume, + isMuted, + isFullscreen, + showControls, + isLoading, + playbackRate, + showSpeedMenu, + showVolumeMenu, + showMoreMenu, + isPiPSupported, + skipAmount, + skipSide, + showSkipIndicator, + toastMessage, + showToast, + viewportWidth, + setShowControls, + setIsLoading + } = state; - const controlsTimeoutRef = useRef(null); - const skipTimeoutRef = useRef(null); - const isDraggingProgressRef = useRef(false); - const menuIdleTimeoutRef = useRef(null); - const submenuIdleTimeoutRef = useRef(null); - const isTogglingRef = useRef(false); - const toastTimeoutRef = useRef(null); + const logic = useMobilePlayerLogic({ + src, + initialTime, + onError, + onTimeUpdate, + refs, + state + }); + + const { + skipVideo, + togglePlay, + handlePlay, + handlePause, + handleTimeUpdateEvent, + handleLoadedMetadata, + handleVideoError, + handleProgressTouchStart, + handleProgressTouchMove, + handleProgressTouchEnd, + handleProgressClick, + toggleMute, + toggleFullscreen, + togglePictureInPicture, + changePlaybackSpeed, + handleCopyLink, + formatTime + } = logic; // Screen orientation management useScreenOrientation(isFullscreen); - // Check for PiP support - useEffect(() => { - if (typeof document !== 'undefined') { - setIsPiPSupported('pictureInPictureEnabled' in document); - } - }, []); - - // Track viewport width for responsive toolbar - useEffect(() => { - const updateViewportWidth = () => { - setViewportWidth(window.innerWidth); - }; - - // Set initial width - updateViewportWidth(); - - // Update on resize - window.addEventListener('resize', updateViewportWidth); - return () => window.removeEventListener('resize', updateViewportWidth); - }, []); - - // Skip forward/backward with indicator - const skipVideo = (seconds: number, side: 'left' | 'right') => { - if (!videoRef.current) return; - - // Clear existing timeout - if (skipTimeoutRef.current) { - clearTimeout(skipTimeoutRef.current); - } - - // If same side, accumulate - const newSkipAmount = skipSide === side ? skipAmount + Math.abs(seconds) : Math.abs(seconds); - setSkipAmount(newSkipAmount); - setSkipSide(side); - setShowSkipIndicator(true); - - // Apply skip - 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); - - // Hide indicator after 1500ms of inactivity - skipTimeoutRef.current = setTimeout(() => { - setShowSkipIndicator(false); - setSkipAmount(0); - setSkipSide(null); - }, 1500); - }; - // Double tap handler const { handleTap } = useDoubleTap({ onDoubleTapLeft: () => skipVideo(10, 'left'), @@ -116,13 +95,8 @@ export function MobileVideoPlayer({ onSkipContinueRight: () => skipVideo(10, 'right'), isSkipModeActive: showSkipIndicator, onSingleTap: () => { - // Single tap behavior: - // - If controls are hidden, show them (don't toggle play/pause) - // - If controls are visible, toggle play/pause if (!showControls) { - // Just show controls, don't toggle play state setShowControls(true); - // Set timeout to hide controls if playing if (controlsTimeoutRef.current) { clearTimeout(controlsTimeoutRef.current); } @@ -132,9 +106,7 @@ export function MobileVideoPlayer({ }, 3000); } } else { - // Controls are visible, now toggle play/pause togglePlay(); - // Reset hide timer if (controlsTimeoutRef.current) { clearTimeout(controlsTimeoutRef.current); } @@ -147,386 +119,9 @@ export function MobileVideoPlayer({ }, }); - // Auto-hide controls only when playing - useEffect(() => { - // If paused, always show controls - if (!isPlaying) { - setShowControls(true); - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - return; - } - - // If playing, auto-hide after 3 seconds - 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]); - - // Auto-close more menu after 2 seconds - useEffect(() => { - if (showMoreMenu) { - // Pause video when menu opens - if (videoRef.current && isPlaying) { - setWasPlayingBeforeMenu(true); - videoRef.current.pause(); - } - - // Clear existing timeout - if (menuIdleTimeoutRef.current) { - clearTimeout(menuIdleTimeoutRef.current); - } - - // Set timeout to auto-close after 2 seconds - menuIdleTimeoutRef.current = setTimeout(() => { - setShowMoreMenu(false); - - // Resume video if it was playing - if (wasPlayingBeforeMenu && videoRef.current) { - videoRef.current.play().catch(err => console.warn('Resume play error:', err)); - setWasPlayingBeforeMenu(false); - } - }, 2000); - } - - return () => { - if (menuIdleTimeoutRef.current) { - clearTimeout(menuIdleTimeoutRef.current); - } - }; - }, [showMoreMenu, isPlaying, wasPlayingBeforeMenu]); - - // Pause video when submenus open (no auto-close) - useEffect(() => { - if (showVolumeMenu || showSpeedMenu) { - // Pause video when submenu opens - if (videoRef.current && isPlaying) { - setWasPlayingBeforeMenu(true); - videoRef.current.pause(); - } - } - }, [showVolumeMenu, showSpeedMenu, isPlaying]); - - // Close all menus when clicking outside - useEffect(() => { - const handleClickOutside = (e: MouseEvent | TouchEvent) => { - const target = e.target as HTMLElement; - - // Check if click is outside menu areas - const isMenuClick = target.closest('.menu-container') || - target.closest('[aria-label="更多"]'); - - if (!isMenuClick && (showMoreMenu || showVolumeMenu || showSpeedMenu)) { - setShowMoreMenu(false); - setShowVolumeMenu(false); - setShowSpeedMenu(false); - - // Resume video if it was playing - if (wasPlayingBeforeMenu && videoRef.current) { - videoRef.current.play().catch(err => 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]); - - const togglePlay = async () => { - if (!videoRef.current || isTogglingRef.current) return; - - isTogglingRef.current = true; - - try { - if (isPlaying) { - videoRef.current.pause(); - } else { - // Close all menus when resuming playback - setShowMoreMenu(false); - setShowVolumeMenu(false); - setShowSpeedMenu(false); - - await videoRef.current.play(); - } - } catch (error) { - console.warn('Play/pause error:', error); - } finally { - // Immediately release the toggle lock - isTogglingRef.current = false; - } - }; - - const handlePlay = () => setIsPlaying(true); - const handlePause = () => setIsPlaying(false); - - const handleTimeUpdateEvent = () => { - 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); - } - }; - - const handleLoadedMetadata = () => { - if (!videoRef.current) return; - setDuration(videoRef.current.duration); - setIsLoading(false); - if (initialTime > 0) { - videoRef.current.currentTime = initialTime; - } - - // Auto-play the video when loaded - videoRef.current.play().catch(err => { - console.warn('Autoplay was prevented:', err); - // Show a toast or indication that user needs to tap to play - }); - }; - - const handleVideoError = () => { - setIsLoading(false); - if (onError) { - onError('Video failed to load'); - } - }; - - // Progress bar seeking with real-time drag - const updateProgressFromEvent = (e: React.MouseEvent | React.TouchEvent | TouchEvent | MouseEvent) => { - if (!videoRef.current || !progressBarRef.current) return; - - const rect = progressBarRef.current.getBoundingClientRect(); - let clientX: number; - - // Handle both mouse and touch events - if ('touches' in e) { - const touch = e.touches[0] || (e as React.TouchEvent).changedTouches?.[0]; - if (!touch) return; - clientX = touch.clientX; - } else { - clientX = (e as MouseEvent | React.MouseEvent).clientX; - } - - const pos = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); - const newTime = pos * duration; - - return newTime; - }; - - const handleProgressTouchStart = (e: React.TouchEvent) => { - isDraggingProgressRef.current = true; - const newTime = updateProgressFromEvent(e); - if (newTime !== undefined) { - setCurrentTime(newTime); - } - }; - - const handleProgressTouchMove = (e: React.TouchEvent) => { - if (!isDraggingProgressRef.current) return; - e.preventDefault(); // Prevent scrolling while dragging - const newTime = updateProgressFromEvent(e); - if (newTime !== undefined) { - setCurrentTime(newTime); - // Update video in real-time - if (videoRef.current) { - videoRef.current.currentTime = newTime; - } - } - }; - - const handleProgressTouchEnd = (e: React.TouchEvent) => { - if (!isDraggingProgressRef.current) return; - isDraggingProgressRef.current = false; - const newTime = updateProgressFromEvent(e); - if (newTime !== undefined && videoRef.current) { - videoRef.current.currentTime = newTime; - setCurrentTime(newTime); - } - }; - - const handleProgressClick = (e: React.MouseEvent) => { - const newTime = updateProgressFromEvent(e); - if (newTime !== undefined && videoRef.current) { - videoRef.current.currentTime = newTime; - setCurrentTime(newTime); - } - }; - - const toggleMute = () => { - if (!videoRef.current) return; - if (isMuted) { - videoRef.current.volume = volume; - setIsMuted(false); - } else { - videoRef.current.volume = 0; - setIsMuted(true); - } - }; - - const toggleFullscreen = () => { - if (!containerRef.current) return; - - if (!isFullscreen) { - // iOS specific handling - if (isIOS && videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { - (videoRef.current as any).webkitEnterFullscreen(); - return; - } - - // Use container fullscreen to maintain custom controls - // Try standard fullscreen API first - if (containerRef.current.requestFullscreen) { - containerRef.current.requestFullscreen().catch(err => { - console.warn('Fullscreen request failed:', err); - }); - } - // Fallback to webkit prefixed version for older iOS - else if ((containerRef.current as any).webkitRequestFullscreen) { - (containerRef.current as any).webkitRequestFullscreen(); - } - // Fallback to webkit prefixed version with different capitalization - else if ((containerRef.current as any).webkitRequestFullScreen) { - (containerRef.current as any).webkitRequestFullScreen(); - } - } else { - // Exit fullscreen - if (document.exitFullscreen) { - document.exitFullscreen().catch(err => { - console.warn('Exit fullscreen failed:', err); - }); - } - // Fallback to webkit prefixed version - else if ((document as any).webkitExitFullscreen) { - (document as any).webkitExitFullscreen(); - } - // Fallback to webkit prefixed version with different capitalization - else if ((document as any).webkitCancelFullScreen) { - (document as any).webkitCancelFullScreen(); - } - } - }; - - useEffect(() => { - const handleFullscreenChange = () => { - // Check for standard fullscreen element or webkit variants - const isInFullscreen = !!( - document.fullscreenElement || - (document as any).webkitFullscreenElement || - (document as any).webkitCurrentFullScreenElement - ); - setIsFullscreen(isInFullscreen); - }; - - // Listen for all fullscreen change events - 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); - }; - }, []); - - const togglePictureInPicture = 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); - } - }; - - const changePlaybackSpeed = (speed: number) => { - if (!videoRef.current) return; - videoRef.current.playbackRate = speed; - setPlaybackRate(speed); - setShowSpeedMenu(false); - }; - - // Show toast notification - const showToastNotification = (message: string) => { - setToastMessage(message); - setShowToast(true); - - if (toastTimeoutRef.current) { - clearTimeout(toastTimeoutRef.current); - } - - toastTimeoutRef.current = setTimeout(() => { - setShowToast(false); - setTimeout(() => setToastMessage(null), 300); - }, 3000); - }; - - // Copy video link - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(src); - showToastNotification('链接已复制到剪贴板'); - } catch (error) { - console.error('Copy failed:', error); - showToastNotification('复制失败,请重试'); - } - }; - const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; - - // Responsive sizing based on viewport width - // Use single breakpoint: < 640px = compact layout, >= 640px = full layout const isCompactLayout = viewportWidth < 640; - // Dynamic sizing - 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'; - - const formatTime = (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 (
- {/* Loading Spinner */} - {isLoading && ( -
-
-
- )} + - {/* Skip Indicators */} - {showSkipIndicator && skipSide && ( -
-
- {skipSide === 'left' ? '-' : '+'}{skipAmount} -
-
- )} + - {/* Controls */} -
- {/* Progress Bar */} -
-
-
-
-
-
-
- - - {/* Controls Bar */} -
- {isCompactLayout ? ( - // Compact Layout for narrow screens (< 640px) -
- {/* Left: Play + Time */} -
- - - {/* Time Display */} - - {formatTime(currentTime)} / {formatTime(duration)} - -
- - {/* Right: More + Fullscreen */} -
- {/* More Menu Button */} -
- - - {/* More Menu Dropdown */} - {showMoreMenu && ( -
-
- {/* Copy Link Option */} - - -
- - {/* Volume Option */} - - - {/* Speed Option */} - - - {/* PiP Option */} - {isPiPSupported && ( - - )} -
-
- )} -
- - {/* Fullscreen Button */} - -
-
- ) : ( - // Full Layout for wide screens (>= 640px) -
- {/* Left Controls */} -
- {/* Play/Pause Button */} - - - {/* Skip Backward Button */} - - - {/* Skip Forward Button */} - - - {/* Volume Button with Slider */} -
- - - {/* Volume Slider Dropdown */} - {showVolumeMenu && ( -
-
- {/* Mute Button */} - - - {/* Vertical Volume Slider */} - { - e.stopPropagation(); - const newVolume = parseFloat(e.target.value); - setVolume(newVolume); - if (videoRef.current) { - videoRef.current.volume = newVolume; - } - setIsMuted(newVolume === 0); - }} - 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' }} - /> - - {/* Volume Percentage */} - - {Math.round((isMuted ? 0 : volume) * 100)} - -
-
- )} -
- - {/* Time Display */} - - {formatTime(currentTime)} / {formatTime(duration)} - -
- - {/* Flexible Space */} -
- - {/* Right Controls */} -
- {/* Speed Button */} -
- - - {/* Speed Menu Dropdown */} - {showSpeedMenu && ( -
-
- {speeds.map((speed) => ( - - ))} -
-
- )} -
- - {/* Picture-in-Picture Button */} - {isPiPSupported && ( - - )} - - {/* More Menu Button */} - - - {/* Fullscreen Button */} - -
-
- )} - - - {/* Volume Menu (shown after clicking from More menu in compact layout) */} - {showVolumeMenu && isCompactLayout && ( -
-
- -
- { - const newVolume = parseFloat(e.target.value) / 100; - setVolume(newVolume); - if (videoRef.current) { - videoRef.current.volume = newVolume; - } - setIsMuted(newVolume === 0); - }} - 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" - /> -
- - {Math.round((isMuted ? 0 : volume) * 100)} - - -
-
- )} - - {/* Speed Menu (shown after clicking from More menu in compact layout) */} - {showSpeedMenu && isCompactLayout && ( -
-
- {speeds.map((speed) => ( - - ))} -
- -
- )} -
-
- - {/* Toast Notification */} - { - showToast && toastMessage && ( -
-
- - {toastMessage} -
-
- ) - } -
+ state.setShowMoreMenu(!showMoreMenu)} + onToggleVolumeMenu={() => state.setShowVolumeMenu(!showVolumeMenu)} + onToggleSpeedMenu={() => state.setShowSpeedMenu(!showSpeedMenu)} + onTogglePiP={togglePictureInPicture} + onVolumeChange={(v) => { + state.setVolume(v); + if (videoRef.current) videoRef.current.volume = v; + state.setIsMuted(v === 0); + }} + onSpeedChange={changePlaybackSpeed} + onCopyLink={handleCopyLink} + onProgressClick={handleProgressClick} + onProgressTouchStart={handleProgressTouchStart} + onProgressTouchMove={handleProgressTouchMove} + onProgressTouchEnd={handleProgressTouchEnd} + formatTime={formatTime} + speeds={speeds} + /> +
); } diff --git a/components/player/desktop/DesktopControls.tsx b/components/player/desktop/DesktopControls.tsx new file mode 100644 index 0000000..2339f85 --- /dev/null +++ b/components/player/desktop/DesktopControls.tsx @@ -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; + volumeBarRef: React.RefObject; + onTogglePlay: () => void; + onSkipForward: () => void; + onSkipBackward: () => void; + onToggleMute: () => void; + onVolumeChange: (e: React.MouseEvent) => void; + onVolumeMouseDown: (e: React.MouseEvent) => void; + onToggleFullscreen: () => void; + onTogglePictureInPicture: () => void; + onShowAirPlayMenu: () => void; + onToggleSpeedMenu: () => void; + onToggleMoreMenu: () => void; + onSpeedChange: (speed: number) => void; + onCopyLink: () => void; + onProgressClick: (e: React.MouseEvent) => void; + onProgressMouseDown: (e: React.MouseEvent) => 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 ( +
+ {/* Progress Bar */} + + + {/* Controls Bar */} +
+
+ {/* Left Controls */} +
+ {/* Play/Pause */} + + + {/* Skip Backward 10s */} + + + {/* Skip Forward 10s */} + + + {/* Volume */} + + + {/* Time */} + + {formatTime(currentTime)} / {formatTime(duration)} + +
+ + {/* Right Controls */} +
+ {/* Playback Speed */} + + + {/* Picture-in-Picture */} + {isPiPSupported && ( + + )} + + {/* AirPlay */} + {isAirPlaySupported && ( + + )} + + {/* More Menu */} + + + {/* Fullscreen */} + +
+
+
+
+ ); +} diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx new file mode 100644 index 0000000..fab7c50 --- /dev/null +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -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 ( +
+ + + {/* More Menu Dropdown */} + {showMoreMenu && ( +
+ +
+ )} +
+ ); +} diff --git a/components/player/desktop/DesktopOverlay.tsx b/components/player/desktop/DesktopOverlay.tsx new file mode 100644 index 0000000..9b23384 --- /dev/null +++ b/components/player/desktop/DesktopOverlay.tsx @@ -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 && ( +
+
+
+ )} + + {/* Skip Forward Indicator */} + {showSkipForwardIndicator && ( +
+
+ +{skipForwardAmount} +
+
+ )} + + {/* Skip Backward Indicator */} + {showSkipBackwardIndicator && ( +
+
+ -{skipBackwardAmount} +
+
+ )} + + {/* Center Play Button (when paused) */} + {!isPlaying && !isLoading && ( +
+ +
+ )} + + {/* Toast Notification */} + {showToast && toastMessage && ( +
+
+ + {toastMessage} +
+
+ )} + + ); +} diff --git a/components/player/desktop/DesktopProgressBar.tsx b/components/player/desktop/DesktopProgressBar.tsx new file mode 100644 index 0000000..d3805e6 --- /dev/null +++ b/components/player/desktop/DesktopProgressBar.tsx @@ -0,0 +1,39 @@ +import React, { RefObject } from 'react'; + +interface DesktopProgressBarProps { + progressBarRef: RefObject; + + currentTime: number; + duration: number; + onProgressClick: (e: React.MouseEvent) => void; + onProgressMouseDown: (e: React.MouseEvent) => void; +} + +export function DesktopProgressBar({ + progressBarRef, + currentTime, + duration, + onProgressClick, + onProgressMouseDown +}: DesktopProgressBarProps) { + return ( +
+
+
+
+
+
+ ); +} diff --git a/components/player/desktop/DesktopSpeedMenu.tsx b/components/player/desktop/DesktopSpeedMenu.tsx new file mode 100644 index 0000000..934e7bd --- /dev/null +++ b/components/player/desktop/DesktopSpeedMenu.tsx @@ -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 ( +
+ + + {/* Speed Menu */} + {showSpeedMenu && ( +
+ {speeds.map((speed) => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/player/desktop/DesktopVolumeControl.tsx b/components/player/desktop/DesktopVolumeControl.tsx new file mode 100644 index 0000000..af2b95c --- /dev/null +++ b/components/player/desktop/DesktopVolumeControl.tsx @@ -0,0 +1,66 @@ +import React, { RefObject } from 'react'; +import { Icons } from '@/components/ui/Icon'; + +interface DesktopVolumeControlProps { + volumeBarRef: RefObject; + + volume: number; + isMuted: boolean; + showVolumeBar: boolean; + onToggleMute: () => void; + onVolumeChange: (e: React.MouseEvent) => void; + onVolumeMouseDown: (e: React.MouseEvent) => void; +} + +export function DesktopVolumeControl({ + volumeBarRef, + volume, + isMuted, + showVolumeBar, + onToggleMute, + onVolumeChange, + onVolumeMouseDown +}: DesktopVolumeControlProps) { + return ( +
+ + + {/* Volume Bar */} +
+
+
+
+
+ + {Math.round((isMuted ? 0 : volume) * 100)} + +
+
+ ); +} diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts new file mode 100644 index 0000000..a6dabd6 --- /dev/null +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -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 + }; +} diff --git a/components/player/hooks/useDesktopPlayerState.ts b/components/player/hooks/useDesktopPlayerState.ts new file mode 100644 index 0000000..d8a6c7d --- /dev/null +++ b/components/player/hooks/useDesktopPlayerState.ts @@ -0,0 +1,87 @@ +import { useState, useRef } from 'react'; + +export function useDesktopPlayerState() { + const videoRef = useRef(null); + const containerRef = useRef(null); + const progressBarRef = useRef(null); + const volumeBarRef = useRef(null); + + // Refs for timeouts and tracking + const controlsTimeoutRef = useRef(null); + const speedMenuTimeoutRef = useRef(null); + const skipForwardTimeoutRef = useRef(null); + const skipBackwardTimeoutRef = useRef(null); + const volumeBarTimeoutRef = useRef(null); + const isDraggingProgressRef = useRef(false); + const isDraggingVolumeRef = useRef(false); + const mouseMoveThrottleRef = useRef(null); + const toastTimeoutRef = useRef(null); + const moreMenuTimeoutRef = useRef(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(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 + } + }; +} diff --git a/components/player/hooks/useMobilePlayerLogic.ts b/components/player/hooks/useMobilePlayerLogic.ts new file mode 100644 index 0000000..2260f2c --- /dev/null +++ b/components/player/hooks/useMobilePlayerLogic.ts @@ -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 + }; +} diff --git a/components/player/hooks/useMobilePlayerState.ts b/components/player/hooks/useMobilePlayerState.ts new file mode 100644 index 0000000..9f2ec31 --- /dev/null +++ b/components/player/hooks/useMobilePlayerState.ts @@ -0,0 +1,75 @@ +import { useState, useRef } from 'react'; + +export function useMobilePlayerState() { + const videoRef = useRef(null); + const containerRef = useRef(null); + const progressBarRef = useRef(null); + + // Refs for timeouts and tracking + const controlsTimeoutRef = useRef(null); + const skipTimeoutRef = useRef(null); + const isDraggingProgressRef = useRef(false); + const menuIdleTimeoutRef = useRef(null); + const submenuIdleTimeoutRef = useRef(null); + const isTogglingRef = useRef(false); + const toastTimeoutRef = useRef(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(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 + } + }; +} diff --git a/components/player/mobile/MobileControls.tsx b/components/player/mobile/MobileControls.tsx new file mode 100644 index 0000000..4a2ede5 --- /dev/null +++ b/components/player/mobile/MobileControls.tsx @@ -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; + + 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) => void; + onProgressTouchStart: (e: React.TouchEvent) => void; + onProgressTouchMove: (e: React.TouchEvent) => void; + onProgressTouchEnd: (e: React.TouchEvent) => 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 ( +
+ {/* Progress Bar */} + + + {/* Controls Bar */} +
+ {isCompactLayout ? ( + // Compact Layout +
+
+ + + + {formatTime(currentTime)} / {formatTime(duration)} + +
+ +
+
+ + + { + onToggleMoreMenu(); + onCopyLink(); + }} + onToggleVolumeMenu={() => { + onToggleMoreMenu(); + onToggleVolumeMenu(); + }} + onToggleSpeedMenu={() => { + onToggleMoreMenu(); + onToggleSpeedMenu(); + }} + onTogglePiP={() => { + onToggleMoreMenu(); + onTogglePiP(); + }} + /> +
+ + +
+
+ ) : ( + // Full Layout +
+
+ + + + + + +
+ + + +
+ + + {formatTime(currentTime)} / {formatTime(duration)} + +
+ +
+ +
+
+ + + +
+ + {isPiPSupported && ( + + )} + + + + +
+
+ )} + + {/* Compact Layout Submenus */} + + + +
+
+ ); +} diff --git a/components/player/mobile/MobileMoreMenu.tsx b/components/player/mobile/MobileMoreMenu.tsx new file mode 100644 index 0000000..18543ab --- /dev/null +++ b/components/player/mobile/MobileMoreMenu.tsx @@ -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 ( +
+
+ {/* Copy Link Option */} + + +
+ + {/* Volume Option */} + + + {/* Speed Option */} + + + {/* PiP Option */} + {isPiPSupported && ( + + )} +
+
+ ); +} diff --git a/components/player/mobile/MobileOverlay.tsx b/components/player/mobile/MobileOverlay.tsx new file mode 100644 index 0000000..574da02 --- /dev/null +++ b/components/player/mobile/MobileOverlay.tsx @@ -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 && ( +
+
+
+ )} + + {/* Toast Notification */} + {showToast && toastMessage && ( +
+
+ + {toastMessage} +
+
+ )} + + ); +} diff --git a/components/player/mobile/MobileProgressBar.tsx b/components/player/mobile/MobileProgressBar.tsx new file mode 100644 index 0000000..a5d3113 --- /dev/null +++ b/components/player/mobile/MobileProgressBar.tsx @@ -0,0 +1,42 @@ +import React, { RefObject } from 'react'; + +interface MobileProgressBarProps { + progressBarRef: RefObject; + + currentTime: number; + duration: number; + onProgressClick: (e: React.MouseEvent) => void; + onProgressTouchStart: (e: React.TouchEvent) => void; + onProgressTouchMove: (e: React.TouchEvent) => void; + onProgressTouchEnd: (e: React.TouchEvent) => void; +} + +export function MobileProgressBar({ + progressBarRef, + currentTime, + duration, + onProgressClick, + onProgressTouchStart, + onProgressTouchMove, + onProgressTouchEnd +}: MobileProgressBarProps) { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/components/player/mobile/MobileSkipIndicator.tsx b/components/player/mobile/MobileSkipIndicator.tsx new file mode 100644 index 0000000..69666b5 --- /dev/null +++ b/components/player/mobile/MobileSkipIndicator.tsx @@ -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 ( +
+
+ {skipSide === 'left' ? '-' : '+'}{skipAmount} +
+
+ ); +} diff --git a/components/player/mobile/MobileSpeedMenu.tsx b/components/player/mobile/MobileSpeedMenu.tsx new file mode 100644 index 0000000..4c95164 --- /dev/null +++ b/components/player/mobile/MobileSpeedMenu.tsx @@ -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 ( +
+
+ {speeds.map((speed) => ( + + ))} +
+ +
+ ); + } + + return ( +
+
+ {speeds.map((speed) => ( + + ))} +
+
+ ); +} diff --git a/components/player/mobile/MobileVolumeMenu.tsx b/components/player/mobile/MobileVolumeMenu.tsx new file mode 100644 index 0000000..3d38609 --- /dev/null +++ b/components/player/mobile/MobileVolumeMenu.tsx @@ -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 ( +
+
+ +
+ 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" + /> +
+ + {Math.round((isMuted ? 0 : volume) * 100)} + + +
+
+ ); + } + + return ( +
+
+ + + { + 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' }} + /> + + + {Math.round((isMuted ? 0 : volume) * 100)} + +
+
+ ); +} diff --git a/components/ui/Icon.tsx b/components/ui/Icon.tsx index 1ac260d..5972104 100644 --- a/components/ui/Icon.tsx +++ b/components/ui/Icon.tsx @@ -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) => ( - - - - - - - - - - - ), - - Play: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - Pause: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - TV: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - // Search & Navigation - Search: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - ChevronLeft: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - ChevronDown: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - // List & Organization - List: ({ className = "", size = 24 }: IconProps) => ( - - - - - - - - - ), - - // Features - Zap: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - Target: ({ className = "", size = 24 }: IconProps) => ( - - - - - - ), - - Sparkles: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - // Info & Details - Calendar: ({ className = "", size = 24 }: IconProps) => ( - - - - - - - ), - - Globe: ({ className = "", size = 24 }: IconProps) => ( - - - - - - ), - - // Empty States - Inbox: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - // Alert & Status - AlertTriangle: ({ className = "", size = 24 }: IconProps) => ( - - - - - - ), - - RefreshCw: ({ className = "", size = 24 }: IconProps) => ( - - - - - - ), - - Volume2: ({ className = "", size = 24 }: IconProps) => ( - - - - - - ), - - Volume1: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - VolumeX: ({ className = "", size = 24 }: IconProps) => ( - - - - - - ), - - Maximize: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - Minimize: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - SkipForward: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - SkipBack: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - PictureInPicture: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - Airplay: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - Check: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - Tag: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - X: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - Star: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - Clock: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - History: ({ className = "", size = 24 }: IconProps) => ( - - - - ), - - Trash: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), - - Download: ({ className = "", size = 24 }: IconProps) => ( - - - - - - ), - - Link: ({ className = "", size = 24 }: IconProps) => ( - - - - - ), + ...MediaIcons, + ...NavigationIcons, + ...UtilityIcons, }; diff --git a/components/ui/icons/media-icons.tsx b/components/ui/icons/media-icons.tsx new file mode 100644 index 0000000..c6bfd2e --- /dev/null +++ b/components/ui/icons/media-icons.tsx @@ -0,0 +1,99 @@ +import { IconProps } from './types'; + +export const MediaIcons = { + Film: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + + + + + ), + + Play: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Pause: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + TV: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + Volume2: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), + + Volume1: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + VolumeX: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), + + Maximize: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Minimize: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + SkipForward: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + SkipBack: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + PictureInPicture: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + Airplay: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), +}; diff --git a/components/ui/icons/navigation-icons.tsx b/components/ui/icons/navigation-icons.tsx new file mode 100644 index 0000000..b811946 --- /dev/null +++ b/components/ui/icons/navigation-icons.tsx @@ -0,0 +1,33 @@ +import { IconProps } from './types'; + +export const NavigationIcons = { + Search: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + ChevronLeft: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + ChevronDown: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + List: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + + + ), +}; diff --git a/components/ui/icons/types.tsx b/components/ui/icons/types.tsx new file mode 100644 index 0000000..4b2748b --- /dev/null +++ b/components/ui/icons/types.tsx @@ -0,0 +1,4 @@ +export interface IconProps { + className?: string; + size?: number; +} diff --git a/components/ui/icons/utility-icons.tsx b/components/ui/icons/utility-icons.tsx new file mode 100644 index 0000000..8b1d751 --- /dev/null +++ b/components/ui/icons/utility-icons.tsx @@ -0,0 +1,124 @@ +import { IconProps } from './types'; + +export const UtilityIcons = { + Zap: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Target: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), + + Sparkles: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Calendar: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + ), + + Globe: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), + + Inbox: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + AlertTriangle: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), + + RefreshCw: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), + + Check: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Tag: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + X: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + Star: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Clock: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + History: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Trash: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + Download: ({ className = "", size = 24 }: IconProps) => ( + + + + + + ), + + Link: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), +}; diff --git a/lib/api/default-sources.ts b/lib/api/default-sources.ts new file mode 100644 index 0000000..e9f0cdb --- /dev/null +++ b/lib/api/default-sources.ts @@ -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 }, +]; diff --git a/lib/api/video-sources.ts b/lib/api/video-sources.ts index 3c939a0..8ee6be5 100644 --- a/lib/api/video-sources.ts +++ b/lib/api/video-sources.ts @@ -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 };