|
2 | 2 |
|
3 | 3 | 'use client'; |
4 | 4 |
|
5 | | -import { AlertCircle, Cloud, Heart, Loader2, Router, Sparkles, X } from 'lucide-react'; |
| 5 | +import { AlertCircle, Cloud, Heart, Keyboard, Loader2, Router, Sparkles, X } from 'lucide-react'; |
6 | 6 | import { useRouter, useSearchParams } from 'next/navigation'; |
7 | 7 | import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; |
8 | 8 |
|
@@ -128,6 +128,34 @@ interface CustomSubtitleState { |
128 | 128 | episodeIndex: number; |
129 | 129 | } |
130 | 130 |
|
| 131 | +const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4]; |
| 132 | +const PLAY_SHORTCUT_GROUPS = [ |
| 133 | + { |
| 134 | + title: '播放控制', |
| 135 | + items: [ |
| 136 | + { keys: ['空格'], description: '播放 / 暂停' }, |
| 137 | + { keys: ['←', '→'], description: '快退 / 快进 10 秒' }, |
| 138 | + { keys: ['↑', '↓'], description: '音量增加 / 减少' }, |
| 139 | + { keys: ['F'], description: '切换全屏' }, |
| 140 | + ], |
| 141 | + }, |
| 142 | + { |
| 143 | + title: '剧集切换', |
| 144 | + items: [ |
| 145 | + { keys: ['Alt', '←'], description: '上一集' }, |
| 146 | + { keys: ['Alt', '→'], description: '下一集' }, |
| 147 | + ], |
| 148 | + }, |
| 149 | + { |
| 150 | + title: '倍速控制', |
| 151 | + items: [ |
| 152 | + { keys: ['小键盘 +'], description: '提高一档倍速' }, |
| 153 | + { keys: ['小键盘 -'], description: '降低一档倍速' }, |
| 154 | + { keys: ['小键盘 /'], description: '恢复 1x' }, |
| 155 | + ], |
| 156 | + }, |
| 157 | +]; |
| 158 | + |
131 | 159 | function PlayPageClient() { |
132 | 160 | const LOCAL_TRANSCODER_BASE_URL = 'http://localhost:19080'; |
133 | 161 | const router = useRouter(); |
@@ -181,6 +209,26 @@ function PlayPageClient() { |
181 | 209 | // 详情面板状态 |
182 | 210 | const [showDetailPanel, setShowDetailPanel] = useState(false); |
183 | 211 |
|
| 212 | + // 快捷键说明弹窗状态 |
| 213 | + const [showShortcutDialog, setShowShortcutDialog] = useState(false); |
| 214 | + |
| 215 | + useEffect(() => { |
| 216 | + if (!showShortcutDialog) { |
| 217 | + return; |
| 218 | + } |
| 219 | + |
| 220 | + const handleShortcutDialogKeyDown = (event: KeyboardEvent) => { |
| 221 | + if (event.key === 'Escape') { |
| 222 | + setShowShortcutDialog(false); |
| 223 | + } |
| 224 | + }; |
| 225 | + |
| 226 | + document.addEventListener('keydown', handleShortcutDialogKeyDown); |
| 227 | + return () => { |
| 228 | + document.removeEventListener('keydown', handleShortcutDialogKeyDown); |
| 229 | + }; |
| 230 | + }, [showShortcutDialog]); |
| 231 | + |
184 | 232 | // 大屏设备检测(判断选集面板是否在右侧) |
185 | 233 | const [isLargeScreen, setIsLargeScreen] = useState(false); |
186 | 234 |
|
@@ -1610,6 +1658,55 @@ function PlayPageClient() { |
1610 | 1658 | localStorage.setItem('preferredPlaybackRate', String(rate)); |
1611 | 1659 | }; |
1612 | 1660 |
|
| 1661 | + const adjustPlaybackRateByStep = (direction: 1 | -1) => { |
| 1662 | + if (!artPlayerRef.current) { |
| 1663 | + return false; |
| 1664 | + } |
| 1665 | + |
| 1666 | + const currentRate = artPlayerRef.current.playbackRate || 1; |
| 1667 | + const currentIndex = PLAYBACK_RATE_OPTIONS.reduce((nearestIndex, rate, index) => { |
| 1668 | + return Math.abs(rate - currentRate) < Math.abs(PLAYBACK_RATE_OPTIONS[nearestIndex] - currentRate) |
| 1669 | + ? index |
| 1670 | + : nearestIndex; |
| 1671 | + }, 0); |
| 1672 | + let nextIndex = -1; |
| 1673 | + if (direction > 0) { |
| 1674 | + nextIndex = PLAYBACK_RATE_OPTIONS.findIndex((rate) => rate > currentRate + 0.01); |
| 1675 | + } else { |
| 1676 | + for (let index = PLAYBACK_RATE_OPTIONS.length - 1; index >= 0; index--) { |
| 1677 | + if (PLAYBACK_RATE_OPTIONS[index] < currentRate - 0.01) { |
| 1678 | + nextIndex = index; |
| 1679 | + break; |
| 1680 | + } |
| 1681 | + } |
| 1682 | + } |
| 1683 | + const boundedNextIndex = nextIndex === -1 ? currentIndex : nextIndex; |
| 1684 | + const effectiveNextIndex = Math.min( |
| 1685 | + Math.max(boundedNextIndex, 0), |
| 1686 | + PLAYBACK_RATE_OPTIONS.length - 1 |
| 1687 | + ); |
| 1688 | + const nextRate = PLAYBACK_RATE_OPTIONS[effectiveNextIndex]; |
| 1689 | + |
| 1690 | + artPlayerRef.current.playbackRate = nextRate; |
| 1691 | + artPlayerRef.current.notice.show = |
| 1692 | + effectiveNextIndex === currentIndex |
| 1693 | + ? direction > 0 |
| 1694 | + ? `已是最高倍速:${nextRate}x` |
| 1695 | + : `已是最低倍速:${nextRate}x` |
| 1696 | + : `倍速:${nextRate}x`; |
| 1697 | + return true; |
| 1698 | + }; |
| 1699 | + |
| 1700 | + const resetPlaybackRate = () => { |
| 1701 | + if (!artPlayerRef.current) { |
| 1702 | + return false; |
| 1703 | + } |
| 1704 | + |
| 1705 | + artPlayerRef.current.playbackRate = 1; |
| 1706 | + artPlayerRef.current.notice.show = '倍速:1x'; |
| 1707 | + return true; |
| 1708 | + }; |
| 1709 | + |
1613 | 1710 | const isDanmakuAutoLoadDisabled = () => { |
1614 | 1711 | if (typeof window === 'undefined') { |
1615 | 1712 | return false; |
@@ -5795,6 +5892,27 @@ function PlayPageClient() { |
5795 | 5892 | } |
5796 | 5893 | } |
5797 | 5894 |
|
| 5895 | + // 小键盘 + = 倍速+ |
| 5896 | + if (e.code === 'NumpadAdd') { |
| 5897 | + if (adjustPlaybackRateByStep(1)) { |
| 5898 | + e.preventDefault(); |
| 5899 | + } |
| 5900 | + } |
| 5901 | + |
| 5902 | + // 小键盘 - = 倍速- |
| 5903 | + if (e.code === 'NumpadSubtract') { |
| 5904 | + if (adjustPlaybackRateByStep(-1)) { |
| 5905 | + e.preventDefault(); |
| 5906 | + } |
| 5907 | + } |
| 5908 | + |
| 5909 | + // 小键盘 / = 恢复 1x |
| 5910 | + if (e.code === 'NumpadDivide') { |
| 5911 | + if (resetPlaybackRate()) { |
| 5912 | + e.preventDefault(); |
| 5913 | + } |
| 5914 | + } |
| 5915 | + |
5798 | 5916 | // f 键 = 切换全屏 |
5799 | 5917 | if (e.key === 'f' || e.key === 'F') { |
5800 | 5918 | if (artPlayerRef.current) { |
@@ -6281,7 +6399,7 @@ function PlayPageClient() { |
6281 | 6399 | const CustomHlsJsLoader = createCustomHlsLoader(Hls); |
6282 | 6400 |
|
6283 | 6401 | // 创建新的播放器实例 |
6284 | | - Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4]; |
| 6402 | + Artplayer.PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS; |
6285 | 6403 | Artplayer.USE_RAF = true; |
6286 | 6404 |
|
6287 | 6405 | // 获取当前集的字幕 |
@@ -9437,6 +9555,22 @@ function PlayPageClient() { |
9437 | 9555 | </button> |
9438 | 9556 | )} |
9439 | 9557 |
|
| 9558 | + {/* 快捷键说明 */} |
| 9559 | + <button |
| 9560 | + onClick={(e) => { |
| 9561 | + e.preventDefault(); |
| 9562 | + setShowShortcutDialog(true); |
| 9563 | + }} |
| 9564 | + className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-500 dark:hover:bg-gray-400 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-500 flex-shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-950' |
| 9565 | + title='快捷键说明' |
| 9566 | + aria-label='查看播放快捷键说明' |
| 9567 | + > |
| 9568 | + <Keyboard className='w-4 h-4 flex-shrink-0 text-gray-700 dark:text-gray-200' /> |
| 9569 | + <span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'> |
| 9570 | + 快捷键 |
| 9571 | + </span> |
| 9572 | + </button> |
| 9573 | + |
9440 | 9574 | {/* PotPlayer */} |
9441 | 9575 | <button |
9442 | 9576 | onClick={(e) => { |
@@ -10064,6 +10198,83 @@ function PlayPageClient() { |
10064 | 10198 | }} |
10065 | 10199 | /> |
10066 | 10200 |
|
| 10201 | + {/* 快捷键说明弹窗 */} |
| 10202 | + {showShortcutDialog && ( |
| 10203 | + <div |
| 10204 | + className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 px-4 py-6 backdrop-blur-sm' |
| 10205 | + onClick={() => setShowShortcutDialog(false)} |
| 10206 | + > |
| 10207 | + <div |
| 10208 | + className='relative w-full max-w-lg overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl shadow-black/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:shadow-black/40' |
| 10209 | + onClick={(e) => e.stopPropagation()} |
| 10210 | + onKeyDown={(e) => e.stopPropagation()} |
| 10211 | + role='dialog' |
| 10212 | + aria-modal='true' |
| 10213 | + aria-labelledby='shortcut-dialog-title' |
| 10214 | + > |
| 10215 | + <div className='absolute inset-x-0 top-0 h-24 bg-gradient-to-br from-green-500/15 via-cyan-500/10 to-transparent pointer-events-none' /> |
| 10216 | + <div className='relative flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4 dark:border-gray-700'> |
| 10217 | + <div className='flex items-center gap-3'> |
| 10218 | + <div className='flex h-10 w-10 items-center justify-center rounded-xl border border-green-500/30 bg-green-500/10 text-green-600 dark:text-green-300'> |
| 10219 | + <Keyboard className='h-5 w-5' /> |
| 10220 | + </div> |
| 10221 | + <div> |
| 10222 | + <h2 id='shortcut-dialog-title' className='text-base font-semibold text-gray-950 dark:text-white'> |
| 10223 | + 播放快捷键 |
| 10224 | + </h2> |
| 10225 | + </div> |
| 10226 | + </div> |
| 10227 | + <button |
| 10228 | + onClick={() => setShowShortcutDialog(false)} |
| 10229 | + className='rounded-lg p-2 text-gray-500 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 cursor-pointer dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white' |
| 10230 | + aria-label='关闭快捷键说明' |
| 10231 | + > |
| 10232 | + <X className='h-5 w-5' /> |
| 10233 | + </button> |
| 10234 | + </div> |
| 10235 | + |
| 10236 | + <div className='relative max-h-[70vh] overflow-y-auto px-5 py-4'> |
| 10237 | + <div className='grid gap-3'> |
| 10238 | + {PLAY_SHORTCUT_GROUPS.map((group) => ( |
| 10239 | + <section |
| 10240 | + key={group.title} |
| 10241 | + className='rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/60' |
| 10242 | + > |
| 10243 | + <h3 className='mb-3 text-sm font-medium text-gray-800 dark:text-gray-200'> |
| 10244 | + {group.title} |
| 10245 | + </h3> |
| 10246 | + <div className='space-y-2'> |
| 10247 | + {group.items.map((item) => ( |
| 10248 | + <div |
| 10249 | + key={`${group.title}-${item.description}`} |
| 10250 | + className='flex items-center justify-between gap-4 rounded-lg px-2 py-1.5 transition-colors duration-200 hover:bg-white dark:hover:bg-gray-700/70' |
| 10251 | + > |
| 10252 | + <div className='flex flex-wrap items-center gap-1.5'> |
| 10253 | + {item.keys.map((key, index) => ( |
| 10254 | + <span key={`${item.description}-${key}`} className='flex items-center gap-1.5'> |
| 10255 | + {index > 0 && ( |
| 10256 | + <span className='text-xs text-gray-400 dark:text-gray-500'>+</span> |
| 10257 | + )} |
| 10258 | + <kbd className='min-w-7 rounded-md border border-gray-300 bg-white px-2 py-1 text-center text-xs font-semibold text-gray-800 shadow-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 dark:shadow-inner dark:shadow-white/5'> |
| 10259 | + {key} |
| 10260 | + </kbd> |
| 10261 | + </span> |
| 10262 | + ))} |
| 10263 | + </div> |
| 10264 | + <span className='text-right text-xs text-gray-600 dark:text-gray-300'> |
| 10265 | + {item.description} |
| 10266 | + </span> |
| 10267 | + </div> |
| 10268 | + ))} |
| 10269 | + </div> |
| 10270 | + </section> |
| 10271 | + ))} |
| 10272 | + </div> |
| 10273 | + </div> |
| 10274 | + </div> |
| 10275 | + </div> |
| 10276 | + )} |
| 10277 | + |
10067 | 10278 | {/* 网盘搜索弹窗 */} |
10068 | 10279 | {showPansouDialog && ( |
10069 | 10280 | isLargeScreen ? ( |
|
0 commit comments