|
|
@@ -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"><</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">✓</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>
|