feat: local push file browser, VOD serving, and admin UX overhaul
Build and Push Docker Image / build (push) Failing after 1m5s

Local push & file browser
- File browser with breadcrumb nav, search, directory memory, .. row,
  and hidden/system folder filtering (./@/#)
- Color-coded file extension tags; file sizes shown inline
- Per-file push modal with random stream key generator and responsive width
- Folder multi-file push modal: independent stream keys per file,
  batch start/stop, inline live-dot status with real-time duration
- Push status inline in file rows replacing top push-jobs area;
  job detail modal with copy/stop/add-stream actions
- /h/<slug> HLS proxy route registered automatically on push start
- Folder push and publish-archive recurse subfolders via os.walk
- "Add to existing stream" dropdown at file, folder, and job modal entries
- Stream editor supports prefilling multiple source links via links array
- list_folder_videos API returns playable files with signed URLs

VOD / video serving
- /video/<token>/<payload> endpoint with HMAC-signed URLs and
  HTTP Range support (206 Partial Content, seek-capable)
- Publish-archive button on file rows and folder rows

Admin UX
- Replace all 18 native alert() with themed Toast notifications
  (success/error/info/warn, 3.5s auto-dismiss, dark mode aware)
- Replace all 3 native confirm() with custom modal (showConfirm)
- Custom overlay scrollbar for admin.html and index.html: no layout
  shift, theme-colored, auto-hides after 1.5s, drag-supported
- background-attachment: fixed on admin and index body backgrounds
- Drag handle for viewport config rows in stream editor
- Pagination and real-time search for hidden push address mapping table
- Pagination for stream analytics detail table with SSE-safe page state
- Stream picker search placeholder i18n
- Lang toggle button title/aria-label i18n
- View URL hash renamed: push -> local, obs -> remote

Index (public) page
- Load more: viewport-aware initial batch calculated from .stream-switch
  bottom position; ghost-style button; card entrance animation with
  50ms per-card stagger on load-more click only

Infrastructure
- Dockerfile: install ffmpeg; separate requirements COPY for layer cache
- docker-compose.yml: add RTMP_HOST, VIDEOS_DIRS env vars, videos volume
- README: document VIDEOS_DIRS mount methods, password reset procedure

Fixes
- action=add 500 error: psycopg3 dict_row does not support int subscript
- Lang toggle button title/aria-label missing i18n keys
- API Keys list not re-rendering on language switch
- Admin stream count per tab showing combined LIVE+ARCHIVE total
- Em dash in api.new_token_hint replaced with hyphen
This commit is contained in:
Stardream
2026-05-22 21:10:33 +10:00
parent 326101958a
commit dc949bdeab
8 changed files with 2509 additions and 72 deletions
+168 -1
View File
@@ -31,12 +31,60 @@
box-sizing: border-box;
}
html {
scrollbar-width: none;
}
html::-webkit-scrollbar {
display: none;
}
#sh-scrollbar {
position: fixed;
right: 4px;
top: 0;
bottom: 0;
width: 6px;
z-index: 9990;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
#sh-scrollbar.sh-sb-vis {
pointer-events: auto;
opacity: 1;
}
#sh-scrollbar.dragging {
pointer-events: auto;
}
#sh-scrollbar-thumb {
position: absolute;
right: 0;
width: 6px;
min-height: 40px;
border-radius: 999px;
background: rgba(78, 126, 232, 0.35);
transition: background 0.15s;
cursor: pointer;
user-select: none;
}
#sh-scrollbar-thumb:hover,
#sh-scrollbar.dragging #sh-scrollbar-thumb {
background: rgba(78, 126, 232, 0.65);
}
:root[data-theme="dark"] #sh-scrollbar-thumb {
background: rgba(147, 197, 253, 0.28);
}
:root[data-theme="dark"] #sh-scrollbar-thumb:hover,
:root[data-theme="dark"] #sh-scrollbar.dragging #sh-scrollbar-thumb {
background: rgba(147, 197, 253, 0.6);
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(135deg, rgba(246, 249, 255, 0.98), rgba(255, 246, 250, 0.92) 50%, rgba(242, 255, 252, 0.96)),
linear-gradient(90deg, rgba(78, 126, 232, 0.08), rgba(242, 99, 137, 0.07));
background-attachment: fixed;
color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
overflow-x: hidden;
@@ -46,6 +94,7 @@
background:
linear-gradient(135deg, rgba(15, 20, 34, 0.98), rgba(28, 35, 54, 0.94) 52%, rgba(30, 24, 42, 0.98)),
linear-gradient(90deg, rgba(25, 184, 177, 0.1), rgba(242, 99, 137, 0.08));
background-attachment: fixed;
color: #d8deea;
}
@@ -625,6 +674,33 @@
grid-template-columns: 1fr;
}
}
#sh-load-more-wrap {
text-align: center;
padding: 16px 0 24px;
}
#sh-load-more-btn {
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 28px;
background: transparent;
color: var(--muted);
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
#sh-load-more-btn:hover {
color: var(--blue);
border-color: rgba(78, 126, 232, 0.4);
transform: translateY(-1px);
}
@keyframes sh-card-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.card-new {
animation: sh-card-in 0.28s ease-out both;
}
</style>
</head>
@@ -661,6 +737,9 @@
<div id="loading-indicator" class="loader"></div>
<p id="error-message" class="message hidden"></p>
<div id="stream-list"></div>
<div id="sh-load-more-wrap" style="display:none;">
<button id="sh-load-more-btn"></button>
</div>
</main>
<footer class="site-footer">
@@ -693,6 +772,7 @@
// empty states
'empty.live': '当前没有可用的直播',
'empty.archive': '当前没有可用的存档',
'list.load_more': '加载更多',
// error
'err.load': '加载失败',
'err.fetch': '获取直播列表失败',
@@ -723,6 +803,7 @@
// empty states
'empty.live': 'No live streams available',
'empty.archive': 'No archives available',
'list.load_more': 'Load more',
// error
'err.load': 'Failed to load',
'err.fetch': 'Failed to fetch streams',
@@ -989,6 +1070,18 @@
const labelKicker = (label) => label === 'ARCHIVE' ? t('kicker.archive') : t('kicker.live');
let _visibleCount = 0;
const _calcBatch = () => {
const w = window.innerWidth, h = window.innerHeight;
const cols = w > 900 ? 3 : w > 768 ? 2 : 1;
const switchEl = document.querySelector('.stream-switch');
const refBottom = switchEl ? switchEl.getBoundingClientRect().bottom : (w > 768 ? 200 : 160);
const listTop = refBottom + 24;
const rowH = 196;
const rows = Math.max(1, Math.floor((h - listTop - 300) / rowH));
return rows * cols;
};
const renderStreams = () => {
streamSectionTitle.textContent = labelTitle(activeStreamLabel);
const kickerEl = document.getElementById('stream-section-kicker');
@@ -1004,9 +1097,12 @@
if (!streams.length) {
errorMessage.textContent = emptyText(activeStreamLabel);
errorMessage.classList.remove('hidden');
document.getElementById('sh-load-more-wrap').style.display = 'none';
return;
}
streamList.innerHTML = streams.map(stream => {
if (_visibleCount === 0) _visibleCount = _calcBatch();
const visStreams = streams.slice(0, _visibleCount);
streamList.innerHTML = visStreams.map(stream => {
const label = normalizeLabel(stream.stream_label);
return `
<a href="player.html?id=${encodeURIComponent(stream.id)}" class="card">
@@ -1024,6 +1120,16 @@
</a>
`;
}).join('');
const lmWrap = document.getElementById('sh-load-more-wrap');
const lmBtn = document.getElementById('sh-load-more-btn');
if (lmWrap && lmBtn) {
if (streams.length > _visibleCount) {
lmBtn.textContent = t('list.load_more');
lmWrap.style.display = '';
} else {
lmWrap.style.display = 'none';
}
}
};
streamSwitchButtons.forEach(btn => {
@@ -1034,12 +1140,25 @@
localStorage.setItem('home_stream_label', activeStreamLabel);
streamList.classList.add('is-switching');
window.setTimeout(() => {
_visibleCount = 0;
renderStreams();
window.requestAnimationFrame(() => streamList.classList.remove('is-switching'));
}, 140);
});
});
document.getElementById('sh-load-more-btn')?.addEventListener('click', () => {
const oldCount = _visibleCount;
_visibleCount += _calcBatch();
renderStreams();
streamList.querySelectorAll('.card').forEach((card, i) => {
if (i >= oldCount) {
card.classList.add('card-new');
card.style.animationDelay = `${(i - oldCount) * 50}ms`;
}
});
});
try {
const settingsResponse = await fetch('/api?action=site_settings');
if (settingsResponse.ok) {
@@ -1065,6 +1184,54 @@
}
});
</script>
<div id="sh-scrollbar"><div id="sh-scrollbar-thumb"></div></div>
<script>
(function() {
var _sb = document.getElementById('sh-scrollbar');
var _thumb = document.getElementById('sh-scrollbar-thumb');
if (!_sb || !_thumb) return;
var _hideTimer = null, _isDragging = false, _dragStartY = 0, _dragScrollY = 0;
function _h() {
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
return Math.max(40, (vis / total) * vis);
}
function _update() {
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
if (total <= vis) { _sb.classList.remove('sh-sb-vis'); return; }
var h = _h(), top = (window.scrollY / (total - vis)) * (vis - h);
_thumb.style.height = h + 'px';
_thumb.style.top = top + 'px';
}
function _show() {
_update();
if (document.documentElement.scrollHeight <= window.innerHeight) return;
_sb.classList.add('sh-sb-vis');
clearTimeout(_hideTimer);
if (!_isDragging) _hideTimer = setTimeout(function() { _sb.classList.remove('sh-sb-vis'); }, 1500);
}
window.addEventListener('scroll', _show, { passive: true });
window.addEventListener('resize', _update);
document.addEventListener('mousemove', function(e) {
if (e.clientX > window.innerWidth - 20) _show();
});
_thumb.addEventListener('mousedown', function(e) {
_isDragging = true; _dragStartY = e.clientY; _dragScrollY = window.scrollY;
_sb.classList.add('dragging'); clearTimeout(_hideTimer); e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!_isDragging) return;
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
var ratio = (total - vis) / (vis - _h());
window.scrollTo(0, _dragScrollY + (e.clientY - _dragStartY) * ratio);
});
document.addEventListener('mouseup', function() {
if (!_isDragging) return;
_isDragging = false; _sb.classList.remove('dragging');
_hideTimer = setTimeout(function() { _sb.classList.remove('sh-sb-vis'); }, 1500);
});
_update();
})();
</script>
</body>
</html>