diff --git a/public/admin.html b/public/admin.html index af239bf..cdd9bb1 100644 --- a/public/admin.html +++ b/public/admin.html @@ -219,6 +219,10 @@ padding: 10px; } + #admin-menu-toggle { display: none; } + #push-sidebar-toggle { display: none; } + #push-browser-layout { align-items: flex-start; } + .admin-menu-btn { border: 1px solid transparent; border-radius: 6px; @@ -364,6 +368,15 @@ outline: none; } + select { + appearance: none; + padding-right: 34px !important; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") !important; + background-repeat: no-repeat !important; + background-position: right 11px center !important; + background-size: 16px 16px !important; + } + textarea { resize: vertical; } @@ -376,6 +389,101 @@ margin-bottom: 10px; } + .link-remove-btn { + align-self: start; + margin-top: 0; + min-height: 42px; + } + + .link-row .link-drm-config { + grid-column: 2 / -2; + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(100, 116, 139, 0.06); + overflow: hidden; + } + + .link-row .link-drm-config > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 11px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 900; + cursor: pointer; + list-style: none; + } + + .link-row .link-drm-config > summary::-webkit-details-marker { + display: none; + } + + .link-row .link-drm-config > summary::after { + content: "DRM"; + border-radius: 999px; + padding: 2px 7px; + background: var(--line); + color: var(--text); + font-size: 0.68rem; + letter-spacing: 0.04em; + } + + .link-row .link-drm-grid { + display: grid; + grid-template-columns: 0.7fr 1.5fr; + gap: 8px; + padding: 0 11px 11px; + } + + .link-row .link-drm-grid textarea { + min-height: 68px; + } + + .link-row .link-drm-wide { + grid-column: 1 / -1; + } + + .link-drm-list { + display: grid; + gap: 8px; + padding: 0 11px 11px; + } + + .drm-config-row { + display: grid; + grid-template-columns: 0.7fr 1.4fr 1.2fr auto; + gap: 8px; + padding: 9px; + border: 1px solid var(--line); + border-radius: 6px; + background: rgba(255, 255, 255, 0.035); + } + + .drm-config-row textarea { + min-height: 58px; + } + + .drm-config-row .l-drm-type { + padding-right: 34px !important; + } + + .drm-remove-btn { + align-self: start; + width: 42px; + height: 42px; + min-width: 42px; + padding: 0; + line-height: 1; + } + + .link-drm-actions { + display: flex; + justify-content: flex-end; + padding: 0 11px 11px; + } + .nav-link-row { display: grid; grid-template-columns: minmax(120px, 0.7fr) minmax(180px, 1.5fr) auto; @@ -867,22 +975,89 @@ grid-template-columns: 1fr; } + .link-row .link-drm-config, + .link-row .link-drm-wide { + grid-column: 1 / -1; + } + + .link-row .link-drm-grid { + grid-template-columns: 1fr; + } + + .drm-config-row { + grid-template-columns: 1fr; + } + .admin-layout { grid-template-columns: 1fr; } + #admin-menu-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 13px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--fg); + font-size: inherit; + cursor: pointer; + text-align: left; + } + #admin-menu-toggle .group-arrow { + transition: transform 0.2s ease; + } + .admin-menu.mobile-open #admin-menu-toggle .group-arrow { + transform: rotate(180deg); + } .admin-menu { - position: static; - display: grid; - grid-template-columns: repeat(5, minmax(118px, 1fr)); - overflow-x: auto; + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; + overflow: visible; + } + .admin-menu > .admin-menu-btn, + .admin-menu > .admin-menu-group { + display: none; + } + .admin-menu.mobile-open #admin-menu-toggle { + margin-bottom: 4px; + } + .admin-menu.mobile-open > .admin-menu-btn, + .admin-menu.mobile-open > .admin-menu-group { + display: flex; + width: 100%; } - .admin-menu-btn { - text-align: center; + #push-browser-layout { flex-direction: column; align-items: stretch; } + #push-sidebar-wrap { width: 100%; } + #push-sidebar-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 13px; + margin-bottom: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--fg); + font-size: inherit; + cursor: pointer; + text-align: left; + } + #push-sidebar-toggle .group-arrow { transition: transform 0.2s ease; } + #push-sidebar-wrap.sidebar-open #push-sidebar-toggle .group-arrow { transform: rotate(180deg); } + #push-sidebar { display: none !important; } + #push-sidebar-wrap.sidebar-open #push-sidebar { + display: flex !important; + width: 100% !important; + margin-top: 6px; } - .admin-menu-group-btn { justify-content:center; } - .admin-sub-btn { text-align:center; padding-left:13px; } .stream-row { align-items: flex-start; @@ -1008,6 +1183,11 @@ .modal-card-wide { width:min(100%,clamp(640px,80vw,1080px)); max-height:90vh; overflow-y:auto; padding:26px; } .stream-stat-summary-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:14px 0 18px; } @media(max-width:600px) { .stream-stat-summary-grid { grid-template-columns:repeat(2,1fr); } } + .fb-row { min-width:0; } + @media(max-width:600px) { + .fb-row { flex-wrap:wrap; } + .fb-actions { width:100%; justify-content:flex-end; margin-top:4px; } + } .stream-stat-mini-card { border:1px solid var(--line); border-radius:7px; padding:12px 14px; } .stream-stat-mini-lbl { font-size:.72rem; font-weight:800; color:var(--muted); text-transform:uppercase; } .stream-stat-mini-val { font-size:1.55rem; font-weight:900; margin:3px 0 1px; } @@ -1112,6 +1292,10 @@
-
-
+
+
+ +
+
@@ -1806,6 +1993,12 @@ 'ph.link_url': '链接 (m3u8/flv/mpd)', 'ph.key_aes': 'AES-128 Key Hex,可多行: main-video=hex', 'ph.clearkey': 'ClearKey 信息,如 {"kid":"key"}', + 'ph.license_url': 'DRM License URL', + 'ph.license_url_widevine':'Widevine License URL', + 'ph.license_url_fairplay':'FairPlay License URL', + 'ph.certificate_url':'FairPlay Certificate URL', + 'ph.license_headers':'License Headers JSON 或 Header: Value 多行', + 'ph.pssh': 'PSSH,可选,用于记录或诊断', // hints 'hint.nav_links': '留空则不显示菜单链接。支持站内锚点如 #stream-list,或完整 http/https 链接。', 'hint.pw_change': '修改后台登录密码后,当前会话会自动退出。', @@ -1814,6 +2007,8 @@ 'hint.obs_rtmp': '推流端使用 RTMP 推流到服务器,播放器使用 SRS 输出的 HLS 或 FLV 地址。', 'hint.obs_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。', 'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息', + 'drm.config': 'DRM 播放授权', + 'drm.none': '无 DRM', 'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)', // TG 'tg.live_section': '直播', 'tg.live_label': 'LIVE', @@ -1852,6 +2047,7 @@ 'probe.detected': '已检测到推流信息', 'probe.waiting': '等待自动检测...', 'probe.closed': '直播已关闭', + 'probe.drm_config_missing':'检测到 DRM 流,但缺少匹配的 DRM 配置', // status messages 'msg.site_saved': '网站设置已保存', 'msg.site_err': '加载网站设置失败', @@ -1935,6 +2131,7 @@ 'push.copy_hls': '复制播放地址', 'push.add_stream': '添加直播', 'push.copied': '已复制', + 'push.dir_label': '目录', 'push.dir_empty': '此目录为空', 'push.publish_archive': '发布归档', 'push.folder': '文件夹', @@ -2196,6 +2393,12 @@ 'ph.link_url': 'URL (m3u8/flv/mpd)', 'ph.key_aes': 'AES-128 Key Hex, multi-line: main-video=hex', 'ph.clearkey': 'ClearKey JSON, e.g. {"kid":"key"}', + 'ph.license_url': 'DRM License URL', + 'ph.license_url_widevine':'Widevine License URL', + 'ph.license_url_fairplay':'FairPlay License URL', + 'ph.certificate_url':'FairPlay Certificate URL', + 'ph.license_headers':'License Headers JSON or Header: Value lines', + 'ph.pssh': 'PSSH, optional note for diagnostics', // hints 'hint.nav_links': 'Leave empty to hide nav links. Supports anchors like #stream-list or full URLs.', 'hint.pw_change': 'Changing the admin password will log you out of the current session.', @@ -2204,6 +2407,8 @@ 'hint.obs_rtmp': 'The encoder pushes via RTMP to the server; the player uses HLS or FLV from SRS.', 'hint.obs_routes': 'Adding a custom stream key generates a public HLS/FLV URL that cannot be reverse-engineered.', 'hint.links': 'Format: name | type | URL | Key Override | ClearKey', + 'drm.config': 'DRM license', + 'drm.none': 'No DRM', 'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)', // TG 'tg.live_section': 'Live', 'tg.live_label': '直播', @@ -2242,6 +2447,7 @@ 'probe.detected': 'Stream detected', 'probe.waiting': 'Waiting for detection...', 'probe.closed': 'Stream disabled', + 'probe.drm_config_missing':'DRM stream detected, but matching DRM config is missing', // status messages 'msg.site_saved': 'Settings saved', 'msg.site_err': 'Failed to load settings', @@ -2325,6 +2531,7 @@ 'push.copy_hls': 'Copy HLS URL', 'push.add_stream': 'Add Stream', 'push.copied': 'Copied', + 'push.dir_label': 'Dirs', 'push.dir_empty': 'Directory is empty', 'push.publish_archive': 'Publish to Archive', 'push.folder': 'Folder', @@ -2510,6 +2717,15 @@ if (btn.dataset.adminViewTarget) switchAdminView(btn.dataset.adminViewTarget); }); }); + const _adminMenuEl = document.querySelector('.admin-menu'); + document.getElementById('admin-menu-toggle')?.addEventListener('click', () => { + _adminMenuEl?.classList.toggle('mobile-open'); + }); + document.querySelectorAll('.admin-menu-btn, .admin-sub-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (window.innerWidth <= 900) _adminMenuEl?.classList.remove('mobile-open'); + }); + }); document.getElementById('records-group-toggle')?.addEventListener('click', () => { document.getElementById('records-group')?.classList.toggle('open'); }); @@ -2702,10 +2918,19 @@ if (state !== 'is-checking') el.dataset.probeComplete = '1'; }; - const probeUrl = async (url, type = '') => { + const getRowDrmConfigs = (row) => Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({ + drmType: drmRow.querySelector('.l-drm-type')?.value || '', + licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '', + certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '', + licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '', + pssh: drmRow.querySelector('.l-pssh')?.value.trim() || '' + })).filter(config => config.drmType && config.licenseUrl); + + const probeUrl = async (url, type = '', drmConfigs = []) => { const res = await apiCall('check_stream_url', { url, - type: inferLinkType(url, type) + type: inferLinkType(url, type), + drmConfigs }); return res.data || { valid: false, message: t('probe.no_info') }; }; @@ -2725,12 +2950,12 @@ row.dataset.probeActive = '1'; if (!silent) setProbeStatus(statusEl, 'is-checking', t('probe.detecting')); try { - const result = await probeUrl(url, type); + const result = await probeUrl(url, type, getRowDrmConfigs(row)); if (!row.isConnected || row.dataset.probeToken !== token) return; setProbeStatus( statusEl, result.valid ? 'is-online' : 'is-offline', - result.valid ? t('probe.detected') : t('probe.no_info') + result.valid ? t('probe.detected') : (t('probe.' + result.code) || t('probe.no_info')) ); } catch (e) { if (row.isConnected && row.dataset.probeToken === token) { @@ -3657,13 +3882,29 @@ els.form.addEventListener('submit', async (e) => { e.preventDefault(); - const links = Array.from(els.linksContainer.children).map(row => ({ - name: row.querySelector('.l-name').value, - type: row.querySelector('.l-type').value, - url: row.querySelector('.l-url').value, - key: row.querySelector('.l-key').value.trim(), - clearkey: row.querySelector('.l-clearkey').value.trim() - })).filter(l => l.name && l.url); + const links = Array.from(els.linksContainer.children).map(row => { + const drmConfigs = Array.from(row.querySelectorAll('.drm-config-row')).map(drmRow => ({ + drmType: drmRow.querySelector('.l-drm-type')?.value || '', + licenseUrl: drmRow.querySelector('.l-license-url')?.value.trim() || '', + certificateUrl: drmRow.querySelector('.l-certificate-url')?.value.trim() || '', + licenseHeaders: drmRow.querySelector('.l-license-headers')?.value.trim() || '', + pssh: drmRow.querySelector('.l-pssh')?.value.trim() || '' + })).filter(config => config.drmType && config.licenseUrl); + const firstDrm = drmConfigs[0] || {}; + return { + name: row.querySelector('.l-name').value, + type: row.querySelector('.l-type').value, + url: row.querySelector('.l-url').value, + key: row.querySelector('.l-key').value.trim(), + clearkey: row.querySelector('.l-clearkey').value.trim(), + drmConfigs, + drmType: firstDrm.drmType || '', + licenseUrl: firstDrm.licenseUrl || '', + certificateUrl: firstDrm.certificateUrl || '', + licenseHeaders: firstDrm.licenseHeaders || '', + pssh: firstDrm.pssh || '' + }; + }).filter(l => l.name && l.url); const payload = { id: els.idInput.value, @@ -3686,7 +3927,14 @@ let _linkDragSrc = null; let _linkDragFromHandle = false; - const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '') => { + const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '', drmType = '', licenseUrl = '', licenseHeaders = '', pssh = '', certificateUrl = '', drmConfigs = []) => { + const rawDrmType = String(drmType || '').toLowerCase(); + const normalizedDrmType = rawDrmType === 'widevine' || rawDrmType === 'fairplay' ? rawDrmType : ''; + const normalizedDrmConfigs = Array.isArray(drmConfigs) && drmConfigs.length + ? drmConfigs + : (normalizedDrmType || licenseUrl || certificateUrl || licenseHeaders || pssh) + ? [{ drmType: normalizedDrmType, licenseUrl, certificateUrl, licenseHeaders, pssh }] + : []; const div = document.createElement('div'); div.className = 'link-row'; div.draggable = true; @@ -3705,9 +3953,77 @@
- + + `; els.linksContainer.appendChild(div); + const drmList = div.querySelector('.link-drm-list'); + const addDrmConfigUI = (config = {}) => { + const drmTypeValue = String(config.drmType || config.drm_type || '').toLowerCase(); + const selectedType = drmTypeValue === 'fairplay' || drmTypeValue === 'widevine' ? drmTypeValue : ''; + const row = document.createElement('div'); + row.className = 'drm-config-row'; + row.innerHTML = ` + + + + + + + `; + drmList.appendChild(row); + const drmTypeSelect = row.querySelector('.l-drm-type'); + const licenseInput = row.querySelector('.l-license-url'); + const syncDrmPlaceholders = () => { + const type = drmTypeSelect?.value || ''; + if (licenseInput) { + licenseInput.placeholder = type === 'fairplay' + ? t('ph.license_url_fairplay') + : type === 'widevine' + ? t('ph.license_url_widevine') + : t('ph.license_url'); + } + row.querySelectorAll('.drm-field-enabled').forEach(el => { + el.style.display = type ? '' : 'none'; + }); + row.querySelectorAll('.drm-field-fairplay').forEach(el => { + el.style.display = type === 'fairplay' ? '' : 'none'; + }); + row.querySelectorAll('.drm-field-widevine').forEach(el => { + el.style.display = type === 'widevine' ? '' : 'none'; + }); + }; + syncDrmPlaceholders(); + const refreshProbe = () => scheduleLinkRowCheck(div); + drmTypeSelect?.addEventListener('change', () => { + syncDrmPlaceholders(); + refreshProbe(); + }); + row.querySelectorAll('input, textarea').forEach(input => { + input.addEventListener('input', refreshProbe); + input.addEventListener('blur', () => scheduleLinkRowCheck(div, 0)); + }); + row.querySelector('.remove-drm-config-btn')?.addEventListener('click', () => { + row.remove(); + refreshProbe(); + }); + }; + normalizedDrmConfigs.forEach(config => addDrmConfigUI(config)); + div.querySelector('.add-drm-config-btn')?.addEventListener('click', () => { + div.querySelector('.link-drm-config')?.setAttribute('open', ''); + addDrmConfigUI(); + scheduleLinkRowCheck(div); + }); div.querySelector('.l-url').addEventListener('input', () => scheduleLinkRowCheck(div)); div.querySelector('.l-url').addEventListener('blur', () => scheduleLinkRowCheck(div, 0)); div.querySelector('.l-type').addEventListener('change', () => scheduleLinkRowCheck(div, 0)); @@ -3861,7 +4177,7 @@ els.cancelBtn.classList.remove('hidden'); els.linksContainer.innerHTML = ''; const links = JSON.parse(stream.links_json || '[]'); - links.forEach(l => addLinkUI(l.name, l.url, l.key, l.clearkey, l.type)); + links.forEach(l => addLinkUI(l.name, l.url, l.key, l.clearkey, l.type, l.drmType || l.drm_type || '', l.licenseUrl || l.license_url || '', l.licenseHeaders || l.license_headers || '', l.pssh || '', l.certificateUrl || l.certificate_url || '', l.drmConfigs || l.drm_configs || [])); if (links.length === 0) addLinkUI(); } else { resetForm(); @@ -5181,6 +5497,7 @@ btn.addEventListener('click', () => { _highlightSidebarBtn(sidebar, root.index); browseDir(root.index, ''); + if (window.innerWidth <= 900) document.getElementById('push-sidebar-wrap')?.classList.remove('sidebar-open'); }); sidebar.appendChild(btn); }); @@ -5273,7 +5590,7 @@ (j.push_rel_path === childPath || j.push_rel_path.startsWith(childPath + '/'))) : false; const folderBtns = (entry.has_playable || entry.has_video) - ? `
+ ? `
${entry.has_playable ? `
` : ''} ${entry.has_video ? (_folderHasJob @@ -5281,9 +5598,9 @@ : ``) : ''}
` : ''; - html += `
+ html += `
📁 -
${_esc(entry.name)}
+
${_esc(entry.name)}
${folderBtns}
`; } else { @@ -5294,13 +5611,13 @@ const _tagHtml = _ext ? `${_esc(_ext)}` : ''; - html += `
+ html += `
🎬
${_esc(_stem)}
${_esc(fmtBytes(entry.size || 0))}${_tagHtml}
-
+
${entry.video_url ? `
` : ''} ${(() => { const _fj = _jobMap[`v|${dirIndex}|${relPath}|${entry.name}`] ?? null; return _fj @@ -5540,6 +5857,9 @@ document.querySelector('[data-admin-view-target="local"]') ?.addEventListener('click', openPushView); document.addEventListener('admin:enter-local', openPushView); + document.getElementById('push-sidebar-toggle')?.addEventListener('click', () => { + document.getElementById('push-sidebar-wrap')?.classList.toggle('sidebar-open'); + }); document.querySelectorAll('.admin-menu-btn:not([data-admin-view-target="local"]), .admin-sub-btn') .forEach(btn => btn.addEventListener('click', stopJobPolling));