play.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>视频播放 - 宁德市建筑业协会</title>
  7. <script src="/static/js/hls.min.js"></script>
  8. <script src="/static/js/md5.min.js"></script>
  9. <style>
  10. * { margin: 0; padding: 0; box-sizing: border-box; }
  11. body {
  12. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  13. background: #f0f2f5;
  14. min-height: 100vh;
  15. }
  16. .pc-player-container {
  17. min-height: 100vh;
  18. background: #f0f2f5;
  19. padding: 20px;
  20. }
  21. .pc-player-wrapper {
  22. max-width: 1440px;
  23. margin: 0 auto;
  24. display: flex;
  25. gap: 20px;
  26. height: calc(100vh - 40px);
  27. }
  28. .pc-player-left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
  29. .panel-toggle {
  30. flex-shrink: 0; width: 20px; display: flex; align-items: center; justify-content: center;
  31. cursor: pointer; color: #999; font-size: 14px; user-select: none;
  32. }
  33. .panel-toggle:hover { color: #409EFF; }
  34. .video-section {
  35. background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden;
  36. display: flex; flex-direction: column; height: 100%;
  37. }
  38. .video-title {
  39. padding: 16px 20px; font-size: 16px; font-weight: 600; color: #1a1a2e;
  40. border-bottom: 1px solid #f0f0f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  41. flex-shrink: 0;
  42. }
  43. .video-player-wrap {
  44. width: 100%; background: #000; position: relative; flex: 1; min-height: 0;
  45. display: flex; align-items: center; justify-content: center;
  46. }
  47. .video-player-wrap video {
  48. width: 100%; height: 100%; display: block; outline: none;
  49. max-height: calc(100vh - 200px);
  50. }
  51. .loading-overlay {
  52. position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  53. background: rgba(0,0,0,0.5); color: #fff; font-size: 16px; z-index: 10;
  54. transition: opacity .3s;
  55. }
  56. .loading-overlay.hidden { opacity: 0; pointer-events: none; }
  57. .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; }
  58. @keyframes spin { to { transform: rotate(360deg); } }
  59. .dialog-footer {
  60. padding: 8px 16px; border-top: 1px solid #f0f0f0; background: #fafafa; flex-shrink: 0;
  61. }
  62. .media-footer { display: flex; align-items: center; line-height: 1; }
  63. .media-time {
  64. font-size: 14px; color: #666; white-space: nowrap; font-variant-numeric: tabular-nums;
  65. display: flex; align-items: center; gap: 4px;
  66. }
  67. .media-center { display: flex; align-items: center; justify-content: center; gap: 12px; flex:1; }
  68. .btn-icon {
  69. display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600;
  70. cursor: pointer; user-select: none; padding: 4px 8px; border: 1px solid #dcdfe6;
  71. border-radius: 4px; color: #606266; background: #fff; transition: all .2s;
  72. }
  73. .btn-icon:hover { color: #409EFF; border-color: #c6e2ff; background: #ecf5ff; }
  74. .btn-icon.is-active { color: #409EFF; border-color: #409EFF; background: #ecf5ff; }
  75. .volume-group { display: inline-flex; align-items: center; gap: 6px; }
  76. .volume-slider { width: 100px; height: 4px; -webkit-appearance: none; appearance: none; background: #dcdfe6; border-radius: 2px; outline: none; }
  77. .volume-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #409EFF; cursor: pointer; }
  78. .quality-select {
  79. height: 28px; font-size: 12px; padding: 0 8px; border: 1px solid #dcdfe6;
  80. border-radius: 4px; outline: none; color: #606266; background: #fff; cursor: pointer;
  81. }
  82. .quality-select:focus { border-color: #409EFF; }
  83. .pc-player-right {
  84. width: 380px; flex-shrink: 0; background: #fff; border-radius: 12px;
  85. box-shadow: 0 2px 12px rgba(0,0,0,0.08); display: flex; flex-direction: column; overflow: hidden;
  86. }
  87. .playlist-header {
  88. padding: 16px 20px 12px; border-bottom: 1px solid #f0f0f0; flex-shrink: 0;
  89. }
  90. .playlist-title { font-size: 16px; font-weight: 600; color: #1a1a2e; }
  91. .playlist-header-row {
  92. display: flex; align-items: center; justify-content: space-between; margin-top: 6px;
  93. }
  94. .playlist-count { font-size: 12px; color: #999; }
  95. .finished-count { color: #67C23A; }
  96. .hide-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; color: #666; user-select: none; }
  97. .hide-toggle input { cursor: pointer; }
  98. .playlist-body { flex: 1; overflow-y: auto; padding: 8px 0; }
  99. .playlist-body::-webkit-scrollbar { width: 6px; }
  100. .playlist-body::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
  101. .playlist-item {
  102. display: flex; align-items: center; padding: 12px 20px; cursor: pointer;
  103. transition: all .2s; border-left: 3px solid transparent; gap: 12px;
  104. }
  105. .playlist-item:hover { background: #f5f7fa; }
  106. .playlist-item.is-active { background: #ecf5ff; border-left-color: #409EFF; }
  107. .playlist-item.is-finished { opacity: .65; }
  108. .item-index {
  109. width: 28px; height: 28px; border-radius: 50%; background: #f0f2f5;
  110. display: flex; align-items: center; justify-content: center; font-size: 12px;
  111. font-weight: 600; color: #666; flex-shrink: 0;
  112. }
  113. .item-index.active-index { background: #409EFF; color: #fff; }
  114. .finish-badge { color: #67C23A; font-size: 14px; }
  115. .item-info { flex: 1; min-width: 0; }
  116. .item-name { font-size: 14px; color: #333; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
  117. .is-finished .item-name { color: #999; text-decoration: line-through; }
  118. .is-active .item-name { color: #409EFF; }
  119. .item-meta { display: flex; gap: 12px; font-size: 12px; color: #999; }
  120. .item-progress { color: #409EFF; }
  121. .is-finished .item-progress { color: #67C23A; }
  122. .item-action { flex-shrink: 0; }
  123. .play-btn {
  124. padding: 4px 10px; font-size: 12px; border: 1px solid #409EFF; color: #409EFF;
  125. background: transparent; border-radius: 4px; cursor: pointer; transition: all .2s;
  126. }
  127. .play-btn:hover { background: #409EFF; color: #fff; }
  128. .play-btn.pause-btn { background: #e6a23c; border-color: #e6a23c; color: #fff; }
  129. .playlist-empty { text-align: center; padding: 60px 20px; color: #ccc; font-size: 14px; }
  130. .toast {
  131. position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 9999;
  132. padding: 10px 24px; border-radius: 6px; font-size: 14px; color: #fff;
  133. transition: all .3s; opacity: 0; pointer-events: none;
  134. }
  135. .toast.show { opacity: 1; }
  136. .toast.success { background: #67C23A; }
  137. .toast.error { background: #F56C6C; }
  138. .toast.info { background: #909399; }
  139. @media (max-width: 900px) {
  140. .pc-player-wrapper { flex-direction: column; height: auto; }
  141. .pc-player-right { width: 100%; max-height: 400px; }
  142. .pc-player-container { padding: 12px; }
  143. .volume-slider { width: 60px; }
  144. }
  145. </style>
  146. </head>
  147. <body>
  148. <div class="pc-player-container">
  149. <div class="pc-player-wrapper">
  150. <div class="pc-player-left">
  151. <div class="video-section">
  152. <div class="video-title" id="videoTitle">加载中...</div>
  153. <div class="video-player-wrap">
  154. <video id="videoElement" playsinline preload="auto"></video>
  155. <div class="loading-overlay" id="loadingOverlay">
  156. <div class="loading-spinner"></div>
  157. <span>加载中...</span>
  158. </div>
  159. </div>
  160. <div class="dialog-footer">
  161. <div class="media-footer">
  162. <div class="media-time">
  163. <span id="currentTime">00:00:00</span>
  164. <strong>/</strong>
  165. <span id="totalTime">00:00:00</span>
  166. </div>
  167. <div class="media-center">
  168. <span class="volume-group">
  169. <span class="btn-icon" id="muteBtn">音量</span>
  170. <input type="range" class="volume-slider" id="volumeSlider" min="0" max="1" step="0.05" value="0.5">
  171. </span>
  172. <span class="btn-icon" id="pipBtn">画中画</span>
  173. </div>
  174. <div style="display:flex;align-items:center;justify-content:flex-end;flex:1;">
  175. <select class="quality-select" id="qualitySelect">
  176. <option value="ld">流畅</option>
  177. <option value="hls" selected>标清</option>
  178. </select>
  179. </div>
  180. </div>
  181. </div>
  182. </div>
  183. </div>
  184. <div class="panel-toggle" id="panelToggle">&lt;</div>
  185. <div class="pc-player-right" id="playlistPanel">
  186. <div class="playlist-header">
  187. <div class="playlist-title">课程列表</div>
  188. <div class="playlist-header-row">
  189. <div class="playlist-count">
  190. 共 <span id="totalCount">0</span> 个视频
  191. <span class="finished-count" id="finishedCountWrap" style="display:none;">,已完成 <span id="finishedCount">0</span> 个</span>
  192. </div>
  193. <label class="hide-toggle">
  194. <input type="checkbox" id="hideFinishedCheck"> 隐藏已完成
  195. </label>
  196. </div>
  197. </div>
  198. <div class="playlist-body" id="playlistBody"></div>
  199. </div>
  200. </div>
  201. </div>
  202. <div class="toast" id="toast"></div>
  203. <script>
  204. const VERSION = '1.0';
  205. const SECRET = '017c0743819088468e590246464cc75f';
  206. const API_BASE = 'https://ndapi.hqedust.com:18443/rental';
  207. // ---- DOM refs ----
  208. const video = document.getElementById('videoElement');
  209. const loadingOverlay = document.getElementById('loadingOverlay');
  210. const videoTitle = document.getElementById('videoTitle');
  211. const currentTimeEl = document.getElementById('currentTime');
  212. const totalTimeEl = document.getElementById('totalTime');
  213. const volumeSlider = document.getElementById('volumeSlider');
  214. const muteBtn = document.getElementById('muteBtn');
  215. const pipBtn = document.getElementById('pipBtn');
  216. const qualitySelect = document.getElementById('qualitySelect');
  217. const panelToggle = document.getElementById('panelToggle');
  218. const playlistPanel = document.getElementById('playlistPanel');
  219. const playlistBody = document.getElementById('playlistBody');
  220. const totalCount = document.getElementById('totalCount');
  221. const finishedCount = document.getElementById('finishedCount');
  222. const finishedCountWrap = document.getElementById('finishedCountWrap');
  223. const hideFinishedCheck = document.getElementById('hideFinishedCheck');
  224. const toast = document.getElementById('toast');
  225. // ---- state ----
  226. let uid = 0, token = '', courseId = 0, mediaId = 0;
  227. let mediaList = [];
  228. let currentMedia = null;
  229. let currentMediaUrl = '';
  230. let isPlaying = false;
  231. let isMuted = false;
  232. let volume = 0.5;
  233. let curTimes = 0;
  234. let tickTimer = null;
  235. let tickNum = 0;
  236. let hasSeeked = false;
  237. let hiddenStartTime = 0;
  238. let hiddenBasePosition = 0;
  239. let isPiP = false;
  240. let hls = null;
  241. let showPanel = true;
  242. // ---- utils ----
  243. function parseTime(seconds) {
  244. seconds = parseInt(seconds) || 0;
  245. const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
  246. const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
  247. const s = String(seconds % 60).padStart(2, '0');
  248. return h + ':' + m + ':' + s;
  249. }
  250. function getPercent(item) {
  251. if (item.isFinish == 1) return 100;
  252. if (!item.duration) return 0;
  253. let p = parseInt((item.position || 0) * 10000 / item.duration) / 100;
  254. return p >= 100 ? 99.99 : p;
  255. }
  256. function showToast(msg, type, duration) {
  257. toast.textContent = msg;
  258. toast.className = 'toast ' + type + ' show';
  259. clearTimeout(toast._timer);
  260. toast._timer = setTimeout(() => toast.classList.remove('show'), duration || 3000);
  261. }
  262. // ---- API ----
  263. function signRequest(method, data) {
  264. const body = JSON.stringify(data);
  265. const mtime = Math.floor(Date.now() / 1000);
  266. const raw = 'hall_' + VERSION + token + body + method + mtime + SECRET;
  267. const sign = md5(raw);
  268. const u = method.startsWith('Auth.') ? '0' : (uid || 0);
  269. return API_BASE + '/' + method + '?t=' + mtime + '&u=' + u + '&v=' + VERSION + '&s=' + sign;
  270. }
  271. async function api(method, data) {
  272. const url = signRequest(method, data);
  273. const res = await fetch(url, {
  274. method: 'POST',
  275. headers: { 'Content-Type': 'application/json' },
  276. body: JSON.stringify(data)
  277. });
  278. const json = await res.json();
  279. if (json.code === 401) {
  280. showToast('用户被踢下线,请重新登入', 'error', 5000);
  281. return json;
  282. }
  283. if (json.code !== 200) {
  284. showToast(json.msg || '请求失败', 'error');
  285. }
  286. return json;
  287. }
  288. function report(action, msg) {
  289. api('course.report', { action, msg });
  290. }
  291. // ---- player ----
  292. function loadVideo(src, position) {
  293. if (hls) {
  294. hls.destroy();
  295. hls = null;
  296. }
  297. video.removeAttribute('src');
  298. if (Hls.isSupported()) {
  299. hls = new Hls({
  300. enableWorker: true,
  301. xhrSetup: function(xhr) {
  302. xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
  303. xhr.setRequestHeader('Pragma', 'no-cache');
  304. }
  305. });
  306. hls.loadSource(src);
  307. hls.attachMedia(video);
  308. hls.on(Hls.Events.MANIFEST_PARSED, function() {
  309. loadingOverlay.classList.add('hidden');
  310. if (position > 0) {
  311. video.currentTime = position;
  312. }
  313. safePlay();
  314. });
  315. hls.on(Hls.Events.ERROR, function(e, data) {
  316. if (data.fatal) {
  317. showToast('视频加载失败,尝试切换清晰度', 'error');
  318. }
  319. });
  320. } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
  321. video.src = src;
  322. video.addEventListener('loadedmetadata', function() {
  323. loadingOverlay.classList.add('hidden');
  324. if (position > 0) video.currentTime = position;
  325. safePlay();
  326. }, { once: true });
  327. } else {
  328. showToast('您的浏览器不支持 HLS 播放', 'error');
  329. }
  330. }
  331. function safePlay() {
  332. const p = video.play();
  333. if (p && p.catch) p.catch(function() {});
  334. }
  335. function setVolume(val) {
  336. volume = Math.max(0, Math.min(1, val));
  337. video.volume = volume;
  338. video.muted = isMuted;
  339. volumeSlider.value = volume;
  340. muteBtn.textContent = volume === 0 || isMuted ? '静音' : '音量';
  341. }
  342. function setPosition(pos) {
  343. if (pos < 10) return;
  344. if (pos > (currentMedia ? currentMedia.duration : Infinity)) pos = currentMedia.duration;
  345. video.currentTime = pos;
  346. safePlay();
  347. if (currentMedia && currentMedia.isFinish) return;
  348. if (currentMedia && currentMedia.position >= currentMedia.duration - 10 && !currentMedia.isFinish) {
  349. tick(true);
  350. }
  351. }
  352. // ---- course loading ----
  353. async function loadCourse() {
  354. const res = await api('course.getCourse', { courseId });
  355. if (res.code !== 200) return;
  356. const { info, extra, list, tpl } = res.data;
  357. const courseInfo = Object.assign(info || {}, extra || {});
  358. mediaList = (list || []).map(function(item) {
  359. item.percent = getPercent(item);
  360. return item;
  361. });
  362. updatePlaylist();
  363. let target = null;
  364. if (mediaId) {
  365. target = mediaList.find(function(i) { return i.id == mediaId || i.mediaId == mediaId; });
  366. }
  367. if (!target) target = mediaList.find(function(i) { return i.isFinish != 1; });
  368. if (!target && mediaList.length) target = mediaList[0];
  369. if (target) {
  370. loadMedia(target);
  371. } else {
  372. videoTitle.textContent = (tpl || {}).name || '暂无视频';
  373. }
  374. }
  375. async function loadMedia(item) {
  376. currentMedia = item;
  377. videoTitle.textContent = item.name;
  378. loadingOverlay.classList.remove('hidden');
  379. hasSeeked = false;
  380. isPlaying = false;
  381. stopTick();
  382. const res = await api('course.GetMedia', { id: item.id });
  383. if (res.code !== 200) {
  384. loadingOverlay.classList.add('hidden');
  385. return;
  386. }
  387. const data = res.data || {};
  388. let mediaUrl = data.mediaUrl || '';
  389. const position = (data.position || 0) < 10 ? 0 : (data.position || 0);
  390. const duration = data.duration || 0;
  391. currentMediaUrl = mediaUrl;
  392. currentMedia.position = position;
  393. currentMedia.duration = duration;
  394. currentMedia.id = data.id || item.id;
  395. qualitySelect.value = mediaUrl.includes('/ld/') ? 'ld' : 'hls';
  396. curTimes = 0;
  397. loadVideo(mediaUrl, position);
  398. startTick();
  399. updatePlaylist();
  400. report('play', 'start');
  401. }
  402. function changeMedia(quality) {
  403. if (!currentMediaUrl) return;
  404. let url = currentMediaUrl;
  405. if (quality === 'ld') {
  406. url = url.replace('/hls/', '/ld/');
  407. } else {
  408. url = url.replace('/ld/', '/hls/');
  409. }
  410. currentMediaUrl = url;
  411. const pos = video.currentTime || 0;
  412. loadVideo(url, pos > 0 ? pos : 0);
  413. }
  414. // ---- tick system ----
  415. function startTick() {
  416. stopTick();
  417. tickTimer = setInterval(tryTick, 5000);
  418. }
  419. function stopTick() {
  420. if (tickTimer) {
  421. clearInterval(tickTimer);
  422. tickTimer = null;
  423. }
  424. }
  425. function getPlayerTime() {
  426. if (hiddenStartTime) {
  427. const elapsed = (Date.now() - hiddenStartTime) / 1000;
  428. const estimated = hiddenBasePosition + elapsed;
  429. const actual = video.currentTime || 0;
  430. if (actual >= estimated - 0.5) {
  431. hiddenStartTime = 0;
  432. return actual;
  433. }
  434. return Math.min(estimated, currentMedia ? currentMedia.duration : Infinity);
  435. }
  436. return video.currentTime || 0;
  437. }
  438. function tryTick() {
  439. try { doTick(); } catch (e) { report('play', '' + e.message); }
  440. }
  441. async function doTick(force) {
  442. if (!currentMedia || currentMedia.isFinish) return;
  443. tickNum++;
  444. const ct = Math.floor(getPlayerTime());
  445. curTimes = ct;
  446. if (navigator.webdriver) {
  447. report('play', 'webdriver');
  448. doPause();
  449. return;
  450. }
  451. let isFinish = force ? 1 : 0;
  452. if (ct >= currentMedia.duration) isFinish = 1;
  453. const res = await api('course.tick', {
  454. id: currentMedia.id,
  455. position: ct,
  456. isFinish: isFinish
  457. });
  458. if (res.code !== 200) return;
  459. const d = res.data || {};
  460. if (d.closed) {
  461. doPause();
  462. showToast(d.msg || '课程已关闭', 'error', 10000);
  463. return;
  464. }
  465. if (d.pause) {
  466. doPause();
  467. showToast(d.msg || '已暂停', 'info', 10000);
  468. setTimeout(async function() {
  469. await loadCourse();
  470. }, 30000);
  471. return;
  472. }
  473. if (!d.skip) {
  474. setPosition(d.position || 0);
  475. }
  476. Object.assign(currentMedia, d);
  477. if (d.isFinish == 1) {
  478. updatePlaylist();
  479. await new Promise(r => setTimeout(r, 1000));
  480. playNext();
  481. }
  482. updatePlaylist();
  483. }
  484. function tick(force) {
  485. doTick(force);
  486. }
  487. // ---- playlist ----
  488. function updatePlaylist() {
  489. const finished = mediaList.filter(function(i) { return i.isFinish == 1; }).length;
  490. totalCount.textContent = mediaList.length;
  491. if (finished > 0) {
  492. finishedCountWrap.style.display = 'inline';
  493. finishedCount.textContent = finished;
  494. } else {
  495. finishedCountWrap.style.display = 'none';
  496. }
  497. const hide = hideFinishedCheck.checked;
  498. const filtered = hide ? mediaList.filter(function(i) { return i.isFinish != 1; }) : mediaList;
  499. playlistBody.innerHTML = '';
  500. filtered.forEach(function(item, idx) {
  501. const div = document.createElement('div');
  502. div.className = 'playlist-item';
  503. if (currentMedia && currentMedia.id === item.id) div.classList.add('is-active');
  504. if (item.isFinish == 1) div.classList.add('is-finished');
  505. const isCurrent = currentMedia && currentMedia.id === item.id;
  506. const origIdx = mediaList.indexOf(item);
  507. div.innerHTML =
  508. '<div class="item-index' + (isCurrent ? ' active-index' : '') + '">' +
  509. (item.isFinish == 1 ? '<span class="finish-badge">&#10003;</span>' : '<span>' + (origIdx + 1) + '</span>') +
  510. '</div>' +
  511. '<div class="item-info">' +
  512. '<div class="item-name">' + item.name + '</div>' +
  513. '<div class="item-meta">' +
  514. '<span>学时:' + (item.xs / 10) + '</span>' +
  515. '<span class="item-progress">' + getPercent(item) + '%</span>' +
  516. '</div>' +
  517. '</div>' +
  518. '<div class="item-action">' +
  519. (isCurrent && isPlaying
  520. ? '<button class="play-btn pause-btn" data-action="pause">暂停</button>'
  521. : isCurrent
  522. ? '<button class="play-btn" data-action="play">播放</button>'
  523. : '<button class="play-btn" data-action="load">播放</button>') +
  524. '</div>';
  525. div.addEventListener('click', function(e) {
  526. if (e.target.tagName === 'BUTTON') return;
  527. loadMedia(item);
  528. });
  529. const btn = div.querySelector('button');
  530. if (btn) {
  531. btn.addEventListener('click', function(e) {
  532. e.stopPropagation();
  533. const action = btn.dataset.action;
  534. if (action === 'play') doPlay();
  535. else if (action === 'pause') doPause();
  536. else loadMedia(item);
  537. });
  538. }
  539. playlistBody.appendChild(div);
  540. });
  541. if (filtered.length === 0) {
  542. playlistBody.innerHTML = '<div class="playlist-empty">暂无视频</div>';
  543. }
  544. }
  545. function playNext() {
  546. if (!currentMedia) return;
  547. const idx = mediaList.findIndex(function(i) { return i.id === currentMedia.id; });
  548. let next = null;
  549. for (let i = idx + 1; i < mediaList.length; i++) {
  550. if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
  551. }
  552. if (!next) {
  553. for (let i = 0; i < idx; i++) {
  554. if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
  555. }
  556. }
  557. if (next) {
  558. showToast('即将播放: ' + next.name, 'success');
  559. setTimeout(function() { loadMedia(next); }, 1000);
  560. }
  561. }
  562. function doPlay() {
  563. isPlaying = true;
  564. startTick();
  565. safePlay();
  566. tickNum = 0;
  567. updatePlaylist();
  568. }
  569. function doPause() {
  570. isPlaying = false;
  571. stopTick();
  572. video.pause();
  573. updatePlaylist();
  574. }
  575. // ---- PiP ----
  576. async function enterPiP() {
  577. if (!document.pictureInPictureEnabled || isPiP) return;
  578. try {
  579. await video.requestPictureInPicture();
  580. } catch (e) {}
  581. }
  582. async function exitPiP() {
  583. if (document.pictureInPictureElement) {
  584. try { await document.exitPictureInPicture(); } catch (e) {}
  585. }
  586. }
  587. function togglePiP() {
  588. if (document.pictureInPictureElement) {
  589. exitPiP();
  590. } else {
  591. enterPiP();
  592. }
  593. }
  594. // ---- visibility change ----
  595. function onVisibilityChange() {
  596. if (document.hidden) {
  597. hiddenStartTime = Date.now();
  598. hiddenBasePosition = video.currentTime || 0;
  599. } else if (hiddenStartTime) {
  600. const elapsed = (Date.now() - hiddenStartTime) / 1000;
  601. const estimated = hiddenBasePosition + elapsed;
  602. hiddenStartTime = 0;
  603. if (estimated < (currentMedia ? currentMedia.duration : Infinity)) {
  604. setPosition(estimated);
  605. }
  606. }
  607. }
  608. // ---- events ----
  609. video.addEventListener('timeupdate', function() {
  610. curTimes = video.currentTime || 0;
  611. currentTimeEl.textContent = parseTime(curTimes);
  612. });
  613. video.addEventListener('loadedmetadata', function() {
  614. totalTimeEl.textContent = parseTime(video.duration || currentMedia?.duration || 0);
  615. });
  616. video.addEventListener('play', function() {
  617. isPlaying = true;
  618. report('play', 'start');
  619. updatePlaylist();
  620. });
  621. video.addEventListener('pause', function() {
  622. isPlaying = false;
  623. report('play', 'pause');
  624. updatePlaylist();
  625. });
  626. video.addEventListener('ended', function() {
  627. report('play', 'end');
  628. tick(true);
  629. setTimeout(playNext, 500);
  630. });
  631. video.addEventListener('error', function(e) {
  632. showToast('视频播放出错', 'error');
  633. report('play', 'error');
  634. });
  635. video.addEventListener('enterpictureinpicture', function() {
  636. isPiP = true;
  637. pipBtn.textContent = '退出画中画';
  638. pipBtn.classList.add('is-active');
  639. });
  640. video.addEventListener('leavepictureinpicture', function() {
  641. isPiP = false;
  642. pipBtn.textContent = '画中画';
  643. pipBtn.classList.remove('is-active');
  644. });
  645. volumeSlider.addEventListener('input', function() {
  646. setVolume(this.valueAsNumber);
  647. });
  648. muteBtn.addEventListener('click', function() {
  649. isMuted = !isMuted;
  650. setVolume(volume);
  651. });
  652. pipBtn.addEventListener('click', togglePiP);
  653. qualitySelect.addEventListener('change', function() {
  654. changeMedia(this.value);
  655. });
  656. panelToggle.addEventListener('click', function() {
  657. showPanel = !showPanel;
  658. playlistPanel.style.display = showPanel ? '' : 'none';
  659. panelToggle.textContent = showPanel ? '>' : '<';
  660. });
  661. hideFinishedCheck.addEventListener('change', updatePlaylist);
  662. // ---- init ----
  663. function init() {
  664. const params = new URLSearchParams(window.location.search);
  665. uid = params.get('uid') || localStorage.getItem('uid') || 0;
  666. token = params.get('token') || localStorage.getItem('token') || '';
  667. courseId = parseInt(params.get('courseId') || params.get('id') || '0');
  668. mediaId = parseInt(params.get('mediaId') || '0');
  669. if (params.get('uid')) localStorage.setItem('uid', uid);
  670. if (params.get('token')) localStorage.setItem('token', token);
  671. document.addEventListener('visibilitychange', onVisibilityChange);
  672. window.addEventListener('beforeunload', function() {
  673. stopTick();
  674. exitPiP();
  675. });
  676. if (courseId) {
  677. loadCourse();
  678. } else {
  679. videoTitle.textContent = '缺少课程ID';
  680. }
  681. }
  682. init();
  683. </script>
  684. </body>
  685. </html>