ZHIWEI666 commited on
Commit
3559190
·
verified ·
1 Parent(s): 8b79a67

Upload 104 files

Browse files
前端页面/components/用户体验_国际化.js CHANGED
@@ -1762,6 +1762,7 @@ const translations = {
1762
  'video.forward': '快进5秒',
1763
  'video.backward': '快退5秒',
1764
  'video.volume': '音量',
 
1765
 
1766
  // 关于插件
1767
  'about.brand': '社区精选',
@@ -3543,6 +3544,7 @@ const translations = {
3543
  'video.forward': 'Forward 5s',
3544
  'video.backward': 'Backward 5s',
3545
  'video.volume': 'Volume',
 
3546
 
3547
  // About plugin
3548
  'about.brand': 'Community Hub',
 
1762
  'video.forward': '快进5秒',
1763
  'video.backward': '快退5秒',
1764
  'video.volume': '音量',
1765
+ 'video.progress': '播放进度',
1766
 
1767
  // 关于插件
1768
  'about.brand': '社区精选',
 
3544
  'video.forward': 'Forward 5s',
3545
  'video.backward': 'Backward 5s',
3546
  'video.volume': 'Volume',
3547
+ 'video.progress': 'Playback progress',
3548
 
3549
  // About plugin
3550
  'about.brand': 'Community Hub',
前端页面/components/视频播放器组件.js CHANGED
@@ -8,6 +8,13 @@
8
 
9
  import { t } from './用户体验增强.js';
10
 
 
 
 
 
 
 
 
11
  // ==========================================
12
  // 🎨 CSS 样式(仅注入一次)
13
  // ==========================================
@@ -24,6 +31,8 @@ function injectVideoPlayerStyles() {
24
  max-height: 500px;
25
  background: #000;
26
  width: 100%;
 
 
27
  outline: none;
28
  }
29
  .video-player-container video {
@@ -90,20 +99,27 @@ function injectVideoPlayerStyles() {
90
  .video-progress-wrap {
91
  position: relative;
92
  width: 100%;
 
 
 
 
 
 
 
 
93
  height: 4px;
94
  background: rgba(255,255,255,0.2);
95
  border-radius: 2px;
96
- cursor: pointer;
97
- margin-bottom: 8px;
98
- transition: height 0.2s ease;
99
  }
100
- .video-progress-wrap:hover {
 
101
  height: 8px;
102
  }
103
  .video-progress-buffered {
104
  position: absolute;
105
- top: 0; left: 0;
106
- height: 100%;
107
  background: rgba(255,255,255,0.3);
108
  border-radius: 2px;
109
  width: 0;
@@ -111,13 +127,15 @@ function injectVideoPlayerStyles() {
111
  }
112
  .video-progress-played {
113
  position: absolute;
114
- top: 0; left: 0;
115
- height: 100%;
116
  background: #4CAF50;
117
  border-radius: 2px;
118
  width: 0;
119
  pointer-events: none;
120
  }
 
 
121
  .video-progress-handle {
122
  position: absolute;
123
  top: 50%;
@@ -137,7 +155,7 @@ function injectVideoPlayerStyles() {
137
  }
138
  .video-progress-tooltip {
139
  position: absolute;
140
- bottom: 16px;
141
  transform: translateX(-50%);
142
  background: rgba(0,0,0,0.8);
143
  color: #fff;
@@ -189,6 +207,7 @@ function injectVideoPlayerStyles() {
189
  }
190
  .video-volume-slider {
191
  width: 60px;
 
192
  cursor: pointer;
193
  accent-color: #4CAF50;
194
  }
@@ -203,7 +222,11 @@ function injectVideoPlayerStyles() {
203
  background: rgba(30,30,30,0.95);
204
  border-radius: 6px;
205
  padding: 4px 0;
206
- min-width: 60px;
 
 
 
 
207
  display: none;
208
  flex-direction: column;
209
  z-index: 20;
@@ -212,7 +235,8 @@ function injectVideoPlayerStyles() {
212
  .video-speed-menu.open {
213
  display: flex;
214
  }
215
- .video-speed-menu > div {
 
216
  padding: 6px 12px;
217
  font-size: 12px;
218
  color: #fff;
@@ -220,8 +244,8 @@ function injectVideoPlayerStyles() {
220
  text-align: center;
221
  transition: background 0.15s ease;
222
  }
223
- .video-speed-menu > div:hover,
224
- .video-speed-menu > div.active {
225
  background: rgba(76,175,80,0.3);
226
  }
227
  .fullscreen-video-overlay {
@@ -274,11 +298,45 @@ function injectVideoPlayerStyles() {
274
  min-width: 32px;
275
  }
276
  .fullscreen-video-overlay .video-progress-wrap {
277
- margin-bottom: 10px;
278
  }
279
  .fullscreen-video-overlay .video-volume-slider {
280
  width: 80px;
281
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  `;
283
  document.head.appendChild(style);
284
  }
@@ -318,6 +376,7 @@ function _updatePlayButton(btnPlay, video) {
318
  const playing = !video.paused && !video.ended;
319
  btnPlay.textContent = getPlayIcon(playing);
320
  btnPlay.title = playing ? t('video.pause') : t('video.play');
 
321
  }
322
 
323
  /**
@@ -359,9 +418,158 @@ function _updateBuffered(progressBuffered, video) {
359
  function _updateVolumeIcon(btnVolume, video) {
360
  if (!btnVolume) return;
361
  btnVolume.textContent = getVolumeIcon(video.volume, video.muted);
 
362
  }
363
 
364
  // ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  // 📺 Part 1: 内嵌播放器
366
  // ==========================================
367
 
@@ -378,7 +586,7 @@ export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) {
378
  const safePoster = (posterUrl || '').replace(/"/g, '"');
379
 
380
  return `
381
- <div class="video-player-container" data-video-url="${safeVideo}" data-poster-url="${safePoster}" tabindex="0">
382
  <video preload="metadata" poster="${safePoster}" playsinline>
383
  <source src="${safeVideo}">
384
  </video>
@@ -394,23 +602,23 @@ export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) {
394
  <div class="video-progress-tooltip">0:00</div>
395
  </div>
396
  <div class="video-controls-row">
397
- <button class="video-btn-play" title="${t('video.play')}">${getPlayIcon(false)}</button>
398
  <span class="video-time">0:00 / 0:00</span>
399
  <div class="video-volume-wrap">
400
- <button class="video-btn-volume" title="${t('video.volume')}">🔊</button>
401
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
402
  </div>
403
  <div class="video-speed-wrap">
404
- <button class="video-btn-speed" title="${t('video.speed')}">1x</button>
405
  <div class="video-speed-menu">
406
- <div data-speed="0.5">0.5x</div>
407
- <div data-speed="1" class="active">1x</div>
408
- <div data-speed="1.25">1.25x</div>
409
- <div data-speed="1.5">1.5x</div>
410
- <div data-speed="2">2x</div>
411
  </div>
412
  </div>
413
- <button class="video-btn-fullscreen" title="${t('video.fullscreen')}">⛶</button>
414
  </div>
415
  </div>
416
  </div>
@@ -423,9 +631,12 @@ export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) {
423
  */
424
  export function setupVideoPlayerEvents(container) {
425
  const player = container.querySelector('.video-player-container');
 
 
 
 
 
426
  if (!player) return;
427
-
428
- const video = player.querySelector('video');
429
  const posterOverlay = player.querySelector('.video-poster-overlay');
430
  const controlsBar = player.querySelector('.video-controls-bar');
431
  const progressWrap = player.querySelector('.video-progress-wrap');
@@ -442,6 +653,14 @@ export function setupVideoPlayerEvents(container) {
442
  const timeDisplay = player.querySelector('.video-time');
443
 
444
  if (!video) return;
 
 
 
 
 
 
 
 
445
 
446
  // 设置按钮文字(fallback)
447
  if (btnPlay) btnPlay.title = t('video.play');
@@ -452,6 +671,7 @@ export function setupVideoPlayerEvents(container) {
452
  let isDraggingProgress = false;
453
  let wasPlayingBeforeDrag = false;
454
  let hideControlsTimer = null;
 
455
  let isFullscreenMode = false;
456
 
457
  // 共享辅助函数(闭包适配)
@@ -505,45 +725,45 @@ export function setupVideoPlayerEvents(container) {
505
 
506
  // 1. 封面层点击
507
  if (posterOverlay) {
508
- posterOverlay.addEventListener('click', () => {
509
  video.play().catch(() => {});
510
  });
511
  }
512
 
513
  // 2. 视频区域点击
514
- video.addEventListener('click', (e) => {
515
  e.stopPropagation();
516
  togglePlay();
517
  });
518
 
519
  // 3. 播放/暂停按钮
520
  if (btnPlay) {
521
- btnPlay.addEventListener('click', (e) => {
522
  e.stopPropagation();
523
  togglePlay();
524
  });
525
  }
526
 
527
  // 4. 进度条交互
528
- video.addEventListener('timeupdate', () => {
529
  if (!isDraggingProgress) updateProgressUI();
530
  });
531
- video.addEventListener('progress', updateBuffered);
532
- video.addEventListener('loadedmetadata', () => {
533
  updateProgressUI();
534
  updateBuffered();
535
  });
536
 
537
  if (progressWrap) {
538
  // 点击进度条
539
- progressWrap.addEventListener('click', (e) => {
540
  if (isDraggingProgress) return;
541
  e.stopPropagation();
542
  seekToRatio(getRatioFromEvent(e, progressWrap));
543
  });
544
 
545
  // 进度条 hover tooltip
546
- progressWrap.addEventListener('mousemove', (e) => {
547
  if (!video.duration || !isFinite(video.duration)) return;
548
  const ratio = getRatioFromEvent(e, progressWrap);
549
  const time = ratio * video.duration;
@@ -553,13 +773,13 @@ export function setupVideoPlayerEvents(container) {
553
  progressTooltip.classList.add('show');
554
  }
555
  });
556
- progressWrap.addEventListener('mouseleave', () => {
557
  if (progressTooltip) progressTooltip.classList.remove('show');
558
  });
559
 
560
  // 拖拽把手
561
  if (progressHandle) {
562
- progressHandle.addEventListener('mousedown', (e) => {
563
  e.stopPropagation();
564
  e.preventDefault();
565
  isDraggingProgress = true;
@@ -569,7 +789,7 @@ export function setupVideoPlayerEvents(container) {
569
  });
570
  }
571
  // 在进度条任意位置按下也可以拖拽
572
- progressWrap.addEventListener('mousedown', (e) => {
573
  if (e.target === progressHandle) return;
574
  isDraggingProgress = true;
575
  if (progressHandle) progressHandle.classList.add('dragging');
@@ -603,12 +823,12 @@ export function setupVideoPlayerEvents(container) {
603
  if (wasPlayingBeforeDrag) video.play().catch(() => {});
604
  };
605
 
606
- window.addEventListener('mousemove', handleMouseMove);
607
- window.addEventListener('mouseup', handleMouseUp);
608
 
609
  // 5. 音量控制
610
  if (volumeSlider) {
611
- volumeSlider.addEventListener('input', () => {
612
  const val = parseInt(volumeSlider.value, 10) / 100;
613
  video.volume = val;
614
  if (val > 0 && video.muted) video.muted = false;
@@ -616,45 +836,18 @@ export function setupVideoPlayerEvents(container) {
616
  });
617
  }
618
  if (btnVolume) {
619
- btnVolume.addEventListener('click', (e) => {
620
  e.stopPropagation();
621
  video.muted = !video.muted;
622
  updateVolumeIcon();
623
  });
624
  }
625
 
626
- // 6. 播放速度
627
- function closeSpeedMenu() {
628
- if (speedMenu) speedMenu.classList.remove('open');
629
- }
630
-
631
- if (btnSpeed && speedMenu) {
632
- btnSpeed.addEventListener('click', (e) => {
633
- e.stopPropagation();
634
- speedMenu.classList.toggle('open');
635
- });
636
- speedMenu.querySelectorAll('div[data-speed]').forEach(item => {
637
- item.addEventListener('click', (e) => {
638
- e.stopPropagation();
639
- const speed = parseFloat(item.dataset.speed);
640
- video.playbackRate = speed;
641
- btnSpeed.textContent = speed + 'x';
642
- speedMenu.querySelectorAll('div').forEach(d => d.classList.remove('active'));
643
- item.classList.add('active');
644
- closeSpeedMenu();
645
- });
646
- });
647
- }
648
- const handleDocClickForSpeed = (e) => {
649
- if (speedMenu && !speedMenu.contains(e.target) && e.target !== btnSpeed) {
650
- closeSpeedMenu();
651
- }
652
- };
653
- document.addEventListener('click', handleDocClickForSpeed);
654
 
655
  // 7. 全屏按钮
656
  if (btnFullscreen) {
657
- btnFullscreen.addEventListener('click', (e) => {
658
  e.stopPropagation();
659
  const currentTime = video.currentTime;
660
  video.pause(); // 先暂停内嵌播放器,避免与全屏播放器同时出声
@@ -662,11 +855,13 @@ export function setupVideoPlayerEvents(container) {
662
  player.dataset.videoUrl || '',
663
  player.dataset.posterUrl || '',
664
  {
 
665
  currentTime,
666
  volume: video.volume,
667
  muted: video.muted,
668
  playbackRate: video.playbackRate,
669
  onClose: (returnTime) => {
 
670
  video.currentTime = returnTime;
671
  updateProgressUI();
672
  updatePlayButton(); // 同步播放按钮状态为暂停
@@ -677,11 +872,11 @@ export function setupVideoPlayerEvents(container) {
677
  }
678
 
679
  // 9. 控制栏自动隐藏
680
- player.addEventListener('mousemove', resetHideTimer);
681
- player.addEventListener('mouseenter', resetHideTimer);
682
- video.addEventListener('play', showControls);
683
- video.addEventListener('pause', showControls);
684
- video.addEventListener('ended', () => {
685
  if (controlsBar) controlsBar.classList.remove('hidden');
686
  });
687
 
@@ -690,12 +885,12 @@ export function setupVideoPlayerEvents(container) {
690
  e.stopPropagation();
691
  e.preventDefault();
692
  };
693
- player.addEventListener('dragstart', stopDrag);
694
- player.addEventListener('dragover', stopDrag);
695
- player.addEventListener('drop', stopDrag);
696
 
697
  // 11. 视频结束回到封面
698
- video.addEventListener('ended', () => {
699
  if (posterOverlay) {
700
  posterOverlay.style.display = 'flex';
701
  posterOverlay.style.opacity = '1';
@@ -704,18 +899,20 @@ export function setupVideoPlayerEvents(container) {
704
  });
705
 
706
  // 播放时隐藏封面
707
- video.addEventListener('play', () => {
708
  if (posterOverlay) {
709
  posterOverlay.style.opacity = '0';
710
- setTimeout(() => {
711
- if (!video.paused) posterOverlay.style.display = 'none';
 
 
712
  }, 300);
713
  }
714
  updatePlayButton();
715
  showControls();
716
  });
717
 
718
- video.addEventListener('pause', () => {
719
  updatePlayButton();
720
  });
721
 
@@ -724,12 +921,18 @@ export function setupVideoPlayerEvents(container) {
724
  updatePlayButton();
725
 
726
  // 🔧 清理函数注册
727
- const cleanup = () => {
 
 
 
728
  if (hideControlsTimer) clearTimeout(hideControlsTimer);
729
- window.removeEventListener('mousemove', handleMouseMove);
730
- window.removeEventListener('mouseup', handleMouseUp);
731
- document.removeEventListener('click', handleDocClickForSpeed);
732
- video.pause();
 
 
 
733
  };
734
  container._videoPlayerCleanup = cleanup;
735
  }
@@ -759,12 +962,18 @@ export function cleanupVideoPlayer(container) {
759
  * @param {boolean} options.muted - 是否静音
760
  * @param {number} options.playbackRate - 播放速度
761
  * @param {Function} options.onClose - 关闭回调(currentTime)
 
762
  */
763
  export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
 
764
  injectVideoPlayerStyles();
765
 
 
766
  const overlay = document.createElement('div');
767
  overlay.className = 'fullscreen-video-overlay';
 
 
 
768
 
769
  const safeVideo = (videoUrl || '').replace(/"/g, '&quot;');
770
  const safePoster = (posterUrl || '').replace(/"/g, '&quot;');
@@ -774,7 +983,7 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
774
  const startRate = options.playbackRate || 1;
775
 
776
  overlay.innerHTML = `
777
- <button class="fs-video-close" title="${t('video.close')}">✕</button>
778
  <video preload="metadata" poster="${safePoster}" playsinline
779
  style="width:100vw;height:100vh;object-fit:contain;">
780
  <source src="${safeVideo}">
@@ -787,23 +996,23 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
787
  <div class="video-progress-tooltip">0:00</div>
788
  </div>
789
  <div class="video-controls-row">
790
- <button class="video-btn-play" title="${t('video.play')}">▶</button>
791
  <span class="video-time">0:00 / 0:00</span>
792
  <div class="video-volume-wrap">
793
- <button class="video-btn-volume" title="${t('video.volume')}">🔊</button>
794
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
795
  </div>
796
  <div class="video-speed-wrap">
797
- <button class="video-btn-speed" title="${t('video.speed')}">1x</button>
798
  <div class="video-speed-menu">
799
- <div data-speed="0.5">0.5x</div>
800
- <div data-speed="1" class="active">1x</div>
801
- <div data-speed="1.25">1.25x</div>
802
- <div data-speed="1.5">1.5x</div>
803
- <div data-speed="2">2x</div>
804
  </div>
805
  </div>
806
- <button class="video-btn-fullscreen" title="${t('video.exit_fullscreen')}">⛶</button>
807
  </div>
808
  </div>
809
  `;
@@ -841,31 +1050,48 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
841
  let isDraggingProgress = false;
842
  let wasPlayingBeforeDrag = false;
843
  let hideControlsTimer = null;
844
- let destroyed = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
845
 
846
  function destroy(returnTime) {
847
  if (destroyed) return;
848
  destroyed = true;
 
 
849
  if (hideControlsTimer) clearTimeout(hideControlsTimer);
850
- overlay.removeEventListener('keydown', handleKeydown);
851
- document.removeEventListener('click', handleDocClickForSpeed);
852
- window.removeEventListener('mousemove', handleMouseMove);
853
- window.removeEventListener('mouseup', handleMouseUp);
854
- video.pause();
855
-
856
- // 退出 Fullscreen API
857
- if (document.fullscreenElement) {
858
- try { document.exitFullscreen(); } catch (e) {}
859
  }
860
 
861
- overlay.style.transition = 'opacity 0.25s ease';
 
862
  overlay.style.opacity = '0';
863
- setTimeout(() => {
864
- if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
865
- if (typeof options.onClose === 'function') {
866
- options.onClose(typeof returnTime === 'number' ? returnTime : video.currentTime);
867
- }
868
- }, 250);
869
  }
870
 
871
  function updatePlayButton() {
@@ -920,34 +1146,34 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
920
  }
921
 
922
  // 播放/暂停按钮
923
- btnPlay.addEventListener('click', (e) => {
924
  e.stopPropagation();
925
  togglePlay();
926
  });
927
 
928
  // 视频点击切换
929
- video.addEventListener('click', (e) => {
930
  e.stopPropagation();
931
  togglePlay();
932
  });
933
 
934
  // 进度条
935
- video.addEventListener('timeupdate', () => {
936
  if (!isDraggingProgress) updateProgressUI();
937
  });
938
- video.addEventListener('progress', updateBuffered);
939
- video.addEventListener('loadedmetadata', () => {
940
  updateProgressUI();
941
  updateBuffered();
942
  });
943
 
944
- progressWrap.addEventListener('click', (e) => {
945
  if (isDraggingProgress) return;
946
  e.stopPropagation();
947
  seekToRatio(getRatioFromEvent(e, progressWrap));
948
  });
949
 
950
- progressWrap.addEventListener('mousemove', (e) => {
951
  if (!video.duration || !isFinite(video.duration)) return;
952
  const ratio = getRatioFromEvent(e, progressWrap);
953
  if (progressTooltip) {
@@ -956,11 +1182,11 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
956
  progressTooltip.classList.add('show');
957
  }
958
  });
959
- progressWrap.addEventListener('mouseleave', () => {
960
  if (progressTooltip) progressTooltip.classList.remove('show');
961
  });
962
 
963
- progressHandle.addEventListener('mousedown', (e) => {
964
  e.stopPropagation();
965
  e.preventDefault();
966
  isDraggingProgress = true;
@@ -968,7 +1194,7 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
968
  wasPlayingBeforeDrag = !video.paused && !video.ended;
969
  if (wasPlayingBeforeDrag) video.pause();
970
  });
971
- progressWrap.addEventListener('mousedown', (e) => {
972
  if (e.target === progressHandle) return;
973
  isDraggingProgress = true;
974
  progressHandle.classList.add('dragging');
@@ -999,58 +1225,47 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
999
  if (progressTooltip) progressTooltip.classList.remove('show');
1000
  if (wasPlayingBeforeDrag) video.play().catch(() => {});
1001
  };
1002
- window.addEventListener('mousemove', handleMouseMove);
1003
- window.addEventListener('mouseup', handleMouseUp);
1004
 
1005
  // 音量
1006
- volumeSlider.addEventListener('input', () => {
1007
  const val = parseInt(volumeSlider.value, 10) / 100;
1008
  video.volume = val;
1009
  if (val > 0 && video.muted) video.muted = false;
1010
  updateVolumeIcon();
1011
  });
1012
- btnVolume.addEventListener('click', (e) => {
1013
  e.stopPropagation();
1014
  video.muted = !video.muted;
1015
  updateVolumeIcon();
1016
  });
1017
 
1018
- // 播放速度
1019
- function closeSpeedMenu() {
1020
- if (speedMenu) speedMenu.classList.remove('open');
1021
- }
1022
- btnSpeed.addEventListener('click', (e) => {
1023
- e.stopPropagation();
1024
- speedMenu.classList.toggle('open');
1025
- });
1026
- speedMenu.querySelectorAll('div[data-speed]').forEach(item => {
1027
- item.addEventListener('click', (e) => {
1028
- e.stopPropagation();
1029
- const speed = parseFloat(item.dataset.speed);
1030
- video.playbackRate = speed;
1031
- btnSpeed.textContent = speed + 'x';
1032
- speedMenu.querySelectorAll('div').forEach(d => d.classList.remove('active'));
1033
- item.classList.add('active');
1034
- closeSpeedMenu();
1035
- });
1036
- });
1037
- const handleDocClickForSpeed = (e) => {
1038
- if (speedMenu && !speedMenu.contains(e.target) && e.target !== btnSpeed) {
1039
- closeSpeedMenu();
1040
- }
1041
- };
1042
- document.addEventListener('click', handleDocClickForSpeed);
1043
 
1044
  // 全屏按钮(在全屏模式下退出全屏)
1045
- btnFullscreen.addEventListener('click', (e) => {
1046
  e.stopPropagation();
1047
  destroy(video.currentTime);
1048
  });
1049
 
1050
  // 关闭按钮
1051
- closeBtn.addEventListener('click', () => destroy(video.currentTime));
1052
 
1053
  const handleKeydown = event => {
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  if (event.defaultPrevented || event.isComposing || event.keyCode === 229
1055
  || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey
1056
  || event.key !== 'Escape') return;
@@ -1058,22 +1273,25 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
1058
  event.stopPropagation();
1059
  destroy(video.currentTime);
1060
  };
1061
- overlay.addEventListener('keydown', handleKeydown);
1062
  closeBtn.focus();
1063
 
1064
  // 控制栏自动隐藏
1065
- overlay.addEventListener('mousemove', resetHideTimer);
1066
- overlay.addEventListener('mouseenter', resetHideTimer);
1067
- video.addEventListener('play', showControls);
1068
- video.addEventListener('pause', showControls);
1069
- video.addEventListener('ended', () => {
1070
  if (controlsBar) controlsBar.classList.remove('hidden');
1071
  });
1072
 
1073
  // 播放/暂停状态更新
1074
- video.addEventListener('play', updatePlayButton);
1075
- video.addEventListener('pause', updatePlayButton);
1076
- video.addEventListener('ended', updatePlayButton);
 
 
 
1077
 
1078
  // 尝试调用 Fullscreen API
1079
  const requestFS = () => {
 
8
 
9
  import { t } from './用户体验增强.js';
10
 
11
+ function releaseVideoMedia(video) {
12
+ video.pause();
13
+ video.removeAttribute('src');
14
+ for (const source of video.querySelectorAll('source')) source.removeAttribute('src');
15
+ video.load();
16
+ }
17
+
18
  // ==========================================
19
  // 🎨 CSS 样式(仅注入一次)
20
  // ==========================================
 
31
  max-height: 500px;
32
  background: #000;
33
  width: 100%;
34
+ container-type: inline-size;
35
+ container-name: ranking-video;
36
  outline: none;
37
  }
38
  .video-player-container video {
 
99
  .video-progress-wrap {
100
  position: relative;
101
  width: 100%;
102
+ height: 24px;
103
+ cursor: pointer;
104
+ margin-bottom: 0;
105
+ }
106
+ .video-progress-wrap::before {
107
+ content: '';
108
+ position: absolute;
109
+ top: 10px; left: 0; right: 0;
110
  height: 4px;
111
  background: rgba(255,255,255,0.2);
112
  border-radius: 2px;
113
+ pointer-events: none;
 
 
114
  }
115
+ .video-progress-wrap:hover::before {
116
+ top: 8px;
117
  height: 8px;
118
  }
119
  .video-progress-buffered {
120
  position: absolute;
121
+ top: 10px; left: 0;
122
+ height: 4px;
123
  background: rgba(255,255,255,0.3);
124
  border-radius: 2px;
125
  width: 0;
 
127
  }
128
  .video-progress-played {
129
  position: absolute;
130
+ top: 10px; left: 0;
131
+ height: 4px;
132
  background: #4CAF50;
133
  border-radius: 2px;
134
  width: 0;
135
  pointer-events: none;
136
  }
137
+ .video-progress-wrap:hover .video-progress-buffered,
138
+ .video-progress-wrap:hover .video-progress-played { top: 8px; height: 8px; }
139
  .video-progress-handle {
140
  position: absolute;
141
  top: 50%;
 
155
  }
156
  .video-progress-tooltip {
157
  position: absolute;
158
+ bottom: calc(100% + 4px);
159
  transform: translateX(-50%);
160
  background: rgba(0,0,0,0.8);
161
  color: #fff;
 
207
  }
208
  .video-volume-slider {
209
  width: 60px;
210
+ height: 24px;
211
  cursor: pointer;
212
  accent-color: #4CAF50;
213
  }
 
222
  background: rgba(30,30,30,0.95);
223
  border-radius: 6px;
224
  padding: 4px 0;
225
+ min-width: 72px;
226
+ box-sizing: border-box;
227
+ overflow-y: auto;
228
+ overscroll-behavior: contain;
229
+ scrollbar-width: thin;
230
  display: none;
231
  flex-direction: column;
232
  z-index: 20;
 
235
  .video-speed-menu.open {
236
  display: flex;
237
  }
238
+ .video-speed-menu > button {
239
+ flex-shrink: 0;
240
  padding: 6px 12px;
241
  font-size: 12px;
242
  color: #fff;
 
244
  text-align: center;
245
  transition: background 0.15s ease;
246
  }
247
+ .video-speed-menu > button:hover,
248
+ .video-speed-menu > button.active {
249
  background: rgba(76,175,80,0.3);
250
  }
251
  .fullscreen-video-overlay {
 
298
  min-width: 32px;
299
  }
300
  .fullscreen-video-overlay .video-progress-wrap {
301
+ margin-bottom: 0;
302
  }
303
  .fullscreen-video-overlay .video-volume-slider {
304
  width: 80px;
305
  }
306
+
307
+ .video-controls-bar:focus-within { opacity: 1; pointer-events: auto; }
308
+ .video-progress-wrap:focus-visible,
309
+ .video-controls-row button:focus-visible,
310
+ .video-volume-slider:focus-visible,
311
+ .fullscreen-video-overlay .fs-video-close:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
312
+ .video-progress-wrap:focus-visible .video-progress-handle { opacity: 1; }
313
+ .video-speed-menu > button { width: 100%; }
314
+ @media (prefers-reduced-motion: reduce) {
315
+ .fullscreen-video-overlay { animation: none; }
316
+ .video-controls-bar, .video-progress-wrap, .video-progress-handle,
317
+ .video-controls-row button, .video-speed-menu > button, .video-poster-overlay { transition: none; }
318
+ }
319
+ @media (forced-colors: active) {
320
+ .video-controls-bar, .video-speed-menu { background: Canvas; color: CanvasText; }
321
+ .video-controls-row button { color: CanvasText; }
322
+ .video-progress-wrap:focus-visible, .video-controls-row button:focus-visible,
323
+ .video-volume-slider:focus-visible, .fullscreen-video-overlay .fs-video-close:focus-visible { outline-color: Highlight; }
324
+ .video-speed-menu > button[aria-pressed="true"] { outline: 1px solid Highlight; }
325
+ }
326
+
327
+ @media (max-width: 480px) {
328
+ .fullscreen-video-overlay .video-controls-row { gap: 6px; }
329
+ .fullscreen-video-overlay .video-controls-row button { padding: 6px; }
330
+ .fullscreen-video-overlay .video-volume-slider { width: 48px; }
331
+ .fullscreen-video-overlay .video-time { min-width: 64px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
332
+ }
333
+
334
+ @container ranking-video (max-width: 360px) {
335
+ .video-controls-row { gap: 4px; }
336
+ .video-controls-row button { padding: 4px; min-width: 24px; min-height: 24px; }
337
+ .video-volume-slider { width: 40px; }
338
+ .video-time { min-width: 0; flex: 1 1 60px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
339
+ }
340
  `;
341
  document.head.appendChild(style);
342
  }
 
376
  const playing = !video.paused && !video.ended;
377
  btnPlay.textContent = getPlayIcon(playing);
378
  btnPlay.title = playing ? t('video.pause') : t('video.play');
379
+ btnPlay.setAttribute('aria-label', btnPlay.title);
380
  }
381
 
382
  /**
 
418
  function _updateVolumeIcon(btnVolume, video) {
419
  if (!btnVolume) return;
420
  btnVolume.textContent = getVolumeIcon(video.volume, video.muted);
421
+ btnVolume.setAttribute('aria-label', video.muted ? t('video.unmute') : t('video.mute'));
422
  }
423
 
424
  // ==========================================
425
+ let videoControlsId = 0;
426
+
427
+ function setupAccessibleVideoControls(scope, video, showControls) {
428
+ const progress = scope.querySelector('.video-progress-wrap');
429
+ const volume = scope.querySelector('.video-volume-slider');
430
+ const speed = scope.querySelector('.video-btn-speed');
431
+ const choices = scope.querySelector('.video-speed-menu');
432
+ const releases = [];
433
+ let active = true;
434
+ const current = () => active && scope.isConnected;
435
+ const listen = (target, type, handler) => {
436
+ if (!target) return;
437
+ target.addEventListener(type, handler);
438
+ releases.push(() => target.removeEventListener(type, handler));
439
+ };
440
+ const plainKey = event => !event.defaultPrevented && !event.isComposing
441
+ && event.keyCode !== 229 && !event.ctrlKey && !event.metaKey
442
+ && !event.altKey && !event.shiftKey;
443
+ const closeChoices = restore => {
444
+ choices?.classList.remove('open');
445
+ speed?.setAttribute('aria-expanded', 'false');
446
+ if (restore && current()) speed?.focus();
447
+ };
448
+ const layoutChoices = preserveScroll => {
449
+ if (!current() || !choices?.classList.contains('open')) return;
450
+ const previousScroll = choices.scrollTop;
451
+ choices.style.top = '';
452
+ choices.style.bottom = '';
453
+ choices.style.left = '';
454
+ choices.style.maxHeight = '';
455
+ const parentElement = choices.offsetParent;
456
+ if (!parentElement) return;
457
+ const bounds = scope.getBoundingClientRect();
458
+ if (bounds.width <= 0 || bounds.height <= 0) return;
459
+ const menu = choices.getBoundingClientRect();
460
+ const parent = parentElement.getBoundingClientRect();
461
+ const availableAbove = menu.bottom - bounds.top - 4;
462
+ if (availableAbove >= 64) {
463
+ choices.style.maxHeight = `${Math.min(menu.height, availableAbove)}px`;
464
+ } else {
465
+ choices.style.bottom = 'auto';
466
+ choices.style.top = `${bounds.top + 4 - parent.top}px`;
467
+ choices.style.maxHeight = `${Math.max(0, bounds.height - 8)}px`;
468
+ }
469
+ const left = Math.max(bounds.left + 4, Math.min(menu.left, bounds.right - menu.width - 4));
470
+ choices.style.left = `${left - parent.left + menu.width / 2}px`;
471
+ if (preserveScroll) choices.scrollTop = previousScroll;
472
+ else {
473
+ const selected = choices.querySelector('[aria-pressed="true"]');
474
+ choices.scrollTop = selected ? selected.offsetTop - (choices.clientHeight - selected.offsetHeight) / 2 : 0;
475
+ }
476
+ };
477
+ const syncRate = () => {
478
+ if (!current() || !speed || !choices) return;
479
+ speed.textContent = video.playbackRate + 'x';
480
+ speed.setAttribute('aria-label', `${t('video.speed')} ${video.playbackRate}x`);
481
+ choices.querySelectorAll('button[data-speed]').forEach(option => {
482
+ const selected = Number(option.dataset.speed) === video.playbackRate;
483
+ option.classList.toggle('active', selected);
484
+ option.setAttribute('aria-pressed', String(selected));
485
+ });
486
+ };
487
+ if (volume) volume.setAttribute('aria-label', t('video.volume'));
488
+ if (speed && choices) {
489
+ choices.id = `ranking-video-speed-${++videoControlsId}`;
490
+ choices.setAttribute('role', 'group');
491
+ choices.setAttribute('aria-label', t('video.speed'));
492
+ speed.setAttribute('aria-controls', choices.id);
493
+ speed.setAttribute('aria-expanded', 'false');
494
+ listen(speed, 'click', event => {
495
+ event.stopPropagation();
496
+ if (!current()) return;
497
+ const open = choices.classList.toggle('open');
498
+ speed.setAttribute('aria-expanded', String(open));
499
+ if (open) layoutChoices(false);
500
+ });
501
+ choices.querySelectorAll('button[data-speed]').forEach(option => {
502
+ listen(option, 'click', event => {
503
+ event.stopPropagation();
504
+ if (!current()) return;
505
+ video.playbackRate = Number(option.dataset.speed);
506
+ syncRate();
507
+ closeChoices(true);
508
+ });
509
+ });
510
+ const resize = new ResizeObserver(() => layoutChoices(true));
511
+ resize.observe(scope);
512
+ releases.push(() => resize.disconnect());
513
+ listen(video, 'ratechange', syncRate);
514
+ listen(scope, 'keydown', event => {
515
+ if (!current() || !plainKey(event) || event.key !== 'Escape'
516
+ || !choices.classList.contains('open')
517
+ || !(choices.contains(event.target) || speed.contains(event.target))) return;
518
+ event.preventDefault();
519
+ event.stopPropagation();
520
+ closeChoices(true);
521
+ });
522
+ listen(document, 'click', event => {
523
+ if (current() && !choices.contains(event.target) && !speed.contains(event.target)) closeChoices(false);
524
+ });
525
+ listen(scope, 'focusout', event => {
526
+ if (!choices.contains(event.relatedTarget) && !speed.contains(event.relatedTarget)) closeChoices(false);
527
+ });
528
+ syncRate();
529
+ }
530
+ const syncProgress = () => {
531
+ if (!current() || !progress) return;
532
+ const available = Number.isFinite(video.duration) && video.duration > 0;
533
+ const duration = available ? video.duration : 1;
534
+ const time = Number.isFinite(video.currentTime) ? Math.max(0, Math.min(duration, video.currentTime)) : 0;
535
+ progress.setAttribute('aria-valuemax', String(duration));
536
+ progress.setAttribute('aria-valuenow', String(time));
537
+ progress.setAttribute('aria-valuetext', `${formatTime(time)} / ${formatTime(available ? duration : 0)}`);
538
+ progress.setAttribute('aria-disabled', String(!available));
539
+ };
540
+ if (progress) {
541
+ progress.setAttribute('role', 'slider');
542
+ progress.tabIndex = 0;
543
+ progress.setAttribute('aria-label', t('video.progress'));
544
+ progress.setAttribute('aria-valuemin', '0');
545
+ listen(progress, 'keydown', event => {
546
+ if (!current() || event.target !== progress || !plainKey(event)
547
+ || !Number.isFinite(video.duration) || video.duration <= 0) return;
548
+ const time = Number.isFinite(video.currentTime) ? video.currentTime : 0;
549
+ let target;
550
+ if (event.key === 'ArrowRight' || event.key === 'ArrowUp') target = time + 5;
551
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') target = time - 5;
552
+ else if (event.key === 'Home') target = 0;
553
+ else if (event.key === 'End') target = video.duration;
554
+ else return;
555
+ event.preventDefault();
556
+ event.stopPropagation();
557
+ video.currentTime = Math.max(0, Math.min(video.duration, target));
558
+ _updateProgressUI(scope.querySelector('.video-progress-played'), scope.querySelector('.video-progress-handle'), scope.querySelector('.video-time'), video);
559
+ syncProgress();
560
+ showControls();
561
+ });
562
+ for (const type of ['loadedmetadata', 'durationchange', 'timeupdate']) listen(video, type, syncProgress);
563
+ syncProgress();
564
+ }
565
+ listen(scope, 'focusin', () => { if (current()) showControls(); });
566
+ return () => {
567
+ active = false;
568
+ for (const release of releases) release();
569
+ closeChoices(false);
570
+ };
571
+ }
572
+
573
  // 📺 Part 1: 内嵌播放器
574
  // ==========================================
575
 
 
586
  const safePoster = (posterUrl || '').replace(/"/g, '&quot;');
587
 
588
  return `
589
+ <div class="video-player-container" data-video-url="${safeVideo}" data-poster-url="${safePoster}">
590
  <video preload="metadata" poster="${safePoster}" playsinline>
591
  <source src="${safeVideo}">
592
  </video>
 
602
  <div class="video-progress-tooltip">0:00</div>
603
  </div>
604
  <div class="video-controls-row">
605
+ <button type="button" class="video-btn-play" title="${t('video.play')}">${getPlayIcon(false)}</button>
606
  <span class="video-time">0:00 / 0:00</span>
607
  <div class="video-volume-wrap">
608
+ <button type="button" class="video-btn-volume" title="${t('video.volume')}">🔊</button>
609
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
610
  </div>
611
  <div class="video-speed-wrap">
612
+ <button type="button" class="video-btn-speed" title="${t('video.speed')}">1x</button>
613
  <div class="video-speed-menu">
614
+ <button type="button" data-speed="0.5">0.5x</button>
615
+ <button type="button" data-speed="1" class="active">1x</button>
616
+ <button type="button" data-speed="1.25">1.25x</button>
617
+ <button type="button" data-speed="1.5">1.5x</button>
618
+ <button type="button" data-speed="2">2x</button>
619
  </div>
620
  </div>
621
+ <button type="button" class="video-btn-fullscreen" title="${t('video.fullscreen')}" aria-label="${t('video.fullscreen')}">⛶</button>
622
  </div>
623
  </div>
624
  </div>
 
631
  */
632
  export function setupVideoPlayerEvents(container) {
633
  const player = container.querySelector('.video-player-container');
634
+ const video = player?.querySelector('video');
635
+ if (container._videoPlayerCleanup) {
636
+ container._videoPlayerCleanup(video);
637
+ container._videoPlayerCleanup = null;
638
+ }
639
  if (!player) return;
 
 
640
  const posterOverlay = player.querySelector('.video-poster-overlay');
641
  const controlsBar = player.querySelector('.video-controls-bar');
642
  const progressWrap = player.querySelector('.video-progress-wrap');
 
653
  const timeDisplay = player.querySelector('.video-time');
654
 
655
  if (!video) return;
656
+ const source = video.querySelector('source');
657
+ if (source && !source.hasAttribute('src') && player.dataset.videoUrl) {
658
+ source.setAttribute('src', player.dataset.videoUrl);
659
+ video.load();
660
+ }
661
+ const listeners = new AbortController();
662
+ let active = true;
663
+ const listen = (target, type, handler) => target.addEventListener(type, handler, { signal: listeners.signal });
664
 
665
  // 设置按钮文字(fallback)
666
  if (btnPlay) btnPlay.title = t('video.play');
 
671
  let isDraggingProgress = false;
672
  let wasPlayingBeforeDrag = false;
673
  let hideControlsTimer = null;
674
+ let hidePosterTimer = null;
675
  let isFullscreenMode = false;
676
 
677
  // 共享辅助函数(闭包适配)
 
725
 
726
  // 1. 封面层点击
727
  if (posterOverlay) {
728
+ listen(posterOverlay, 'click', () => {
729
  video.play().catch(() => {});
730
  });
731
  }
732
 
733
  // 2. 视频区域点击
734
+ listen(video, 'click', (e) => {
735
  e.stopPropagation();
736
  togglePlay();
737
  });
738
 
739
  // 3. 播放/暂停按钮
740
  if (btnPlay) {
741
+ listen(btnPlay, 'click', (e) => {
742
  e.stopPropagation();
743
  togglePlay();
744
  });
745
  }
746
 
747
  // 4. 进度条交互
748
+ listen(video, 'timeupdate', () => {
749
  if (!isDraggingProgress) updateProgressUI();
750
  });
751
+ listen(video, 'progress', updateBuffered);
752
+ listen(video, 'loadedmetadata', () => {
753
  updateProgressUI();
754
  updateBuffered();
755
  });
756
 
757
  if (progressWrap) {
758
  // 点击进度条
759
+ listen(progressWrap, 'click', (e) => {
760
  if (isDraggingProgress) return;
761
  e.stopPropagation();
762
  seekToRatio(getRatioFromEvent(e, progressWrap));
763
  });
764
 
765
  // 进度条 hover tooltip
766
+ listen(progressWrap, 'mousemove', (e) => {
767
  if (!video.duration || !isFinite(video.duration)) return;
768
  const ratio = getRatioFromEvent(e, progressWrap);
769
  const time = ratio * video.duration;
 
773
  progressTooltip.classList.add('show');
774
  }
775
  });
776
+ listen(progressWrap, 'mouseleave', () => {
777
  if (progressTooltip) progressTooltip.classList.remove('show');
778
  });
779
 
780
  // 拖拽把手
781
  if (progressHandle) {
782
+ listen(progressHandle, 'mousedown', (e) => {
783
  e.stopPropagation();
784
  e.preventDefault();
785
  isDraggingProgress = true;
 
789
  });
790
  }
791
  // 在进度条任意位置按下也可以拖拽
792
+ listen(progressWrap, 'mousedown', (e) => {
793
  if (e.target === progressHandle) return;
794
  isDraggingProgress = true;
795
  if (progressHandle) progressHandle.classList.add('dragging');
 
823
  if (wasPlayingBeforeDrag) video.play().catch(() => {});
824
  };
825
 
826
+ listen(window, 'mousemove', handleMouseMove);
827
+ listen(window, 'mouseup', handleMouseUp);
828
 
829
  // 5. 音量控制
830
  if (volumeSlider) {
831
+ listen(volumeSlider, 'input', () => {
832
  const val = parseInt(volumeSlider.value, 10) / 100;
833
  video.volume = val;
834
  if (val > 0 && video.muted) video.muted = false;
 
836
  });
837
  }
838
  if (btnVolume) {
839
+ listen(btnVolume, 'click', (e) => {
840
  e.stopPropagation();
841
  video.muted = !video.muted;
842
  updateVolumeIcon();
843
  });
844
  }
845
 
846
+ const cleanupAccessibleControls = setupAccessibleVideoControls(player, video, showControls);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
847
 
848
  // 7. 全屏按钮
849
  if (btnFullscreen) {
850
+ listen(btnFullscreen, 'click', (e) => {
851
  e.stopPropagation();
852
  const currentTime = video.currentTime;
853
  video.pause(); // 先暂停内嵌播放器,避免与全屏播放器同时出声
 
855
  player.dataset.videoUrl || '',
856
  player.dataset.posterUrl || '',
857
  {
858
+ signal: listeners.signal,
859
  currentTime,
860
  volume: video.volume,
861
  muted: video.muted,
862
  playbackRate: video.playbackRate,
863
  onClose: (returnTime) => {
864
+ if (!active || !player.isConnected) return;
865
  video.currentTime = returnTime;
866
  updateProgressUI();
867
  updatePlayButton(); // 同步播放按钮状态为暂停
 
872
  }
873
 
874
  // 9. 控制栏自动隐藏
875
+ listen(player, 'mousemove', resetHideTimer);
876
+ listen(player, 'mouseenter', resetHideTimer);
877
+ listen(video, 'play', showControls);
878
+ listen(video, 'pause', showControls);
879
+ listen(video, 'ended', () => {
880
  if (controlsBar) controlsBar.classList.remove('hidden');
881
  });
882
 
 
885
  e.stopPropagation();
886
  e.preventDefault();
887
  };
888
+ listen(player, 'dragstart', stopDrag);
889
+ listen(player, 'dragover', stopDrag);
890
+ listen(player, 'drop', stopDrag);
891
 
892
  // 11. 视频结束回到封面
893
+ listen(video, 'ended', () => {
894
  if (posterOverlay) {
895
  posterOverlay.style.display = 'flex';
896
  posterOverlay.style.opacity = '1';
 
899
  });
900
 
901
  // 播放时隐藏封面
902
+ listen(video, 'play', () => {
903
  if (posterOverlay) {
904
  posterOverlay.style.opacity = '0';
905
+ if (hidePosterTimer) clearTimeout(hidePosterTimer);
906
+ hidePosterTimer = setTimeout(() => {
907
+ hidePosterTimer = null;
908
+ if (active && !video.paused) posterOverlay.style.display = 'none';
909
  }, 300);
910
  }
911
  updatePlayButton();
912
  showControls();
913
  });
914
 
915
+ listen(video, 'pause', () => {
916
  updatePlayButton();
917
  });
918
 
 
921
  updatePlayButton();
922
 
923
  // 🔧 清理函数注册
924
+ const cleanup = (replacementVideo) => {
925
+ if (!active) return;
926
+ active = false;
927
+ listeners.abort();
928
  if (hideControlsTimer) clearTimeout(hideControlsTimer);
929
+ if (hidePosterTimer) clearTimeout(hidePosterTimer);
930
+ hideControlsTimer = hidePosterTimer = null;
931
+ isDraggingProgress = false;
932
+ cleanupAccessibleControls();
933
+ if (replacementVideo === video) video.pause();
934
+ else releaseVideoMedia(video);
935
+ updatePlayButton();
936
  };
937
  container._videoPlayerCleanup = cleanup;
938
  }
 
962
  * @param {boolean} options.muted - 是否静音
963
  * @param {number} options.playbackRate - 播放速度
964
  * @param {Function} options.onClose - 关闭回调(currentTime)
965
+ * @param {AbortSignal} options.signal - 所属页面的关闭信号
966
  */
967
  export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
968
+ if (options.signal?.aborted) return;
969
  injectVideoPlayerStyles();
970
 
971
+ const previousFocus = document.activeElement;
972
  const overlay = document.createElement('div');
973
  overlay.className = 'fullscreen-video-overlay';
974
+ overlay.setAttribute('role', 'dialog');
975
+ overlay.setAttribute('aria-modal', 'true');
976
+ overlay.setAttribute('aria-label', t('video.fullscreen'));
977
 
978
  const safeVideo = (videoUrl || '').replace(/"/g, '&quot;');
979
  const safePoster = (posterUrl || '').replace(/"/g, '&quot;');
 
983
  const startRate = options.playbackRate || 1;
984
 
985
  overlay.innerHTML = `
986
+ <button type="button" class="fs-video-close" title="${t('video.close')}" aria-label="${t('video.close')}">✕</button>
987
  <video preload="metadata" poster="${safePoster}" playsinline
988
  style="width:100vw;height:100vh;object-fit:contain;">
989
  <source src="${safeVideo}">
 
996
  <div class="video-progress-tooltip">0:00</div>
997
  </div>
998
  <div class="video-controls-row">
999
+ <button type="button" class="video-btn-play" title="${t('video.play')}">▶</button>
1000
  <span class="video-time">0:00 / 0:00</span>
1001
  <div class="video-volume-wrap">
1002
+ <button type="button" class="video-btn-volume" title="${t('video.volume')}">🔊</button>
1003
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
1004
  </div>
1005
  <div class="video-speed-wrap">
1006
+ <button type="button" class="video-btn-speed" title="${t('video.speed')}">1x</button>
1007
  <div class="video-speed-menu">
1008
+ <button type="button" data-speed="0.5">0.5x</button>
1009
+ <button type="button" data-speed="1" class="active">1x</button>
1010
+ <button type="button" data-speed="1.25">1.25x</button>
1011
+ <button type="button" data-speed="1.5">1.5x</button>
1012
+ <button type="button" data-speed="2">2x</button>
1013
  </div>
1014
  </div>
1015
+ <button type="button" class="video-btn-fullscreen" title="${t('video.exit_fullscreen')}" aria-label="${t('video.exit_fullscreen')}">⛶</button>
1016
  </div>
1017
  </div>
1018
  `;
 
1050
  let isDraggingProgress = false;
1051
  let wasPlayingBeforeDrag = false;
1052
  let hideControlsTimer = null;
1053
+ const listeners = new AbortController();
1054
+ const listen = (target, type, handler) => target.addEventListener(type, handler, { signal: listeners.signal });
1055
+ let destroyed = false, finished = false, closeTimer = null;
1056
+ let finalTime = 0, restoreFocus = false;
1057
+
1058
+ function finishClose() {
1059
+ if (finished) return;
1060
+ finished = true;
1061
+ if (closeTimer) clearTimeout(closeTimer);
1062
+ closeTimer = null;
1063
+ options.signal?.removeEventListener('abort', handleOwnerAbort);
1064
+ overlay.remove();
1065
+ if (options.signal?.aborted) return;
1066
+ if (typeof options.onClose === 'function') options.onClose(finalTime);
1067
+ if (restoreFocus && previousFocus instanceof HTMLElement && previousFocus.isConnected
1068
+ && (document.activeElement === document.body || overlay.contains(document.activeElement))) previousFocus.focus();
1069
+ }
1070
+
1071
+ function handleOwnerAbort() {
1072
+ if (destroyed) finishClose();
1073
+ else destroy(video.currentTime);
1074
+ }
1075
 
1076
  function destroy(returnTime) {
1077
  if (destroyed) return;
1078
  destroyed = true;
1079
+ finalTime = typeof returnTime === 'number' ? returnTime : video.currentTime;
1080
+ restoreFocus = !options.signal?.aborted && (overlay.contains(document.activeElement) || document.activeElement === document.body);
1081
  if (hideControlsTimer) clearTimeout(hideControlsTimer);
1082
+ listeners.abort();
1083
+ cleanupAccessibleControls();
1084
+ releaseVideoMedia(video);
1085
+
1086
+ if (document.fullscreenElement === overlay) {
1087
+ try { Promise.resolve(document.exitFullscreen()).catch(() => {}); } catch {}
 
 
 
1088
  }
1089
 
1090
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1091
+ overlay.style.transition = reducedMotion ? 'none' : 'opacity 0.25s ease';
1092
  overlay.style.opacity = '0';
1093
+ if (options.signal?.aborted) finishClose();
1094
+ else closeTimer = setTimeout(finishClose, reducedMotion ? 0 : 250);
 
 
 
 
1095
  }
1096
 
1097
  function updatePlayButton() {
 
1146
  }
1147
 
1148
  // 播放/暂停按钮
1149
+ listen(btnPlay, 'click', (e) => {
1150
  e.stopPropagation();
1151
  togglePlay();
1152
  });
1153
 
1154
  // 视频点击切换
1155
+ listen(video, 'click', (e) => {
1156
  e.stopPropagation();
1157
  togglePlay();
1158
  });
1159
 
1160
  // 进度条
1161
+ listen(video, 'timeupdate', () => {
1162
  if (!isDraggingProgress) updateProgressUI();
1163
  });
1164
+ listen(video, 'progress', updateBuffered);
1165
+ listen(video, 'loadedmetadata', () => {
1166
  updateProgressUI();
1167
  updateBuffered();
1168
  });
1169
 
1170
+ listen(progressWrap, 'click', (e) => {
1171
  if (isDraggingProgress) return;
1172
  e.stopPropagation();
1173
  seekToRatio(getRatioFromEvent(e, progressWrap));
1174
  });
1175
 
1176
+ listen(progressWrap, 'mousemove', (e) => {
1177
  if (!video.duration || !isFinite(video.duration)) return;
1178
  const ratio = getRatioFromEvent(e, progressWrap);
1179
  if (progressTooltip) {
 
1182
  progressTooltip.classList.add('show');
1183
  }
1184
  });
1185
+ listen(progressWrap, 'mouseleave', () => {
1186
  if (progressTooltip) progressTooltip.classList.remove('show');
1187
  });
1188
 
1189
+ listen(progressHandle, 'mousedown', (e) => {
1190
  e.stopPropagation();
1191
  e.preventDefault();
1192
  isDraggingProgress = true;
 
1194
  wasPlayingBeforeDrag = !video.paused && !video.ended;
1195
  if (wasPlayingBeforeDrag) video.pause();
1196
  });
1197
+ listen(progressWrap, 'mousedown', (e) => {
1198
  if (e.target === progressHandle) return;
1199
  isDraggingProgress = true;
1200
  progressHandle.classList.add('dragging');
 
1225
  if (progressTooltip) progressTooltip.classList.remove('show');
1226
  if (wasPlayingBeforeDrag) video.play().catch(() => {});
1227
  };
1228
+ listen(window, 'mousemove', handleMouseMove);
1229
+ listen(window, 'mouseup', handleMouseUp);
1230
 
1231
  // 音量
1232
+ listen(volumeSlider, 'input', () => {
1233
  const val = parseInt(volumeSlider.value, 10) / 100;
1234
  video.volume = val;
1235
  if (val > 0 && video.muted) video.muted = false;
1236
  updateVolumeIcon();
1237
  });
1238
+ listen(btnVolume, 'click', (e) => {
1239
  e.stopPropagation();
1240
  video.muted = !video.muted;
1241
  updateVolumeIcon();
1242
  });
1243
 
1244
+ const cleanupAccessibleControls = setupAccessibleVideoControls(overlay, video, showControls);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1245
 
1246
  // 全屏按钮(在全屏模式下退出全屏)
1247
+ listen(btnFullscreen, 'click', (e) => {
1248
  e.stopPropagation();
1249
  destroy(video.currentTime);
1250
  });
1251
 
1252
  // 关闭按钮
1253
+ listen(closeBtn, 'click', () => destroy(video.currentTime));
1254
 
1255
  const handleKeydown = event => {
1256
+ if (!event.defaultPrevented && !event.isComposing && event.keyCode !== 229
1257
+ && !event.ctrlKey && !event.metaKey && !event.altKey && event.key === 'Tab') {
1258
+ const focusable = [...overlay.querySelectorAll('button, input, [tabindex]')]
1259
+ .filter(node => !node.disabled && node.tabIndex >= 0 && node.getClientRects().length);
1260
+ const first = focusable[0], last = focusable.at(-1);
1261
+ if (first && ((event.shiftKey && document.activeElement === first)
1262
+ || (!event.shiftKey && document.activeElement === last))) {
1263
+ event.preventDefault();
1264
+ event.stopPropagation();
1265
+ (event.shiftKey ? last : first).focus();
1266
+ }
1267
+ return;
1268
+ }
1269
  if (event.defaultPrevented || event.isComposing || event.keyCode === 229
1270
  || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey
1271
  || event.key !== 'Escape') return;
 
1273
  event.stopPropagation();
1274
  destroy(video.currentTime);
1275
  };
1276
+ listen(overlay, 'keydown', handleKeydown);
1277
  closeBtn.focus();
1278
 
1279
  // 控制栏自动隐藏
1280
+ listen(overlay, 'mousemove', resetHideTimer);
1281
+ listen(overlay, 'mouseenter', resetHideTimer);
1282
+ listen(video, 'play', showControls);
1283
+ listen(video, 'pause', showControls);
1284
+ listen(video, 'ended', () => {
1285
  if (controlsBar) controlsBar.classList.remove('hidden');
1286
  });
1287
 
1288
  // 播放/暂停状态更新
1289
+ listen(video, 'play', updatePlayButton);
1290
+ listen(video, 'pause', updatePlayButton);
1291
+ listen(video, 'ended', updatePlayButton);
1292
+
1293
+ options.signal?.addEventListener('abort', handleOwnerAbort, { once: true });
1294
+ if (options.signal?.aborted) { handleOwnerAbort(); return; }
1295
 
1296
  // 尝试调用 Fullscreen API
1297
  const requestFS = () => {
前端页面/components/顶部广告组件.js CHANGED
@@ -309,142 +309,60 @@ export async function refreshBanner() {
309
  * 创建广告详情页视图
310
  * @returns {HTMLElement} 详情页DOM
311
  */
312
- function createBannerDetailView() {
313
- const wrapper = document.createElement("div");
314
- Object.assign(wrapper.style, {
315
- width: "100%",
316
- height: "100%",
317
- display: "flex",
318
- flexDirection: "column",
319
- backgroundColor: "#1a1a1a",
320
- color: "#fff"
321
- });
322
-
323
- // 顶部返回栏
324
- const topBar = document.createElement("div");
325
- Object.assign(topBar.style, {
326
- display: "flex",
327
- alignItems: "center",
328
- padding: "12px 16px",
329
- borderBottom: "1px solid #333",
330
- flexShrink: "0"
331
- });
332
-
333
- const backBtn = document.createElement("button");
334
- Object.assign(backBtn.style, {
335
- marginLeft: "15px",
336
- marginTop: "20px",
337
- background: "rgba(51,51,51,0.8)",
338
- border: "1px solid rgba(85,85,85,0.8)",
339
- color: "#fff",
340
- cursor: "pointer",
341
- fontSize: "13px",
342
- padding: "6px 14px",
343
- borderRadius: "6px",
344
- fontWeight: "bold",
345
- display: "flex",
346
- alignItems: "center",
347
- gap: "6px",
348
- boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
349
- transition: "0.2s"
350
- });
351
- backBtn.textContent = `⬅ ${t("common.back")}`;
352
- backBtn.onmouseover = () => {
353
- backBtn.style.background = "#4CAF50";
354
- backBtn.style.borderColor = "#4CAF50";
355
- };
356
- backBtn.onmouseout = () => {
357
- backBtn.style.background = "rgba(51,51,51,0.8)";
358
- backBtn.style.borderColor = "rgba(85,85,85,0.8)";
359
- };
360
- backBtn.onclick = () => {
361
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
362
- };
363
-
364
- const pageTitle = document.createElement("span");
365
- pageTitle.textContent = activeConfig.title || t("common.advertisement_details");
366
- Object.assign(pageTitle.style, {
367
- marginLeft: "12px",
368
- fontSize: "16px",
369
- fontWeight: "bold"
370
- });
371
-
372
- topBar.appendChild(backBtn);
373
- topBar.appendChild(pageTitle);
374
-
375
- // 内容区(可滚动)
376
- const content = document.createElement("div");
377
- Object.assign(content.style, {
378
- flex: "1",
379
- overflowY: "auto",
380
- padding: "24px 20px",
381
- display: "flex",
382
- flexDirection: "column",
383
- alignItems: "center",
384
- gap: "20px"
385
- });
386
-
387
- // 广告标题
388
- if (activeConfig.title) {
389
- const titleEl = document.createElement("h2");
390
- titleEl.textContent = activeConfig.title;
391
- Object.assign(titleEl.style, {
392
- margin: "0",
393
- fontSize: "22px",
394
- fontWeight: "bold",
395
- textAlign: "center"
396
- });
397
- content.appendChild(titleEl);
398
- }
399
-
400
- // 广告描述
401
- if (activeConfig.description) {
402
- const descEl = document.createElement("p");
403
- descEl.textContent = activeConfig.description;
404
- Object.assign(descEl.style, {
405
- margin: "0",
406
- fontSize: "15px",
407
- color: "#aaa",
408
- textAlign: "center",
409
- lineHeight: "1.6"
410
- });
411
- content.appendChild(descEl);
412
- }
413
-
414
- // 广告大图
415
- if (activeConfig.detailImage) {
416
- const detailImg = document.createElement("img");
417
- detailImg.src = activeConfig.detailImage;
418
- detailImg.alt = activeConfig.title || t("common.advertisement_details_image");
419
- Object.assign(detailImg.style, {
420
- maxWidth: "100%",
421
- borderRadius: "8px"
422
- });
423
- content.appendChild(detailImg);
424
- }
425
-
426
- // 详细内容
427
- if (activeConfig.detailContent) {
428
- const detailEl = document.createElement("div");
429
- Object.assign(detailEl.style, {
430
- width: "100%",
431
- maxWidth: "600px",
432
- padding: "20px",
433
- backgroundColor: "#252525",
434
- borderRadius: "8px",
435
- lineHeight: "1.8",
436
- fontSize: "14px",
437
- color: "#ddd",
438
- whiteSpace: "pre-wrap",
439
- wordWrap: "break-word"
440
- });
441
- // 支持HTML内容
442
- detailEl.replaceChildren(sanitizeBannerDetailContent(activeConfig.detailContent));
443
- content.appendChild(detailEl);
444
- }
445
-
446
- wrapper.appendChild(topBar);
447
- wrapper.appendChild(content);
448
-
449
- return wrapper;
450
- }
 
309
  * 创建广告详情页视图
310
  * @returns {HTMLElement} 详情页DOM
311
  */
312
+ function createBannerDetailView() {
313
+ const wrapper = document.createElement("div");
314
+ wrapper.className = "banner-detail";
315
+
316
+ const topBar = document.createElement("div");
317
+ topBar.className = "banner-detail__header";
318
+
319
+ const backBtn = document.createElement("button");
320
+ backBtn.type = "button";
321
+ backBtn.className = "about-back banner-detail__back";
322
+ backBtn.textContent = `⬅ ${t("common.back")}`;
323
+ backBtn.onclick = () => {
324
+ window.dispatchEvent(new CustomEvent("comfy-route-back"));
325
+ };
326
+
327
+ const pageTitle = document.createElement("h2");
328
+ pageTitle.className = "banner-detail__heading";
329
+ pageTitle.textContent = activeConfig.title || t("common.advertisement_details");
330
+ topBar.appendChild(backBtn);
331
+ topBar.appendChild(pageTitle);
332
+
333
+ const content = document.createElement("div");
334
+ content.className = "banner-detail__content";
335
+
336
+ if (activeConfig.title) {
337
+ const titleEl = document.createElement("h2");
338
+ titleEl.className = "banner-detail__title";
339
+ titleEl.textContent = activeConfig.title;
340
+ content.appendChild(titleEl);
341
+ }
342
+
343
+ if (activeConfig.description) {
344
+ const descEl = document.createElement("p");
345
+ descEl.className = "banner-detail__description";
346
+ descEl.textContent = activeConfig.description;
347
+ content.appendChild(descEl);
348
+ }
349
+
350
+ if (activeConfig.detailImage) {
351
+ const detailImg = document.createElement("img");
352
+ detailImg.className = "banner-detail__image";
353
+ detailImg.src = activeConfig.detailImage;
354
+ detailImg.alt = activeConfig.title || t("common.advertisement_details_image");
355
+ content.appendChild(detailImg);
356
+ }
357
+
358
+ if (activeConfig.detailContent) {
359
+ const detailEl = document.createElement("div");
360
+ detailEl.className = "banner-detail__rich-content";
361
+ detailEl.replaceChildren(sanitizeBannerDetailContent(activeConfig.detailContent));
362
+ content.appendChild(detailEl);
363
+ }
364
+
365
+ wrapper.appendChild(topBar);
366
+ wrapper.appendChild(content);
367
+ return wrapper;
368
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
前端页面/core/网络请求_基础设施.js CHANGED
@@ -623,7 +623,7 @@ async function performRequest(endpoint, options = {}, sample = null) {
623
  }
624
 
625
  // 🚀 P1优化:精确清除相关缓存(替代暴力清空)
626
- if (["POST", "PUT", "DELETE"].includes(method)) {
627
  invalidateRelatedCache(endpoint, method);
628
  }
629
 
 
623
  }
624
 
625
  // 🚀 P1优化:精确清除相关缓存(替代暴力清空)
626
+ if (["POST", "PUT", "DELETE"].includes(method) && responseData?.status === "success") {
627
  invalidateRelatedCache(endpoint, method);
628
  }
629
 
前端页面/styles/主题基础.css CHANGED
@@ -2777,3 +2777,25 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
2777
  @keyframes ranking-skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2778
  @media (prefers-reduced-motion: reduce) { .skeleton-container .skeleton-item { animation: none; } }
2779
  @media (forced-colors: active) { .skeleton-container .skeleton-card { background: Canvas; border-color: CanvasText; } .skeleton-container .skeleton-item { background: GrayText; animation: none; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2777
  @keyframes ranking-skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2778
  @media (prefers-reduced-motion: reduce) { .skeleton-container .skeleton-item { animation: none; } }
2779
  @media (forced-colors: active) { .skeleton-container .skeleton-card { background: Canvas; border-color: CanvasText; } .skeleton-container .skeleton-item { background: GrayText; animation: none; } }
2780
+
2781
+ .banner-detail { display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; box-sizing: border-box; background: var(--rk-bg, var(--comfy-menu-bg, #191d24)); color: var(--rk-text, var(--fg-color, #eee)); }
2782
+ .banner-detail__header { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; flex: 0 0 auto; min-width: 0; padding: 12px 16px; border-bottom: 1px solid var(--rk-border, var(--border-color, #555)); }
2783
+ .banner-detail__back { flex: 0 0 auto; max-width: 100%; }
2784
+ .banner-detail__heading { flex: 1 1 120px; min-width: 0; margin: 0; font-size: 16px; line-height: 1.4; overflow-wrap: anywhere; }
2785
+ .banner-detail__content { display: flex; flex-direction: column; align-items: center; gap: 20px; flex: 1 1 auto; min-width: 0; min-height: 0; overflow-y: auto; padding: 24px 20px; box-sizing: border-box; }
2786
+ .banner-detail__content > * { min-width: 0; max-width: 100%; box-sizing: border-box; overflow-wrap: anywhere; }
2787
+ .banner-detail__title { margin: 0; font-size: 22px; font-weight: bold; text-align: center; }
2788
+ .banner-detail__description { margin: 0; font-size: 15px; line-height: 1.6; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); text-align: center; }
2789
+ .banner-detail__image { height: auto; border-radius: var(--rk-radius-md, 8px); }
2790
+ .banner-detail__rich-content { width: 100%; max-width: 600px; padding: 20px; border-radius: var(--rk-radius-md, 8px); background: var(--rk-panel, var(--comfy-input-bg, #292929)); color: var(--rk-text, var(--fg-color, #eee)); font-size: 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
2791
+ .banner-detail__rich-content :is(pre, code, a) { max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; }
2792
+ .banner-detail__rich-content a[href] { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
2793
+ .banner-detail__rich-content a[href]:focus-visible { outline: 2px solid var(--rk-focus, #8ab4ff); outline-offset: 2px; }
2794
+ @media (forced-colors: active) {
2795
+ .banner-detail, .banner-detail__rich-content { background: Canvas; color: CanvasText; }
2796
+ .banner-detail__header { border-color: ButtonText; }
2797
+ .banner-detail__description { color: CanvasText; }
2798
+ .banner-detail__back { background: ButtonFace; color: ButtonText; border-color: ButtonText; }
2799
+ .banner-detail__rich-content a[href] { color: LinkText; }
2800
+ .banner-detail :is(button, a[href]):focus-visible { outline-color: Highlight; }
2801
+ }