Przeglądaj źródła

播放改成静态页面

y595705120 2 tygodni temu
rodzic
commit
08f2401831

+ 4 - 1
src/containers/center/play/index.vue

@@ -347,7 +347,10 @@
       loadMedia( item, index ) {
           let courseId = this.courseId
           let mediaId = item.mediaId;
-          const {href} = this.$router.resolve({name:'playMedia', params:{courseId}, query:{mediaId}})
+          let uid = localStorage.uid
+          let token = localStorage.token
+          let href = `/play.html?courseId=${courseId}&uid=${uid}&token=${token}&mediaId=${mediaId}`
+          // const {href} = this.$router.resolve({name:'playMedia', params:{courseId}, query:{mediaId}})
           window.open(href, '_blank');
       }
     }

+ 0 - 7
src/containers/center/play/play.vue

@@ -471,15 +471,8 @@
         if (!myPlayer) return;
         let curTimes = parseInt(this.getPlayerTime());
         this.curTimes = curTimes || 0
-        if (curTimes < 4) {
-          return;
-        }
-        if (this.media.position > curTimes + 5 && !force) return;
         let isFinish = force ? 1 : 0
         if (curTimes >= media.duration) isFinish = 1;
-        // if (!isFinish) {
-        //   if (!this.onPlay) return;
-        // }
         if (window.navigator.webdriver) {
           this.reportErr("play", 'webdriver');
           this.doPause()

Plik diff jest za duży
+ 0 - 0
static/js/hls.min.js


Plik diff jest za duży
+ 0 - 0
static/js/md5.min.js


+ 754 - 0
static/play.html

@@ -0,0 +1,754 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>视频播放 - 宁德市建筑业协会</title>
+  <script src="./js/hls.min.js"></script>
+  <script src="./js/md5.min.js"></script>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+      background: #f0f2f5;
+      min-height: 100vh;
+    }
+    .pc-player-container {
+      min-height: 100vh;
+      background: #f0f2f5;
+      padding: 20px;
+    }
+    .pc-player-wrapper {
+      max-width: 1440px;
+      margin: 0 auto;
+      display: flex;
+      gap: 20px;
+      height: calc(100vh - 40px);
+    }
+    .pc-player-left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+    .panel-toggle {
+      flex-shrink: 0; width: 20px; display: flex; align-items: center; justify-content: center;
+      cursor: pointer; color: #999; font-size: 14px; user-select: none;
+    }
+    .panel-toggle:hover { color: #409EFF; }
+    .video-section {
+      background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden;
+      display: flex; flex-direction: column; height: 100%;
+    }
+    .video-title {
+      padding: 16px 20px; font-size: 16px; font-weight: 600; color: #1a1a2e;
+      border-bottom: 1px solid #f0f0f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+      flex-shrink: 0;
+    }
+    .video-player-wrap {
+      width: 100%; background: #000; position: relative; flex: 1; min-height: 0;
+      display: flex; align-items: center; justify-content: center;
+    }
+    .video-player-wrap video {
+      width: 100%; height: 100%; display: block; outline: none;
+      max-height: calc(100vh - 200px);
+    }
+    .loading-overlay {
+      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+      background: rgba(0,0,0,0.5); color: #fff; font-size: 16px; z-index: 10;
+      transition: opacity .3s;
+    }
+    .loading-overlay.hidden { opacity: 0; pointer-events: none; }
+    .loading-spinner { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 12px; }
+    @keyframes spin { to { transform: rotate(360deg); } }
+    .dialog-footer {
+      padding: 8px 16px; border-top: 1px solid #f0f0f0; background: #fafafa; flex-shrink: 0;
+    }
+    .media-footer { display: flex; align-items: center; line-height: 1; }
+    .media-time {
+      font-size: 14px; color: #666; white-space: nowrap; font-variant-numeric: tabular-nums;
+      display: flex; align-items: center; gap: 4px;
+    }
+    .media-center { display: flex; align-items: center; justify-content: center; gap: 12px; flex:1; }
+    .btn-icon {
+      display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600;
+      cursor: pointer; user-select: none; padding: 4px 8px; border: 1px solid #dcdfe6;
+      border-radius: 4px; color: #606266; background: #fff; transition: all .2s;
+    }
+    .btn-icon:hover { color: #409EFF; border-color: #c6e2ff; background: #ecf5ff; }
+    .btn-icon.is-active { color: #409EFF; border-color: #409EFF; background: #ecf5ff; }
+    .volume-group { display: inline-flex; align-items: center; gap: 6px; }
+    .volume-slider { width: 100px; height: 4px; -webkit-appearance: none; appearance: none; background: #dcdfe6; border-radius: 2px; outline: none; }
+    .volume-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #409EFF; cursor: pointer; }
+    .quality-select {
+      height: 28px; font-size: 12px; padding: 0 8px; border: 1px solid #dcdfe6;
+      border-radius: 4px; outline: none; color: #606266; background: #fff; cursor: pointer;
+    }
+    .quality-select:focus { border-color: #409EFF; }
+
+    .pc-player-right {
+      width: 380px; flex-shrink: 0; background: #fff; border-radius: 12px;
+      box-shadow: 0 2px 12px rgba(0,0,0,0.08); display: flex; flex-direction: column; overflow: hidden;
+    }
+    .playlist-header {
+      padding: 16px 20px 12px; border-bottom: 1px solid #f0f0f0; flex-shrink: 0;
+    }
+    .playlist-title { font-size: 16px; font-weight: 600; color: #1a1a2e; }
+    .playlist-header-row {
+      display: flex; align-items: center; justify-content: space-between; margin-top: 6px;
+    }
+    .playlist-count { font-size: 12px; color: #999; }
+    .finished-count { color: #67C23A; }
+    .hide-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; color: #666; user-select: none; }
+    .hide-toggle input { cursor: pointer; }
+    .playlist-body { flex: 1; overflow-y: auto; padding: 8px 0; }
+    .playlist-body::-webkit-scrollbar { width: 6px; }
+    .playlist-body::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
+    .playlist-item {
+      display: flex; align-items: center; padding: 12px 20px; cursor: pointer;
+      transition: all .2s; border-left: 3px solid transparent; gap: 12px;
+    }
+    .playlist-item:hover { background: #f5f7fa; }
+    .playlist-item.is-active { background: #ecf5ff; border-left-color: #409EFF; }
+    .playlist-item.is-finished { opacity: .65; }
+    .item-index {
+      width: 28px; height: 28px; border-radius: 50%; background: #f0f2f5;
+      display: flex; align-items: center; justify-content: center; font-size: 12px;
+      font-weight: 600; color: #666; flex-shrink: 0;
+    }
+    .item-index.active-index { background: #409EFF; color: #fff; }
+    .finish-badge { color: #67C23A; font-size: 14px; }
+    .item-info { flex: 1; min-width: 0; }
+    .item-name { font-size: 14px; color: #333; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
+    .is-finished .item-name { color: #999; text-decoration: line-through; }
+    .is-active .item-name { color: #409EFF; }
+    .item-meta { display: flex; gap: 12px; font-size: 12px; color: #999; }
+    .item-progress { color: #409EFF; }
+    .is-finished .item-progress { color: #67C23A; }
+    .item-action { flex-shrink: 0; }
+    .play-btn {
+      padding: 4px 10px; font-size: 12px; border: 1px solid #409EFF; color: #409EFF;
+      background: transparent; border-radius: 4px; cursor: pointer; transition: all .2s;
+    }
+    .play-btn:hover { background: #409EFF; color: #fff; }
+    .play-btn.pause-btn { background: #e6a23c; border-color: #e6a23c; color: #fff; }
+    .playlist-empty { text-align: center; padding: 60px 20px; color: #ccc; font-size: 14px; }
+
+    .toast {
+      position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 9999;
+      padding: 10px 24px; border-radius: 6px; font-size: 14px; color: #fff;
+      transition: all .3s; opacity: 0; pointer-events: none;
+    }
+    .toast.show { opacity: 1; }
+    .toast.success { background: #67C23A; }
+    .toast.error { background: #F56C6C; }
+    .toast.info { background: #909399; }
+
+    @media (max-width: 900px) {
+      .pc-player-wrapper { flex-direction: column; height: auto; }
+      .pc-player-right { width: 100%; max-height: 400px; }
+      .pc-player-container { padding: 12px; }
+      .volume-slider { width: 60px; }
+    }
+  </style>
+</head>
+<body>
+  <div class="pc-player-container">
+    <div class="pc-player-wrapper">
+      <div class="pc-player-left">
+        <div class="video-section">
+          <div class="video-title" id="videoTitle">加载中...</div>
+          <div class="video-player-wrap">
+            <video id="videoElement" controls playsinline preload="auto"></video>
+            <div class="loading-overlay" id="loadingOverlay">
+              <div class="loading-spinner"></div>
+              <span>加载中...</span>
+            </div>
+          </div>
+          <div class="dialog-footer">
+            <div class="media-footer">
+              <div class="media-time">
+                <span id="currentTime">00:00:00</span>
+                <strong>/</strong>
+                <span id="totalTime">00:00:00</span>
+              </div>
+              <div class="media-center">
+                <span class="volume-group">
+                  <span class="btn-icon" id="muteBtn">音量</span>
+                  <input type="range" class="volume-slider" id="volumeSlider" min="0" max="1" step="0.05" value="0.5">
+                </span>
+                <span class="btn-icon" id="pipBtn">画中画</span>
+              </div>
+              <div style="display:flex;align-items:center;justify-content:flex-end;flex:1;">
+                <select class="quality-select" id="qualitySelect">
+                  <option value="ld">流畅</option>
+                  <option value="hls" selected>标清</option>
+                </select>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="panel-toggle" id="panelToggle">&lt;</div>
+
+      <div class="pc-player-right" id="playlistPanel">
+        <div class="playlist-header">
+          <div class="playlist-title">课程列表</div>
+          <div class="playlist-header-row">
+            <div class="playlist-count">
+              共 <span id="totalCount">0</span> 个视频
+              <span class="finished-count" id="finishedCountWrap" style="display:none;">,已完成 <span id="finishedCount">0</span> 个</span>
+            </div>
+            <label class="hide-toggle">
+              <input type="checkbox" id="hideFinishedCheck"> 隐藏已完成
+            </label>
+          </div>
+        </div>
+        <div class="playlist-body" id="playlistBody"></div>
+      </div>
+    </div>
+  </div>
+
+  <div class="toast" id="toast"></div>
+
+  <script>
+    const VERSION = '1.0';
+    const SECRET = '017c0743819088468e590246464cc75f';
+    const API_BASE = 'https://ndapi.hqedust.com:18443/rental';
+
+    // ---- DOM refs ----
+    const video = document.getElementById('videoElement');
+    const loadingOverlay = document.getElementById('loadingOverlay');
+    const videoTitle = document.getElementById('videoTitle');
+    const currentTimeEl = document.getElementById('currentTime');
+    const totalTimeEl = document.getElementById('totalTime');
+    const volumeSlider = document.getElementById('volumeSlider');
+    const muteBtn = document.getElementById('muteBtn');
+    const pipBtn = document.getElementById('pipBtn');
+    const qualitySelect = document.getElementById('qualitySelect');
+    const panelToggle = document.getElementById('panelToggle');
+    const playlistPanel = document.getElementById('playlistPanel');
+    const playlistBody = document.getElementById('playlistBody');
+    const totalCount = document.getElementById('totalCount');
+    const finishedCount = document.getElementById('finishedCount');
+    const finishedCountWrap = document.getElementById('finishedCountWrap');
+    const hideFinishedCheck = document.getElementById('hideFinishedCheck');
+    const toast = document.getElementById('toast');
+
+    // ---- state ----
+    let uid = 0, token = '', courseId = 0, mediaId = 0;
+    let mediaList = [];
+    let currentMedia = null;
+    let currentMediaUrl = '';
+    let isPlaying = false;
+    let isMuted = false;
+    let volume = 0.5;
+    let curTimes = 0;
+    let tickTimer = null;
+    let tickNum = 0;
+    let hasSeeked = false;
+    let hiddenStartTime = 0;
+    let hiddenBasePosition = 0;
+    let isPiP = false;
+    let hls = null;
+    let showPanel = true;
+
+    // ---- utils ----
+    function parseTime(seconds) {
+      seconds = parseInt(seconds) || 0;
+      const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
+      const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
+      const s = String(seconds % 60).padStart(2, '0');
+      return h + ':' + m + ':' + s;
+    }
+
+    function getPercent(item) {
+      if (item.isFinish == 1) return 100;
+      if (!item.duration) return 0;
+      let p = parseInt((item.position || 0) * 10000 / item.duration) / 100;
+      return p >= 100 ? 99.99 : p;
+    }
+
+    function showToast(msg, type, duration) {
+      toast.textContent = msg;
+      toast.className = 'toast ' + type + ' show';
+      clearTimeout(toast._timer);
+      toast._timer = setTimeout(() => toast.classList.remove('show'), duration || 3000);
+    }
+
+    // ---- API ----
+    function signRequest(method, data) {
+      const body = JSON.stringify(data);
+      const mtime = Math.floor(Date.now() / 1000);
+      const raw = 'hall_' + VERSION + token + body + method + mtime + SECRET;
+      const sign = md5(raw);
+      const u = method.startsWith('Auth.') ? '0' : (uid || 0);
+      return API_BASE + '/' + method + '?t=' + mtime + '&u=' + u + '&v=' + VERSION + '&s=' + sign;
+    }
+
+    async function api(method, data) {
+      const url = signRequest(method, data);
+      const res = await fetch(url, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(data)
+      });
+      const json = await res.json();
+      if (json.code === 401) {
+        showToast('用户被踢下线,请重新登入', 'error', 5000);
+        return json;
+      }
+      if (json.code !== 200) {
+        showToast(json.msg || '请求失败', 'error');
+      }
+      return json;
+    }
+
+    function report(action, msg) {
+      api('course.report', { action, msg });
+    }
+
+    // ---- player ----
+    function loadVideo(src, position) {
+      if (hls) {
+        hls.destroy();
+        hls = null;
+      }
+      video.removeAttribute('src');
+
+      if (Hls.isSupported()) {
+        hls = new Hls({
+          enableWorker: true,
+          xhrSetup: function(xhr) {
+            xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+            xhr.setRequestHeader('Pragma', 'no-cache');
+          }
+        });
+        hls.loadSource(src);
+        hls.attachMedia(video);
+        hls.on(Hls.Events.MANIFEST_PARSED, function() {
+          loadingOverlay.classList.add('hidden');
+          if (position > 0) {
+            video.currentTime = position;
+          }
+          safePlay();
+        });
+        hls.on(Hls.Events.ERROR, function(e, data) {
+          if (data.fatal) {
+            showToast('视频加载失败,尝试切换清晰度', 'error');
+          }
+        });
+      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
+        video.src = src;
+        video.addEventListener('loadedmetadata', function() {
+          loadingOverlay.classList.add('hidden');
+          if (position > 0) video.currentTime = position;
+          safePlay();
+        }, { once: true });
+      } else {
+        showToast('您的浏览器不支持 HLS 播放', 'error');
+      }
+    }
+
+    function safePlay() {
+      const p = video.play();
+      if (p && p.catch) p.catch(function() {});
+    }
+
+    function setVolume(val) {
+      volume = Math.max(0, Math.min(1, val));
+      video.volume = volume;
+      video.muted = isMuted;
+      volumeSlider.value = volume;
+      muteBtn.textContent = volume === 0 || isMuted ? '静音' : '音量';
+    }
+
+    function setPosition(pos) {
+      if (pos < 10) return;
+      if (pos > (currentMedia ? currentMedia.duration : Infinity)) pos = currentMedia.duration;
+      video.currentTime = pos;
+      safePlay();
+      if (currentMedia && currentMedia.isFinish) return;
+      if (currentMedia && currentMedia.position >= currentMedia.duration - 10 && !currentMedia.isFinish) {
+        tick(true);
+      }
+    }
+
+    // ---- course loading ----
+    async function loadCourse() {
+      const res = await api('course.getCourse', { courseId });
+      if (res.code !== 200) return;
+      const { info, extra, list, tpl } = res.data;
+      const courseInfo = Object.assign(info || {}, extra || {});
+      mediaList = (list || []).map(function(item) {
+        item.percent = getPercent(item);
+        return item;
+      });
+      updatePlaylist();
+
+      let target = null;
+      if (mediaId) {
+        target = mediaList.find(function(i) { return i.id == mediaId || i.mediaId == mediaId; });
+      }
+      if (!target) target = mediaList.find(function(i) { return i.isFinish != 1; });
+      if (!target && mediaList.length) target = mediaList[0];
+      if (target) {
+        loadMedia(target);
+      } else {
+        videoTitle.textContent = (tpl || {}).name || '暂无视频';
+      }
+    }
+
+    async function loadMedia(item) {
+      currentMedia = item;
+      videoTitle.textContent = item.name;
+      loadingOverlay.classList.remove('hidden');
+      hasSeeked = false;
+      isPlaying = false;
+      stopTick();
+
+      const res = await api('course.GetMedia', { id: item.id });
+      if (res.code !== 200) {
+        loadingOverlay.classList.add('hidden');
+        return;
+      }
+      const data = res.data || {};
+      let mediaUrl = data.mediaUrl || '';
+      const position = (data.position || 0) < 10 ? 0 : (data.position || 0);
+      const duration = data.duration || 0;
+
+      currentMediaUrl = mediaUrl;
+      currentMedia.position = position;
+      currentMedia.duration = duration;
+      currentMedia.id = data.id || item.id;
+
+      qualitySelect.value = mediaUrl.includes('/ld/') ? 'ld' : 'hls';
+
+      curTimes = 0;
+      loadVideo(mediaUrl, position);
+      startTick();
+      updatePlaylist();
+      report('play', 'start');
+    }
+
+    function changeMedia(quality) {
+      if (!currentMediaUrl) return;
+      let url = currentMediaUrl;
+      if (quality === 'ld') {
+        url = url.replace('/hls/', '/ld/');
+      } else {
+        url = url.replace('/ld/', '/hls/');
+      }
+      currentMediaUrl = url;
+      const pos = video.currentTime || 0;
+      loadVideo(url, pos > 0 ? pos : 0);
+    }
+
+    // ---- tick system ----
+    function startTick() {
+      stopTick();
+      tickTimer = setInterval(tryTick, 5000);
+    }
+
+    function stopTick() {
+      if (tickTimer) {
+        clearInterval(tickTimer);
+        tickTimer = null;
+      }
+    }
+
+    function getPlayerTime() {
+      if (hiddenStartTime) {
+        const elapsed = (Date.now() - hiddenStartTime) / 1000;
+        const estimated = hiddenBasePosition + elapsed;
+        const actual = video.currentTime || 0;
+        if (actual >= estimated - 0.5) {
+          hiddenStartTime = 0;
+          return actual;
+        }
+        return Math.min(estimated, currentMedia ? currentMedia.duration : Infinity);
+      }
+      return video.currentTime || 0;
+    }
+
+    function tryTick() {
+      try { doTick(); } catch (e) { report('play', '' + e.message); }
+    }
+
+    async function doTick(force) {
+      if (!currentMedia || currentMedia.isFinish) return;
+      tickNum++;
+      const ct = Math.floor(getPlayerTime());
+      curTimes = ct;
+
+      if (navigator.webdriver) {
+        report('play', 'webdriver');
+        doPause();
+        return;
+      }
+
+      let isFinish = force ? 1 : 0;
+      if (ct >= currentMedia.duration) isFinish = 1;
+
+      const res = await api('course.tick', {
+        id: currentMedia.id,
+        position: ct,
+        isFinish: isFinish
+      });
+      if (res.code !== 200) return;
+      const d = res.data || {};
+      if (d.closed) {
+        doPause();
+        showToast(d.msg || '课程已关闭', 'error', 10000);
+        return;
+      }
+      if (d.pause) {
+        doPause();
+        showToast(d.msg || '已暂停', 'info', 10000);
+        setTimeout(playNext, 10000);
+        return;
+      }
+      if (!d.skip) {
+        const diff = Math.abs((d.position || 0) - ct);
+        if (diff > 3) setPosition(d.position);
+      }
+      Object.assign(currentMedia, d);
+      if (d.isFinish == 1) {
+        updatePlaylist();
+        await new Promise(r => setTimeout(r, 1000));
+        playNext();
+      }
+      updatePlaylist();
+    }
+
+    function tick(force) {
+      doTick(force);
+    }
+
+    // ---- playlist ----
+    function updatePlaylist() {
+      const finished = mediaList.filter(function(i) { return i.isFinish == 1; }).length;
+      totalCount.textContent = mediaList.length;
+      if (finished > 0) {
+        finishedCountWrap.style.display = 'inline';
+        finishedCount.textContent = finished;
+      } else {
+        finishedCountWrap.style.display = 'none';
+      }
+
+      const hide = hideFinishedCheck.checked;
+      const filtered = hide ? mediaList.filter(function(i) { return i.isFinish != 1; }) : mediaList;
+
+      playlistBody.innerHTML = '';
+      filtered.forEach(function(item, idx) {
+        const div = document.createElement('div');
+        div.className = 'playlist-item';
+        if (currentMedia && currentMedia.id === item.id) div.classList.add('is-active');
+        if (item.isFinish == 1) div.classList.add('is-finished');
+
+        const isCurrent = currentMedia && currentMedia.id === item.id;
+        const origIdx = mediaList.indexOf(item);
+
+        div.innerHTML =
+          '<div class="item-index' + (isCurrent ? ' active-index' : '') + '">' +
+            (item.isFinish == 1 ? '<span class="finish-badge">&#10003;</span>' : '<span>' + (origIdx + 1) + '</span>') +
+          '</div>' +
+          '<div class="item-info">' +
+            '<div class="item-name">' + item.name + '</div>' +
+            '<div class="item-meta">' +
+              '<span>学时:' + (item.xs / 10) + '</span>' +
+              '<span class="item-progress">' + getPercent(item) + '%</span>' +
+            '</div>' +
+          '</div>' +
+          '<div class="item-action">' +
+            (isCurrent && isPlaying
+              ? '<button class="play-btn pause-btn" data-action="pause">暂停</button>'
+              : isCurrent
+                ? '<button class="play-btn" data-action="play">播放</button>'
+                : '<button class="play-btn" data-action="load">播放</button>') +
+          '</div>';
+
+        div.addEventListener('click', function(e) {
+          if (e.target.tagName === 'BUTTON') return;
+          loadMedia(item);
+        });
+        const btn = div.querySelector('button');
+        if (btn) {
+          btn.addEventListener('click', function(e) {
+            e.stopPropagation();
+            const action = btn.dataset.action;
+            if (action === 'play') doPlay();
+            else if (action === 'pause') doPause();
+            else loadMedia(item);
+          });
+        }
+        playlistBody.appendChild(div);
+      });
+
+      if (filtered.length === 0) {
+        playlistBody.innerHTML = '<div class="playlist-empty">暂无视频</div>';
+      }
+    }
+
+    function playNext() {
+      if (!currentMedia) return;
+      const idx = mediaList.findIndex(function(i) { return i.id === currentMedia.id; });
+      let next = null;
+      for (let i = idx + 1; i < mediaList.length; i++) {
+        if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
+      }
+      if (!next) {
+        for (let i = 0; i < idx; i++) {
+          if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
+        }
+      }
+      if (next) {
+        showToast('即将播放: ' + next.name, 'success');
+        setTimeout(function() { loadMedia(next); }, 1000);
+      }
+    }
+
+    function doPlay() {
+      isPlaying = true;
+      startTick();
+      safePlay();
+      tickNum = 0;
+      updatePlaylist();
+    }
+
+    function doPause() {
+      isPlaying = false;
+      stopTick();
+      video.pause();
+      updatePlaylist();
+    }
+
+    // ---- PiP ----
+    async function enterPiP() {
+      if (!document.pictureInPictureEnabled || isPiP) return;
+      try {
+        await video.requestPictureInPicture();
+      } catch (e) {}
+    }
+
+    async function exitPiP() {
+      if (document.pictureInPictureElement) {
+        try { await document.exitPictureInPicture(); } catch (e) {}
+      }
+    }
+
+    function togglePiP() {
+      if (document.pictureInPictureElement) {
+        exitPiP();
+      } else {
+        enterPiP();
+      }
+    }
+
+    // ---- visibility change ----
+    function onVisibilityChange() {
+      if (document.hidden) {
+        hiddenStartTime = Date.now();
+        hiddenBasePosition = video.currentTime || 0;
+      } else if (hiddenStartTime) {
+        const elapsed = (Date.now() - hiddenStartTime) / 1000;
+        const estimated = hiddenBasePosition + elapsed;
+        hiddenStartTime = 0;
+        if (estimated < (currentMedia ? currentMedia.duration : Infinity)) {
+          setPosition(estimated);
+        }
+      }
+    }
+
+    // ---- events ----
+    video.addEventListener('timeupdate', function() {
+      curTimes = video.currentTime || 0;
+      currentTimeEl.textContent = parseTime(curTimes);
+    });
+
+    video.addEventListener('loadedmetadata', function() {
+      totalTimeEl.textContent = parseTime(video.duration || currentMedia?.duration || 0);
+    });
+
+    video.addEventListener('play', function() {
+      isPlaying = true;
+      report('play', 'start');
+      updatePlaylist();
+    });
+
+    video.addEventListener('pause', function() {
+      isPlaying = false;
+      report('play', 'pause');
+      updatePlaylist();
+    });
+
+    video.addEventListener('ended', function() {
+      report('play', 'end');
+      tick(true);
+      setTimeout(playNext, 500);
+    });
+
+    video.addEventListener('error', function(e) {
+      showToast('视频播放出错', 'error');
+      report('play', 'error');
+    });
+
+    video.addEventListener('enterpictureinpicture', function() {
+      isPiP = true;
+      pipBtn.textContent = '退出画中画';
+      pipBtn.classList.add('is-active');
+    });
+
+    video.addEventListener('leavepictureinpicture', function() {
+      isPiP = false;
+      pipBtn.textContent = '画中画';
+      pipBtn.classList.remove('is-active');
+    });
+
+    volumeSlider.addEventListener('input', function() {
+      setVolume(this.valueAsNumber);
+    });
+
+    muteBtn.addEventListener('click', function() {
+      isMuted = !isMuted;
+      setVolume(volume);
+    });
+
+    pipBtn.addEventListener('click', togglePiP);
+
+    qualitySelect.addEventListener('change', function() {
+      changeMedia(this.value);
+    });
+
+    panelToggle.addEventListener('click', function() {
+      showPanel = !showPanel;
+      playlistPanel.style.display = showPanel ? '' : 'none';
+      panelToggle.textContent = showPanel ? '>' : '<';
+    });
+
+    hideFinishedCheck.addEventListener('change', updatePlaylist);
+
+    // ---- init ----
+    function init() {
+      const params = new URLSearchParams(window.location.search);
+      uid = params.get('uid') || localStorage.getItem('uid') || 0;
+      token = params.get('token') || localStorage.getItem('token') || '';
+      courseId = parseInt(params.get('courseId') || params.get('id') || '0');
+      mediaId = parseInt(params.get('mediaId') || '0');
+
+      if (params.get('uid')) localStorage.setItem('uid', uid);
+      if (params.get('token')) localStorage.setItem('token', token);
+
+      document.addEventListener('visibilitychange', onVisibilityChange);
+      window.addEventListener('beforeunload', function() {
+        stopTick();
+        exitPiP();
+      });
+
+      if (courseId) {
+        loadCourse();
+      } else {
+        videoTitle.textContent = '缺少课程ID';
+      }
+    }
+
+    init();
+  </script>
+</body>
+</html>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików