play2.html 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  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="./js/hls.min.js"></script>
  8. <script src="./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. .qr-modal-overlay {
  140. position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 9998;
  141. display: none; align-items: center; justify-content: center;
  142. }
  143. .qr-modal-overlay.show { display: flex; }
  144. .qr-modal {
  145. background: #fff; border-radius: 12px; padding: 24px; text-align: center;
  146. box-shadow: 0 8px 32px rgba(0,0,0,0.2); max-width: 320px; width: 90%;
  147. position: relative;
  148. }
  149. .qr-modal-title { font-size: 16px; font-weight: 600; color: #1a1a2e; margin-bottom: 16px; }
  150. .qr-modal img { width: 200px; height: 200px; border: 1px solid #eee; border-radius: 8px; }
  151. .qr-modal-hint { font-size: 12px; color: #999; margin-top: 12px; }
  152. .qr-modal-close {
  153. position: absolute; top: 12px; right: 12px; width: 28px; height: 28px;
  154. border: none; background: #f0f0f0; border-radius: 50%; cursor: pointer;
  155. font-size: 16px; color: #666; display: flex; align-items: center; justify-content: center;
  156. }
  157. .qr-modal-close:hover { background: #e0e0e0; }
  158. @media (max-width: 900px) {
  159. .pc-player-wrapper { flex-direction: column; height: auto; }
  160. .pc-player-right { width: 100%; max-height: 400px; }
  161. .pc-player-container { padding: 12px; }
  162. .volume-slider { width: 60px; }
  163. }
  164. </style>
  165. </head>
  166. <body>
  167. <div class="pc-player-container">
  168. <div class="pc-player-wrapper">
  169. <div class="pc-player-left">
  170. <div class="video-section">
  171. <div class="video-title" id="videoTitle">加载中...</div>
  172. <div class="video-player-wrap">
  173. <video id="videoElement" playsinline preload="auto"></video>
  174. <div class="loading-overlay" id="loadingOverlay">
  175. <div class="loading-spinner"></div>
  176. <span>加载中...</span>
  177. </div>
  178. </div>
  179. <div class="dialog-footer">
  180. <div class="media-footer">
  181. <div class="media-time">
  182. <span id="currentTime">00:00:00</span>
  183. <strong>/</strong>
  184. <span id="totalTime">00:00:00</span>
  185. </div>
  186. <div class="media-center">
  187. <span class="volume-group">
  188. <span class="btn-icon" id="muteBtn">音量</span>
  189. <input type="range" class="volume-slider" id="volumeSlider" min="0" max="1" step="0.05" value="0.5">
  190. </span>
  191. <span class="btn-icon" id="pipBtn">画中画</span>
  192. <span class="btn-icon" id="qrBtn">二维码</span>
  193. </div>
  194. <div style="display:flex;align-items:center;justify-content:flex-end;flex:1;">
  195. <select class="quality-select" id="qualitySelect">
  196. <option value="ld">流畅</option>
  197. <option value="hls" selected>标清</option>
  198. <option value="mp4">适配</option>
  199. </select>
  200. </div>
  201. </div>
  202. </div>
  203. </div>
  204. </div>
  205. <div class="panel-toggle" id="panelToggle">&lt;</div>
  206. <div class="pc-player-right" id="playlistPanel">
  207. <div class="playlist-header">
  208. <div class="playlist-title">课程列表</div>
  209. <div class="playlist-header-row">
  210. <div class="playlist-count">
  211. 共 <span id="totalCount">0</span> 个视频
  212. <span class="finished-count" id="finishedCountWrap" style="display:none;">,已完成 <span id="finishedCount">0</span> 个</span>
  213. </div>
  214. <label class="hide-toggle">
  215. <input type="checkbox" id="hideFinishedCheck"> 隐藏已完成
  216. </label>
  217. </div>
  218. </div>
  219. <div class="playlist-body" id="playlistBody"></div>
  220. </div>
  221. </div>
  222. </div>
  223. <div class="toast" id="toast"></div>
  224. <div class="qr-modal-overlay" id="qrModalOverlay">
  225. <div class="qr-modal">
  226. <button class="qr-modal-close" id="qrModalClose">&times;</button>
  227. <div class="qr-modal-title">扫码分享当前页面</div>
  228. <img id="qrCodeImg" src="" alt="二维码">
  229. <div class="qr-modal-hint">使用手机扫描二维码打开当前页面</div>
  230. </div>
  231. </div>
  232. <script>
  233. const VERSION = '1.0';
  234. const SECRET = '017c0743819088468e590246464cc75f';
  235. const API_BASE = 'https://ndapi.hqedust.com:18443/rental';
  236. // ---- DOM refs ----
  237. const video = document.getElementById('videoElement');
  238. const loadingOverlay = document.getElementById('loadingOverlay');
  239. const videoTitle = document.getElementById('videoTitle');
  240. const currentTimeEl = document.getElementById('currentTime');
  241. const totalTimeEl = document.getElementById('totalTime');
  242. const volumeSlider = document.getElementById('volumeSlider');
  243. const muteBtn = document.getElementById('muteBtn');
  244. const pipBtn = document.getElementById('pipBtn');
  245. const qualitySelect = document.getElementById('qualitySelect');
  246. const panelToggle = document.getElementById('panelToggle');
  247. const playlistPanel = document.getElementById('playlistPanel');
  248. const playlistBody = document.getElementById('playlistBody');
  249. const totalCount = document.getElementById('totalCount');
  250. const finishedCount = document.getElementById('finishedCount');
  251. const finishedCountWrap = document.getElementById('finishedCountWrap');
  252. const hideFinishedCheck = document.getElementById('hideFinishedCheck');
  253. const toast = document.getElementById('toast');
  254. // ---- state ----
  255. let uid = 0, token = '', courseId = 0, mediaId = 0;
  256. let mediaList = [];
  257. let currentMedia = null;
  258. let currentMediaUrl = '';
  259. let isPlaying = false;
  260. let isMuted = false;
  261. let volume = 0.5;
  262. let curTimes = 0;
  263. let tickTimer = null;
  264. let tickNum = 0;
  265. let hasSeeked = false;
  266. let hiddenStartTime = 0;
  267. let hiddenBasePosition = 0;
  268. let isPiP = false;
  269. let hls = null;
  270. let showPanel = true;
  271. // ---- utils ----
  272. function parseTime(seconds) {
  273. seconds = parseInt(seconds) || 0;
  274. const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
  275. const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
  276. const s = String(seconds % 60).padStart(2, '0');
  277. return h + ':' + m + ':' + s;
  278. }
  279. function getPercent(item) {
  280. if (item.isFinish == 1) return 100;
  281. if (!item.duration) return 0;
  282. let p = parseInt((item.position || 0) * 10000 / item.duration) / 100;
  283. return p >= 100 ? 99.99 : p;
  284. }
  285. function showToast(msg, type, duration) {
  286. toast.textContent = msg;
  287. toast.className = 'toast ' + type + ' show';
  288. clearTimeout(toast._timer);
  289. toast._timer = setTimeout(() => toast.classList.remove('show'), duration || 3000);
  290. }
  291. // ---- API ----
  292. function signRequest(method, data) {
  293. const body = JSON.stringify(data);
  294. const mtime = Math.floor(Date.now() / 1000);
  295. const raw = 'hall_' + VERSION + token + body + method + mtime + SECRET;
  296. const sign = md5(raw);
  297. const u = method.startsWith('Auth.') ? '0' : (uid || 0);
  298. return API_BASE + '/' + method + '?t=' + mtime + '&u=' + u + '&v=' + VERSION + '&s=' + sign;
  299. }
  300. async function api(method, data) {
  301. const url = signRequest(method, data);
  302. const res = await fetch(url, {
  303. method: 'POST',
  304. headers: { 'Content-Type': 'application/json' },
  305. body: JSON.stringify(data)
  306. });
  307. const json = await res.json();
  308. if (json.code === 401) {
  309. showToast('用户被踢下线,请重新登入', 'error', 5000);
  310. return json;
  311. }
  312. if (json.code !== 200) {
  313. showToast(json.msg || '请求失败', 'error');
  314. }
  315. return json;
  316. }
  317. function report(action, msg) {
  318. api('course.report', { action, msg });
  319. }
  320. // ---- player ----
  321. function loadVideo(src, position) {
  322. if (hls) {
  323. hls.destroy();
  324. hls = null;
  325. }
  326. video.removeAttribute('src');
  327. if (src.includes('/mp4/')) {
  328. video.src = src;
  329. video.addEventListener('loadedmetadata', function() {
  330. loadingOverlay.classList.add('hidden');
  331. if (position > 0) video.currentTime = position;
  332. safePlay();
  333. }, { once: true });
  334. video.addEventListener('error', function() {
  335. showToast('视频加载失败,尝试切换清晰度', 'error');
  336. }, { once: true });
  337. } else if (Hls.isSupported()) {
  338. hls = new Hls({
  339. enableWorker: true,
  340. xhrSetup: function(xhr) {
  341. xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
  342. xhr.setRequestHeader('Pragma', 'no-cache');
  343. }
  344. });
  345. hls.loadSource(src);
  346. hls.attachMedia(video);
  347. hls.on(Hls.Events.MANIFEST_PARSED, function() {
  348. loadingOverlay.classList.add('hidden');
  349. if (position > 0) {
  350. video.currentTime = position;
  351. }
  352. safePlay();
  353. });
  354. hls.on(Hls.Events.ERROR, function(e, data) {
  355. if (data.fatal) {
  356. showToast('视频加载失败,尝试切换清晰度', 'error');
  357. }
  358. });
  359. } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
  360. video.src = src;
  361. video.addEventListener('loadedmetadata', function() {
  362. loadingOverlay.classList.add('hidden');
  363. if (position > 0) video.currentTime = position;
  364. safePlay();
  365. }, { once: true });
  366. } else {
  367. showToast('您的浏览器不支持 HLS 播放', 'error');
  368. }
  369. }
  370. function safePlay() {
  371. const p = video.play();
  372. if (p && p.catch) p.catch(function() {});
  373. }
  374. function setVolume(val) {
  375. volume = Math.max(0, Math.min(1, val));
  376. video.volume = volume;
  377. video.muted = isMuted;
  378. volumeSlider.value = volume;
  379. muteBtn.textContent = volume === 0 || isMuted ? '静音' : '音量';
  380. }
  381. function setPosition(pos) {
  382. if (pos > (currentMedia ? currentMedia.duration : Infinity)) pos = currentMedia.duration;
  383. video.currentTime = pos;
  384. safePlay();
  385. if (currentMedia && currentMedia.isFinish) return;
  386. if (currentMedia && currentMedia.position >= currentMedia.duration - 10 && !currentMedia.isFinish) {
  387. tick(true);
  388. }
  389. }
  390. // ---- course loading ----
  391. async function loadCourse() {
  392. const res = await api('course.getCourse', { courseId });
  393. if (res.code !== 200) return;
  394. const { info, extra, list, tpl } = res.data;
  395. const courseInfo = Object.assign(info || {}, extra || {});
  396. mediaList = (list || []).map(function(item) {
  397. item.percent = getPercent(item);
  398. return item;
  399. });
  400. updatePlaylist();
  401. let target = null;
  402. if (mediaId) {
  403. target = mediaList.find(function(i) { return i.id == mediaId || i.mediaId == mediaId; });
  404. }
  405. if (!target) target = mediaList.find(function(i) { return i.isFinish != 1; });
  406. if (!target && mediaList.length) target = mediaList[0];
  407. if (target) {
  408. loadMedia(target);
  409. } else {
  410. videoTitle.textContent = (tpl || {}).name || '暂无视频';
  411. }
  412. }
  413. async function loadMedia(item, replay) {
  414. currentMedia = item;
  415. videoTitle.textContent = item.name;
  416. loadingOverlay.classList.remove('hidden');
  417. hasSeeked = false;
  418. isPlaying = false;
  419. stopTick();
  420. const res = await api('course.GetMedia', { id: item.id });
  421. if (res.code !== 200) {
  422. loadingOverlay.classList.add('hidden');
  423. return;
  424. }
  425. const data = res.data || {};
  426. let mediaUrl = data.mediaUrl || '';
  427. const position = (data.position || 0) < 10 ? 0 : (data.position || 0);
  428. const duration = data.duration || 0;
  429. currentMedia.position = position;
  430. currentMedia.duration = duration;
  431. currentMedia.id = data.id || item.id;
  432. if(!replay){
  433. currentMediaUrl = mediaUrl;
  434. qualitySelect.value = mediaUrl.includes('/mp4/') ? 'mp4' : (mediaUrl.includes('/ld/') ? 'ld' : 'hls');
  435. }
  436. curTimes = 0;
  437. loadVideo(mediaUrl, position);
  438. startTick();
  439. updatePlaylist();
  440. report('play', 'start');
  441. }
  442. function changeMedia(quality) {
  443. if (!currentMediaUrl) return;
  444. const u = new URL(currentMediaUrl);
  445. const m = u.pathname.match(/(\d+)(?:\.mp4|\.m3u8)/);
  446. if (!m) return;
  447. const id = m[1];
  448. if (quality === 'mp4') {
  449. u.pathname = '/mp4/' + id + '.mp4';
  450. } else if (quality === 'hls') {
  451. u.pathname = '/hls/' + id + '/' + id + '.m3u8';
  452. } else {
  453. u.pathname = '/ld/' + id + '/' + id + '.m3u8';
  454. }
  455. currentMediaUrl = u.toString();
  456. const pos = video.currentTime || 0;
  457. loadVideo(currentMediaUrl, pos > 0 ? pos : 0);
  458. }
  459. // ---- tick system ----
  460. function startTick() {
  461. stopTick();
  462. tickTimer = setInterval(tryTick, 5000);
  463. }
  464. function stopTick() {
  465. if (tickTimer) {
  466. clearInterval(tickTimer);
  467. tickTimer = null;
  468. }
  469. }
  470. function getPlayerTime() {
  471. if (hiddenStartTime) {
  472. const elapsed = (Date.now() - hiddenStartTime) / 1000;
  473. const estimated = hiddenBasePosition + elapsed;
  474. const actual = video.currentTime || 0;
  475. if (actual >= estimated - 0.5) {
  476. hiddenStartTime = 0;
  477. return actual;
  478. }
  479. return Math.min(estimated, currentMedia ? currentMedia.duration : Infinity);
  480. }
  481. return video.currentTime || 0;
  482. }
  483. function tryTick() {
  484. try { doTick(); } catch (e) { report('play', '' + e.message); }
  485. }
  486. async function doTick(force) {
  487. if (!currentMedia || currentMedia.isFinish) return;
  488. tickNum++;
  489. const ct = Math.floor(getPlayerTime());
  490. curTimes = ct;
  491. if (navigator.webdriver) {
  492. report('play', 'webdriver');
  493. doPause();
  494. return;
  495. }
  496. let isFinish = force ? 1 : 0;
  497. if (ct >= currentMedia.duration) isFinish = 1;
  498. const res = await api('course.tick', {
  499. id: currentMedia.id,
  500. position: ct,
  501. isFinish: isFinish
  502. });
  503. if (res.code !== 200) return;
  504. const d = res.data || {};
  505. if (d.closed) {
  506. doPause();
  507. showToast(d.msg || '课程已关闭', 'error', 10000);
  508. return;
  509. }
  510. if (d.pause) {
  511. doPause();
  512. showToast(d.msg || '已暂停', 'info', 10000);
  513. setTimeout(async function() {
  514. console.log("load", currentMedia)
  515. loadMedia(currentMedia);
  516. }, 10000);
  517. return;
  518. }
  519. if (!d.skip) {
  520. const diff = Math.abs((d.position || 0) - ct);
  521. if (diff > 3) setPosition(d.position);
  522. }
  523. Object.assign(currentMedia, d);
  524. if (d.isFinish == 1) {
  525. updatePlaylist();
  526. await new Promise(r => setTimeout(r, 1000));
  527. playNext();
  528. }
  529. updatePlaylist();
  530. }
  531. function tick(force) {
  532. doTick(force);
  533. }
  534. // ---- playlist ----
  535. function updatePlaylist() {
  536. const finished = mediaList.filter(function(i) { return i.isFinish == 1; }).length;
  537. totalCount.textContent = mediaList.length;
  538. if (finished > 0) {
  539. finishedCountWrap.style.display = 'inline';
  540. finishedCount.textContent = finished;
  541. } else {
  542. finishedCountWrap.style.display = 'none';
  543. }
  544. const hide = hideFinishedCheck.checked;
  545. const filtered = hide ? mediaList.filter(function(i) { return i.isFinish != 1; }) : mediaList;
  546. playlistBody.innerHTML = '';
  547. filtered.forEach(function(item, idx) {
  548. const div = document.createElement('div');
  549. div.className = 'playlist-item';
  550. if (currentMedia && currentMedia.id === item.id) div.classList.add('is-active');
  551. if (item.isFinish == 1) div.classList.add('is-finished');
  552. const isCurrent = currentMedia && currentMedia.id === item.id;
  553. const origIdx = mediaList.indexOf(item);
  554. div.innerHTML =
  555. '<div class="item-index' + (isCurrent ? ' active-index' : '') + '">' +
  556. (item.isFinish == 1 ? '<span class="finish-badge">&#10003;</span>' : '<span>' + (origIdx + 1) + '</span>') +
  557. '</div>' +
  558. '<div class="item-info">' +
  559. '<div class="item-name">' + item.name + '</div>' +
  560. '<div class="item-meta">' +
  561. '<span>学时:' + (item.xs / 10) + '</span>' +
  562. '<span class="item-progress">' + getPercent(item) + '%</span>' +
  563. '</div>' +
  564. '</div>' +
  565. '<div class="item-action">' +
  566. (isCurrent && isPlaying
  567. ? '<button class="play-btn pause-btn" data-action="pause">暂停</button>'
  568. : isCurrent
  569. ? '<button class="play-btn" data-action="play">播放</button>'
  570. : '<button class="play-btn" data-action="load">播放</button>') +
  571. '</div>';
  572. div.addEventListener('click', function(e) {
  573. if (e.target.tagName === 'BUTTON') return;
  574. loadMedia(item);
  575. });
  576. const btn = div.querySelector('button');
  577. if (btn) {
  578. btn.addEventListener('click', function(e) {
  579. e.stopPropagation();
  580. const action = btn.dataset.action;
  581. if (action === 'play') doPlay();
  582. else if (action === 'pause') doPause();
  583. else loadMedia(item);
  584. });
  585. }
  586. playlistBody.appendChild(div);
  587. });
  588. if (filtered.length === 0) {
  589. playlistBody.innerHTML = '<div class="playlist-empty">暂无视频</div>';
  590. }
  591. }
  592. function playNext() {
  593. if (!currentMedia) return;
  594. const idx = mediaList.findIndex(function(i) { return i.id === currentMedia.id; });
  595. let next = null;
  596. for (let i = idx + 1; i < mediaList.length; i++) {
  597. if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
  598. }
  599. if (!next) {
  600. for (let i = 0; i < idx; i++) {
  601. if (mediaList[i].isFinish != 1) { next = mediaList[i]; break; }
  602. }
  603. }
  604. if (next) {
  605. showToast('即将播放: ' + next.name, 'success');
  606. setTimeout(function() { loadMedia(next); }, 1000);
  607. }
  608. }
  609. function doPlay() {
  610. isPlaying = true;
  611. startTick();
  612. safePlay();
  613. tickNum = 0;
  614. updatePlaylist();
  615. }
  616. function doPause() {
  617. isPlaying = false;
  618. stopTick();
  619. video.pause();
  620. updatePlaylist();
  621. }
  622. // ---- PiP ----
  623. async function enterPiP() {
  624. if (!document.pictureInPictureEnabled || isPiP) return;
  625. try {
  626. await video.requestPictureInPicture();
  627. } catch (e) {}
  628. }
  629. async function exitPiP() {
  630. if (document.pictureInPictureElement) {
  631. try { await document.exitPictureInPicture(); } catch (e) {}
  632. }
  633. }
  634. function togglePiP() {
  635. if (document.pictureInPictureElement) {
  636. exitPiP();
  637. } else {
  638. enterPiP();
  639. }
  640. }
  641. // ---- visibility change ----
  642. function onVisibilityChange() {
  643. if (document.hidden) {
  644. hiddenStartTime = Date.now();
  645. hiddenBasePosition = video.currentTime || 0;
  646. } else if (hiddenStartTime) {
  647. const elapsed = (Date.now() - hiddenStartTime) / 1000;
  648. const estimated = hiddenBasePosition + elapsed;
  649. hiddenStartTime = 0;
  650. if (estimated < (currentMedia ? currentMedia.duration : Infinity)) {
  651. setPosition(estimated);
  652. }
  653. }
  654. }
  655. // ---- events ----
  656. video.addEventListener('timeupdate', function() {
  657. curTimes = video.currentTime || 0;
  658. currentTimeEl.textContent = parseTime(curTimes);
  659. });
  660. video.addEventListener('loadedmetadata', function() {
  661. totalTimeEl.textContent = parseTime(video.duration || currentMedia?.duration || 0);
  662. });
  663. video.addEventListener('play', function() {
  664. isPlaying = true;
  665. report('play', 'start');
  666. updatePlaylist();
  667. });
  668. video.addEventListener('pause', function() {
  669. isPlaying = false;
  670. report('play', 'pause');
  671. updatePlaylist();
  672. });
  673. video.addEventListener('ended', function() {
  674. report('play', 'end');
  675. tick(true);
  676. setTimeout(playNext, 500);
  677. });
  678. video.addEventListener('error', function(e) {
  679. showToast('视频播放出错', 'error');
  680. report('play', 'error');
  681. });
  682. video.addEventListener('enterpictureinpicture', function() {
  683. isPiP = true;
  684. pipBtn.textContent = '退出画中画';
  685. pipBtn.classList.add('is-active');
  686. });
  687. video.addEventListener('leavepictureinpicture', function() {
  688. isPiP = false;
  689. pipBtn.textContent = '画中画';
  690. pipBtn.classList.remove('is-active');
  691. });
  692. volumeSlider.addEventListener('input', function() {
  693. setVolume(this.valueAsNumber);
  694. });
  695. muteBtn.addEventListener('click', function() {
  696. isMuted = !isMuted;
  697. setVolume(volume);
  698. });
  699. pipBtn.addEventListener('click', togglePiP);
  700. qualitySelect.addEventListener('change', function() {
  701. changeMedia(this.value);
  702. });
  703. panelToggle.addEventListener('click', function() {
  704. showPanel = !showPanel;
  705. playlistPanel.style.display = showPanel ? '' : 'none';
  706. panelToggle.textContent = showPanel ? '>' : '<';
  707. });
  708. hideFinishedCheck.addEventListener('change', updatePlaylist);
  709. // ---- QR code ----
  710. const qrBtn = document.getElementById('qrBtn');
  711. const qrModalOverlay = document.getElementById('qrModalOverlay');
  712. const qrModalClose = document.getElementById('qrModalClose');
  713. const qrCodeImg = document.getElementById('qrCodeImg');
  714. function showQRCode() {
  715. const url = window.location.href;
  716. qrCodeImg.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url);
  717. qrModalOverlay.classList.add('show');
  718. }
  719. function hideQRCode() {
  720. qrModalOverlay.classList.remove('show');
  721. }
  722. qrBtn.addEventListener('click', showQRCode);
  723. qrModalClose.addEventListener('click', hideQRCode);
  724. qrModalOverlay.addEventListener('click', function(e) {
  725. if (e.target === qrModalOverlay) hideQRCode();
  726. });
  727. // ---- init ----
  728. function init() {
  729. const params = new URLSearchParams(window.location.search);
  730. uid = params.get('uid') || localStorage.getItem('uid') || 0;
  731. token = params.get('token') || localStorage.getItem('token') || '';
  732. courseId = parseInt(params.get('courseId') || params.get('id') || '0');
  733. mediaId = parseInt(params.get('mediaId') || '0');
  734. if (params.get('uid')) localStorage.setItem('uid', uid);
  735. if (params.get('token')) localStorage.setItem('token', token);
  736. document.addEventListener('visibilitychange', onVisibilityChange);
  737. window.addEventListener('beforeunload', function() {
  738. stopTick();
  739. exitPiP();
  740. });
  741. if (courseId) {
  742. loadCourse();
  743. } else {
  744. videoTitle.textContent = '缺少课程ID';
  745. }
  746. }
  747. init();
  748. </script>
  749. </body>
  750. </html>